Files
gerbeur/api/routes/files.ts
khannurien fac0e2db28 v4: an audio file's waveform is made once by the server, and the player seeks on one accessible scrubber — every player downloaded the whole file a second time and decoded all of it in the browser to draw 150 bars, so a long FLAC cost tens of megabytes of memory per page, and the waveform could only be clicked: no drag, no keyboard, no slider for a screen reader. GET /api/dumps/:id/waveform now answers an audio dump's peaks, which lib/ffmpeg.ts decodes with ffmpeg on first request, mono at 8 kHz and streamed, keeps beside the thumbnails as the response itself, and serves with an ETag (a ten-minute MP3 takes about a second, then a few hundred bytes). The peaks are taken by shared/waveform.ts's peaksOf, scaled to the loudest so a quiet recording fills the track, and the dump form's preview of a file not yet uploaded decodes it with the same function, at 8 kHz in an OfflineAudioContext. Scrubber replaces Waveform and SeekBar: a role="slider" that seeks where a pointer lets go, follows a drag (the clock shows where it is held), takes arrows, Home and End, and draws the bars as one path with the played part clipped by a nested svg, so it moves smoothly and a timeupdate redraws two attributes instead of 150 rects; audio without a waveform, and video, get a plain bar. MediaPlayer reads whether it plays from the element's events alone, so a click no longer reports the change twice, its handles for PlayerProvider are stable callbacks instead of refs to refs, the drag no longer reattaches every listener, and the <source> children that src made dead are gone, with the mime prop. The file, thumbnail and waveform routes resolve their dump, token and caching through lib/dump-media.ts, and what is generated from an upload is named and removed in lib/ffmpeg.ts, the custom thumbnail by dump-service's customThumbnailPath, instead of paths spelled out in each. The test setup's play() and pause() fire their events as a browser does. 277 backend tests and 684 frontend tests pass, and deno check, tsc, eslint, doc:lint and i18n:compile --strict are clean; checked in the app with a real FLAC on its dump page and in the global player at 1280 and 390px, seeking by keyboard.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 13:34:22 +00:00

137 lines
5.0 KiB
TypeScript

/**
* Serves and replaces file dumps' uploads, a sub-resource of their dump. The
* players, viewers and download links of the frontend build the URL with
* `dumpFileUrl`, and its edit page replaces the file.
*
* - `GET /api/dumps/:dumpId/file?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.
* - `PUT /api/dumps/:dumpId/file` — signed in, as its poster or with
* `dump:moderate`. Replaces a file dump's upload and comment, and any of its
* title, slug, categories and visibility (multipart).
*
* @module
*/
import { Router } from "@oak/oak";
import { APIException } from "../lib/api-exception.ts";
import type { APIResponse } from "../../shared/api.ts";
import type { Dump } from "../../shared/dump.ts";
import { DUMPS_DIR } from "../config.ts";
import { resolveMediaDump } from "../lib/dump-media.ts";
import { can } from "../lib/permissions.ts";
import { serveFile } from "../lib/serve-file.ts";
import { authMiddleware } from "../middleware/auth.ts";
import { parseCategoryIds } from "../validators/dump.ts";
import { getDump, replaceFileDump } 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/dumps" });
/**
* 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/file", async (ctx) => {
const { dumpId } = ctx.params;
const { dump, cacheControl } = await resolveMediaDump(ctx, dumpId);
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'",
);
}
const served = await serveFile(ctx, path, {
mime: dump.fileMime,
cacheControl,
ranges: true,
});
if (!served) {
throw new APIException(404, "File not found");
}
});
router.put("/:dumpId/file", authMiddleware, async (ctx) => {
const userId = ctx.state.user.userId;
const dump = getDump(ctx.params.dumpId, userId);
const canModerate = can(ctx.state.user, "dump:moderate");
if (userId !== dump.userId && !canModerate) {
throw new APIException(403, "Not authorized to update dump");
}
const formData = await ctx.request.body.formData();
const file = formData.get("file");
const comment = formData.get("comment");
const title = formData.get("title");
const slug = formData.get("slug");
if (!(file instanceof File)) {
throw new APIException(400, "A file is required");
}
const updatedDump = await replaceFileDump(
dump.id,
file,
typeof comment === "string" && comment ? comment : undefined,
typeof title === "string" && title ? title : undefined,
formData.has("categoryIds")
? parseCategoryIds(formData.get("categoryIds"))
: undefined,
formData.has("isPrivate")
? formData.get("isPrivate") === "true"
: undefined,
typeof slug === "string" && slug ? slug : undefined,
canModerate,
);
const responseBody: APIResponse<Dump> = { success: true, data: updatedDump };
ctx.response.body = responseBody;
});
export default router;