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
63 lines
1.9 KiB
TypeScript
63 lines
1.9 KiB
TypeScript
import { JOB_TIMEOUT_MS } from "../config";
|
|
import { unprocessable } from "../errors";
|
|
|
|
/**
|
|
* ffprobe, with its own field names passed through untouched.
|
|
*
|
|
* `codec_name`, `r_frame_rate`, `nb_frames` and the rest reach the caller exactly as ffprobe emits
|
|
* them. Renaming them into some house style would mean maintaining a translation table and would
|
|
* make ffprobe's own documentation stop applying to this API, which is the opposite of the point.
|
|
*/
|
|
|
|
export type FfprobeStream = {
|
|
index: number;
|
|
codec_type?: string;
|
|
codec_name?: string;
|
|
width?: number;
|
|
height?: number;
|
|
[key: string]: unknown;
|
|
};
|
|
|
|
export type FfprobeResult = {
|
|
format?: { format_name?: string; duration?: string; bit_rate?: string; [key: string]: unknown };
|
|
streams?: FfprobeStream[];
|
|
};
|
|
|
|
export async function ffprobe(path: string): Promise<FfprobeResult> {
|
|
// Spawned as an argv ARRAY, never a command string — there is no shell here to quote for, so a
|
|
// filename containing shell metacharacters is inert data. See docs/SECURITY.md, Layer 1.
|
|
//
|
|
// `-protocol_whitelist file` is the second lock: `path` is always one this process constructed,
|
|
// but if that ever stops being true, ffprobe still cannot be talked into speaking http.
|
|
const proc = Bun.spawn(
|
|
[
|
|
"ffprobe",
|
|
"-v", "error",
|
|
"-protocol_whitelist", "file",
|
|
"-print_format", "json",
|
|
"-show_format",
|
|
"-show_streams",
|
|
path,
|
|
],
|
|
{ stdout: "pipe", stderr: "pipe", stdin: "ignore" },
|
|
);
|
|
|
|
const timer = setTimeout(() => proc.kill(), JOB_TIMEOUT_MS);
|
|
|
|
try {
|
|
const [stdout, stderr, code] = await Promise.all([
|
|
new Response(proc.stdout).text(),
|
|
new Response(proc.stderr).text(),
|
|
proc.exited,
|
|
]);
|
|
|
|
if (code !== 0) {
|
|
throw unprocessable(`Could not read that file: ${stderr.trim().split("\n").at(-1) ?? "ffprobe failed"}`);
|
|
}
|
|
|
|
return JSON.parse(stdout) as FfprobeResult;
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|