adds a path mode to /v1/ffmpeg:
{ input: "as_…" } → stored as a new asset, 201
{ inputPath: "/…", outputPath: "/…" } → written where you said, 200 { path, bytes }
THIS REVISES THE INVARIANT IN docs/SECURITY.md, deliberately, and that document now
says so at the top rather than being quietly contradicted by the code.
it used to read "the caller never supplies a path, for input or output". that was
right for an upload service and av-club is not one: both of its real consumers —
a right-click on ~/Movies/thing.mkv, and a commander watching an upload folder —
already have the file, and obeying the rule meant copying gigabytes in and back out
to convert something that never moved. a rule that expensive gets worked around,
and the workaround is where the hole would have been.
so it is narrower now and says what it was always for. argv never a shell,
-protocol_whitelist file, filters allowlisted, work bounded — all unchanged. what
is gone is only the claim that a path never appears, replaced by one small module
every path goes through:
absolute (a relative path resolves against a cwd the caller cannot see)
realpath FIRST, then check — checking the path as written and opening the
resolved one is the classic way this fails
inside as `root` or `root + sep`, never a bare startsWith, which accepts
/home/user-backup for a root of /home/user
for an output the PARENT is resolved, and must already exist
errors never say which of "outside" or "missing" it was, or this becomes an
oracle for what exists elsewhere on the disk
VERIFIED against the running service, not reasoned about: `..` traversal, an
absolute path outside the root, a write outside the root, a relative path, a
symlink pointing into /etc, and a write THROUGH a symlinked directory — all
refused, nothing written outside the root.
the output is moved into place at the END. ffmpeg encodes into a temp dir, so a job
killed by the timeout or a restart leaves the caller's directory as it was. pointing
ffmpeg at the destination would leave a truncated file where a good one might have
been — and a task that then deleted its source would have destroyed something.
rename first, copy on EXDEV, which is the normal case rather than the error one
since tmp is often tmpfs.
docs/API.md said this endpoint was "designed, not yet built" while it was routed and
working. that is fixed too: it was read as a gap and nearly rebuilt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
251 lines
10 KiB
TypeScript
251 lines
10 KiB
TypeScript
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]?$/;
|
|
|
|
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<string, FfmpegParam>, 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<string, unknown>)) {
|
|
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<string, Record<string, FfmpegParam>>, 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<string, unknown>);
|
|
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<string, unknown>).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<FfmpegResult> {
|
|
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<string, unknown>;
|
|
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,
|
|
"-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}`}`);
|
|
}
|
|
|
|
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 });
|
|
}
|
|
}
|