import { timingSafeEqual } from "node:crypto"; import { ASSETS_DIR, MAX_CONCURRENT_JOBS, MAX_UPLOAD_BYTES, PORT, requireToken } from "./config"; import { ApiError, invalid, notFound, toErrorResponse } from "./errors"; import { contentPath, deleteAsset, putAsset, readAsset } from "./assets/store"; import { ffprobe } from "./probe/ffprobe"; import { runSharp, type SharpRequest } from "./sharp/run"; import { runFfmpeg, type FfmpegRequest } from "./ffmpeg/run"; import { safeInputPath } from "./paths"; import sharp from "sharp"; import { createSidecarConnector } from "@@/sidecar/connect"; import { API_URL } from "@@/officer-url.mjs"; /** * av-club — ffmpeg, sharp and ffprobe over HTTP. * * The security model is in docs/SECURITY.md and is not optional reading: the caller never names a * path, and that single invariant is what makes it safe to expose the rest of these tools' surface. */ const encoder = new TextEncoder(); // Read once, at boot, so a missing token stops the process here rather than at the first request. const TOKEN = requireToken(); /** * Constant-time bearer check. * * `===` on secrets leaks their length and their common prefix through timing. The cost of doing it * properly is three lines. */ function authorised(request: Request): boolean { /* The platform, having already authenticated somebody. `createSidecarProxy` forwards `X-Officer-User` after the account gate and the `av-club` permission gate have both passed, and this server binds LOOPBACK — so that header is only settable by something already on this machine, which is the same trust boundary caldav's DAV door relies on. Requiring the bearer token as well would mean shipping av-club's secret to every caller for no gain: the platform is the thing deciding who may encode, and it has already decided. */ if (request.headers.get("x-officer-user")) return true; const header = request.headers.get("authorization"); if (!header?.startsWith("Bearer ")) return false; const provided = encoder.encode(header.slice(7)); const expected = encoder.encode(TOKEN); if (provided.byteLength !== expected.byteLength) return false; return timingSafeEqual(provided, expected); } /** * Bound concurrent processing. * * sharp and ffmpeg will each take every core available. Without this, two large requests can make * the box unresponsive to everything including the health check. */ let running = 0; const queue: (() => void)[] = []; async function withSlot(work: () => Promise): Promise { if (running >= MAX_CONCURRENT_JOBS) { await new Promise((resolve) => queue.push(resolve)); } running++; try { return await work(); } finally { running--; queue.shift()?.(); } } /** Serve stored bytes, honouring Range — browsers will not scrub a