import { join } from "node:path"; import { mkdtemp, rename, rm, stat } 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 { safeInputPath, safeOutputPath } from "../paths"; 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; /** A path on disk, instead of `input`. Confined by `safeInputPath`. */ inputPath?: unknown; /** Where to write. Only with `inputPath`; without it the result is stored as an asset. */ outputPath?: unknown; preset?: unknown; inputOptions?: unknown; outputOptions?: unknown; filters?: unknown; format?: unknown; }; const BITRATE = /^[0-9]+(\.[0-9]+)?[kKmM]?$/; /** `null` means "emit the flag with no value" — see the `flag` kind in vocabulary.ts. */ function value(spec: FfmpegParam, raw: unknown, path: string): string | null { switch (spec.kind) { case "flag": if (raw !== true) throw invalid(`${path} must be true, or left out`, path); return null; 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}`); } const resolved = value(spec, entry, `${path}.${key}`); // A flag is the option and nothing else. Pushing an empty string here would hand ffmpeg an // argument it reads as a filename, which is the bug this replaced. if (resolved === null) args.push(`-${key}`); else args.push(`-${key}`, resolved); } 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(","); } export type FfmpegResult = Asset | { path: string; bytes: number }; /** * Run ffmpeg, either on a stored asset or on a file already on disk. * * TWO MODES, and exactly one of them per call: * * { input: "as_…" } → result stored as a new asset * { inputPath: "/…", outputPath: "/…" } → result written where the caller said * * The second exists because both real consumers already have the file: a right-click in the file * browser, and a commander watching a folder. See `sidecar/paths.ts` for what confines them. * * **The input is never modified in either mode.** ffmpeg writes to a temp file and it is moved into * place at the end, so a job that dies halfway leaves the original untouched rather than a truncated * output where a good file used to be. Deleting a source is the caller's business and not this * service's — the tasks do it themselves, after checking the result. */ export async function runFfmpeg(body: FfmpegRequest): Promise { const byPath = body.inputPath !== undefined; if (byPath && body.input !== undefined) { throw invalid("give either input or inputPath, not both", "input"); } if (!byPath && typeof body.input !== "string") throw invalid("input must be an asset id", "input"); const sourcePath = byPath ? await safeInputPath(body.inputPath, "inputPath") : null; const destination = body.outputPath !== undefined ? await safeOutputPath(body.outputPath, "outputPath") : null; if (destination && !byPath) throw invalid("outputPath needs inputPath", "outputPath"); const source = byPath ? null : await readAsset(body.input as string); if (source && 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", sourcePath ?? contentPath(source!.id), ...(videoChain ? ["-vf", videoChain] : []), ...(audioChain ? ["-af", audioChain] : []), ...presetOptions, // After the preset, so an explicit option wins. ...outputArgs, // ffmpeg's own container name, which is not always what the format is called — `matroska` for // mkv, `ipod` for m4a. The caller says the familiar one. "-f", FORMATS[format]!.container ?? 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}`}`); } if (!destination) return await putAsset(await Bun.file(output).arrayBuffer()); /* Into place at the END, never written there directly. ffmpeg has been encoding into a temp directory this whole time, so a job that is killed — by the timeout above, by a restart, by the machine — leaves the caller's directory exactly as it was. The alternative, pointing ffmpeg at the destination, means a crash leaves a truncated file sitting where a good one might have been, and a task that then deletes its source has destroyed something. `rename` first because it is atomic when both sides are on one filesystem. They usually are not — the temp dir is often tmpfs and the destination is somebody's home — so EXDEV is expected rather than exceptional, and the copy is the normal path rather than the error path. */ try { await rename(output, destination); } catch (error) { if ((error as { code?: string }).code !== "EXDEV") throw error; await Bun.write(destination, Bun.file(output)); } return { path: destination, bytes: (await stat(destination)).size }; } finally { await rm(workDir, { recursive: true, force: true }); } }