/** * Serves and changes dumps' thumbnails, a sub-resource of their dump. The * cards and pages that show a file dump build the URL with * `dumpThumbnailUrl`, and the edit page uploads a custom one. Thumbnails made * from an upload are generated with ffmpeg on first request and kept in * `THUMBNAILS_DIR`. * * - `GET /api/dumps/:dumpId/thumbnail?v=&token=` — public; a private dump's * thumbnail needs its poster's media `token`. Serves the dump's custom * thumbnail, else one made from its upload: a still of a video, or a * scaled-down copy of an image (the image itself when none can be made); 404 * when there is neither. * - `PUT /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with * `dump:moderate`. Replaces its custom thumbnail (multipart `file`, at most 5 * MB). * - `DELETE /api/dumps/:dumpId/thumbnail` — signed in, as its poster or with * `dump:moderate`. Removes its custom thumbnail. * * @module */ import { Router } from "@oak/oak"; import { APIException } from "../lib/api-exception.ts"; import type { APIResponse } from "../../shared/api.ts"; import { type Dump, dumpFileVersion } from "../../shared/dump.ts"; import { getDump, getDumpThumbnailInfo, removeDumpThumbnail, setDumpThumbnail, } from "../services/dump-service.ts"; import { serveFile } from "../lib/serve-file.ts"; import { replaceUploadedFile, serveUploadedFile, validateImageUpload, } from "../lib/upload.ts"; import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts"; import { UUID_RE } from "../../shared/validation.ts"; import { authenticateMedia } from "../lib/auth.ts"; import { can } from "../lib/permissions.ts"; import { type AuthContext, authMiddleware } from "../middleware/auth.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" }); /** * Spares a process per uncached thumbnail request: whether ffmpeg runs is * probed once per process, and kept here. */ let ffmpegProbe: Promise | undefined; function ffmpegAvailable(): Promise { ffmpegProbe ??= (async () => { try { const { success } = await new Deno.Command("ffmpeg", { args: ["-version"], stderr: "null", stdout: "null", }).output(); return success; } catch { return false; } })(); return ffmpegProbe; } /** * Lists the container formats ffmpeg may read a video as. The upload's type is * only what the uploader claimed, and ffmpeg guesses the format from the bytes, * so a "video" could be an HLS or concat playlist pointing it at local files or * internal URLs. With this list, and `-protocol_whitelist file`, ffmpeg reads * nothing but the file, and only as a video. */ const VIDEO_FORMATS = "mov,mp4,m4a,3gp,3g2,mj2,matroska,webm,avi,flv,mpegts,mpeg,ogg,asf,h264,hevc"; /** * Lists the formats ffmpeg may read an image as, for the reason given at * {@link VIDEO_FORMATS}. */ const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe"; /** * Maps the image types that get a scaled-down preview to the preview's format. * A GIF is served as it is, to keep its animation. */ const PREVIEWABLE_IMAGES: Record = { "image/jpeg": "jpg", "image/bmp": "jpg", // May carry transparency, which JPEG would fill with black. "image/png": "png", "image/webp": "png", }; /** * Caps an image preview's width. It covers the widest feed card on a * high-density screen, at a fraction of a phone photo's bytes. */ const PREVIEW_WIDTH = 960; /** * Runs ffmpeg with `args`, writing to `outPath`. * * @throws {Error} When ffmpeg fails. */ async function runFfmpeg(args: string[], outPath: string): Promise { await Deno.mkdir(THUMBNAILS_DIR, { recursive: true }); // Written beside and renamed over, so a concurrent reader never gets half. const tmpPath = `${outPath}.${crypto.randomUUID()}.tmp`; const { success } = await new Deno.Command("ffmpeg", { args: ["-protocol_whitelist", "file", ...args, "-y", tmpPath], stdout: "null", stderr: "null", }).output(); if (!success) { await Deno.remove(tmpPath).catch(() => {}); throw new Error("ffmpeg failed"); } await Deno.rename(tmpPath, outPath); } /** Writes a 320-pixel-wide JPEG still of a video, one second in. */ function videoStill(srcPath: string, outPath: string): Promise { return runFfmpeg([ "-format_whitelist", VIDEO_FORMATS, "-ss", "00:00:01", "-i", srcPath, "-frames:v", "1", "-vf", "scale=320:-1", "-f", "image2", "-c:v", "mjpeg", ], outPath); } /** Writes a copy of an image at most {@link PREVIEW_WIDTH} wide. */ function imagePreview(srcPath: string, outPath: string): Promise { return runFfmpeg([ "-format_whitelist", IMAGE_FORMATS, "-i", srcPath, "-frames:v", "1", "-vf", `scale='min(${PREVIEW_WIDTH},iw)':-2`, "-f", "image2", ...(outPath.endsWith(".png") ? ["-c:v", "png"] : ["-c:v", "mjpeg", "-q:v", "4"]), ], outPath); } /** * Lets a feed full of cards asking for the same new still at once wait on a * single ffmpeg run. It holds the generations under way, by output path. */ const inFlight = new Map>(); /** * Makes `outPath` with `generate` unless it exists. Resolves false when it * can't be made, because ffmpeg is missing or failed. */ async function ensureGenerated( outPath: string, generate: () => Promise, ): Promise { if (await Deno.stat(outPath).then(() => true, () => false)) return true; if (!await ffmpegAvailable()) return false; let pending = inFlight.get(outPath); if (!pending) { pending = generate().finally(() => inFlight.delete(outPath)); inFlight.set(outPath, pending); } return await pending.then(() => true, () => false); } router.get("/:dumpId/thumbnail", async (ctx) => { const { dumpId } = ctx.params; // The id names files on disk: nothing but an id's characters gets through. if (!UUID_RE.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. This privacy check covers the custom // thumbnail too, since getDumpThumbnailInfo makes none. const token = ctx.request.url.searchParams.get("token"); const viewerId = token ? await authenticateMedia(token) : null; const dump = getDump(dumpId, viewerId ?? undefined); // The URL may carry a media token, which must not leak through a Referer. ctx.response.headers.set("Referrer-Policy", "no-referrer"); // What is generated from the file is cached for good under a URL naming its // current version (dumpFileVersion), and revalidated under any other. A // private dump's thumbnail must not sit in a shared cache. const scope = dump.isPrivate ? "private" : "public"; const generatedCache = ctx.request.url.searchParams.get("v") === dumpFileVersion(dump) ? `${scope}, max-age=31536000, immutable` : `${scope}, no-cache`; const customThumb = getDumpThumbnailInfo(dumpId); if (customThumb) { // Replacing it doesn't change the file's version, so it is always // revalidated. const served = await serveUploadedFile( ctx, `${THUMBNAILS_DIR}/${customThumb.id}-custom`, customThumb.mime, 0, dump.isPrivate, ); if (served) return; } const mime = dump.kind === "file" ? dump.fileMime ?? "" : ""; const srcPath = `${DUMPS_DIR}/${dump.id}`; if (mime.startsWith("video/")) { const thumbPath = `${THUMBNAILS_DIR}/${dump.id}.jpg`; if ( !await ensureGenerated(thumbPath, () => videoStill(srcPath, thumbPath)) ) { throw new APIException(404, "Could not generate thumbnail"); } if ( await serveFile(ctx, thumbPath, { mime: "image/jpeg", cacheControl: generatedCache, }) ) return; } if (mime.startsWith("image/")) { // A scaled-down copy for the feed; the original when there's none to make // (a GIF, no ffmpeg, an image ffmpeg can't read). ctx.response.headers.set("X-Content-Type-Options", "nosniff"); const ext = PREVIEWABLE_IMAGES[mime]; if (ext) { const previewPath = `${THUMBNAILS_DIR}/${dump.id}.preview.${ext}`; if ( await ensureGenerated( previewPath, () => imagePreview(srcPath, previewPath), ) && await serveFile(ctx, previewPath, { mime: ext === "png" ? "image/png" : "image/jpeg", cacheControl: generatedCache, }) ) return; } if ( /^image\/(png|jpeg|gif|webp|avif|bmp)$/.test(mime) && await serveFile(ctx, srcPath, { mime, cacheControl: generatedCache }) ) return; } throw new APIException(404, "No thumbnail for this dump"); }); /** * Returns the dump `ctx` names if the caller may change it, as its poster or * with `dump:moderate`. * * @throws {APIException} 404 when the caller can't see it, 403 when they may * not change it. */ function editableDump(ctx: AuthContext<"/:dumpId/thumbnail">): Dump { const userId = ctx.state.user.userId; const dump = getDump(ctx.params.dumpId, userId); if (userId !== dump.userId && !can(ctx.state.user, "dump:moderate")) { throw new APIException(403, "Not authorized to update dump"); } return dump; } router.put( "/:dumpId/thumbnail", authMiddleware, async (ctx: AuthContext<"/:dumpId/thumbnail">) => { const dump = editableDump(ctx); const formData = await ctx.request.body.formData(); const file = formData.get("file"); if (!(file instanceof File)) { throw new APIException(400, "Missing file field"); } const data = new Uint8Array(await file.arrayBuffer()); const mime = validateImageUpload(data); // Written before setDumpThumbnail announces it, or clients would fetch it // too early. await replaceUploadedFile(`${THUMBNAILS_DIR}/${dump.id}-custom`, data); const responseBody: APIResponse = { success: true, data: setDumpThumbnail(dump.id, mime), }; ctx.response.body = responseBody; }, ); router.delete( "/:dumpId/thumbnail", authMiddleware, async (ctx: AuthContext<"/:dumpId/thumbnail">) => { const dump = editableDump(ctx); const responseBody: APIResponse = { success: true, data: await removeDumpThumbnail(dump.id), }; ctx.response.body = responseBody; }, ); export default router;