mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
- 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>
94 lines
4.2 KiB
TypeScript
94 lines
4.2 KiB
TypeScript
/**
|
||
* @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);
|
||
}
|
||
}
|