Files
HealthLog/src/lib/documents/thumbnail.ts
T
Marc-André Bombeck 45da6c3c7d chore(release): v1.28.27 — runs on CPUs without AVX2
The bundled Skia x64 build (document thumbnails, scanned-PDF rasterization)
uses AVX2 instructions; on an x86-64 host without them — Celeron/Atom-class
NAS CPUs, pre-2013 Xeons, VMs masking CPU flags — the first canvas call is an
uncatchable SIGILL that kills the process and crash-loops the container (live
incident: a Synology DS1520+, exit 132 ~18 s after every boot, reported with
kernel traps and the correct root cause in hand).

The renderer is now gated on a one-time CPU-feature check before the module
ever loads: linux/x64 requires the avx2 cpuinfo flag; arm64 and non-Linux are
unaffected; unreadable cpuinfo fails open. Behind a closed gate, thumbnail
generation and PDF rasterization take their existing fail-soft paths (vault
shows type icons; PDFs read as text) and the boot-time thumbnail backfill
skips enqueueing. NATIVE_CANVAS=off|on overrides detection, documented in the
env example and whitelisted in compose.
2026-07-11 10:02:29 +02:00

303 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Server-side preview-thumbnail generation for the document vault.
*
* Turns a stored original into a small JPEG the vault card renders at its
* leading edge: an image is decoded + downscaled; a PDF's FIRST page is
* rasterised (via `rasterizePdf`, bounded to one page) then downscaled; every
* other MIME type has no preview (the card keeps its kind icon).
*
* `@napi-rs/canvas` + pdfjs are pulled LAZILY via runtime `import()` — pdfjs's
* module top-level references the browser-only `DOMMatrix` global, so a STATIC
* import would make the Turbopack server chunk evaluate that reference at
* instantiation and throw `DOMMatrix is not defined`, taking down every
* route/worker sharing the chunk. The lazy import (the exact pattern
* `rasterize-pdf.ts` documents) keeps both out of the eager chunk. Rendering
* is pure local compute — no network, nothing written to disk.
*
* Security: the thumbnail is produced by decode → draw onto a FRESH canvas →
* `toBuffer("image/jpeg")`. A canvas re-encode emits a brand-new JPEG carrying
* NO source metadata (EXIF, GPS, orientation, maker notes are all dropped), so
* the preview is metadata-free by construction — a defence-in-depth bonus over
* the original blob.
*
* NEVER throws: a malformed / unrenderable image or PDF, a missing native
* binary, or any render error resolves to `{ ok: false }`, and the caller
* leaves the document without a thumbnail (the card falls back to its kind
* icon). This preserves the "a bad document never aborts an upload/backfill"
* contract.
*/
import { Buffer } from "node:buffer";
import { nativeCanvasSupported } from "./native-canvas-support";
import { rasterizePdf } from "@/lib/documents/rasterize-pdf";
import { annotate } from "@/lib/logging/context";
/** Longest-edge pixel cap for the preview — a legible card tile at ~10-25 KB. */
export const THUMB_LONG_EDGE = 320;
/** JPEG quality (0-100). q70 keeps the preview crisp at a small byte cost. */
export const THUMB_QUALITY = 70;
/**
* Source-pixel ceiling enforced BEFORE decode. `loadImage` allocates the full
* decoded bitmap (W×H×4 bytes) before we downscale, so a tiny, highly-compressed
* image that declares enormous dimensions — a decompression bomb — would balloon
* the decode buffer and OOM the serial thumbnail worker, crash-looping it and
* denying background-job processing to every tenant. 60 MP (~240 MB RGBA) admits
* any real photo or high-DPI document scan (a 600-DPI A4 is ~35 MP) while refusing
* the multi-hundred-MP bombs. The header sniff below reads the declared size
* cheaply; anything over the cap gets no preview.
*/
export const MAX_SOURCE_PIXELS = 60_000_000;
/** Image MIME types the canvas decoder can downscale directly. */
const THUMBNAILABLE_IMAGE_MIMES = new Set([
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
]);
/**
* Read a source image's pixel count (width × height) from its container header
* WITHOUT decoding the bitmap — the guard that keeps a decompression bomb out of
* `loadImage`. Covers the four thumbnailable formats. Returns null when the
* header can't be read (a malformed file `loadImage` will itself reject, or a
* format we don't pre-screen); a bomb necessarily carries a parseable header
* declaring huge dimensions, so a null here is never the bomb case.
*/
function sniffSourcePixels(bytes: Buffer, mimeType: string): number | null {
try {
switch (mimeType) {
case "image/png": {
// 8-byte signature + 4-byte length + "IHDR" → width@16, height@20 (BE).
if (bytes.length < 24 || bytes.readUInt32BE(12) !== 0x49484452)
return null;
return bytes.readUInt32BE(16) * bytes.readUInt32BE(20);
}
case "image/gif": {
// "GIF8" + logical-screen descriptor: width@6, height@8 (uint16 LE).
if (bytes.length < 10) return null;
return bytes.readUInt16LE(6) * bytes.readUInt16LE(8);
}
case "image/jpeg":
return sniffJpegPixels(bytes);
case "image/webp":
return sniffWebpPixels(bytes);
default:
return null;
}
} catch {
return null;
}
}
/** Walk JPEG marker segments to the first Start-Of-Frame and read its W×H. */
function sniffJpegPixels(bytes: Buffer): number | null {
if (bytes.length < 4 || bytes.readUInt16BE(0) !== 0xffd8) return null; // SOI
let offset = 2;
while (offset + 9 < bytes.length) {
if (bytes[offset] !== 0xff) return null;
const marker = bytes[offset + 1]!;
const segLen = bytes.readUInt16BE(offset + 2);
if (segLen < 2) return null;
// SOF0-15 carry dimensions, EXCEPT DHT (C4), JPG (C8), DAC (CC).
const isSof =
marker >= 0xc0 &&
marker <= 0xcf &&
marker !== 0xc4 &&
marker !== 0xc8 &&
marker !== 0xcc;
if (isSof) {
const height = bytes.readUInt16BE(offset + 5);
const width = bytes.readUInt16BE(offset + 7);
return width * height;
}
offset += 2 + segLen;
}
return null;
}
/** Read W×H from a WebP RIFF header (VP8 lossy / VP8L lossless / VP8X ext). */
function sniffWebpPixels(bytes: Buffer): number | null {
if (bytes.length < 30) return null;
if (bytes.readUInt32BE(0) !== 0x52494646) return null; // "RIFF"
if (bytes.readUInt32BE(8) !== 0x57454250) return null; // "WEBP"
const fourcc = bytes.toString("ascii", 12, 16);
if (fourcc === "VP8 ") {
// Lossy: 14-bit width/height (LE) at 26/28.
const w = bytes.readUInt16LE(26) & 0x3fff;
const h = bytes.readUInt16LE(28) & 0x3fff;
return w * h;
}
if (fourcc === "VP8L") {
// Lossless: 14-bit each, bit-packed after the 1-byte signature at 21.
const b0 = bytes[21]!;
const b1 = bytes[22]!;
const b2 = bytes[23]!;
const b3 = bytes[24]!;
const w = 1 + (((b1 & 0x3f) << 8) | b0);
const h = 1 + (((b3 & 0x0f) << 10) | (b2 << 2) | ((b1 & 0xc0) >> 6));
return w * h;
}
if (fourcc === "VP8X") {
// Extended: 24-bit LE (stored value + 1) width@24, height@27.
const w = 1 + (bytes[24]! | (bytes[25]! << 8) | (bytes[26]! << 16));
const h = 1 + (bytes[27]! | (bytes[28]! << 8) | (bytes[29]! << 16));
return w * h;
}
return null;
}
/** The generated preview plus its decoded dimensions (for `Content-Length`). */
export interface Thumbnail {
jpeg: Buffer;
width: number;
height: number;
}
/** Best-effort generation outcome — never an exception. */
export type ThumbnailResult =
{ ok: true; thumbnail: Thumbnail } | { ok: false };
// Minimal structural types for the slice of `@napi-rs/canvas` this module uses.
// A type-only shape (erased at build) so we never eagerly evaluate the module
// for its types; the real module is pulled at runtime via `import()`.
interface CanvasImage {
width: number;
height: number;
}
interface CanvasContext2D {
drawImage(
image: CanvasImage,
dx: number,
dy: number,
dWidth: number,
dHeight: number,
): void;
}
interface CanvasLike {
getContext(type: "2d"): CanvasContext2D;
toBuffer(mime: "image/jpeg", quality?: number): Buffer;
}
interface NapiCanvasModule {
createCanvas(width: number, height: number): CanvasLike;
loadImage(source: Buffer | Uint8Array): Promise<CanvasImage>;
}
/**
* Downscale a decoded image onto a fresh JPEG whose long edge is ≤
* `THUMB_LONG_EDGE` (never upscaled). Shared by the image and PDF paths.
*/
function drawScaledJpeg(
napi: NapiCanvasModule,
image: CanvasImage,
): Thumbnail | null {
const srcW = image.width;
const srcH = image.height;
if (srcW <= 0 || srcH <= 0) return null;
const longEdge = Math.max(srcW, srcH);
// Cap the long edge, never upscale (scale ≤ 1).
const scale = Math.min(1, THUMB_LONG_EDGE / longEdge);
const width = Math.max(1, Math.round(srcW * scale));
const height = Math.max(1, Math.round(srcH * scale));
const canvas = napi.createCanvas(width, height);
const ctx = canvas.getContext("2d");
ctx.drawImage(image, 0, 0, width, height);
const jpeg = canvas.toBuffer("image/jpeg", THUMB_QUALITY);
if (jpeg.byteLength === 0) return null;
return { jpeg, width, height };
}
/**
* Generate a small JPEG preview for a stored document, or `{ ok: false }` when
* the type is unsupported or rendering fails. Never throws.
*/
export async function generateThumbnail(
bytes: Buffer,
mimeType: string,
): Promise<ThumbnailResult> {
try {
// CPU-feature gate BEFORE the module ever loads: the bundled Skia x64
// build uses AVX2, and on a CPU without it the first canvas call is an
// uncatchable SIGILL that kills the whole process (crash-loop incident on
// a Celeron NAS). With the gate closed, thumbnails simply do not exist —
// the vault falls back to kind icons.
if (!nativeCanvasSupported()) {
annotate({
action: { name: "documents.thumbnail.rejected" },
meta: { reason: "native_canvas_unsupported" },
});
return { ok: false };
}
// Lazy import: keeps @napi-rs/canvas (+ transitively pdfjs on the PDF path)
// out of the eager server chunk. Resolved on first generation, then cached.
const napi =
(await import("@napi-rs/canvas")) as unknown as NapiCanvasModule;
if (THUMBNAILABLE_IMAGE_MIMES.has(mimeType)) {
// Refuse a decompression bomb BEFORE `loadImage` allocates the bitmap.
const pixels = sniffSourcePixels(bytes, mimeType);
if (pixels !== null && pixels > MAX_SOURCE_PIXELS) {
annotate({
action: { name: "documents.thumbnail.rejected" },
meta: { source: "image", reason: "pixel_cap", pixels },
});
return { ok: false };
}
const image = await napi.loadImage(bytes);
// Post-decode backstop: the header sniff returns null on any marker
// desync (a malformed-but-decodable JPEG), falling through to here with
// NO ceiling. Re-check the DECODED dimensions so such a source can't
// bypass the cap and OOM the serial worker.
if (image.width * image.height > MAX_SOURCE_PIXELS) {
annotate({
action: { name: "documents.thumbnail.rejected" },
meta: {
source: "image",
reason: "pixel_cap_post_decode",
pixels: image.width * image.height,
},
});
return { ok: false };
}
const thumb = drawScaledJpeg(napi, image);
if (!thumb) return { ok: false };
annotate({
action: { name: "documents.thumbnail.ok" },
meta: { source: "image", width: thumb.width, height: thumb.height },
});
return { ok: true, thumbnail: thumb };
}
if (mimeType === "application/pdf") {
// Page 1 only — a preview never renders the whole document.
const raster = await rasterizePdf(bytes, 1);
if (!raster.ok || raster.images.length === 0) return { ok: false };
const pageJpeg = Buffer.from(raster.images[0]!.dataBase64, "base64");
const image = await napi.loadImage(pageJpeg);
const thumb = drawScaledJpeg(napi, image);
if (!thumb) return { ok: false };
annotate({
action: { name: "documents.thumbnail.ok" },
meta: { source: "pdf", width: thumb.width, height: thumb.height },
});
return { ok: true, thumbnail: thumb };
}
// Every other MIME (Office/text/TIFF/HEIC/XML/JSON) — no preview.
return { ok: false };
} catch (err) {
annotate({
action: { name: "documents.thumbnail.failed" },
meta: {
mimeType,
reason: err instanceof Error ? err.name : "unknown",
message: err instanceof Error ? err.message.slice(0, 300) : String(err),
},
});
return { ok: false };
}
}