disclosure-bureau/web/app/api/static/[...path]/route.ts

58 lines
1.9 KiB
TypeScript

/**
* Serve files from /Users/guto/ufo/processing/* and /Users/guto/ufo/raw/* via
* /api/static/png/<doc>/<file>, /api/static/crops/<doc>/<page>/<file>, etc.
*
* Sandboxed to UFO_ROOT to prevent path traversal.
*/
import fs from "node:fs/promises";
import path from "node:path";
import { NextResponse } from "next/server";
import { UFO_ROOT } from "@/lib/wiki";
const ALLOWED_ROOTS = ["processing", "raw"];
function mimeFor(ext: string): string {
switch (ext.toLowerCase()) {
case ".png": return "image/png";
case ".jpg":
case ".jpeg": return "image/jpeg";
case ".webp": return "image/webp";
case ".gif": return "image/gif";
case ".svg": return "image/svg+xml";
case ".pdf": return "application/pdf";
case ".txt": return "text/plain; charset=utf-8";
case ".json": return "application/json";
case ".mp4": return "video/mp4";
default: return "application/octet-stream";
}
}
export async function GET(_req: Request, ctx: { params: Promise<{ path: string[] }> }) {
const { path: parts } = await ctx.params;
if (!parts || parts.length < 2) {
return new NextResponse("Bad Request", { status: 400 });
}
const root = parts[0];
if (!ALLOWED_ROOTS.includes(root)) {
return new NextResponse("Forbidden", { status: 403 });
}
const rel = parts.slice(1).join("/");
const abs = path.resolve(UFO_ROOT, root, rel);
// Path traversal guard
const expectedPrefix = path.resolve(UFO_ROOT, root) + path.sep;
if (!abs.startsWith(expectedPrefix) && abs !== path.resolve(UFO_ROOT, root)) {
return new NextResponse("Forbidden", { status: 403 });
}
try {
const buf = await fs.readFile(abs);
const ext = path.extname(abs);
return new NextResponse(buf, {
headers: {
"content-type": mimeFor(ext),
"cache-control": "public, max-age=3600",
},
});
} catch {
return new NextResponse("Not Found", { status: 404 });
}
}