329 lines
10 KiB
TypeScript
329 lines
10 KiB
TypeScript
/**
|
|
* 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 } from "../../shared/dump.ts";
|
|
import {
|
|
dumpFileVersion,
|
|
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 { 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<boolean> | undefined;
|
|
|
|
function ffmpegAvailable(): Promise<boolean> {
|
|
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<string, "jpg" | "png"> = {
|
|
"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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<string, Promise<void>>();
|
|
|
|
/**
|
|
* 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<void>,
|
|
): Promise<boolean> {
|
|
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 (!/^[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");
|
|
});
|
|
|
|
/**
|
|
* 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<Dump> = {
|
|
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<Dump> = {
|
|
success: true,
|
|
data: await removeDumpThumbnail(dump.id),
|
|
};
|
|
ctx.response.body = responseBody;
|
|
},
|
|
);
|
|
|
|
export default router;
|