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
147 lines
6.4 KiB
TypeScript
147 lines
6.4 KiB
TypeScript
import sharp from "sharp";
|
|
import { MAX_INPUT_PIXELS } from "../config";
|
|
import { invalid, unprocessable } from "../errors";
|
|
import { contentPath, putAsset, readAsset, type Asset } from "../assets/store";
|
|
import { INPUT_OPTIONS, OPERATIONS, OUTPUT_FORMATS } from "./vocabulary";
|
|
import { validateObject } from "./validate";
|
|
import { decodableInput } from "./decodable";
|
|
|
|
export type SharpRequest = {
|
|
input?: unknown;
|
|
options?: unknown;
|
|
pipeline?: unknown;
|
|
output?: unknown;
|
|
};
|
|
|
|
/**
|
|
* Run a validated sharp pipeline and store the result as a new asset.
|
|
*
|
|
* The input asset is never modified — every operation produces a new asset. That is what makes it
|
|
* safe to re-run a pipeline with different settings six months from now, which is the whole reason
|
|
* originals are kept (docs/PIPELINE.md: "The original is never destroyed").
|
|
*/
|
|
export async function runSharp(body: SharpRequest): 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 ${source.kind}, not an image. Use /v1/ffmpeg for video and audio.`, "input");
|
|
}
|
|
|
|
const inputOptions = validateObject(INPUT_OPTIONS, body.options, "options");
|
|
|
|
// limitInputPixels is set HERE and cannot be supplied by the caller: it is the decompression-bomb
|
|
// guard, and a caller who could raise it could turn a 1 KB PNG into a 40 GB allocation.
|
|
// HEIC has to go through ImageMagick first — sharp's bundled libheif cannot decode it. A no-op
|
|
// for every other format. See decodable.ts.
|
|
const decoded = await decodableInput(contentPath(source.id), source.format);
|
|
|
|
let image = sharp(decoded.path, {
|
|
...inputOptions,
|
|
limitInputPixels: MAX_INPUT_PIXELS,
|
|
// `unlimited` is what gets past libheif's iref-reference cap, which iPhone HEICs carrying
|
|
// burst or depth data routinely exceed — sharp reports it as "corrupt header", which it is not.
|
|
// It loosens libvips' own buffer guards, so `limitInputPixels` is passed EXPLICITLY alongside:
|
|
// verified that the pixel limit still rejects a decompression bomb with unlimited set.
|
|
unlimited: true,
|
|
});
|
|
|
|
// ── The pipeline ─────────────────────────────────────────────────────────────────────────────
|
|
const steps = body.pipeline ?? [];
|
|
if (!Array.isArray(steps)) throw invalid("pipeline must be an array of operations", "pipeline");
|
|
|
|
for (const [i, step] of steps.entries()) {
|
|
const at = `pipeline[${i}]`;
|
|
if (!step || typeof step !== "object" || Array.isArray(step)) {
|
|
throw invalid(`${at} must be an object like { "resize": { "width": 800 } }`, at);
|
|
}
|
|
|
|
const entries = Object.entries(step as Record<string, unknown>);
|
|
if (entries.length !== 1) {
|
|
throw invalid(`${at} must name exactly one operation, got ${entries.length}`, at);
|
|
}
|
|
|
|
const [name, rawArgs] = entries[0]!;
|
|
const spec = OPERATIONS[name];
|
|
if (!spec) {
|
|
throw invalid(
|
|
`Unknown operation ${JSON.stringify(name)}. See docs/SHARP.md for the full list.`,
|
|
`${at}.${name}`,
|
|
);
|
|
}
|
|
|
|
const args = validateObject(spec.params, rawArgs, `${at}.${name}`);
|
|
const method = (image as unknown as Record<string, (...a: unknown[]) => typeof image>)[name];
|
|
if (typeof method !== "function") {
|
|
// The vocabulary named something sharp does not have — our bug, not the caller's.
|
|
throw new Error(`vocabulary lists ${name}, but sharp has no such method`);
|
|
}
|
|
|
|
try {
|
|
// Operations sharp declares positionally — median(size), blur(sigma) — are called that way.
|
|
// Everything else takes an options object.
|
|
if (spec.positional && spec.positional in args) {
|
|
const positional = args[spec.positional];
|
|
const rest = { ...args };
|
|
delete rest[spec.positional];
|
|
image = Object.keys(rest).length
|
|
? method.call(image, positional, rest)
|
|
: method.call(image, positional);
|
|
} else if (Object.keys(args).length === 0) {
|
|
image = method.call(image);
|
|
} else if (name === "composite") {
|
|
// composite() takes an ARRAY, and its `input` is an asset id that only the store may turn
|
|
// into a path. This is the one operation that reads a second file, and it is the reason
|
|
// `assetId` exists as a parameter kind.
|
|
const overlay = await readAsset(args.input as string);
|
|
if (overlay.kind !== "image") {
|
|
throw invalid(`composite input ${overlay.id} is ${overlay.kind}, not an image`, `${at}.composite.input`);
|
|
}
|
|
const { input: _dropped, ...placement } = args;
|
|
image = image.composite([{ input: contentPath(overlay.id), ...placement } as never]);
|
|
} else {
|
|
image = method.call(image, args);
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof Error && !("type" in error)) {
|
|
throw invalid(`${name} failed: ${error.message}`, `${at}.${name}`);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// ── Output ───────────────────────────────────────────────────────────────────────────────────
|
|
const output = (body.output ?? {}) as Record<string, unknown>;
|
|
if (typeof output !== "object" || Array.isArray(output)) {
|
|
throw invalid("output must be an object", "output");
|
|
}
|
|
|
|
const format = output.format ?? "webp";
|
|
if (typeof format !== "string" || !OUTPUT_FORMATS[format]) {
|
|
throw invalid(
|
|
`output.format must be one of: ${Object.keys(OUTPUT_FORMATS).join(", ")}`,
|
|
"output.format",
|
|
);
|
|
}
|
|
|
|
const encoderOptions = validateObject(OUTPUT_FORMATS[format]!.params, output.options, "output.options");
|
|
const encode = (image as unknown as Record<string, (o: unknown) => typeof image>)[format]!;
|
|
|
|
let buffer: Buffer;
|
|
try {
|
|
buffer = await encode.call(image, encoderOptions).toBuffer();
|
|
} catch (error) {
|
|
await decoded.cleanup();
|
|
// A failure here is usually the source file, not the request — a truncated JPEG, a PNG whose
|
|
// declared dimensions disagree with its data.
|
|
throw unprocessable(
|
|
`Could not process that image: ${error instanceof Error ? error.message : String(error)}`,
|
|
);
|
|
}
|
|
|
|
await decoded.cleanup();
|
|
return putAsset(buffer);
|
|
}
|