/** * 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 on first request by lib/ffmpeg.ts, which keeps * them. * * - `GET /api/dumps/:dumpId/thumbnail?v=&w=&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 * its image. An image comes scaled down to `w` (`shared/image.ts`), or to * the widest of those widths without one, and as it is when no copy 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 } from "../../shared/dump.ts"; import { customThumbnailPath, getDump, removeDumpThumbnail, setDumpThumbnail, } from "../services/dump-service.ts"; import { serveFile } from "../lib/serve-file.ts"; import { replaceUploadedFile, serveImage, serveUploadedFile, validateImageUpload, } from "../lib/upload.ts"; import { DUMPS_DIR } from "../config.ts"; import { MAX_IMAGE_WIDTH } from "../../shared/image.ts"; import { resolveMediaDump } from "../lib/dump-media.ts"; import { ensureGenerated, generatedPaths, videoStill } from "../lib/ffmpeg.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" }); router.get("/:dumpId/thumbnail", async (ctx) => { const { dumpId } = ctx.params; const { dump, cacheControl: generatedCache } = await resolveMediaDump( ctx, dumpId, ); if (dump.thumbnailMime) { // Replacing it doesn't change the file's version, so it is always // revalidated. const served = await serveUploadedFile( ctx, customThumbnailPath(dump.id), dump.thumbnailMime, 0, dump.isPrivate, MAX_IMAGE_WIDTH, ); if (served) return; } const mime = dump.kind === "file" ? dump.fileMime ?? "" : ""; const srcPath = `${DUMPS_DIR}/${dump.id}`; const generated = generatedPaths(dump.id); if (mime.startsWith("video/")) { const thumbPath = generated.still; 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; } // A scaled-down copy for the feed; the original when there's none to make // (no ffmpeg, an image it can't read). if ( /^image\/(png|jpeg|gif|webp|avif|bmp)$/.test(mime) && await serveImage(ctx, srcPath, mime, generatedCache, MAX_IMAGE_WIDTH) ) 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(customThumbnailPath(dump.id), 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;