133 lines
4.8 KiB
TypeScript
133 lines
4.8 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 { authenticateMedia } from "../lib/auth.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(
|
|
type: "inline" | "attachment",
|
|
name: string,
|
|
): string {
|
|
const asciiFallback = name
|
|
.replace(/["\\]/g, "_")
|
|
.replace(/[^\x20-\x7e]/g, "_");
|
|
return `${type}; filename="${asciiFallback}"; filename*=UTF-8''${
|
|
encodeURIComponent(name)
|
|
}`;
|
|
}
|
|
|
|
// The MIME type of an upload is whatever the uploader's browser claimed, and
|
|
// these responses come from the app's own origin — where the session token
|
|
// lives. Only types a browser renders without running the file's own code are
|
|
// served inline; anything else (text/html, image/svg+xml, XML, …) downloads.
|
|
// <img>, <audio> and fetch() ignore Content-Disposition, so previews that embed
|
|
// or read the file still work.
|
|
const INLINE_MIME_RE =
|
|
/^(image\/(png|jpeg|gif|webp|avif|bmp)|audio\/[\w.+-]+|video\/[\w.+-]+|application\/pdf|text\/plain)$/;
|
|
|
|
function isInlineSafe(mime: string): boolean {
|
|
return INLINE_MIME_RE.test(mime.split(";")[0].trim().toLowerCase());
|
|
}
|
|
|
|
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 viewerId = token ? await authenticateMedia(token) : null;
|
|
const dump = getDump(dumpId, viewerId ?? undefined);
|
|
|
|
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;
|
|
const inline = isInlineSafe(dump.fileMime);
|
|
ctx.response.headers.set("Content-Type", dump.fileMime);
|
|
ctx.response.headers.set(
|
|
"Content-Disposition",
|
|
contentDisposition(inline ? "inline" : "attachment", dump.fileName),
|
|
);
|
|
ctx.response.headers.set("X-Content-Type-Options", "nosniff");
|
|
// Belt and braces for a file opened directly: no scripts, no plugins, no
|
|
// same-origin access. Chrome won't render a PDF in a sandboxed document, and
|
|
// its viewer never runs a PDF's scripts anyway, so PDFs go without.
|
|
if (dump.fileMime !== "application/pdf") {
|
|
ctx.response.headers.set(
|
|
"Content-Security-Policy",
|
|
"sandbox; default-src 'none'; img-src 'self'; media-src 'self'; style-src 'unsafe-inline'",
|
|
);
|
|
}
|
|
// The URL may carry a media token; don't pass it on to wherever the file
|
|
// links.
|
|
ctx.response.headers.set("Referrer-Policy", "no-referrer");
|
|
// A private dump's bytes must not sit in a shared cache.
|
|
if (dump.isPrivate) ctx.response.headers.set("Cache-Control", "private");
|
|
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;
|