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"; import { safeInputPath, safeOutputPath } from "../paths"; import { stat } from "node:fs/promises"; export type SharpRequest = { 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; 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 type SharpResult = Asset | { path: string; bytes: number }; /** * Run a validated sharp pipeline, on a stored asset or on a file already on disk. * * Two modes, exactly as `/v1/ffmpeg` has, and for the same reason: the file browser and the fofos * commander both already have the file. See `sidecar/paths.ts`. * * The input is never modified in either mode — every operation produces a new file. That is what makes * it safe to re-run a pipeline with different settings six months from now, which is why originals are * kept at all (docs/PIPELINE.md: "The original is never destroyed"). */ export async function runSharp(body: SharpRequest): Promise { 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 ${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. /* For a path we have no stored `format`, so sharp is asked. `metadata()` only reads the header, which is precisely the part of a HEIC that works — the pixels are what its libheif cannot reach — so this correctly identifies the files that need routing through ImageMagick. */ const sourceFile = sourcePath ?? contentPath(source!.id); const sourceFormat = source?.format ?? (await sharp(sourceFile).metadata().catch(() => null))?.format ?? ""; const decoded = await decodableInput(sourceFile, sourceFormat); 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); 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 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; if (typeof output !== "object" || Array.isArray(output)) { throw invalid("output must be an object", "output"); } /* Unknown keys are refused, because the shape of this object is guessable and the wrong guess is SILENT. `output: { webp: { quality: 82 } }` reads perfectly and mirrors how `pipeline` names an operation, so it is what a caller writes from memory. Accepted quietly it meant: format fell through to the "webp" default — right answer, wrong reason — and the options were never read, so the quality asked for was not the quality encoded. Ask for `{ png: … }` the same way and you get a WebP with a .png on the end of it. Every other part of this API rejects a name it does not know. This one did not, and the failure it let through was the kind nobody finds by looking at the output. */ const known = new Set(["format", "options"]); const stray = Object.keys(output).filter((key) => !known.has(key)); if (stray.length) { throw invalid( `output has no ${stray.map((key) => JSON.stringify(key)).join(", ")}. It takes { "format": "webp", "options": { … } } — the format is a NAME, not a key.`, "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 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(); if (!destination) return putAsset(buffer); // Written whole, in one call, after the encode has already succeeded — so a failure leaves the // destination untouched rather than half a file where a good one might have been. await Bun.write(destination, buffer); return { path: destination, bytes: (await stat(destination)).size }; }