Files
pinkudex/lib/api/serveAssets.ts
T
2026-05-26 22:46:00 +02:00

159 lines
6.4 KiB
TypeScript

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<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
};
const SLOT_LABEL: Record<string, string> = { "1": "p1", "2": "p2", "3": "p3", "4": "p4", "h": "l" };
export async function serveImage(req: NextRequest, lookup: { id?: string | null; code?: string | null }): Promise<Response> {
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<Response> {
// Canonical thumbnail filename is either "<sha>.webp" or
// "<CODE>-<sha>.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<Response> {
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 });
}
}