mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-15 23:12:04 +00:00
feat(documents): preview thumbnails in the vault overview
Render a small JPEG preview for each stored document and show it at the card's leading edge, falling back to the kind icon when none exists. - Generate previews in the background on upload (new document-thumbnail pg-boss queue) plus a boot-time corpus backfill for existing documents; upload never blocks on or fails because of it. Images are downscaled via @napi-rs/canvas, a PDF's first page is rasterised then downscaled, every other type is skipped. Generation never throws. - Store the preview encrypted at rest in a 1:1 DocumentThumbnail side table (migration 0236) so the list/detail queries that omit the original blob never drag the preview either. Same AES-256-GCM string codec + fail-closed decrypt as the content index; wired into the key-rotation walk. - Serve it through an owner-scoped GET /api/documents/inbound/[id]/thumbnail (auth + module gate + userId-scoped query + fail-closed decrypt), always image/jpeg + nosniff + private, no-store; 404 on miss. The card renders an authed lazy <img>, onError falls back to the icon. - Add hasThumbnail to the document DTO so the card only fetches a preview that exists. Also drop the "search looks inside your documents" caption from the overview (the index-all prompt stays) and the per-card searchable marker; both were noise. Remove the now-unused i18n keys across all six locales.
This commit is contained in:
1 parent
54c78b527e
commit
ee76672798
34 files changed
+1342
-160
No files matched your search
@@ -92,6 +92,13 @@ describe("rasterizePdf", () => {
|
||||
expect(result.images).toHaveLength(RASTER_MAX_PAGES);
|
||||
});
|
||||
|
||||
it("honours a caller maxPages bound (thumbnail path renders page 1 only)", async () => {
|
||||
const result = await rasterizePdf(buildPdf(5, true), 1);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.images).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("returns { ok: false } and never throws on malformed input", async () => {
|
||||
await expect(
|
||||
rasterizePdf(Buffer.from("this is not a pdf")),
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import { createCanvas } from "@napi-rs/canvas";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({ annotate: vi.fn() }));
|
||||
|
||||
import { generateThumbnail, THUMB_LONG_EDGE } from "../thumbnail";
|
||||
|
||||
/**
|
||||
* Preview-thumbnail generation downscales an image (or a PDF's first page) to a
|
||||
* small JPEG, bounded to `THUMB_LONG_EDGE` on the long edge, and NEVER throws:
|
||||
* an unsupported MIME or malformed bytes resolve to `{ ok: false }` so the card
|
||||
* falls back to its kind icon. The re-encode always emits a fresh JPEG (EXIF/GPS
|
||||
* stripped by construction).
|
||||
*/
|
||||
|
||||
/** A solid-colour source image at the given size, encoded to the given type. */
|
||||
function sourceImage(
|
||||
width: number,
|
||||
height: number,
|
||||
mime: "image/jpeg" | "image/png" | "image/webp",
|
||||
): Buffer {
|
||||
const canvas = createCanvas(width, height);
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.fillStyle = "#3366cc";
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
if (mime === "image/png") return canvas.toBuffer("image/png");
|
||||
if (mime === "image/webp") return canvas.toBuffer("image/webp");
|
||||
return canvas.toBuffer("image/jpeg", 90);
|
||||
}
|
||||
|
||||
/** JPEG magic bytes. */
|
||||
function isJpeg(buf: Buffer): boolean {
|
||||
return buf.subarray(0, 2).toString("hex") === "ffd8";
|
||||
}
|
||||
|
||||
describe("generateThumbnail", () => {
|
||||
it("downscales a large JPEG to a bounded JPEG preview", async () => {
|
||||
const result = await generateThumbnail(
|
||||
sourceImage(1200, 800, "image/jpeg"),
|
||||
"image/jpeg",
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(isJpeg(result.thumbnail.jpeg)).toBe(true);
|
||||
expect(Math.max(result.thumbnail.width, result.thumbnail.height)).toBe(
|
||||
THUMB_LONG_EDGE,
|
||||
);
|
||||
// Landscape source keeps its aspect ratio (320 × 213).
|
||||
expect(result.thumbnail.width).toBe(THUMB_LONG_EDGE);
|
||||
expect(result.thumbnail.height).toBe(213);
|
||||
});
|
||||
|
||||
it("renders a PNG source to a JPEG preview", async () => {
|
||||
const result = await generateThumbnail(
|
||||
sourceImage(640, 640, "image/png"),
|
||||
"image/png",
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(isJpeg(result.thumbnail.jpeg)).toBe(true);
|
||||
expect(result.thumbnail.width).toBe(THUMB_LONG_EDGE);
|
||||
expect(result.thumbnail.height).toBe(THUMB_LONG_EDGE);
|
||||
});
|
||||
|
||||
it("never upscales a small source", async () => {
|
||||
const result = await generateThumbnail(
|
||||
sourceImage(100, 60, "image/jpeg"),
|
||||
"image/jpeg",
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(result.thumbnail.width).toBe(100);
|
||||
expect(result.thumbnail.height).toBe(60);
|
||||
});
|
||||
|
||||
it("renders a PDF's first page to a JPEG preview", async () => {
|
||||
// A tiny hand-assembled one-page graphics PDF (no text layer needed).
|
||||
const content = "0 0 1 rg 20 20 160 160 re f";
|
||||
const bodies: string[] = [];
|
||||
bodies[1] = "<< /Type /Catalog /Pages 2 0 R >>";
|
||||
bodies[2] = "<< /Type /Pages /Kids [3 0 R] /Count 1 >>";
|
||||
bodies[3] =
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Contents 4 0 R >>";
|
||||
bodies[4] = `<< /Length ${Buffer.byteLength(content, "latin1")} >>\nstream\n${content}\nendstream`;
|
||||
let out = "%PDF-1.4\n";
|
||||
const offsets: number[] = [];
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
offsets[i] = Buffer.byteLength(out, "latin1");
|
||||
out += `${i} 0 obj\n${bodies[i]}\nendobj\n`;
|
||||
}
|
||||
const xrefOffset = Buffer.byteLength(out, "latin1");
|
||||
out += "xref\n0 5\n0000000000 65535 f \n";
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
out += `${String(offsets[i]).padStart(10, "0")} 00000 n \n`;
|
||||
}
|
||||
out += `trailer\n<< /Size 5 /Root 1 0 R >>\nstartxref\n${xrefOffset}\n%%EOF`;
|
||||
|
||||
const result = await generateThumbnail(
|
||||
Buffer.from(out, "latin1"),
|
||||
"application/pdf",
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) return;
|
||||
expect(isJpeg(result.thumbnail.jpeg)).toBe(true);
|
||||
expect(
|
||||
Math.max(result.thumbnail.width, result.thumbnail.height),
|
||||
).toBeLessThanOrEqual(THUMB_LONG_EDGE);
|
||||
});
|
||||
|
||||
it("returns { ok: false } for an unsupported MIME type", async () => {
|
||||
await expect(
|
||||
generateThumbnail(Buffer.from("plain text"), "text/plain"),
|
||||
).resolves.toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it("returns { ok: false } and never throws on malformed image bytes", async () => {
|
||||
await expect(
|
||||
generateThumbnail(Buffer.from("not a real image"), "image/png"),
|
||||
).resolves.toEqual({ ok: false });
|
||||
});
|
||||
|
||||
it("returns { ok: false } on a malformed PDF", async () => {
|
||||
await expect(
|
||||
generateThumbnail(Buffer.from("not a pdf"), "application/pdf"),
|
||||
).resolves.toEqual({ ok: false });
|
||||
});
|
||||
});
|
||||
@@ -112,12 +112,16 @@ interface PdfjsModule {
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the first `RASTER_MAX_PAGES` pages of a PDF to bounded JPEG images.
|
||||
* Returns `{ ok: false }` on any failure (malformed PDF, render throw, missing
|
||||
* binary, or zero renderable pages) — the caller degrades to local text or
|
||||
* un-indexed. Never throws.
|
||||
* Render the first `RASTER_MAX_PAGES` pages of a PDF to bounded JPEG images
|
||||
* (or fewer when `maxPages` is passed — the thumbnail path renders page 1
|
||||
* only). Returns `{ ok: false }` on any failure (malformed PDF, render throw,
|
||||
* missing binary, or zero renderable pages) — the caller degrades to local
|
||||
* text or un-indexed. Never throws.
|
||||
*/
|
||||
export async function rasterizePdf(buffer: Buffer): Promise<RasterResult> {
|
||||
export async function rasterizePdf(
|
||||
buffer: Buffer,
|
||||
maxPages: number = RASTER_MAX_PAGES,
|
||||
): Promise<RasterResult> {
|
||||
let doc: PdfDocument | null = null;
|
||||
try {
|
||||
// Lazy import: keeps pdfjs (DOMMatrix) + @napi-rs/canvas out of the eager
|
||||
@@ -133,7 +137,9 @@ export async function rasterizePdf(buffer: Buffer): Promise<RasterResult> {
|
||||
});
|
||||
doc = await task.promise;
|
||||
|
||||
const pageCount = Math.min(doc.numPages, RASTER_MAX_PAGES);
|
||||
// Never exceed the standing DoS ceiling, even if a caller asks for more.
|
||||
const cap = Math.min(Math.max(1, maxPages), RASTER_MAX_PAGES);
|
||||
const pageCount = Math.min(doc.numPages, cap);
|
||||
const images: RasterImage[] = [];
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= pageCount; pageNumber++) {
|
||||
|
||||
@@ -91,6 +91,28 @@ export function decryptDocumentContent(buf: Uint8Array, codec: string): Buffer {
|
||||
throw new Error(`Unknown document content codec '${codec}'`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a JPEG preview thumbnail into the `Bytes` payload the schema stores
|
||||
* (`DocumentThumbnail.thumbnailEncrypted`). Uses the shared AES-256-GCM string
|
||||
* codec (base64 of the binary → `encrypt()` string → UTF-8 bytes), the same
|
||||
* `encrypt()`-string-as-bytes shape `DocumentContentIndex.textEncrypted` uses —
|
||||
* so the standard key-rotation walk (`rotateBytesColumn`) and corpus scan cover
|
||||
* it with no binary-codec special-casing. A scanned medical preview is PHI,
|
||||
* same posture (versioned key id, fail-closed decrypt) as every other column.
|
||||
*/
|
||||
export function encryptThumbnail(jpeg: Buffer): Uint8Array<ArrayBuffer> {
|
||||
return encryptToBytes(jpeg.toString("base64"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a stored thumbnail's `Bytes` payload back to the JPEG. Fail-closed:
|
||||
* a bad / missing key id throws — the serve route treats a throw as "cannot
|
||||
* serve" and never falls back to the ciphertext.
|
||||
*/
|
||||
export function decryptThumbnail(buf: Uint8Array): Buffer {
|
||||
return Buffer.from(decryptFromBytes(buf), "base64");
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt a staged fact's FHIR-staged payload into the `Bytes` column the
|
||||
* schema stores. The structured clinical values (diagnosis text, lab values,
|
||||
@@ -137,6 +159,7 @@ export function serialiseDocument(
|
||||
conditionLinks: DocumentConditionLinkDto[] = [],
|
||||
hasContentIndex = false,
|
||||
contentIndexSource: DocumentContentIndexSourceValue | null = null,
|
||||
hasThumbnail = false,
|
||||
): InboundDocumentDto {
|
||||
return {
|
||||
id: doc.id,
|
||||
@@ -160,6 +183,7 @@ export function serialiseDocument(
|
||||
servingClass: servingClassFor(doc.mimeType),
|
||||
hasContentIndex,
|
||||
contentIndexSource: hasContentIndex ? contentIndexSource : null,
|
||||
hasThumbnail,
|
||||
createdAt: doc.createdAt.toISOString(),
|
||||
updatedAt: doc.updatedAt.toISOString(),
|
||||
};
|
||||
@@ -187,6 +211,7 @@ export function serialiseDocumentDetail(
|
||||
conditionLinks: DocumentConditionLinkDto[] = [],
|
||||
hasContentIndex = false,
|
||||
contentIndexSource: DocumentContentIndexSourceValue | null = null,
|
||||
hasThumbnail = false,
|
||||
): InboundDocumentDetailDto {
|
||||
const pendingCount = facts.filter((f) => f.status === "PENDING").length;
|
||||
// `factCount` excludes REJECTED facts (a rejected fact is discarded, not part
|
||||
@@ -199,6 +224,7 @@ export function serialiseDocumentDetail(
|
||||
conditionLinks,
|
||||
hasContentIndex,
|
||||
contentIndexSource,
|
||||
hasThumbnail,
|
||||
),
|
||||
facts: facts.map(serialiseFact),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* 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;
|
||||
|
||||
/** Image MIME types the canvas decoder can downscale directly. */
|
||||
const THUMBNAILABLE_IMAGE_MIMES = new Set([
|
||||
"image/jpeg",
|
||||
"image/png",
|
||||
"image/webp",
|
||||
"image/gif",
|
||||
]);
|
||||
|
||||
/** 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)) {
|
||||
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 };
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user