mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-09 16:59:43 +02:00
fix(review): worker-thread HEIC conversion with bomb guard, concurrency cap, and magic-byte routing (PR #151)
- Event-loop blockage: HEIC decode/encode (CPU-synchronous libheif WASM + jpeg-js) now runs in a per-conversion worker_threads Worker (src/web/heic-jpeg-worker.ts, spawned by heic-jpeg-converter.ts) with resourceLimits and a 30s hard timeout that terminates the worker — verified end-to-end under tsx and against compiled dist/ output with a real iPhone HEIC (event-loop max stall 52ms during conversion). - No server-side concurrency cap: conversions now acquire a slot from the existing global runWithConversionLimit() pool (document-conversion-limiter), bounding peak decode memory/CPU across simultaneous uploads. - Decompression bomb: header-declared dimensions are read via heic-decode's allocation-free `.all` path and rejected above 64MP BEFORE decode() can allocate width*height*4 bytes (a <300-byte crafted file can declare 30000x30000 = 3.6GB). Regression-tested with a crafted ISOBMFF fixture against the real heic-decode WASM (test/heic-jpeg-core.test.ts). - Mislabeled HEIC (documented Android/MIUI case): conversion now routes on ftyp magic-byte sniff of the raw buffer regardless of declared ext/Content-Type, so a HEIF uploaded as image/jpeg converts instead of 415ing; the magic-mismatch 415 only fires for genuinely unrecognized bytes. - Brand allowlist narrowed to what heic-decode's isHeic() accepts (heim/heis/hevm/hevs dropped — they could only ever fail conversion). - Converted-output size: the JPEG result is checked against MAX_PASTE_IMAGE_BYTES (jpeg-js can inflate a within-limit HEIC past the cap). - Deps: heic-convert replaced with its underlying heic-decode + jpeg-js (the wrapper could not expose the pre-decode dimension check); lockfile synced, drops pngjs. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @fileoverview HEIC/HEIF → JPEG conversion core + worker-thread entry.
|
||||
*
|
||||
* Spawned per conversion by `heic-jpeg-converter.ts` so the CPU-synchronous
|
||||
* libheif WASM decode + jpeg-js encode never run on the server's main thread
|
||||
* (on the event loop they would freeze every session's SSE/PTY/WS handling
|
||||
* for seconds per photo). Input arrives via `workerData`; the result (or
|
||||
* error message) is posted back as a single message and the thread exits.
|
||||
*
|
||||
* The conversion logic lives in this same file (exported, guarded bootstrap)
|
||||
* rather than a sibling module: the worker runs from `.ts` source under tsx
|
||||
* in dev, where relative `.js` imports don't resolve inside worker threads —
|
||||
* only `node:` builtins are imported at top level. Unit tests import
|
||||
* `convertHeicBufferToJpeg` directly; the bootstrap only runs when spawned
|
||||
* with our `workerData` shape.
|
||||
*
|
||||
* Decompression-bomb guard: heic-decode's `.all` path exposes the
|
||||
* header-declared {width, height} per image WITHOUT decoding pixels, while
|
||||
* its plain decode path allocates `width * height * 4` bytes straight from
|
||||
* those header values — a <1KB crafted file declaring 30000×30000 would
|
||||
* demand a 3.6GB allocation. We reject anything above MAX_HEIC_DECODE_PIXELS
|
||||
* before calling `decode()`.
|
||||
*/
|
||||
|
||||
import { parentPort, workerData } from 'node:worker_threads';
|
||||
|
||||
/** Max header-declared pixel count we will decode (64MP ≈ 256MB RGBA). */
|
||||
export const MAX_HEIC_DECODE_PIXELS = 64_000_000;
|
||||
|
||||
/** JPEG quality used for converted HEIC uploads (matches heic-convert's default). */
|
||||
export const HEIC_JPEG_QUALITY = 0.92;
|
||||
|
||||
export interface HeicWorkerInput {
|
||||
heicInput: Uint8Array;
|
||||
quality: number;
|
||||
}
|
||||
|
||||
export type HeicWorkerResult = { ok: true; data: Uint8Array } | { ok: false; error: string };
|
||||
|
||||
/**
|
||||
* Convert HEIC/HEIF bytes to JPEG bytes. Throws on non-HEIC input, empty
|
||||
* containers, over-limit dimensions, and non-JPEG encoder output.
|
||||
*/
|
||||
export async function convertHeicBufferToJpeg(input: Uint8Array, quality: number = HEIC_JPEG_QUALITY): Promise<Buffer> {
|
||||
const { default: decode } = await import('heic-decode');
|
||||
const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input.buffer, input.byteOffset, input.byteLength);
|
||||
const images = await decode.all({ buffer });
|
||||
try {
|
||||
if (images.length === 0) throw new Error('no image found in HEIC container');
|
||||
const { width, height } = images[0];
|
||||
if (
|
||||
!Number.isSafeInteger(width) ||
|
||||
!Number.isSafeInteger(height) ||
|
||||
width <= 0 ||
|
||||
height <= 0 ||
|
||||
width * height > MAX_HEIC_DECODE_PIXELS
|
||||
) {
|
||||
throw new Error(
|
||||
`HEIC dimensions ${width}x${height} exceed the ${Math.floor(MAX_HEIC_DECODE_PIXELS / 1_000_000)}MP decode limit`
|
||||
);
|
||||
}
|
||||
const decoded = await images[0].decode();
|
||||
const { encode } = await import('jpeg-js');
|
||||
// Same output path as heic-convert's JPEG format (jpeg-js at quality*100).
|
||||
const jpeg = encode(
|
||||
{ data: decoded.data, width: decoded.width, height: decoded.height },
|
||||
Math.floor(quality * 100)
|
||||
).data;
|
||||
const jpegBytes = Buffer.isBuffer(jpeg) ? jpeg : Buffer.from(jpeg);
|
||||
if (jpegBytes.length < 3 || jpegBytes[0] !== 0xff || jpegBytes[1] !== 0xd8 || jpegBytes[2] !== 0xff) {
|
||||
throw new Error('HEIC conversion did not produce JPEG bytes');
|
||||
}
|
||||
return jpegBytes;
|
||||
} finally {
|
||||
images.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Worker bootstrap ──────────────────────────────────────────────────────
|
||||
// Runs only when spawned by heic-jpeg-converter.ts: requires a parent port
|
||||
// AND our exact workerData shape, so importing this module from the main
|
||||
// thread (or a test runner's own worker pool) stays inert.
|
||||
const request = workerData as HeicWorkerInput | null | undefined;
|
||||
if (parentPort && request && request.heicInput instanceof Uint8Array && typeof request.quality === 'number') {
|
||||
const port = parentPort;
|
||||
try {
|
||||
const jpegBytes = await convertHeicBufferToJpeg(request.heicInput, request.quality);
|
||||
port.postMessage({ ok: true, data: jpegBytes } satisfies HeicWorkerResult);
|
||||
} catch (err: unknown) {
|
||||
const error = err instanceof Error ? err.message : String(err);
|
||||
port.postMessage({ ok: false, error } satisfies HeicWorkerResult);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user