Files
gerbeur/api/routes/files.ts
khannurien d62e4a9883
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
Build and deploy docs / build-and-push (push) Successful in 26s
v3: the backend is documented for deno doc, one way in every file, and the docs build and publish themselves — comments were in three shapes: file headers as // lines, as an untagged /** */ (which deno doc drops, only an @module block counts) or missing; 43 exports described by a // deno doc can't see, two JSDoc blocks sitting on the wrong declaration and one duplicated; endpoints commented in 5 of 18 route files, three ways, on router.get() calls no doc tool reads. The rule, applied to every non-test module of api/, migrations included: each opens with a /** … @module */ block; every export has JSDoc, and so does every field of an exported interface; a comment describing a declaration is /** */, // only notes code; functions open with a verb, everything else with a noun phrase; parameters are named in the prose, {@link} only for symbols in scope; whatever throws to its caller says so with @throws (for an APIException, the statuses and when); a router's JSDoc lists its endpoints, method and path, who may call it, what it does. deno doc --lint goes from 528 errors to none in api/ (shared/ keeps its 385; it is next). The lint also wanted explicit types on config.ts's settings and a few return types, JsonLdResult exported, and the routers typed Router. deno task doc builds the HTML reference into docs/ from every module of api/ and shared/ but the tests, deno task doc:lint lints it; the index page is the module doc of a root mod.ts (deno.json's exports), which introduces the backend, shared/ and the frontend to come and links every module, and mod.test.ts fails when one is missing. The docs workflow builds it on every push to main that touches them and publishes it to the gh-pages branch, like publish-assistant's. Two defects found along the way are fixed: a private playlist's cover was served to anyone who had its id or slug, and now needs its owner's media token, as private dump files do (playlistImageUrl adds it for private playlists only, so public cover URLs stay the same for everyone); og.ts and static.ts each filled index.html's placeholders, and now share lib/index-html.ts, so the OG middleware picks up a rebuilt index.html instead of keeping the first it read. The README states the documentation rule. 205 backend and 628 frontend tests pass.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 21:07:40 +00:00

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;