119 lines
4.4 KiB
TypeScript
119 lines
4.4 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 { 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";
|
|
|
|
/**
|
|
* 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(APIErrorCode.BAD_REQUEST, 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(
|
|
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");
|
|
|
|
// 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(APIErrorCode.NOT_FOUND, 404, "File not found");
|
|
}
|
|
});
|
|
|
|
export default router;
|