Files
HealthLog/src/lib/documents/thumbnail.ts
T
Marc-André Bombeck eb102ac50c chore(release): v1.28.15 — document thumbnails; admin-shared AI access
Document vault shows a per-file thumbnail: a server-rendered preview,
downscaled and re-encoded so location and camera metadata are dropped,
served only to the file's owner over an authenticated request. Files that
cannot be previewed keep their type icon. Generation is bounded — a header
sniff refuses an over-large source before decode, so a crafted image cannot
exhaust the preview worker.

Also folds in admin-managed central AI access — an administrator connects
one access on the server and each user opts in or keeps their own provider,
off until an admin turns it on — and trims the redundant documents-search
caption and per-file searchable/read badges.
2026-07-10 01:32:28 +02:00

274 lines
10 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 { 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 {
// 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);
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 };
}
}