/** * The route serving dumps' thumbnails, generated with ffmpeg where needed. * * - `GET /api/thumbnails/:dumpId?token=&v=` — 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 { APIErrorCode } from "../../shared/api.ts"; 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"; /** The router serving the endpoints the module doc lists. */ const router: Router = new Router({ prefix: "/api/thumbnails" }); /** * Whether ffmpeg runs, probed once per process: spawning `ffmpeg -version` on * every uncached thumbnail request cost a process per card. */ let ffmpegProbe: Promise | undefined; /** Checks whether ffmpeg is available. */ 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; } /** * The container formats ffmpeg may read a video as. The upload's type is only * what the uploader claimed, and ffmpeg probes the real format from the bytes — * a "video" could be an HLS or concat playlist pointing ffmpeg at local files * or internal URLs. Read nothing but the file itself, and only as the kind of * container it's meant to be. */ const VIDEO_FORMATS = "mov,mp4,m4a,3gp,3g2,mj2,matroska,webm,avi,flv,mpegts,mpeg,ogg,asf,h264,hevc"; /** The formats ffmpeg may read an image as (see {@link VIDEO_FORMATS}). */ const IMAGE_FORMATS = "jpeg_pipe,png_pipe,webp_pipe,bmp_pipe"; /** * The image types the feed shows through a scaled-down preview, and the * preview's format. GIFs keep their animation (served as they are); anything * else has no preview to make. */ 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", }; /** * The width of an image preview: comfortably covers the widest feed card on a * high-density screen, a fraction of a phone photo's bytes. */ const PREVIEW_WIDTH = 960; /** * Runs ffmpeg with `args` into `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 scaled down to {@link PREVIEW_WIDTH}. */ 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); } /** * The generations under way, by output path: one ffmpeg run per output, so a * feed full of cards asking for the same new still at once waits on a single * generation. */ const inFlight = new Map>(); /** * Makes `outPath` with `generate` unless it exists; false when it can't be * made (no ffmpeg, or ffmpeg 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; if (!/^[0-9a-f-]{36}$/.test(dumpId)) { throw new APIException(APIErrorCode.BAD_REQUEST, 400, "Invalid dump ID"); } // Token via query param (media elements can't send headers) — authorizes the // owner's private dumps and gates the custom-thumbnail branch below too. 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; keep it out of anyone else's logs. ctx.response.headers.set("Referrer-Policy", "no-referrer"); // A private dump's thumbnail must not sit in a shared cache. The app's URLs // carry the file's version (see dumpFileVersion): what's generated from the // file is cached for good under the current one, and revalidated otherwise. 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) { // Replaceable without the URL changing: always revalidated (a 304 when // unchanged). 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( APIErrorCode.NOT_FOUND, 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( APIErrorCode.NOT_FOUND, 404, "No thumbnail for this dump", ); }); export default router;