import "server-only"; import path from "node:path"; import fs from "node:fs/promises"; import crypto from "node:crypto"; import { NextRequest } from "next/server"; import { db, rawDb } from "@/lib/db/client"; import { images } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; const LIBRARY_ROOT = path.join(process.cwd(), "library"); const THUMB_ROOT = path.join(process.cwd(), "data", "thumbs"); const PORTRAIT_ROOT = path.join(process.cwd(), "data", "portraits"); const IMAGE_MIME: Record = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", }; const SLOT_LABEL: Record = { "1": "p1", "2": "p2", "3": "p3", "4": "p4", "h": "l" }; export async function serveImage(req: NextRequest, lookup: { id?: string | null; code?: string | null }): Promise { let row: typeof images.$inferSelect | undefined; if (lookup.id) { const numId = Number(lookup.id); if (!Number.isFinite(numId)) return new Response("not found", { status: 404 }); const match = rawDb.prepare(`SELECT id FROM images WHERE id = ? AND deleted_at IS NULL`).get(numId) as { id: number } | undefined; if (match) row = db.select().from(images).where(eq(images.id, match.id)).get(); } else if (lookup.code) { // Look up by code. If multiple rows share the code, pick the lowest id (most stable). const matches = rawDb.prepare(` SELECT id FROM images WHERE code = ? AND deleted_at IS NULL AND parent_image_id IS NULL ORDER BY id LIMIT 2 `).all(lookup.code) as Array<{ id: number }>; if (matches.length >= 1) { row = db.select().from(images).where(eq(images.id, matches[0].id)).get(); } } if (!row) return new Response("not found", { status: 404 }); const etag = `"${row.sha256}"`; if (req.headers.get("if-none-match") === etag) { return new Response(null, { status: 304, headers: { ETag: etag, "Cache-Control": "public, max-age=0, must-revalidate" } }); } const abs = path.join(LIBRARY_ROOT, row.relPath); // Defence in depth: relPath is set internally during ingest, but a // malformed backup-import could plant a row with "../" segments. Refuse // anything that resolves outside LIBRARY_ROOT. const rel = path.relative(LIBRARY_ROOT, abs); if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) { return new Response("not found", { status: 404 }); } try { const buf = await fs.readFile(abs); const ext = path.extname(abs).toLowerCase(); const downloadName = `${row.code ?? `image-${row.id}`}${ext}`; return new Response(new Uint8Array(buf), { headers: { "Content-Type": IMAGE_MIME[ext] ?? "application/octet-stream", "Content-Disposition": contentDisposition(downloadName), "Cache-Control": "public, max-age=0, must-revalidate", ETag: etag, }, }); } catch { return new Response("not found", { status: 404 }); } } /** * Produce a Content-Disposition value with a properly RFC 5987-encoded * filename. Without this, codes containing characters like `"`, `\`, or * non-ASCII bytes would terminate the quoted-string early and produce a * malformed header. Real-world risk on this app is small (codes are * sanitized at ingest) but the cost of being correct is two lines. */ function contentDisposition(filename: string): string { // Strip control chars + quotes/backslashes for the legacy fallback; // emit the full UTF-8 form via filename* per RFC 5987. const safe = filename.replace(/[-"\\]/g, "_"); const encoded = encodeURIComponent(filename); return `inline; filename="${safe}"; filename*=UTF-8''${encoded}`; } export async function serveThumb(name: string): Promise { // Canonical thumbnail filename is either ".webp" or // "-.webp" where the code is uppercase letters/digits/dash. // Reject anything else outright; defence-in-depth against path traversal // (the regex implicitly forbids "/", "\", ".."). const m = name.match(/^(?:([A-Z0-9-]{1,40})-)?([a-f0-9]{64})\.webp$/); if (!m) { return new Response("not found", { status: 404 }); } const sha = m[2]; const abs = path.join(THUMB_ROOT, name); try { const buf = await fs.readFile(abs); const row = rawDb.prepare(`SELECT code, id FROM images WHERE sha256 = ?`).get(sha) as | { code: string | null; id: number } | undefined; const downloadName = `${row?.code ?? `image-${row?.id ?? sha.slice(0, 8)}`}.webp`; return new Response(new Uint8Array(buf), { headers: { "Content-Type": "image/webp", "Content-Disposition": contentDisposition(downloadName), "Cache-Control": "public, max-age=31536000, immutable", }, }); } catch { return new Response("not found", { status: 404 }); } } function derivePortraitDownloadName(filename: string): string | null { const ext = path.extname(filename).toLowerCase(); const stem = ext ? filename.slice(0, -ext.length) : filename; const newFmt = stem.match(/^(\d+)-([1-4h])-[a-f0-9]+$/); const oldFmt = !newFmt ? stem.match(/^(\d+)-[a-f0-9]+$/) : null; const m = newFmt ?? oldFmt; if (!m) return null; const actressId = Number(m[1]); const slot = newFmt ? newFmt[2] : "1"; const row = rawDb.prepare(`SELECT slug FROM actresses WHERE id = ?`).get(actressId) as | { slug: string } | undefined; if (!row) return null; const suffix = slot === "1" ? "" : `-${SLOT_LABEL[slot]}`; return `${row.slug}${suffix}${ext}`; } export async function servePortrait(name: string): Promise { if (name.includes("/") || name.includes("\\") || name.includes("..")) { return new Response("not found", { status: 404 }); } const abs = path.join(PORTRAIT_ROOT, name); try { const buf = await fs.readFile(abs); const ext = path.extname(abs).toLowerCase(); const downloadName = derivePortraitDownloadName(name) ?? name; // Hash content so a re-uploaded portrait at the same filename // invalidates browser caches; using the filename alone served // stale bytes. 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": contentDisposition(downloadName), "Cache-Control": "public, max-age=0, must-revalidate", ETag: etag, }, }); } catch { return new Response("not found", { status: 404 }); } }