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; } function getOgImageUrl(ogImage: unknown): string | undefined { const entry = Array.isArray(ogImage) ? ogImage[0] : ogImage; if (!entry || typeof entry !== 'object') return undefined; const { url } = entry as { url?: unknown }; return typeof url === 'string' ? url : undefined; } async function downloadImage(url: string): Promise { 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 { try { const parsed = await ogs({ url: pageUrl, timeout: FETCH_TIMEOUT_MS }); if (parsed.error || !parsed.result) return; const imageUrl = getOgImageUrl(parsed.result.ogImage); 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); }); }