import { join } from "node:path"; import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { JOB_TIMEOUT_MS } from "../config"; import { invalid, unprocessable, ApiError } from "../errors"; import { contentPath, putAsset, readAsset, type Asset } from "../assets/store"; import { AUDIO_FILTERS, FORMATS, INPUT_OPTIONS, OUTPUT_OPTIONS, PRESETS, VIDEO_FILTERS, type FfmpegParam, } from "./vocabulary"; /** * Running ffmpeg. * * Everything the caller supplies is a NAME or a NUMBER from the vocabulary. Paths are constructed * here from an asset id, and the process is spawned with an argv array so there is no shell to quote * for. See docs/SECURITY.md. */ export type FfmpegRequest = { input?: unknown; preset?: unknown; inputOptions?: unknown; outputOptions?: unknown; filters?: unknown; format?: unknown; }; const BITRATE = /^[0-9]+(\.[0-9]+)?[kKmM]?$/; function value(spec: FfmpegParam, raw: unknown, path: string): string { switch (spec.kind) { case "int": if (typeof raw !== "number" || !Number.isInteger(raw)) throw invalid(`${path} must be an integer`, path); if (raw < spec.min || raw > spec.max) throw invalid(`${path} must be between ${spec.min} and ${spec.max}`, path); return String(raw); case "number": if (typeof raw !== "number" || !Number.isFinite(raw)) throw invalid(`${path} must be a number`, path); if (raw < spec.min || raw > spec.max) throw invalid(`${path} must be between ${spec.min} and ${spec.max}`, path); return String(raw); case "enum": if (typeof raw !== "string" || !spec.values.includes(raw)) { throw invalid(`${path} must be one of: ${spec.values.join(", ")}`, path); } return raw; case "bitrate": if (typeof raw !== "string" || !BITRATE.test(raw)) { throw invalid(`${path} must be a bitrate like "2M" or "128k"`, path); } return raw; } } function options(table: Record, raw: unknown, path: string): string[] { if (raw === undefined || raw === null) return []; if (typeof raw !== "object" || Array.isArray(raw)) throw invalid(`${path} must be an object`, path); const args: string[] = []; for (const [key, entry] of Object.entries(raw as Record)) { const spec = table[key]; if (!spec) { throw invalid(`Unknown option ${JSON.stringify(key)}. Accepted here: ${Object.keys(table).join(", ")}`, `${path}.${key}`); } args.push(`-${key}`, value(spec, entry, `${path}.${key}`)); } return args; } /** * A filter chain, built from structured objects rather than parsed from a string. * * ffmpeg's filter syntax has its own escaping rules, and parsing it well enough to validate it is * harder than accepting `{ name: args }`. The names and parameters are ffmpeg's; only the * punctuation is ours — and every filter that can read a file or load a plugin is simply absent from * the table, so it cannot be named. */ function filterChain(table: Record>, raw: unknown, path: string): string { if (raw === undefined || raw === null) return ""; if (!Array.isArray(raw)) throw invalid(`${path} must be an array of filters`, path); return raw .map((entry, i) => { const at = `${path}[${i}]`; if (!entry || typeof entry !== "object") throw invalid(`${at} must be an object`, at); const pairs = Object.entries(entry as Record); if (pairs.length !== 1) throw invalid(`${at} must name exactly one filter`, at); const [name, rawArgs] = pairs[0]!; const spec = table[name]; if (!spec) throw invalid(`Unknown filter ${JSON.stringify(name)}. See docs/FFMPEG.md.`, `${at}.${name}`); const args = Object.entries((rawArgs ?? {}) as Record).map(([key, v]) => { const argSpec = spec[key]; if (!argSpec) { throw invalid(`Unknown parameter ${JSON.stringify(key)} for ${name}`, `${at}.${name}.${key}`); } return `${key}=${value(argSpec, v, `${at}.${name}.${key}`)}`; }); return args.length ? `${name}=${args.join(":")}` : name; }) .join(","); } /** Run ffmpeg on an asset and store the result as a new asset. The input is never modified. */ export async function runFfmpeg(body: FfmpegRequest): Promise { if (typeof body.input !== "string") throw invalid("input must be an asset id", "input"); const source = await readAsset(body.input); if (source.kind === "image") { throw invalid(`Asset ${source.id} is an image. Use /v1/sharp for images.`, "input"); } // A preset supplies the options; anything given explicitly overrides it. So "web-1080 but crf 20" // does not mean writing out every flag again. let format = "mp4"; let presetOptions: string[] = []; let presetFilters = ""; if (body.preset !== undefined) { if (typeof body.preset !== "string" || !PRESETS[body.preset]) { throw invalid(`preset must be one of: ${Object.keys(PRESETS).join(", ")}`, "preset"); } const preset = PRESETS[body.preset]!; format = preset.format; presetOptions = options(OUTPUT_OPTIONS, preset.options, "preset"); presetFilters = preset.filters?.v ? filterChain(VIDEO_FILTERS, preset.filters.v.map((f) => ({ [f.name]: f.args })), "preset.filters") : ""; } if (body.format !== undefined) { if (typeof body.format !== "string" || !FORMATS[body.format]) { throw invalid(`format must be one of: ${Object.keys(FORMATS).join(", ")}`, "format"); } format = body.format; } const inputArgs = options(INPUT_OPTIONS, body.inputOptions, "inputOptions"); const outputArgs = options(OUTPUT_OPTIONS, body.outputOptions, "outputOptions"); const filters = (body.filters ?? {}) as Record; const videoChain = filterChain(VIDEO_FILTERS, filters.v, "filters.v") || presetFilters; const audioChain = filterChain(AUDIO_FILTERS, filters.a, "filters.a"); // One directory per job, removed however this ends — otherwise disk fills with the failures. const workDir = await mkdtemp(join(tmpdir(), "avclub-")); const output = join(workDir, `out.${FORMATS[format]!.extension}`); try { const argv = [ "ffmpeg", "-nostdin", "-y", "-v", "error", // Nothing but local files. Kills SSRF and the concat:/subfile: tricks outright — the second // lock behind "the caller never names a path". "-protocol_whitelist", "file", ...inputArgs, "-i", contentPath(source.id), ...(videoChain ? ["-vf", videoChain] : []), ...(audioChain ? ["-af", audioChain] : []), ...presetOptions, // After the preset, so an explicit option wins. ...outputArgs, "-f", format, output, ]; const proc = Bun.spawn(argv, { stdout: "pipe", stderr: "pipe", stdin: "ignore" }); // A crafted file can make an encoder run effectively forever. let timedOut = false; const timer = setTimeout(() => { timedOut = true; proc.kill(); }, JOB_TIMEOUT_MS); const [stderr, code] = await Promise.all([new Response(proc.stderr).text(), proc.exited]); clearTimeout(timer); if (timedOut) { throw new ApiError("timeout", `Encoding took longer than ${JOB_TIMEOUT_MS / 1000}s and was stopped.`); } if (code !== 0) { throw unprocessable(`ffmpeg failed: ${stderr.trim().split("\n").at(-1) ?? `exit ${code}`}`); } return await putAsset(await Bun.file(output).arrayBuffer()); } finally { await rm(workDir, { recursive: true, force: true }); } }