Files
gerbeur/api/routes/files.ts
khannurien 76de798faf
Some checks failed
Build and Publish Docker Image / build-and-push (push) Failing after 20s
v3: fixed authorization bug with stale tokens, fixed private dump access bug, various small fixes across the app
2026-06-27 10:40:07 +00:00

101 lines
3.2 KiB
TypeScript

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";
import { verifyJWT } from "../lib/jwt.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");
}
// Media elements can't send an Authorization header, so the viewer's token
// arrives as a query param (same as the WebSocket route). Needed to authorize
// access to private dumps the requester owns.
const token = ctx.request.url.searchParams.get("token");
const payload = token ? await verifyJWT(token) : null;
const dump = getDump(dumpId, payload?.userId);
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;