159 lines
6.4 KiB
TypeScript
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(/[ |