/** * Serves dumps' thumbnails to the cards and pages that show a file dump, which * build the URL with `dumpThumbnailUrl`. Thumbnails made from an upload are * generated with ffmpeg on first request and kept in `THUMBNAILS_DIR`. * * - `GET /api/thumbnails/:dumpId?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. * * @module */ import { Router } from "@oak/oak"; import { APIException } from "../lib/api-exception.ts"; import { dumpFileVersion, getDump, getDumpThumbnailInfo, } from "../services/dump-service.ts"; import { serveFile } from "../lib/serve-file.ts"; import { serveUploadedFile } from "../lib/upload.ts"; import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts"; import { authenticateMedia } from "../lib/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/thumbnails" }); /** * Holds whether ffmpeg runs, probed once per process: probing on every uncached * thumbnail request cost a process per card. */ 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); } /** * Holds the generations under way, by output path. A feed full of cards asking * for the same new still at once then waits on a single ffmpeg run. */ 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", async (ctx) => { const { dumpId } = ctx.params; // The id names files 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. 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"); }); export default router;