100 lines
3.4 KiB
TypeScript
100 lines
3.4 KiB
TypeScript
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<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 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);
|
|
});
|
|
}
|