fix: make OCR portable and reliable across AMD64 and ARM64 (#519)

* fix: make OCR portable and reliable

* fix: harden OCR installation portability

* fix: pin OCR partials across downloads

* fix: make OCR execution reliably asynchronous

* fix: harden OCR portability and docs routes

* fix: preserve decoder and docs safeguards
This commit is contained in:
SnapOtter
2026-07-15 03:34:24 +08:00
committed by GitHub
parent 58121f205f
commit 991c981529
409 changed files with 67151 additions and 8076 deletions
@@ -21,6 +21,8 @@
export interface QueuedInstall {
bundleId: string;
jobId: string;
/** Shared destructive-mutation generation observed when this work was submitted. */
mutationEpoch: string;
}
let activeInstall: QueuedInstall | null = null;
@@ -54,6 +56,10 @@ export function enqueue(entry: QueuedInstall): string {
}
const existing = queue.find((q) => q.bundleId === entry.bundleId);
if (existing) {
// A request submitted after reset/uninstall explicitly reauthorizes this
// bundle. Preserve the existing job id/FIFO position while refreshing its
// epoch so the pump does not cancel genuinely new work with the stale one.
existing.mutationEpoch = entry.mutationEpoch;
return existing.jobId;
}
queue.push(entry);
File diff suppressed because it is too large Load Diff
+460 -112
View File
@@ -9,6 +9,221 @@ import sharp from "sharp";
const execFileAsync = promisify(execFile);
export interface DecodeSafetyOptions {
/** Maximum decoded width * height accepted by this operation. */
maxPixels?: number;
/** Maximum decoded width or height accepted by this operation. */
maxDimension?: number;
/** Cancels external decoder processes when the owning job is canceled. */
signal?: AbortSignal;
}
function commandOptions(options: DecodeSafetyOptions, timeout: number) {
return { timeout, signal: options.signal };
}
function assertDimensionsWithinLimit(
width: number | undefined,
height: number | undefined,
options: DecodeSafetyOptions,
): void {
if (width === undefined || height === undefined || width <= 0 || height <= 0) {
return;
}
if (
options.maxDimension !== undefined &&
(width > options.maxDimension || height > options.maxDimension)
) {
throw new Error(
`Decoded image exceeds the ${options.maxDimension.toLocaleString("en-US")} pixel dimension safety limit (${width.toLocaleString("en-US")}x${height.toLocaleString("en-US")})`,
);
}
if (options.maxPixels !== undefined && width * height > options.maxPixels) {
throw new Error(
`Decoded image exceeds the ${options.maxPixels.toLocaleString("en-US")} pixel safety limit (${width}x${height})`,
);
}
}
async function assertDecodedWithinLimit(
buffer: Buffer,
options: DecodeSafetyOptions,
): Promise<Buffer> {
options.signal?.throwIfAborted();
if (options.maxPixels !== undefined || options.maxDimension !== undefined) {
try {
const metadata = await sharp(buffer, {
limitInputPixels: options.maxPixels ?? false,
}).metadata();
assertDimensionsWithinLimit(metadata.width, metadata.height, options);
} catch (error) {
if (isImageSafetyError(error)) throw error;
throw new Error("Decoded image exceeds the configured image safety limits", {
cause: error,
});
}
}
options.signal?.throwIfAborted();
return buffer;
}
interface ImageDimensions {
width: number;
height: number;
}
function readKnownEncodedDimensions(buffer: Buffer, format: string): ImageDimensions | undefined {
if (format === "qoi" && buffer.length >= 14) {
return { width: buffer.readUInt32BE(4), height: buffer.readUInt32BE(8) };
}
if (format === "psd" && buffer.length >= 22 && buffer.subarray(0, 4).toString() === "8BPS") {
return { width: buffer.readUInt32BE(18), height: buffer.readUInt32BE(14) };
}
if (format === "tga" && buffer.length >= 18) {
return { width: buffer.readUInt16LE(12), height: buffer.readUInt16LE(14) };
}
if (format === "bmp" && buffer.length >= 26 && buffer.subarray(0, 2).toString() === "BM") {
const dibSize = buffer.readUInt32LE(14);
if (dibSize === 12) {
return { width: buffer.readUInt16LE(18), height: buffer.readUInt16LE(20) };
}
return { width: Math.abs(buffer.readInt32LE(18)), height: Math.abs(buffer.readInt32LE(22)) };
}
if (format === "dds" && buffer.length >= 20 && buffer.subarray(0, 4).toString() === "DDS ") {
return { width: buffer.readUInt32LE(16), height: buffer.readUInt32LE(12) };
}
if ((format === "ico" || format === "cur") && buffer.length >= 6) {
const count = buffer.readUInt16LE(4);
let largest: ImageDimensions | undefined;
for (let index = 0; index < count && 6 + index * 16 + 16 <= buffer.length; index++) {
const offset = 6 + index * 16;
const width = buffer[offset] || 256;
const height = buffer[offset + 1] || 256;
if (!largest || width * height > largest.width * largest.height) largest = { width, height };
}
return largest;
}
if (format === "hdr") {
const match = buffer
.subarray(0, Math.min(buffer.length, 64 * 1024))
.toString("ascii")
.match(/[+-]Y\s+(\d+)\s+[+-]X\s+(\d+)/i);
if (match) return { width: Number(match[2]), height: Number(match[1]) };
}
if (format === "ppm" || format === "pgm" || format === "pbm") {
const header = buffer.subarray(0, Math.min(buffer.length, 64 * 1024)).toString("ascii");
if (/^P7(?:\s|$)/.test(header)) {
const width = header.match(/^WIDTH\s+(\d+)/im)?.[1];
const height = header.match(/^HEIGHT\s+(\d+)/im)?.[1];
if (width && height) return { width: Number(width), height: Number(height) };
} else {
const tokens = header
.replace(/#[^\r\n]*/g, " ")
.trim()
.split(/\s+/);
if (/^P[1-6]$/.test(tokens[0] ?? "") && tokens[1] && tokens[2]) {
return { width: Number(tokens[1]), height: Number(tokens[2]) };
}
}
}
if (format === "dpx" && buffer.length >= 780) {
const magic = buffer.subarray(0, 4).toString("ascii");
if (magic === "SDPX") {
return { width: buffer.readUInt32BE(772), height: buffer.readUInt32BE(776) };
}
if (magic === "XPDS") {
return { width: buffer.readUInt32LE(772), height: buffer.readUInt32LE(776) };
}
}
if (format === "fits") {
const header = buffer.subarray(0, Math.min(buffer.length, 1024 * 1024)).toString("ascii");
const width = header.match(/(?:^|\s)NAXIS1\s*=\s*(\d+)/)?.[1];
const height = header.match(/(?:^|\s)NAXIS2\s*=\s*(\d+)/)?.[1];
if (width && height) return { width: Number(width), height: Number(height) };
}
if (format === "eps") {
// BoundingBox is normally near the prolog, or near the trailer when the
// prolog says `(atend)`. Avoid duplicating a potentially large EPS buffer
// as a JavaScript string just to inspect those two regions.
const sampleSize = 1024 * 1024;
const source =
buffer.length <= sampleSize * 2
? buffer.toString("latin1")
: `${buffer.subarray(0, sampleSize).toString("latin1")}\n${buffer
.subarray(buffer.length - sampleSize)
.toString("latin1")}`;
const matches = [
...source.matchAll(
/^%%(?:HiRes)?BoundingBox:\s*(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)/gim,
),
];
const match = matches.at(-1);
if (match) {
// EPS is rasterized by this module at 300 DPI; bounding boxes use points.
return {
width: Math.ceil(((Number(match[3]) - Number(match[1])) * 300) / 72),
height: Math.ceil(((Number(match[4]) - Number(match[2])) * 300) / 72),
};
}
}
return undefined;
}
async function preflightEncodedDimensions(
buffer: Buffer,
format: string,
ext: string | undefined,
options: DecodeSafetyOptions,
): Promise<void> {
if (options.maxPixels === undefined && options.maxDimension === undefined) return;
options.signal?.throwIfAborted();
const known = readKnownEncodedDimensions(buffer, format);
if (
known &&
Number.isFinite(known.width) &&
Number.isFinite(known.height) &&
known.width > 0 &&
known.height > 0
) {
assertDimensionsWithinLimit(known.width, known.height, options);
return;
}
// ExifTool reads container metadata without rasterizing pixels and supports
// the hard-to-parse formats here (camera RAW, EXR, JXL and JPEG 2000). It is
// part of every supported container image alongside these decoders.
const id = randomUUID();
const safeExt = (ext || format || "img").replace(/[^a-z0-9]/gi, "") || "img";
const inputPath = join(tmpdir(), `dimensions-${id}.${safeExt}`);
try {
await writeTempExclusive(inputPath, buffer);
const { stdout } = await execFileAsync(
"exiftool",
["-fast2", "-s3", "-ImageWidth", "-ImageHeight", inputPath],
{
timeout: 15_000,
maxBuffer: 64 * 1024,
signal: options.signal,
},
);
const values = String(stdout).trim().split(/\s+/).map(Number);
if (values.length >= 2 && values.every(Number.isFinite)) {
assertDimensionsWithinLimit(values[0], values[1], options);
return;
}
} catch (error) {
options.signal?.throwIfAborted();
if (isImageSafetyError(error)) throw error;
} finally {
await rm(inputPath, { force: true }).catch(() => {});
}
throw new Error(
`Cannot safely decode ${format.toUpperCase()}: encoded dimensions are unavailable for the configured image safety limits`,
);
}
/**
* Write a buffer to a temp file exclusively (O_CREAT | O_EXCL | O_WRONLY).
* Prevents symlink / race-condition attacks on predictable temp paths.
@@ -63,53 +278,80 @@ export async function decodeToSharpCompat(
buffer: Buffer,
format: string,
ext?: string,
options: DecodeSafetyOptions = {},
): Promise<Buffer> {
options.signal?.throwIfAborted();
await preflightEncodedDimensions(buffer, format, ext, options);
let decoded: Buffer;
switch (format) {
case "raw":
return decodeRaw(buffer, ext);
decoded = await decodeRaw(buffer, ext, options);
break;
case "ico":
return decodeIco(buffer);
decoded = await decodeIco(buffer, options);
break;
case "psd":
return decodePsd(buffer);
decoded = await decodePsd(buffer, options);
break;
case "tga":
return decodeTga(buffer);
decoded = await decodeTga(buffer, options);
break;
case "exr":
return decodeExr(buffer);
decoded = await decodeExr(buffer, options);
break;
case "hdr":
return decodeHdr(buffer);
decoded = await decodeHdr(buffer, options);
break;
case "bmp":
return decodeBmp(buffer);
decoded = await decodeBmp(buffer, options);
break;
case "jxl":
return decodeJxl(buffer);
decoded = await decodeJxl(buffer, options);
break;
case "jp2":
return decodeJp2(buffer);
decoded = await decodeJp2(buffer, options);
break;
case "eps":
return decodeEps(buffer);
decoded = await decodeEps(buffer, options);
break;
case "dds":
return decodeDds(buffer);
decoded = await decodeDds(buffer, options);
break;
case "cur":
return decodeIco(buffer); // CUR is structurally identical to ICO
decoded = await decodeIco(buffer, options); // CUR is structurally identical to ICO
break;
case "dpx":
return decodeDpx(buffer);
decoded = await decodeDpx(buffer, options);
break;
case "fits":
return decodeFits(buffer);
decoded = await decodeFits(buffer, options);
break;
case "qoi":
return decodeQoi(buffer);
decoded = await decodeQoi(buffer, options);
break;
case "ppm":
case "pgm":
case "pbm":
return decodeNetpbm(buffer, format);
decoded = await decodeNetpbm(buffer, format, options);
break;
default:
return buffer;
decoded = buffer;
}
return assertDecodedWithinLimit(decoded, options);
}
/**
* Last-resort decode: convert any image to PNG via ImageMagick.
* Used when Sharp's bundled decoders fail (e.g. AVIF 2.0 bitstreams).
*/
export async function decodeAnyFormat(buffer: Buffer, format: string): Promise<Buffer> {
const cmd = await findMagickCmd();
export async function decodeAnyFormat(
buffer: Buffer,
format: string,
options: DecodeSafetyOptions = {},
): Promise<Buffer> {
options.signal?.throwIfAborted();
await preflightEncodedDimensions(buffer, format, undefined, options);
const cmd = await findMagickCmd(options);
const id = randomUUID();
const ext = format || "img";
const inputPath = join(tmpdir(), `any-in-${id}.${ext}`);
@@ -119,10 +361,10 @@ export async function decodeAnyFormat(buffer: Buffer, format: string): Promise<B
await writeTempExclusive(inputPath, buffer);
await execFileAsync(
cmd,
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", `png:${outputPath}`]),
{ timeout: 120_000 },
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", `png:${outputPath}`], options),
commandOptions(options, 120_000),
);
return await readFile(outputPath);
return await assertDecodedWithinLimit(await readFile(outputPath), options);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
@@ -133,28 +375,73 @@ export async function decodeAnyFormat(buffer: Buffer, format: string): Promise<B
let cachedMagickCmd: string | null = null;
async function findMagickCmd(): Promise<string> {
async function findMagickCmd(options: DecodeSafetyOptions = {}): Promise<string> {
options.signal?.throwIfAborted();
if (cachedMagickCmd) return cachedMagickCmd;
for (const cmd of ["magick", "convert"]) {
try {
await execFileAsync(cmd, ["--version"], { timeout: 5_000 });
await execFileAsync(cmd, ["--version"], commandOptions(options, 5_000));
cachedMagickCmd = cmd;
return cmd;
} catch {
options.signal?.throwIfAborted();
// try next
}
}
throw new Error("No ImageMagick found. Install imagemagick (provides convert/magick).");
}
function magickArgs(cmd: string, args: string[]): string[] {
return cmd === "magick" ? ["convert", ...args] : args;
/** Build resource limits with syntax shared by ImageMagick 6 and 7. */
export function buildImageMagickResourceLimitArgs(options: DecodeSafetyOptions = {}): string[] {
// Defense in depth for the conversion subprocess. ImageMagick's `area`
// limit is a cache/spill threshold, not the strict pixel-product check;
// preflightEncodedDimensions and assertDecodedWithinLimit provide that.
const pixelCacheBytes = options.maxPixels === undefined ? undefined : options.maxPixels * 16;
const sideLimit = options.maxDimension ?? options.maxPixels;
if (sideLimit === undefined || pixelCacheBytes === undefined) return [];
// Width and height are pixel counts when unitless. A trailing `P` is not a
// pixel unit: ImageMagick 6 treats it as an overflowing SI prefix and
// resolves the limit to zero, while ImageMagick 7 clamps it near infinity.
return [
"-limit",
"width",
String(sideLimit),
"-limit",
"height",
String(sideLimit),
"-limit",
"area",
`${pixelCacheBytes}B`,
"-limit",
"memory",
`${pixelCacheBytes}B`,
"-limit",
"map",
`${pixelCacheBytes}B`,
"-limit",
"disk",
`${pixelCacheBytes * 2}B`,
];
}
function magickArgs(cmd: string, args: string[], options: DecodeSafetyOptions = {}): string[] {
const limits = buildImageMagickResourceLimitArgs(options);
const convertArgs = [...limits, ...args];
return cmd === "magick" ? ["convert", ...convertArgs] : convertArgs;
}
function isImageSafetyError(error: unknown): error is Error {
return (
error instanceof Error &&
/(?:pixel (?:dimension )?safety limit|input image exceeds pixel limit)/i.test(error.message)
);
}
// ── ICO decoder ────────────────────────────────────────────────
async function decodeIco(buffer: Buffer): Promise<Buffer> {
const cmd = await findMagickCmd();
async function decodeIco(buffer: Buffer, options: DecodeSafetyOptions): Promise<Buffer> {
const cmd = await findMagickCmd(options);
const id = randomUUID();
const inputPath = join(tmpdir(), `ico-in-${id}.ico`);
const outputPath = join(tmpdir(), `ico-out-${id}.png`);
@@ -162,9 +449,11 @@ async function decodeIco(buffer: Buffer): Promise<Buffer> {
try {
await writeTempExclusive(inputPath, buffer);
// ICO contains multiple sizes; extract the largest by sorting
await execFileAsync(cmd, magickArgs(cmd, [`${inputPath}[-1]`, `png:${outputPath}`]), {
timeout: 120_000,
});
await execFileAsync(
cmd,
magickArgs(cmd, [`${inputPath}[-1]`, `png:${outputPath}`], options),
commandOptions(options, 120_000),
);
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
@@ -184,7 +473,11 @@ async function decodeIco(buffer: Buffer): Promise<Buffer> {
// ImageMagick delegate is last because on many distros it is the deprecated
// ufraw-batch, which fails outright on newer RAW formats (see issue #289).
async function decodeRaw(buffer: Buffer, ext?: string): Promise<Buffer> {
async function decodeRaw(
buffer: Buffer,
ext: string | undefined,
options: DecodeSafetyOptions,
): Promise<Buffer> {
const id = randomUUID();
// Use the original extension so LibRaw / ExifTool / ImageMagick can identify
// the RAW variant.
@@ -200,13 +493,19 @@ async function decodeRaw(buffer: Buffer, ext?: string): Promise<Buffer> {
// Attempt 1: dcraw_emu (direct LibRaw decode to TIFF) -- full resolution.
try {
await execFileAsync("dcraw_emu", ["-T", "-w", "-o", "1", inputPath], { timeout: 120_000 });
await execFileAsync(
"dcraw_emu",
["-T", "-w", "-o", "1", inputPath],
commandOptions(options, 120_000),
);
const tiffBuf = await readFile(dcrawOutput);
if (tiffBuf.length > 0) {
// Sharp handles TIFF natively.
return await sharp(tiffBuf).png().toBuffer();
return await sharp(tiffBuf, { limitInputPixels: options.maxPixels }).png().toBuffer();
}
} catch {
} catch (error) {
options.signal?.throwIfAborted();
if (error instanceof Error && /pixel safety limit/i.test(error.message)) throw error;
// dcraw_emu not available or unsupported format -- fall through
}
@@ -217,6 +516,7 @@ async function decodeRaw(buffer: Buffer, ext?: string): Promise<Buffer> {
encoding: "buffer",
maxBuffer: 50 * 1024 * 1024,
timeout: 30_000,
signal: options.signal,
} as never);
// stdout is a Buffer when encoding is "buffer"
const jpegBuf = stdout as unknown as Buffer;
@@ -225,6 +525,7 @@ async function decodeRaw(buffer: Buffer, ext?: string): Promise<Buffer> {
return jpegBuf;
}
} catch {
options.signal?.throwIfAborted();
// ExifTool not available or no embedded JPEG -- fall through
}
@@ -235,6 +536,7 @@ async function decodeRaw(buffer: Buffer, ext?: string): Promise<Buffer> {
encoding: "buffer",
maxBuffer: 50 * 1024 * 1024,
timeout: 30_000,
signal: options.signal,
} as never);
const previewBuf = stdout as unknown as Buffer;
if (
@@ -246,16 +548,21 @@ async function decodeRaw(buffer: Buffer, ext?: string): Promise<Buffer> {
return previewBuf;
}
} catch {
options.signal?.throwIfAborted();
// fall through
}
// Attempt 4: ImageMagick (last resort -- its RAW delegate may be the
// deprecated ufraw-batch, which fails on modern formats).
const cmd = await findMagickCmd();
const cmd = await findMagickCmd(options);
await execFileAsync(
cmd,
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", "-auto-orient", `png:${outputPath}`]),
{ timeout: 120_000 },
magickArgs(
cmd,
[inputPath, "-colorspace", "sRGB", "-auto-orient", `png:${outputPath}`],
options,
),
commandOptions(options, 120_000),
);
return await readFile(outputPath);
} finally {
@@ -271,17 +578,19 @@ async function decodeRaw(buffer: Buffer, ext?: string): Promise<Buffer> {
/**
* Decode PSD to PNG. Uses [0] to read only the flattened composite layer.
*/
async function decodePsd(buffer: Buffer): Promise<Buffer> {
const cmd = await findMagickCmd();
async function decodePsd(buffer: Buffer, options: DecodeSafetyOptions): Promise<Buffer> {
const cmd = await findMagickCmd(options);
const id = randomUUID();
const inputPath = join(tmpdir(), `psd-in-${id}.psd`);
const outputPath = join(tmpdir(), `psd-out-${id}.png`);
try {
await writeTempExclusive(inputPath, buffer);
await execFileAsync(cmd, magickArgs(cmd, [`${inputPath}[0]`, `png:${outputPath}`]), {
timeout: 120_000,
});
await execFileAsync(
cmd,
magickArgs(cmd, [`${inputPath}[0]`, `png:${outputPath}`], options),
commandOptions(options, 120_000),
);
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
@@ -292,17 +601,19 @@ async function decodePsd(buffer: Buffer): Promise<Buffer> {
/**
* Decode TGA to PNG.
*/
async function decodeTga(buffer: Buffer): Promise<Buffer> {
const cmd = await findMagickCmd();
async function decodeTga(buffer: Buffer, options: DecodeSafetyOptions): Promise<Buffer> {
const cmd = await findMagickCmd(options);
const id = randomUUID();
const inputPath = join(tmpdir(), `tga-in-${id}.tga`);
const outputPath = join(tmpdir(), `tga-out-${id}.png`);
try {
await writeTempExclusive(inputPath, buffer);
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), {
timeout: 120_000,
});
await execFileAsync(
cmd,
magickArgs(cmd, [inputPath, `png:${outputPath}`], options),
commandOptions(options, 120_000),
);
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
@@ -314,7 +625,7 @@ async function decodeTga(buffer: Buffer): Promise<Buffer> {
* Decode EXR to PNG. Colorspace conversion from linear to sRGB is needed
* because EXR files are typically stored in linear light.
*/
async function decodeExr(buffer: Buffer): Promise<Buffer> {
async function decodeExr(buffer: Buffer, options: DecodeSafetyOptions): Promise<Buffer> {
const id = randomUUID();
const inputPath = join(tmpdir(), `exr-in-${id}.exr`);
const outputPath = join(tmpdir(), `exr-out-${id}.png`);
@@ -324,21 +635,26 @@ async function decodeExr(buffer: Buffer): Promise<Buffer> {
// ImageMagick needs the OpenEXR delegate which is often missing on macOS
try {
const cmd = await findMagickCmd();
const cmd = await findMagickCmd(options);
await execFileAsync(
cmd,
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", "-depth", "8", `png:${outputPath}`]),
{ timeout: 120_000 },
magickArgs(
cmd,
[inputPath, "-colorspace", "sRGB", "-depth", "8", `png:${outputPath}`],
options,
),
commandOptions(options, 120_000),
);
return await readFile(outputPath);
} catch {
options.signal?.throwIfAborted();
// ImageMagick failed, try ffmpeg
}
await execFileAsync(
"ffmpeg",
["-y", "-i", inputPath, "-pix_fmt", "rgba", "-update", "1", outputPath],
{ timeout: 120_000 },
commandOptions(options, 120_000),
);
return await readFile(outputPath);
} finally {
@@ -350,8 +666,8 @@ async function decodeExr(buffer: Buffer): Promise<Buffer> {
/**
* Decode Radiance HDR to PNG. Same colorspace handling as EXR.
*/
async function decodeHdr(buffer: Buffer): Promise<Buffer> {
const cmd = await findMagickCmd();
async function decodeHdr(buffer: Buffer, options: DecodeSafetyOptions): Promise<Buffer> {
const cmd = await findMagickCmd(options);
const id = randomUUID();
const inputPath = join(tmpdir(), `hdr-in-${id}.hdr`);
const outputPath = join(tmpdir(), `hdr-out-${id}.png`);
@@ -360,8 +676,12 @@ async function decodeHdr(buffer: Buffer): Promise<Buffer> {
await writeTempExclusive(inputPath, buffer);
await execFileAsync(
cmd,
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", "-depth", "8", `png:${outputPath}`]),
{ timeout: 120_000 },
magickArgs(
cmd,
[inputPath, "-colorspace", "sRGB", "-depth", "8", `png:${outputPath}`],
options,
),
commandOptions(options, 120_000),
);
return await readFile(outputPath);
} finally {
@@ -370,17 +690,19 @@ async function decodeHdr(buffer: Buffer): Promise<Buffer> {
}
}
async function decodeBmp(buffer: Buffer): Promise<Buffer> {
const cmd = await findMagickCmd();
async function decodeBmp(buffer: Buffer, options: DecodeSafetyOptions): Promise<Buffer> {
const cmd = await findMagickCmd(options);
const id = randomUUID();
const inputPath = join(tmpdir(), `bmp-in-${id}.bmp`);
const outputPath = join(tmpdir(), `bmp-out-${id}.png`);
try {
await writeTempExclusive(inputPath, buffer);
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), {
timeout: 120_000,
});
await execFileAsync(
cmd,
magickArgs(cmd, [inputPath, `png:${outputPath}`], options),
commandOptions(options, 120_000),
);
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
@@ -388,7 +710,7 @@ async function decodeBmp(buffer: Buffer): Promise<Buffer> {
}
}
async function decodeJxl(buffer: Buffer): Promise<Buffer> {
async function decodeJxl(buffer: Buffer, options: DecodeSafetyOptions): Promise<Buffer> {
const id = randomUUID();
const inputPath = join(tmpdir(), `jxl-in-${id}.jxl`);
const outputPath = join(tmpdir(), `jxl-out-${id}.png`);
@@ -399,16 +721,19 @@ async function decodeJxl(buffer: Buffer): Promise<Buffer> {
// Try djxl first (from libjxl-tools) — works even when ImageMagick
// lacks a JXL delegate (common on Ubuntu stock packages).
try {
await execFileAsync("djxl", [inputPath, outputPath], { timeout: 120_000 });
await execFileAsync("djxl", [inputPath, outputPath], commandOptions(options, 120_000));
return await readFile(outputPath);
} catch {
options.signal?.throwIfAborted();
// djxl not available, fall back to ImageMagick
}
const cmd = await findMagickCmd();
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), {
timeout: 120_000,
});
const cmd = await findMagickCmd(options);
await execFileAsync(
cmd,
magickArgs(cmd, [inputPath, `png:${outputPath}`], options),
commandOptions(options, 120_000),
);
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
@@ -418,7 +743,7 @@ async function decodeJxl(buffer: Buffer): Promise<Buffer> {
// ── JPEG 2000 decoder (opj_decompress-first, ImageMagick fallback) ──
async function decodeJp2(buffer: Buffer): Promise<Buffer> {
async function decodeJp2(buffer: Buffer, options: DecodeSafetyOptions): Promise<Buffer> {
const id = randomUUID();
const inputPath = join(tmpdir(), `jp2-in-${id}.jp2`);
const outputPath = join(tmpdir(), `jp2-out-${id}.png`);
@@ -427,15 +752,19 @@ async function decodeJp2(buffer: Buffer): Promise<Buffer> {
try {
await execFileAsync("opj_decompress", ["-i", inputPath, "-o", outputPath], {
timeout: 60_000,
signal: options.signal,
});
return await readFile(outputPath);
} catch {
options.signal?.throwIfAborted();
// opj_decompress not available, fall back to ImageMagick
}
const cmd = await findMagickCmd();
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), {
timeout: 120_000,
});
const cmd = await findMagickCmd(options);
await execFileAsync(
cmd,
magickArgs(cmd, [inputPath, `png:${outputPath}`], options),
commandOptions(options, 120_000),
);
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
@@ -447,13 +776,13 @@ async function decodeJp2(buffer: Buffer): Promise<Buffer> {
const MAX_EPS_SIZE = 50 * 1024 * 1024;
async function decodeEps(buffer: Buffer): Promise<Buffer> {
async function decodeEps(buffer: Buffer, options: DecodeSafetyOptions): Promise<Buffer> {
if (buffer.length > MAX_EPS_SIZE) {
throw new Error(
`EPS file too large (${(buffer.length / 1024 / 1024).toFixed(1)}MB, limit: 50MB)`,
);
}
const cmd = await findMagickCmd();
const cmd = await findMagickCmd(options);
const id = randomUUID();
const inputPath = join(tmpdir(), `eps-in-${id}.eps`);
const outputPath = join(tmpdir(), `eps-out-${id}.png`);
@@ -461,17 +790,21 @@ async function decodeEps(buffer: Buffer): Promise<Buffer> {
await writeTempExclusive(inputPath, buffer);
await execFileAsync(
cmd,
magickArgs(cmd, [
"-density",
"300",
"-define",
"gs:MaxBitmap=500000000",
inputPath,
"-colorspace",
"sRGB",
`png:${outputPath}`,
]),
{ timeout: 30_000 },
magickArgs(
cmd,
[
"-density",
"300",
"-define",
"gs:MaxBitmap=500000000",
inputPath,
"-colorspace",
"sRGB",
`png:${outputPath}`,
],
options,
),
commandOptions(options, 30_000),
);
return await readFile(outputPath);
} finally {
@@ -482,16 +815,18 @@ async function decodeEps(buffer: Buffer): Promise<Buffer> {
// ── DDS decoder ──
async function decodeDds(buffer: Buffer): Promise<Buffer> {
const cmd = await findMagickCmd();
async function decodeDds(buffer: Buffer, options: DecodeSafetyOptions): Promise<Buffer> {
const cmd = await findMagickCmd(options);
const id = randomUUID();
const inputPath = join(tmpdir(), `dds-in-${id}.dds`);
const outputPath = join(tmpdir(), `dds-out-${id}.png`);
try {
await writeTempExclusive(inputPath, buffer);
await execFileAsync(cmd, magickArgs(cmd, [`${inputPath}[0]`, `png:${outputPath}`]), {
timeout: 120_000,
});
await execFileAsync(
cmd,
magickArgs(cmd, [`${inputPath}[0]`, `png:${outputPath}`], options),
commandOptions(options, 120_000),
);
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
@@ -501,8 +836,8 @@ async function decodeDds(buffer: Buffer): Promise<Buffer> {
// ── DPX / Cineon decoder ──
async function decodeDpx(buffer: Buffer): Promise<Buffer> {
const cmd = await findMagickCmd();
async function decodeDpx(buffer: Buffer, options: DecodeSafetyOptions): Promise<Buffer> {
const cmd = await findMagickCmd(options);
const id = randomUUID();
const inputPath = join(tmpdir(), `dpx-in-${id}.dpx`);
const outputPath = join(tmpdir(), `dpx-out-${id}.png`);
@@ -510,8 +845,8 @@ async function decodeDpx(buffer: Buffer): Promise<Buffer> {
await writeTempExclusive(inputPath, buffer);
await execFileAsync(
cmd,
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", `png:${outputPath}`]),
{ timeout: 120_000 },
magickArgs(cmd, [inputPath, "-colorspace", "sRGB", `png:${outputPath}`], options),
commandOptions(options, 120_000),
);
return await readFile(outputPath);
} finally {
@@ -522,8 +857,8 @@ async function decodeDpx(buffer: Buffer): Promise<Buffer> {
// ── FITS decoder ──
async function decodeFits(buffer: Buffer): Promise<Buffer> {
const cmd = await findMagickCmd();
async function decodeFits(buffer: Buffer, options: DecodeSafetyOptions): Promise<Buffer> {
const cmd = await findMagickCmd(options);
const id = randomUUID();
const inputPath = join(tmpdir(), `fits-in-${id}.fits`);
const outputPath = join(tmpdir(), `fits-out-${id}.png`);
@@ -531,14 +866,12 @@ async function decodeFits(buffer: Buffer): Promise<Buffer> {
await writeTempExclusive(inputPath, buffer);
await execFileAsync(
cmd,
magickArgs(cmd, [
`${inputPath}[0]`,
"-normalize",
"-colorspace",
"sRGB",
`png:${outputPath}`,
]),
{ timeout: 120_000 },
magickArgs(
cmd,
[`${inputPath}[0]`, "-normalize", "-colorspace", "sRGB", `png:${outputPath}`],
options,
),
commandOptions(options, 120_000),
);
return await readFile(outputPath);
} finally {
@@ -549,9 +882,16 @@ async function decodeFits(buffer: Buffer): Promise<Buffer> {
// ── QOI decoder ──
async function decodeQoi(buffer: Buffer): Promise<Buffer> {
async function decodeQoi(buffer: Buffer, options: DecodeSafetyOptions): Promise<Buffer> {
options.signal?.throwIfAborted();
if (buffer.length < 14 || buffer.subarray(0, 4).toString("ascii") !== "qoif") {
throw new Error("Invalid QOI header");
}
assertDimensionsWithinLimit(buffer.readUInt32BE(4), buffer.readUInt32BE(8), options);
const { qoiDecode } = await import("@snapotter/image-engine");
options.signal?.throwIfAborted();
const { header, pixels } = qoiDecode(new Uint8Array(buffer));
assertDimensionsWithinLimit(header.width, header.height, options);
return sharp(Buffer.from(pixels), {
raw: { width: header.width, height: header.height, channels: 4 },
})
@@ -561,20 +901,28 @@ async function decodeQoi(buffer: Buffer): Promise<Buffer> {
// ── Netpbm (PPM/PGM/PBM) decoder ──
async function decodeNetpbm(buffer: Buffer, format: string): Promise<Buffer> {
async function decodeNetpbm(
buffer: Buffer,
format: string,
options: DecodeSafetyOptions,
): Promise<Buffer> {
options.signal?.throwIfAborted();
try {
return await sharp(buffer).png().toBuffer();
return await sharp(buffer, { limitInputPixels: options.maxPixels }).png().toBuffer();
} catch {
const cmd = await findMagickCmd();
options.signal?.throwIfAborted();
const cmd = await findMagickCmd(options);
const id = randomUUID();
const ext = format === "pgm" ? "pgm" : format === "pbm" ? "pbm" : "ppm";
const inputPath = join(tmpdir(), `netpbm-in-${id}.${ext}`);
const outputPath = join(tmpdir(), `netpbm-out-${id}.png`);
try {
await writeTempExclusive(inputPath, buffer);
await execFileAsync(cmd, magickArgs(cmd, [inputPath, `png:${outputPath}`]), {
timeout: 120_000,
});
await execFileAsync(
cmd,
magickArgs(cmd, [inputPath, `png:${outputPath}`], options),
commandOptions(options, 120_000),
);
return await readFile(outputPath);
} finally {
await rm(inputPath, { force: true }).catch(() => {});
+131 -9
View File
@@ -5,9 +5,59 @@ import { open, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import sharp from "sharp";
const execFileAsync = promisify(execFile);
export interface HeicDecodeOptions {
maxDimension?: number;
maxPixels?: number;
signal?: AbortSignal;
}
function assertImageLimits(
width: number | undefined,
height: number | undefined,
maxPixels: number | undefined,
maxDimension: number | undefined,
): void {
if (width === undefined || height === undefined || width <= 0 || height <= 0) {
return;
}
if (maxDimension !== undefined && (width > maxDimension || height > maxDimension)) {
throw new Error(
`Decoded image exceeds the ${maxDimension.toLocaleString("en-US")} pixel dimension safety limit (${width.toLocaleString("en-US")}x${height.toLocaleString("en-US")})`,
);
}
if (maxPixels !== undefined && width * height > maxPixels) {
throw new Error(
`Decoded image exceeds the ${maxPixels.toLocaleString("en-US")} pixel safety limit (${width}x${height})`,
);
}
}
function readIspeDimensions(buffer: Buffer): Array<{ width: number; height: number }> {
// HEIF stores the display dimensions in an Image Spatial Extents (`ispe`)
// full box. Reading it avoids invoking a pixel decoder merely to enforce a
// pre-decode allocation bound.
let offset = 0;
const dimensions: Array<{ width: number; height: number }> = [];
while (offset + 20 <= buffer.length) {
const index = buffer.indexOf("ispe", offset, "ascii");
if (index < 0 || index + 16 > buffer.length) break;
const boxStart = index - 4;
const boxSize = boxStart >= 0 ? buffer.readUInt32BE(boxStart) : 0;
if (boxSize >= 20 && boxStart + boxSize <= buffer.length) {
dimensions.push({
width: buffer.readUInt32BE(index + 8),
height: buffer.readUInt32BE(index + 12),
});
}
offset = index + 4;
}
return dimensions;
}
/**
* Write a buffer to a temp file exclusively (O_CREAT | O_EXCL | O_WRONLY).
* Prevents symlink / race-condition attacks on predictable temp paths.
@@ -27,14 +77,19 @@ async function writeTempExclusive(filePath: string, buffer: Buffer): Promise<voi
*/
let cachedDecodeCmd: string | null = null;
async function findDecodeCmd(): Promise<string> {
async function findDecodeCmd(options: HeicDecodeOptions = {}): Promise<string> {
options.signal?.throwIfAborted();
if (cachedDecodeCmd) return cachedDecodeCmd;
for (const cmd of ["heif-convert", "heif-dec"]) {
try {
await execFileAsync(cmd, ["--version"], { timeout: 5_000 });
await execFileAsync(cmd, ["--version"], {
timeout: 5_000,
signal: options.signal,
});
cachedDecodeCmd = cmd;
return cmd;
} catch {
options.signal?.throwIfAborted();
// try next
}
}
@@ -50,8 +105,44 @@ async function findDecodeCmd(): Promise<string> {
* to add numeric suffixes (-1, -2, ...) to the output filename. We try the
* exact path first, then fall back to the -1 suffixed path.
*/
export async function decodeHeic(buffer: Buffer): Promise<Buffer> {
const cmd = await findDecodeCmd();
export async function decodeHeic(buffer: Buffer, options: HeicDecodeOptions = {}): Promise<Buffer> {
options.signal?.throwIfAborted();
const encodedDimensions = readIspeDimensions(buffer);
for (const dimensions of encodedDimensions) {
assertImageLimits(dimensions.width, dimensions.height, options.maxPixels, options.maxDimension);
}
if (
(options.maxPixels !== undefined || options.maxDimension !== undefined) &&
encodedDimensions.length === 0
) {
let dimensionsVerified = false;
try {
const metadata = await sharp(buffer, {
limitInputPixels: options.maxPixels ?? false,
}).metadata();
assertImageLimits(metadata.width, metadata.height, options.maxPixels, options.maxDimension);
dimensionsVerified =
metadata.width !== undefined &&
metadata.height !== undefined &&
metadata.width > 0 &&
metadata.height > 0;
} catch (error) {
// Sharp often has enough libheif support for metadata but not HEVC pixel
// decode. Only turn a proven size-limit failure into a rejection.
if (
error instanceof Error &&
/(?:pixel (?:dimension )?safety limit|input image exceeds pixel limit)/i.test(error.message)
) {
throw error;
}
}
if (!dimensionsVerified) {
throw new Error("Cannot safely decode HEIF: encoded dimensions are unavailable");
}
}
const cmd = await findDecodeCmd(options);
// Include the PID so concurrent processes (and test workers) write to
// distinct, attributable temp paths in the shared tmpdir.
const id = `${process.pid}-${randomUUID()}`;
@@ -61,14 +152,41 @@ export async function decodeHeic(buffer: Buffer): Promise<Buffer> {
try {
await writeTempExclusive(inputPath, buffer);
await execFileAsync(cmd, [inputPath, outputPath], { timeout: 120_000 });
await execFileAsync(cmd, [inputPath, outputPath], {
timeout: 120_000,
signal: options.signal,
});
options.signal?.throwIfAborted();
// Single-image HEIF: exact filename. Multi-image: -1 suffix on first image.
let decoded: Buffer;
try {
return await readFile(outputPath);
decoded = await readFile(outputPath);
} catch {
return await readFile(suffixedPath);
decoded = await readFile(suffixedPath);
}
if (options.maxPixels !== undefined || options.maxDimension !== undefined) {
try {
const metadata = await sharp(decoded, {
limitInputPixels: options.maxPixels ?? false,
}).metadata();
assertImageLimits(metadata.width, metadata.height, options.maxPixels, options.maxDimension);
} catch (error) {
if (
error instanceof Error &&
/(?:pixel (?:dimension )?safety limit|input image exceeds pixel limit)/i.test(
error.message,
)
) {
throw error;
}
throw new Error("Decoded image exceeds the configured image safety limits", {
cause: error,
});
}
}
options.signal?.throwIfAborted();
return decoded;
} finally {
await rm(inputPath, { force: true }).catch(() => {});
await rm(outputPath, { force: true }).catch(() => {});
@@ -95,10 +213,14 @@ function isHeifBuffer(buffer: Buffer): boolean {
* Ensure a buffer is decodable by Sharp. HEIC/HEIF buffers are decoded to
* PNG via the system decoder; all other formats pass through unchanged.
*/
export async function ensureSharpCompat(buffer: Buffer): Promise<Buffer> {
export async function ensureSharpCompat(
buffer: Buffer,
options: HeicDecodeOptions = {},
): Promise<Buffer> {
if (isHeifBuffer(buffer)) {
return decodeHeic(buffer);
return decodeHeic(buffer, options);
}
options.signal?.throwIfAborted();
return buffer;
}
+9 -4
View File
@@ -9,7 +9,7 @@ export interface MultipartFilePart {
filename: string;
encoding: string;
mimetype: string;
file: Readable;
file: Readable & { truncated?: boolean };
}
export interface MultipartFieldPart {
@@ -38,13 +38,18 @@ const DONE = Symbol("multipart-done");
* signal (a client abort surfaces as an "error" on the stream and as a
* truncated-part error from busboy).
*/
export async function* multipartParts(request: FastifyRequest): AsyncGenerator<MultipartPart> {
export async function* multipartParts(
request: FastifyRequest,
limits: { fileSize?: number; files?: number } = {},
): AsyncGenerator<MultipartPart> {
const raw = request.raw;
const bb = new Busboy({
headers: raw.headers as BusboyHeaders,
limits: {
fileSize: env.MAX_UPLOAD_SIZE_MB > 0 ? env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 : undefined,
files: env.MAX_BATCH_SIZE > 0 ? env.MAX_BATCH_SIZE : undefined,
fileSize:
limits.fileSize ??
(env.MAX_UPLOAD_SIZE_MB > 0 ? env.MAX_UPLOAD_SIZE_MB * 1024 * 1024 : undefined),
files: limits.files ?? (env.MAX_BATCH_SIZE > 0 ? env.MAX_BATCH_SIZE : undefined),
},
});
+133 -17
View File
@@ -1,5 +1,6 @@
import { randomUUID } from "node:crypto";
import { createReadStream, createWriteStream, existsSync } from "node:fs";
import { mkdir, readdir, rm, stat, statfs, unlink, writeFile } from "node:fs/promises";
import { mkdir, readdir, rename, rm, stat, statfs, unlink, writeFile } from "node:fs/promises";
import { dirname, join, normalize, sep } from "node:path";
import type { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
@@ -116,34 +117,55 @@ export async function putObject(key: string, data: Buffer): Promise<void> {
export async function putObjectStream(
key: string,
source: Readable,
opts: { maxBytes?: number } = {},
opts: { maxBytes?: number; signal?: AbortSignal } = {},
): Promise<number> {
assertValidKey(key);
opts.signal?.throwIfAborted();
const abortSource = () => {
const reason =
opts.signal?.reason instanceof Error
? opts.signal.reason
: Object.assign(new Error("The operation was aborted"), { name: "AbortError" });
source.destroy(reason);
};
opts.signal?.addEventListener("abort", abortSource, { once: true });
let written = 0;
const counter = async function* (src: AsyncIterable<Buffer>) {
for await (const chunk of src) {
opts.signal?.throwIfAborted();
written += chunk.length;
if (opts.maxBytes && written > opts.maxBytes) {
throw new Error(`Upload exceeds the maximum allowed size (${opts.maxBytes} bytes)`);
if (opts.maxBytes !== undefined && written > opts.maxBytes) {
throw objectSizeLimitError(opts.maxBytes);
}
yield chunk;
}
};
if (isS3Enabled()) {
const s3 = await getS3();
await s3.putGenericObjectStream(key, counter(source));
return written;
}
await assertLocalCapacity();
const p = localPath(key);
await mkdir(dirname(p), { recursive: true });
try {
await pipeline(counter(source), createWriteStream(p));
} catch (err) {
await unlink(p).catch(() => {});
throw err;
if (isS3Enabled()) {
const s3 = await getS3();
try {
await s3.putGenericObjectStream(key, counter(source), opts.signal);
opts.signal?.throwIfAborted();
return written;
} catch (error) {
await s3.deleteGenericObject(key).catch(() => {});
throw error;
}
}
const p = localPath(key);
try {
await assertLocalCapacity();
await mkdir(dirname(p), { recursive: true });
await pipeline(counter(source), createWriteStream(p), { signal: opts.signal });
opts.signal?.throwIfAborted();
} catch (err) {
await unlink(p).catch(() => {});
throw normalizeOperationalWriteError(err);
}
return written;
} finally {
opts.signal?.removeEventListener("abort", abortSource);
}
return written;
}
export async function getObjectStream(
@@ -164,6 +186,100 @@ export async function getObjectBuffer(key: string): Promise<Buffer> {
return Buffer.concat(chunks);
}
export interface CopyObjectToFileOptions {
/** Hard ceiling enforced both from object metadata and while streaming. */
maxBytes: number;
signal?: AbortSignal;
}
export interface CopyReadableToFileOptions {
maxBytes?: number;
signal?: AbortSignal;
}
function objectSizeLimitError(maxBytes: number): Error & { statusCode: 413 } {
return Object.assign(new Error(`Object exceeds the maximum allowed size (${maxBytes} bytes)`), {
statusCode: 413 as const,
});
}
function normalizeOperationalWriteError(error: unknown): unknown {
const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined;
if (error instanceof Error && code && ["EACCES", "EDQUOT", "ENOSPC", "EROFS"].includes(code)) {
return Object.assign(error, { statusCode: 503 });
}
return error;
}
/**
* Copy one object into scratch storage without materializing it as a Buffer.
*
* The metadata check avoids downloading a known-oversized S3 object, while
* the streaming counter remains authoritative if the object changes between
* the size lookup and read. Partial destinations are always removed.
*/
export async function copyObjectToFile(
key: string,
destination: string,
opts: CopyObjectToFileOptions,
): Promise<number> {
if (!Number.isSafeInteger(opts.maxBytes) || opts.maxBytes < 0) {
throw new Error("maxBytes must be a non-negative safe integer");
}
opts.signal?.throwIfAborted();
const declaredSize = await getObjectSize(key);
opts.signal?.throwIfAborted();
if (declaredSize > opts.maxBytes) throw objectSizeLimitError(opts.maxBytes);
const source = await getObjectStream(key);
return copyReadableToFile(source, destination, opts);
}
/** Atomically spool a readable to scratch with bounded memory and cleanup. */
export async function copyReadableToFile(
source: Readable,
destination: string,
opts: CopyReadableToFileOptions = {},
): Promise<number> {
if (opts.maxBytes !== undefined && (!Number.isSafeInteger(opts.maxBytes) || opts.maxBytes < 0)) {
throw new Error("maxBytes must be a non-negative safe integer");
}
opts.signal?.throwIfAborted();
const stagingPath = `${destination}.${randomUUID()}.partial`;
let written = 0;
const countAndLimit = async function* (chunks: AsyncIterable<Buffer | Uint8Array | string>) {
for await (const chunk of chunks) {
opts.signal?.throwIfAborted();
const bytes = typeof chunk === "string" ? Buffer.byteLength(chunk) : chunk.byteLength;
written += bytes;
if (opts.maxBytes !== undefined && written > opts.maxBytes) {
throw objectSizeLimitError(opts.maxBytes);
}
yield chunk;
}
};
try {
await mkdir(dirname(destination), { recursive: true });
// Create the staging inode before constructing the stream. A write stream
// opened with `wx` may fail the pipeline before its asynchronous open has
// completed; cleanup can then observe ENOENT and the delayed open can leave
// an orphan behind. Opening the already-created file with `r+` cannot
// recreate it after cleanup.
await writeFile(stagingPath, Buffer.alloc(0), { flag: "wx", mode: 0o600 });
await pipeline(countAndLimit(source), createWriteStream(stagingPath, { flags: "r+" }), {
signal: opts.signal,
});
opts.signal?.throwIfAborted();
await rename(stagingPath, destination);
return written;
} catch (error) {
await unlink(stagingPath).catch(() => {});
throw normalizeOperationalWriteError(error);
}
}
export async function getObjectSize(key: string): Promise<number> {
assertValidKey(key);
if (isS3Enabled()) {
+134
View File
@@ -0,0 +1,134 @@
import {
FAST_KOREAN_UNSUPPORTED_REASON,
getOcrRuntimeCapability,
type OcrRuntimeCapability,
type OcrRuntimeQuality,
} from "@snapotter/ai";
export type OcrIngressQuality = "fast" | OcrRuntimeQuality;
export const OCR_FAST_KOREAN_GUIDANCE = FAST_KOREAN_UNSUPPORTED_REASON;
export type OcrIngressResolution =
| {
ok: true;
settings: unknown;
}
| {
ok: false;
code: "FEATURE_NOT_INSTALLED" | "FEATURE_INCOMPATIBLE";
reason: string;
requestedQuality: OcrIngressQuality;
guidance?: string;
};
type CapabilityReader = () => OcrRuntimeCapability;
export interface OcrIngressOptions {
/** Original client settings, before a schema applies defaults. */
requestedSettings?: unknown;
/** Test seam for the filesystem-backed capability reader. */
readCapability?: CapabilityReader;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function isOcrQuality(value: unknown): value is OcrIngressQuality {
return value === "fast" || value === "balanced" || value === "best";
}
/**
* Pin an OCR quality tier at API ingress and enforce the optional runtime's
* advertised capabilities. Callers must pass settings only after their tool
* schema has accepted them; the returned settings are the value persisted on
* the job, preventing a queued job from silently selecting another tier.
*/
export function resolveOcrIngressSettings(
toolId: string,
settings: unknown,
options: OcrIngressOptions = {},
): OcrIngressResolution {
if (toolId !== "ocr" && toolId !== "ocr-pdf") {
return { ok: true, settings };
}
const parsedSettings = isRecord(settings) ? settings : {};
const requestedSettings = isRecord(options.requestedSettings)
? options.requestedSettings
: parsedSettings;
const readCapability = options.readCapability ?? getOcrRuntimeCapability;
const explicitQuality = isOcrQuality(requestedSettings.quality)
? requestedSettings.quality
: undefined;
const legacyQuality =
explicitQuality === undefined
? requestedSettings.engine === "tesseract"
? "fast"
: requestedSettings.engine === "paddleocr"
? "balanced"
: undefined
: undefined;
let capability: OcrRuntimeCapability | undefined;
let quality = explicitQuality ?? legacyQuality;
if (!quality) {
capability = readCapability();
if (parsedSettings.language === "ko") {
quality =
capability.available && capability.qualities.includes("best")
? "best"
: capability.available && capability.qualities.includes("balanced")
? "balanced"
: "best";
} else {
quality =
capability.available && capability.qualities.includes("best")
? "best"
: capability.available && capability.qualities.includes("balanced")
? "balanced"
: "fast";
}
}
const { engine: _legacyEngine, ...normalizedSettings } = parsedSettings;
const settingsWithQuality = { ...normalizedSettings, quality };
if (quality === "fast") {
if (parsedSettings.language === "ko") {
return {
ok: false,
code: "FEATURE_INCOMPATIBLE",
reason: "fast-korean-unsupported",
requestedQuality: "fast",
guidance: OCR_FAST_KOREAN_GUIDANCE,
};
}
return { ok: true, settings: settingsWithQuality };
}
capability ??= readCapability();
if (capability.available) {
if (capability.qualities.includes(quality)) {
return { ok: true, settings: settingsWithQuality };
}
return {
ok: false,
code: "FEATURE_INCOMPATIBLE",
reason: "quality-not-supported",
requestedQuality: quality,
};
}
return {
ok: false,
code:
capability.status === "invalid" || capability.status === "incompatible"
? "FEATURE_INCOMPATIBLE"
: "FEATURE_NOT_INSTALLED",
reason: capability.reason,
requestedQuality: quality,
};
}
+16
View File
@@ -0,0 +1,16 @@
import { MAX_OCR_INPUT_DIMENSION, MAX_OCR_INPUT_PIXELS } from "@snapotter/ai";
import type { PreparedInput } from "../modality/contract.js";
import { inputHandlerFor } from "../modality/input-handler.js";
/** Safely normalize an image before an OCR batch or pipeline stores it. */
export function prepareOcrIngressImage(
input: Buffer,
filename: string,
scratchDir: string,
): Promise<PreparedInput> {
return inputHandlerFor("image").prepare(input, filename, {
scratchDir,
maxDimension: MAX_OCR_INPUT_DIMENSION,
maxPixels: MAX_OCR_INPUT_PIXELS,
});
}
+81
View File
@@ -0,0 +1,81 @@
/** Hard ceiling for each encoded image or PDF accepted by every OCR ingress path. */
export const OCR_MAX_ENCODED_INPUT_BYTES = 512 * 1024 * 1024;
/** Independent aggregate ceiling for all encoded objects in one OCR batch request. */
export const OCR_MAX_BATCH_ENCODED_INPUT_BYTES = OCR_MAX_ENCODED_INPUT_BYTES;
export interface OcrUploadLimits {
/** Maximum encoded bytes for each individual input object. */
fileBytes: number;
/** Maximum encoded bytes across all input objects in one request. */
aggregateBytes: number;
}
export interface OcrEncodedInputViolation {
scope: "file" | "aggregate";
limitBytes: number;
}
/** Apply the operator's smaller limit without allowing unlimited/large values to remove OCR's cap. */
export function resolveOcrEncodedInputLimit(maxUploadSizeMb: number): number {
if (!Number.isFinite(maxUploadSizeMb) || maxUploadSizeMb <= 0) {
return OCR_MAX_ENCODED_INPUT_BYTES;
}
return Math.min(Math.floor(maxUploadSizeMb * 1024 * 1024), OCR_MAX_ENCODED_INPUT_BYTES);
}
/** Keep the operator's configured limit per file; the OCR aggregate has its own hard ceiling. */
export function resolveOcrUploadLimits(maxUploadSizeMb: number): OcrUploadLimits {
return {
fileBytes: resolveOcrEncodedInputLimit(maxUploadSizeMb),
aggregateBytes: OCR_MAX_BATCH_ENCODED_INPUT_BYTES,
};
}
/** Validate buffered ingress sizes when the route could not identify OCR until after multipart. */
export function findOcrEncodedInputViolation(
inputBytes: readonly number[],
maxUploadSizeMb: number,
): OcrEncodedInputViolation | null {
const limits = resolveOcrUploadLimits(maxUploadSizeMb);
let aggregateBytes = 0;
for (const bytes of inputBytes) {
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > limits.fileBytes) {
return { scope: "file", limitBytes: limits.fileBytes };
}
aggregateBytes += bytes;
if (aggregateBytes > limits.aggregateBytes) {
return { scope: "aggregate", limitBytes: limits.aggregateBytes };
}
}
return null;
}
/** Preserve a useful HTTP status across Fastify and object-storage limit errors. */
export function ocrUploadErrorStatus(error: unknown): 400 | 413 | 503 {
if (
typeof error === "object" &&
error !== null &&
"statusCode" in error &&
error.statusCode === 503
) {
return 503;
}
if (
(typeof error === "object" &&
error !== null &&
"statusCode" in error &&
error.statusCode === 413) ||
(error instanceof Error &&
/(?:file too large|upload exceeds.*(?:maximum|limit))/i.test(error.message))
) {
return 413;
}
return 400;
}
export function ocrUploadErrorMessage(statusCode: 400 | 413 | 503): string {
if (statusCode === 503) return "Upload storage unavailable";
if (statusCode === 413) return "Upload exceeds the allowed size";
return "Failed to parse multipart request";
}
+52
View File
@@ -0,0 +1,52 @@
import { createReadStream } from "node:fs";
import { join } from "node:path";
import type { MultipartFile } from "@fastify/multipart";
import { validatePdfPath } from "../modality/document-input.js";
import { sanitizeFilename } from "./filename.js";
import { copyReadableToFile, deleteObject, putObjectStream } from "./object-storage.js";
export interface SpooledMultipartFile {
path: string;
filename: string;
size: number;
}
export function configuredUploadLimit(maxUploadSizeMb: number): number | undefined {
if (!Number.isFinite(maxUploadSizeMb) || maxUploadSizeMb <= 0) return undefined;
return Math.floor(maxUploadSizeMb * 1024 * 1024);
}
/** Stream a multipart file to request scratch without accumulating chunks. */
export async function spoolMultipartFile(
part: MultipartFile,
scratchDir: string,
index: number,
opts: { maxBytes?: number; signal?: AbortSignal } = {},
): Promise<SpooledMultipartFile> {
const filename = sanitizeFilename(part.filename || "file");
const path = join(scratchDir, `${index}-${filename}`);
const size = await copyReadableToFile(part.file, path, opts);
return { path, filename, size };
}
/** Validate a spooled PDF by path, then stream the same bytes to its final object ref. */
export async function storeValidatedOcrPdf(
file: SpooledMultipartFile,
key: string,
opts: { maxBytes: number; signal?: AbortSignal },
): Promise<number> {
await validatePdfPath(file.path, {
rejectPasswordProtected: true,
signal: opts.signal,
});
try {
const written = await putObjectStream(key, createReadStream(file.path), opts);
if (written !== file.size) {
throw new Error(`OCR PDF scratch file changed size (${file.size} to ${written} bytes)`);
}
return written;
} catch (error) {
await deleteObject(key).catch(() => {});
throw error;
}
}
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
import { mkdir, mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { env } from "../config.js";
const SAFE_SCRATCH_PREFIX = /^[a-z][a-z0-9-]{0,31}$/;
function routeScratchRoot(): string {
return env.SCRATCH_PATH || join(tmpdir(), "snapotter-scratch");
}
/**
* Own a request-scoped scratch root for exactly one operation.
*
* Keeping creation and recursive cleanup in one primitive makes early HTTP
* returns and thrown decoder errors follow the same cleanup path.
*/
export async function withRouteScratch<T>(
prefix: string,
operation: (path: string) => Promise<T>,
): Promise<T> {
if (!SAFE_SCRATCH_PREFIX.test(prefix)) {
throw new Error("Route scratch prefix must be a safe path component");
}
const root = routeScratchRoot();
let path: string;
try {
await mkdir(root, { recursive: true });
path = await mkdtemp(join(root, `${prefix}-`));
} catch (error) {
const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined;
if (error instanceof Error && code && ["EACCES", "EDQUOT", "ENOSPC", "EROFS"].includes(code)) {
throw Object.assign(error, { statusCode: 503 });
}
throw error;
}
try {
return await operation(path);
} finally {
await rm(path, { recursive: true, force: true });
}
}
+5 -2
View File
@@ -20,10 +20,13 @@ export interface ReceivedUpload {
export async function receiveUpload(
part: MultipartFile,
jobId: string,
opts: { maxBytes?: number } = {},
opts: { maxBytes?: number; signal?: AbortSignal } = {},
): Promise<ReceivedUpload> {
const filename = sanitizeFilename(part.filename || "upload");
const key = `uploads/${jobId}/${filename}`;
const size = await putObjectStream(key, part.file, { maxBytes: opts.maxBytes });
const size = await putObjectStream(key, part.file, {
maxBytes: opts.maxBytes,
signal: opts.signal,
});
return { key, filename, size };
}