Files
av-club/sidecar/ffmpeg/run.ts
T
pastilhasandClaude Opus 5 79ff366802 become an Officer plugin
av-club keeps doing exactly what it did — sharp, ffmpeg and ffprobe behind one
API, and the invariant that the caller never names a path. What changes is how it
is reached and who decides who may reach it.

`src/` is `sidecar/`, because that is the name the platform looks for:
`sidecar/index.ts` present means "this plugin ships a process", and presence is
the declaration for everything structural here. `manifest.ts` at the root is the
other half of that rule.

api/router.ts is a proxy and nothing else. the platform authenticates, checks the
`av-club` permission, and forwards — so the sidecar now accepts a request
carrying `X-Officer-User` and no longer demands its own bearer token from
callers who came that way. it binds LOOPBACK to make that trustworthy, which is
the same boundary caldav's DAV door already relies on. AV_CLUB_TOKEN stays, and
still guards anything calling the port directly: fofos/platform is the stated
first consumer and is not Officer.

THREE THINGS HAD TO GO, and each one broke it in a way worth writing down.

package.json and bun.lock: a package.json inside a plugin is a package boundary,
so bun stops walking up for the platform's tsconfig and `@@/sidecar/connect`
stops resolving. the sidecar died on its first import, fifteen times. npm
dependencies go in the manifest instead, where npm-deps.ts intersects them across
plugins into one plugins/node_modules — which is also how this surfaced a real
conflict: sharp ^0.33.5 (atelier) and ^0.35.3 here do not overlap, since a 0.x
caret pins the minor. atelier moved up; it calls one method.

tsconfig.json: same failure one layer earlier, shadowing the platform's `@@/*`
paths so api/router.ts would not mount. no other plugin has one.

no screens, deliberately. this is an API and a body of documentation; its callers
are a commander watching a folder, an agent, or curl. a dock tile would advertise
a page with nothing on it.

verified through /api/av-club with an agent key: a 3000x2000 jpeg uploaded,
probed, and resized to a 1200x800 avif — 166 KB to 1 KB — without the caller ever
holding av-club's own token.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-28 22:36:53 +01:00

200 lines
7.4 KiB
TypeScript

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<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(",");
}
/** 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<Asset> {
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<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", 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 });
}
}