feat(backend): add fastify api, auth, prisma schema and jobs

This commit is contained in:
Anton
2026-04-23 16:04:44 +03:00
parent 5f6a551b6c
commit 2972090c48
34 changed files with 1313 additions and 0 deletions

View File

@@ -0,0 +1,50 @@
import type { FastifyInstance } from 'fastify';
import { MAX_UPLOAD_BYTES } from '../../plugins/multipart.js';
import { WishesService } from '../wishes/wishes.service.js';
import { ValidationError } from '../../utils/errors.js';
import { deleteLocalImageIfAny, saveUploadedImage } from './storage.service.js';
import { fetchOgImageForWish } from './og.service.js';
export default async function imagesRoutes(app: FastifyInstance) {
app.addHook('preHandler', app.authenticate);
const wishes = new WishesService(app.prisma);
app.post('/:id/image', async (request) => {
const { id } = request.params as { id: string };
const current = await wishes.getOwned(request.user.id, id);
const data = await request.file();
if (!data) throw new ValidationError('No file uploaded');
const buffer = await data.toBuffer();
if (buffer.byteLength > MAX_UPLOAD_BYTES) {
throw new ValidationError('File too large');
}
const { imageUrl } = await saveUploadedImage(id, data.mimetype, buffer);
await deleteLocalImageIfAny(current.imageUrl);
return app.prisma.wish.update({
where: { id },
data: { imageUrl, imageSource: 'UPLOADED' },
});
});
app.post('/:id/image/refresh-og', async (request) => {
const { id } = request.params as { id: string };
const wish = await wishes.getOwned(request.user.id, id);
if (!wish.url) throw new ValidationError('Wish has no url');
await fetchOgImageForWish(app, id, wish.url);
return app.prisma.wish.findUniqueOrThrow({ where: { id } });
});
app.delete('/:id/image', async (request) => {
const { id } = request.params as { id: string };
const wish = await wishes.getOwned(request.user.id, id);
await deleteLocalImageIfAny(wish.imageUrl);
return app.prisma.wish.update({
where: { id },
data: { imageUrl: null, imageSource: 'DEFAULT' },
});
});
}

View File

@@ -0,0 +1,93 @@
import type { FastifyInstance } from 'fastify';
import { request as undiciRequest } from 'undici';
import ogs from 'open-graph-scraper';
import { writeFile } from 'node:fs/promises';
import { resolve, extname } from 'node:path';
import { nanoid } from 'nanoid';
import { env } from '../../config/env.js';
const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
const ALLOWED_MIME = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
const FETCH_TIMEOUT_MS = 10_000;
interface DownloadResult {
buffer: Buffer;
ext: string;
contentType: string;
}
async function downloadImage(url: string): Promise<DownloadResult | null> {
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
try {
const res = await undiciRequest(url, {
method: 'GET',
signal: controller.signal,
headers: { 'user-agent': 'FamilyWishlistBot/1.0 (+image-fetch)' },
});
if (res.statusCode >= 400) return null;
const contentType = (res.headers['content-type']?.toString() ?? '').split(';')[0].trim();
if (!ALLOWED_MIME.has(contentType)) return null;
const chunks: Buffer[] = [];
let total = 0;
for await (const chunk of res.body) {
const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
total += buf.length;
if (total > MAX_IMAGE_BYTES) return null;
chunks.push(buf);
}
const buffer = Buffer.concat(chunks);
const extFromCt = contentType.split('/')[1] ?? 'jpg';
const extFromUrl = extname(new URL(url).pathname).replace('.', '').toLowerCase();
const ext = ['jpg', 'jpeg', 'png', 'webp', 'gif'].includes(extFromUrl)
? extFromUrl
: extFromCt;
return { buffer, ext, contentType };
} finally {
clearTimeout(timer);
}
} catch {
return null;
}
}
export async function fetchOgImageForWish(
app: FastifyInstance,
wishId: string,
pageUrl: string,
): Promise<void> {
try {
const parsed = await ogs({ url: pageUrl, timeout: FETCH_TIMEOUT_MS });
if (parsed.error || !parsed.result) return;
const imageEntry = parsed.result.ogImage;
const imageUrl = Array.isArray(imageEntry) ? imageEntry[0]?.url : imageEntry?.url;
if (!imageUrl) return;
const absolute = new URL(imageUrl, pageUrl).toString();
const dl = await downloadImage(absolute);
if (!dl) return;
const filename = `${wishId}-${nanoid(8)}.${dl.ext}`;
const absPath = resolve(env.UPLOADS_DIR, 'og', filename);
await writeFile(absPath, dl.buffer);
const current = await app.prisma.wish.findUnique({ where: { id: wishId } });
if (!current) return;
if (current.imageSource === 'UPLOADED') return; // do not overwrite user upload
await app.prisma.wish.update({
where: { id: wishId },
data: { imageUrl: `/uploads/og/${filename}`, imageSource: 'OG' },
});
} catch (err) {
app.log.warn({ err, wishId, pageUrl }, 'OG image fetch failed');
}
}
export function enqueueOgFetch(app: FastifyInstance, wishId: string, pageUrl: string): void {
// Fire-and-forget. Errors are swallowed inside fetchOgImageForWish.
setImmediate(() => {
void fetchOgImageForWish(app, wishId, pageUrl);
});
}

View File

@@ -0,0 +1,38 @@
import { writeFile, unlink } from 'node:fs/promises';
import { resolve } from 'node:path';
import { nanoid } from 'nanoid';
import { env } from '../../config/env.js';
import { ValidationError } from '../../utils/errors.js';
const MIME_TO_EXT: Record<string, string> = {
'image/jpeg': 'jpg',
'image/png': 'png',
'image/webp': 'webp',
'image/gif': 'gif',
};
export async function saveUploadedImage(
wishId: string,
mime: string,
buffer: Buffer,
): Promise<{ imageUrl: string }> {
const ext = MIME_TO_EXT[mime];
if (!ext) throw new ValidationError('Unsupported image type');
const filename = `${wishId}-${nanoid(8)}.${ext}`;
const relative = `/uploads/upload/${filename}`;
const absPath = resolve(env.UPLOADS_DIR, 'upload', filename);
await writeFile(absPath, buffer);
return { imageUrl: relative };
}
export async function deleteLocalImageIfAny(imageUrl: string | null): Promise<void> {
if (!imageUrl) return;
if (!imageUrl.startsWith('/uploads/')) return;
const rel = imageUrl.replace(/^\/uploads\//, '');
const absPath = resolve(env.UPLOADS_DIR, rel);
try {
await unlink(absPath);
} catch {
// already gone — ignore
}
}