123 lines
4.6 KiB
TypeScript
123 lines
4.6 KiB
TypeScript
/**
|
|
* The route serving file dumps' uploads.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { Router } from "@oak/oak";
|
|
import { APIErrorCode } from "../../shared/api.ts";
|
|
import { APIException } from "../lib/api-exception.ts";
|
|
import { DUMPS_DIR } from "../lib/upload.ts";
|
|
import { authenticateMedia } from "../lib/auth.ts";
|
|
import { serveFile } from "../lib/serve-file.ts";
|
|
import { dumpFileVersion, getDump } from "../services/dump-service.ts";
|
|
|
|
/**
|
|
* The route serving file dumps' uploads:
|
|
*
|
|
* - `GET /api/files/:dumpId?token=` — public; a private dump's file needs its
|
|
* poster's media `token`. Serves the file, inline when that is safe and as a
|
|
* download otherwise, with byte ranges.
|
|
*/
|
|
const router: 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 types served inline. 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)$/;
|
|
|
|
/** Checks whether a file of type `mime` may be served inline. */
|
|
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}`;
|
|
const inline = isInlineSafe(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");
|
|
|
|
// The app's URLs carry the file's version (see dumpFileVersion), so one
|
|
// naming the current version never changes and is cached for good; any
|
|
// other URL is revalidated against the ETag. A private dump's bytes must not
|
|
// sit in a shared cache.
|
|
const scope = dump.isPrivate ? "private" : "public";
|
|
const current = ctx.request.url.searchParams.get("v") ===
|
|
dumpFileVersion(dump);
|
|
const served = await serveFile(ctx, path, {
|
|
mime: dump.fileMime,
|
|
cacheControl: current
|
|
? `${scope}, max-age=31536000, immutable`
|
|
: `${scope}, no-cache`,
|
|
ranges: true,
|
|
});
|
|
if (!served) {
|
|
throw new APIException(APIErrorCode.NOT_FOUND, 404, "File not found");
|
|
}
|
|
});
|
|
|
|
export default router;
|