137 lines
3.9 KiB
TypeScript
137 lines
3.9 KiB
TypeScript
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<boolean> {
|
|
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<void> {
|
|
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;
|