import { NextRequest } from "next/server"; import path from "node:path"; import fs from "node:fs/promises"; import crypto from "node:crypto"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const COVER_ROOT = path.join(process.cwd(), "data", "category-covers"); const IMAGE_MIME: Record = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", }; export async function GET(_req: NextRequest, ctx: { params: Promise<{ file: string }> }) { const { file } = await ctx.params; const name = decodeURIComponent(file); // Stored filenames are always `${id}-${slot}-${sha16}.${ext}`. Reject // anything containing path separators or traversal segments before // touching the disk. if (name.includes("/") || name.includes("\\") || name.includes("..")) { return new Response("not found", { status: 404 }); } const abs = path.join(COVER_ROOT, name); try { const buf = await fs.readFile(abs); const ext = path.extname(abs).toLowerCase(); const etag = `"${crypto.createHash("sha256").update(buf).digest("hex")}"`; return new Response(new Uint8Array(buf), { headers: { "Content-Type": IMAGE_MIME[ext] ?? "application/octet-stream", "Content-Disposition": rfc5987Disposition(name), "Cache-Control": "public, max-age=0, must-revalidate", ETag: etag, }, }); } catch { return new Response("not found", { status: 404 }); } } function rfc5987Disposition(filename: string): string { // ASCII-safe fallback: strip non-ASCII + escape quotes/backslashes for // the legacy `filename=` token. UTF-8 path uses RFC 5987 percent-encoding. const ascii = filename.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_"); const utf8 = encodeURIComponent(filename); return `inline; filename="${ascii}"; filename*=UTF-8''${utf8}`; }