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
This commit is contained in:
2026-08-28 22:36:53 +01:00
co-authored by Claude Opus 5
parent dc4fe758f3
commit 79ff366802
18 changed files with 131 additions and 130 deletions
+199
View File
@@ -0,0 +1,199 @@
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 });
}
}
+136
View File
@@ -0,0 +1,136 @@
/**
* The ffmpeg surface av-club exposes, as data — the same shape as the sharp vocabulary.
*
* Options are spelled the way ffmpeg spells them (`c:v`, `crf`, `pix_fmt`), so ffmpeg's own
* documentation is the reference. What is NOT here is anything that names a file: no `-i`, no output
* path, no protocol. See docs/SECURITY.md — that single restriction is what makes the rest of this
* safe to open up.
*/
export type FfmpegParam =
| { kind: "int"; min: number; max: number; note?: string }
| { kind: "number"; min: number; max: number; note?: string }
| { kind: "enum"; values: readonly string[]; note?: string }
| { kind: "bitrate"; note?: string };
/** Options that go BEFORE `-i`: seeking and trimming, which are far cheaper applied to the input. */
export const INPUT_OPTIONS: Record<string, FfmpegParam> = {
ss: { kind: "number", min: 0, max: 86_400, note: "Start at this many seconds." },
t: { kind: "number", min: 0.001, max: 86_400, note: "Take this many seconds." },
to: { kind: "number", min: 0, max: 86_400, note: "Stop at this timestamp." },
};
/**
* Output options.
*
* The encoder list is short on purpose: these are the codecs a browser plays. An encoder ffmpeg
* happens to have compiled in is not automatically something worth publishing.
*/
export const OUTPUT_OPTIONS: Record<string, FfmpegParam> = {
"c:v": { kind: "enum", values: ["libx264", "libvpx-vp9", "libaom-av1", "copy"], note: "Video encoder. libx264 is the one every browser plays." },
"c:a": { kind: "enum", values: ["aac", "libopus", "copy"], note: "Audio encoder." },
crf: { kind: "int", min: 0, max: 63, note: "Quality. Lower is better and bigger; 23 is a sane default for x264." },
preset: { kind: "enum", values: ["ultrafast", "superfast", "veryfast", "faster", "fast", "medium", "slow", "slower", "veryslow"], note: "Encoder effort. Slower is smaller at the same quality." },
tune: { kind: "enum", values: ["film", "animation", "grain", "stillimage", "fastdecode", "zerolatency"] },
"profile:v": { kind: "enum", values: ["baseline", "main", "high"], note: "`high` is fine everywhere current." },
level: { kind: "enum", values: ["3.0", "3.1", "4.0", "4.1", "4.2", "5.0", "5.1"] },
pix_fmt: { kind: "enum", values: ["yuv420p", "yuv422p", "yuv444p"], note: "yuv420p, or Safari and QuickTime will not play it." },
"b:v": { kind: "bitrate", note: "Target video bitrate, e.g. 2M." },
"b:a": { kind: "bitrate", note: "Target audio bitrate, e.g. 128k." },
maxrate: { kind: "bitrate" },
bufsize: { kind: "bitrate" },
r: { kind: "number", min: 1, max: 120, note: "Output frame rate." },
g: { kind: "int", min: 1, max: 600, note: "Keyframe interval." },
ar: { kind: "enum", values: ["22050", "44100", "48000"], note: "Audio sample rate." },
ac: { kind: "int", min: 1, max: 2 },
"frames:v": { kind: "int", min: 1, max: 10_000, note: "Stop after this many frames — 1 gives a poster." },
movflags: { kind: "enum", values: ["+faststart"], note: "Moves the index to the front so playback can start before the file has arrived. Always want this for web." },
an: { kind: "enum", values: ["1"], note: "Drop audio entirely." },
};
/** Output containers. `image2` is how a single frame comes out as a still. */
export const FORMATS: Record<string, { extension: string; describe: string }> = {
mp4: { extension: "mp4", describe: "H.264/AAC in MP4 — plays everywhere." },
webm: { extension: "webm", describe: "VP9/Opus. Smaller, but not on older Safari." },
mp3: { extension: "mp3", describe: "Audio only." },
image2: { extension: "jpg", describe: "A single frame, for poster images." },
};
/**
* Filters, default-deny.
*
* ffmpeg's filter graph is a language, and most of it is arithmetic on pixels. A small, specific part
* of it reads files or loads native code — `movie`, `subtitles`, `frei0r`, `ladspa` and relatives —
* and those are not in this list, so they are refused. A filter nobody has classified is refused too:
* a blocklist would silently admit whatever the next ffmpeg release adds.
*/
export const VIDEO_FILTERS: Record<string, Record<string, FfmpegParam>> = {
scale: {
w: { kind: "int", min: -2, max: 8192, note: "-2 keeps the aspect ratio and an even number of pixels, which h264 requires." },
h: { kind: "int", min: -2, max: 8192 },
},
crop: {
w: { kind: "int", min: 1, max: 8192 },
h: { kind: "int", min: 1, max: 8192 },
x: { kind: "int", min: 0, max: 8192 },
y: { kind: "int", min: 0, max: 8192 },
},
fps: { fps: { kind: "number", min: 1, max: 120 } },
format: { pix_fmts: { kind: "enum", values: ["yuv420p", "yuv422p", "yuv444p", "rgb24"] } },
transpose: { dir: { kind: "enum", values: ["0", "1", "2", "3"] } },
hflip: {},
vflip: {},
eq: {
brightness: { kind: "number", min: -1, max: 1 },
contrast: { kind: "number", min: -1000, max: 1000 },
saturation: { kind: "number", min: 0, max: 3 },
gamma: { kind: "number", min: 0.1, max: 10 },
},
unsharp: {
luma_msize_x: { kind: "int", min: 3, max: 23 },
luma_msize_y: { kind: "int", min: 3, max: 23 },
luma_amount: { kind: "number", min: -2, max: 5 },
},
};
export const AUDIO_FILTERS: Record<string, Record<string, FfmpegParam>> = {
volume: { volume: { kind: "number", min: 0, max: 10 } },
aresample: { osr: { kind: "enum", values: ["22050", "44100", "48000"] } },
loudnorm: {
I: { kind: "number", min: -70, max: -5, note: "Target loudness in LUFS. -16 suits the web." },
TP: { kind: "number", min: -9, max: 0 },
},
};
/**
* Named presets, so a caller does not have to know encoder settings to get a web-playable file.
*
* This is where the media knowledge lives — the whole reason av-club is a separate repo. A consumer
* says what the media is FOR; what that means in flags is decided here and can be revised in one
* place for every site that uses it.
*/
export const PRESETS: Record<string, { describe: string; format: string; options: Record<string, string | number>; filters?: { v?: { name: string; args: Record<string, number | string> }[] } }> = {
"web-1080": {
describe: "H.264 up to 1080p. The safe default — plays in every browser.",
format: "mp4",
options: { "c:v": "libx264", crf: 23, preset: "medium", "profile:v": "high", pix_fmt: "yuv420p", "c:a": "aac", "b:a": "128k", movflags: "+faststart" },
filters: { v: [{ name: "scale", args: { w: -2, h: 1080 } }] },
},
"web-720": {
describe: "H.264 up to 720p.",
format: "mp4",
options: { "c:v": "libx264", crf: 23, preset: "medium", "profile:v": "high", pix_fmt: "yuv420p", "c:a": "aac", "b:a": "128k", movflags: "+faststart" },
filters: { v: [{ name: "scale", args: { w: -2, h: 720 } }] },
},
"web-480": {
describe: "H.264 up to 480p, for slow connections.",
format: "mp4",
options: { "c:v": "libx264", crf: 26, preset: "medium", "profile:v": "main", pix_fmt: "yuv420p", "c:a": "aac", "b:a": "96k", movflags: "+faststart" },
filters: { v: [{ name: "scale", args: { w: -2, h: 480 } }] },
},
poster: {
describe: "One frame as a JPEG, for a video's poster image.",
format: "image2",
options: { "frames:v": 1 },
},
};