import { Router } from "@oak/oak"; import { APIErrorCode, APIException } from "../model/interfaces.ts"; import { getDump, getDumpThumbnailInfo } from "../services/dump-service.ts"; import { serveUploadedFile } from "../lib/upload.ts"; import { DUMPS_DIR, THUMBNAILS_DIR } from "../config.ts"; import { authenticateMedia } from "../lib/auth.ts"; const router = new Router({ prefix: "/api/thumbnails" }); async function ffmpegAvailable(): Promise { try { const cmd = new Deno.Command("ffmpeg", { args: ["-version"], stderr: "null", stdout: "null", }); const { success } = await cmd.output(); return success; } catch { return false; } } async function generateThumbnail( srcPath: string, outPath: string, ): Promise { await Deno.mkdir(THUMBNAILS_DIR, { recursive: true }); const cmd = new Deno.Command("ffmpeg", { args: [ // 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 an actual video container. "-protocol_whitelist", "file", "-format_whitelist", "mov,mp4,m4a,3gp,3g2,mj2,matroska,webm,avi,flv,mpegts,mpeg,ogg,asf,h264,hevc", "-ss", "00:00:01", "-i", srcPath, "-frames:v", "1", "-vf", "scale=320:-1", "-f", "image2", "-y", outPath, ], stdout: "null", stderr: "null", }); const { success } = await cmd.output(); if (!success) throw new Error("ffmpeg failed"); } 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. const cacheControl = dump.isPrivate ? "private, max-age=31536000, immutable" : "public, max-age=31536000, immutable"; const customThumb = getDumpThumbnailInfo(dumpId); if (customThumb) { const served = await serveUploadedFile( ctx, `${THUMBNAILS_DIR}/${customThumb.id}-custom`, customThumb.mime, 3600, dump.isPrivate, ); if (served) return; } if (dump.kind !== "file" || !dump.fileMime?.startsWith("video/")) { throw new APIException( APIErrorCode.NOT_FOUND, 404, "No video for this dump", ); } const thumbPath = `${THUMBNAILS_DIR}/${dumpId}.jpg`; // Serve cached thumbnail if it exists try { const data = await Deno.readFile(thumbPath); ctx.response.headers.set("Content-Type", "image/jpeg"); ctx.response.headers.set("Cache-Control", cacheControl); ctx.response.body = data; return; } catch { // Not cached yet — generate it } if (!await ffmpegAvailable()) { throw new APIException( APIErrorCode.NOT_FOUND, 404, "Thumbnail generation unavailable", ); } const srcPath = `${DUMPS_DIR}/${dumpId}`; try { await generateThumbnail(srcPath, thumbPath); } catch { throw new APIException( APIErrorCode.NOT_FOUND, 404, "Could not generate thumbnail", ); } const data = await Deno.readFile(thumbPath); ctx.response.headers.set("Content-Type", "image/jpeg"); ctx.response.headers.set("Cache-Control", cacheControl); ctx.response.body = data; }); export default router;