import { Router } from "@oak/oak"; import { APIErrorCode, APIException } from "../model/interfaces.ts"; import { getDump } from "../services/dump-service.ts"; import { DUMPS_DIR } from "../lib/upload.ts"; const router = new Router({ prefix: "/api/files" }); /** * Builds an RFC 6266 `Content-Disposition` value that is always a valid * ByteString. `headers.set` throws a TypeError if the value contains any * character > U+00FF, so a filename with an emoji / accent / CJK character * (e.g. an uploaded file) would otherwise crash the response. We provide an * ASCII-only `filename="…"` fallback plus an RFC 5987 `filename*` with the * full UTF-8 name for modern browsers. */ function contentDisposition(name: string): string { const asciiFallback = name .replace(/["\\]/g, "_") .replace(/[^\x20-\x7e]/g, "_"); return `inline; filename="${asciiFallback}"; filename*=UTF-8''${ encodeURIComponent(name) }`; } router.get("/:dumpId", async (ctx) => { const { dumpId } = ctx.params; // Guard against path traversal (UUIDs are safe, but be explicit) if (!/^[0-9a-f-]{36}$/.test(dumpId)) { throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID"); } const dump = getDump(dumpId); if (dump.kind !== "file" || !dump.fileMime || !dump.fileName) { throw new APIException( APIErrorCode.NOT_FOUND, 404, "No file for this dump", ); } const path = `${DUMPS_DIR}/${dumpId}`; let data: Uint8Array; try { data = await Deno.readFile(path); } catch { throw new APIException(APIErrorCode.NOT_FOUND, 404, "File not found"); } const total = data.byteLength; ctx.response.headers.set("Content-Type", dump.fileMime); ctx.response.headers.set( "Content-Disposition", contentDisposition(dump.fileName), ); ctx.response.headers.set("Accept-Ranges", "bytes"); const rangeHeader = ctx.request.headers.get("Range"); if (rangeHeader) { const match = rangeHeader.match(/^bytes=(\d*)-(\d*)$/); if (!match) { ctx.response.status = 416; ctx.response.headers.set("Content-Range", `bytes */${total}`); return; } const start = match[1] ? parseInt(match[1], 10) : total - parseInt(match[2], 10); const end = match[2] ? Math.min(parseInt(match[2], 10), total - 1) : total - 1; if (start > end || start >= total) { ctx.response.status = 416; ctx.response.headers.set("Content-Range", `bytes */${total}`); return; } const chunk = data.subarray(start, end + 1); ctx.response.status = 206; ctx.response.headers.set("Content-Range", `bytes ${start}-${end}/${total}`); ctx.response.headers.set("Content-Length", String(chunk.byteLength)); ctx.response.body = chunk; } else { ctx.response.headers.set("Content-Length", String(total)); ctx.response.body = data; } }); export default router;