/** * Static-asset middleware. * * Serves the built viewer bundle from a configured directory. * Directory traversal is blocked by resolving every request path * against the root and verifying containment. * * Content-Type is derived from the file extension — we keep a small * hard-coded MIME map instead of shelling out to `mime-types`. */ import { createReadStream } from "node:fs"; import { stat, readFile } from "node:fs/promises"; import path from "node:path"; import type { ServerResponse } from "node:http"; import { writeNotFound, writeText } from "./io.js"; import type { ServerOptions } from "../types.js"; const MIME: Record = { ".html": "text/html; charset=utf-8", ".js": "application/javascript; charset=utf-8", ".mjs": "application/javascript; charset=utf-8", ".css": "text/css; charset=utf-8", ".json": "application/json; charset=utf-8", ".svg": "image/svg+xml", ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".ico": "image/x-icon", ".webp": "image/webp", ".woff": "font/woff", ".woff2": "font/woff2", ".ttf": "font/ttf", ".otf": "font/otf", ".txt": "text/plain; charset=utf-8", ".md": "text/markdown; charset=utf-8", }; export async function serveStatic( res: ServerResponse, pathname: string, opts: ServerOptions, ): Promise { if (!opts.staticRoot) return false; const relative = pathname === "/" || pathname === "/viewer" ? "/index.html" : pathname; return await tryServe(res, opts.staticRoot, relative); } async function tryServe( res: ServerResponse, root: string, relative: string, ): Promise { const resolvedRoot = path.resolve(root); const target = path.resolve(resolvedRoot, "." + relative); if (!target.startsWith(resolvedRoot)) { writeText(res, 403, "forbidden"); return true; } try { const st = await stat(target); if (!st.isFile()) { writeNotFound(res); return true; } const ext = path.extname(target).toLowerCase(); res.writeHead(200, { "content-type": MIME[ext] ?? "application/octet-stream", "content-length": String(st.size), "cache-control": "public, max-age=60", }); // For small files (≤128 KiB) read to buffer and end atomically; // for larger, stream and wait on the response's finish event. if (st.size <= 128 * 1024) { const buf = await readFile(target); res.end(buf); } else { await new Promise((resolve, reject) => { const stream = createReadStream(target); stream.on("error", reject); res.on("finish", () => resolve()); res.on("close", () => resolve()); stream.pipe(res); }); } return true; } catch (err: any) { if (err?.code === "ENOENT") return false; // let the caller write 404 writeText(res, 500, "static: " + (err instanceof Error ? err.message : String(err))); return true; } }