Error statuses follow one rule: APIException takes a status and derives the code from it (BAD_REQUEST 400, UNAUTHORIZED 401, FORBIDDEN 403, NOT_FOUND 404, CONFLICT 409, RATE_LIMITED 429, SERVER_ERROR 500, BAD_GATEWAY 502), so a code can't contradict its status. That fixed real bugs: a wrong current password on change-password answered 401, on which authFetch signs the user out; editing or deleting someone else's comment or chat message answered 401 rather than 403; one invalid dump edit answered 422. A taken username is 409, like a taken email. Along the way: the database is created from schema.sql by connection.ts when the file doesn't exist, so every task works on a fresh checkout (dev and serve never ran db/init.ts, which is gone); the startup purge compared ISO dates with SQLite's datetime() as text and kept anything from the current UTC day, and now compares ISO cutoffs; the seeded admin's hash comes from lib/password.ts (split out of jwt.ts, which needs the JWT secret) rather than a copy of its format; avatar, thumbnail and playlist-cover uploads go through replaceUploadedFile, which writes beside the old file and renames over it before the record announces it (a failed avatar write lost the old avatar; a thumbnail was announced before it existed); upload.ts derives its size message from MAX_IMAGE_SIZE_BYTES and stops re-exporting the upload folders; ALLOWED_IMAGE_MIMES, which duplicated detectImageMime, is gone; the SPA fallback no longer calls next() after answering; a reply must answer a comment on the same dump (a foreign or unknown parent was a 500 or a cross-dump thread). 211 backend and 628 frontend tests pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
114 lines
4.2 KiB
TypeScript
114 lines
4.2 KiB
TypeScript
/**
|
|
* Serves file dumps' uploads to the players, viewers and download links of the
|
|
* frontend, which builds the URL with `dumpFileUrl`.
|
|
*
|
|
* - `GET /api/files/:dumpId?v=&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.
|
|
*
|
|
* @module
|
|
*/
|
|
|
|
import { Router } from "@oak/oak";
|
|
import { APIException } from "../lib/api-exception.ts";
|
|
import { DUMPS_DIR } from "../config.ts";
|
|
import { authenticateMedia } from "../lib/auth.ts";
|
|
import { serveFile } from "../lib/serve-file.ts";
|
|
import { dumpFileVersion, getDump } from "../services/dump-service.ts";
|
|
|
|
/**
|
|
* Mounted by `api/main.ts` behind the error, Open Graph and CORS middleware.
|
|
* An `APIException` thrown by a handler becomes the JSON error response there.
|
|
*/
|
|
const router: Router = new Router({ prefix: "/api/files" });
|
|
|
|
/**
|
|
* Builds a `Content-Disposition` value for a file called `name`. `Headers.set`
|
|
* throws on any character above U+00FF, so `filename` gets an ASCII copy of the
|
|
* name and `filename*` (RFC 5987) the full one, percent-encoded.
|
|
*/
|
|
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)
|
|
}`;
|
|
}
|
|
|
|
/**
|
|
* Matches the types served inline. An upload's type is whatever the uploader's
|
|
* browser claimed, and the file comes from the app's own origin, where the
|
|
* session token lives. So only types a browser shows without running code in
|
|
* them are inline, and anything else (HTML, SVG, XML…) downloads. `<img>`,
|
|
* `<audio>` and `fetch()` ignore `Content-Disposition`, so the players and
|
|
* previews 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;
|
|
|
|
// The id names a file on disk: nothing but an id's characters gets through.
|
|
if (!/^[0-9a-f-]{36}$/.test(dumpId)) {
|
|
throw new APIException(400, "Invalid dump ID");
|
|
}
|
|
|
|
// Media elements can't send an Authorization header, so the viewer's media
|
|
// token comes in the query string.
|
|
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(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");
|
|
|
|
// A URL naming the file's current version (dumpFileVersion) always serves the
|
|
// same bytes, so it is cached for good; any other 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(404, "File not found");
|
|
}
|
|
});
|
|
|
|
export default router;
|