Merge pull request #459 from MBombeck/release/v1.28.15

release/v1.28.15
This commit is contained in:
Marc-André Bombeck authored and GitHub committed 2026-07-10 01:44:10 +02:00
commit fcff168112
37 files changed
+1490 -163

No files matched your search

+18
View File
@@ -2,6 +2,24 @@
## [Unreleased]
## [1.28.15] — 2026-07-10 — Document thumbnails; admin-shared AI access
The documents library now shows a thumbnail for each file — a small preview
rendered on the server, downscaled and re-encoded so location and camera
metadata are dropped, and served only to the file's owner over an authenticated
request. Files that cannot be previewed keep their type icon, and a file that
fails to render simply shows the icon rather than an error.
An administrator can connect one central AI access on the server and offer it to
the people who use it: each user decides in their own settings whether to use the
shared access or keep their own provider. It is set up in the admin area and
stays off until an admin turns it on, so anyone already using their own key is
unaffected.
Also trims two now-redundant pieces of the documents view: the caption noting
that search covers file contents, and the per-file searchable/read badges. The
same actions remain available without the extra chrome.
## [1.28.13] — 2026-07-10 — Sharing a document shares only the document
Fixes a scope bug: a link created from a document carried the full health record
+38 -3
View File
@@ -1,7 +1,7 @@
openapi: 3.1.0
info:
title: HealthLog API
version: 1.28.13
version: 1.28.15
description: >-
Self-hosted personal-health-tracking PWA — public API surface for the iOS native client and external ingest.
@@ -9374,6 +9374,34 @@ paths:
"401": *a1
"422": *a3
"429": *a4
/api/documents/inbound/{id}/thumbnail:
get:
tags:
- Documents
summary: View the document preview thumbnail
description: "Decrypts and serves the document's small JPEG preview thumbnail (~320px long edge), rendered in the
background from the original and stored encrypted at rest under `DocumentThumbnail.thumbnailEncrypted`.
Owner-scoped + gated on the opt-in `inboundDocuments` module. Always `Content-Type: image/jpeg` + `nosniff` +
`Cache-Control: private, no-store`; loaded as an `<img>` subresource. 404 when no thumbnail exists yet (freshly
uploaded / still rendering / unsupported type) — the caller shows a placeholder. Fails closed (500) on a decrypt
error — it never returns the ciphertext."
parameters:
- name: id
in: path
required: true
schema:
type: string
responses:
"200":
description: The decrypted JPEG preview thumbnail bytes.
content:
image/jpeg:
schema:
type: string
format: binary
"401": *a1
"422": *a3
"429": *a4
/api/documents/inbound/{id}/extract:
post:
tags:
@@ -26923,6 +26951,8 @@ components:
- local-pdf
- local-ocr
- type: "null"
hasThumbnail:
type: boolean
createdAt:
type: string
updatedAt:
@@ -26945,6 +26975,7 @@ components:
- servingClass
- hasContentIndex
- contentIndexSource
- hasThumbnail
- createdAt
- updatedAt
additionalProperties: false
@@ -26955,8 +26986,9 @@ components:
download-only (`attachment`); render/download by it, never by MIME guess. `hasContentIndex` is true when the
document has a content-search index (auto-indexed on upload); `contentIndexSource` says how that index was
produced — `vision` means an AI provider read the original, the other values are provider-free local
extractions, and it is null when `hasContentIndex` is false. Treat an unknown `kind` value as OTHER when
decoding.
extractions, and it is null when `hasContentIndex` is false. `hasThumbnail` is true when a preview thumbnail has
been rendered in the background (fetch it from `/api/documents/inbound/{id}/thumbnail`); false means no preview
yet or an unsupported type — show a placeholder. Treat an unknown `kind` value as OTHER when decoding.
DocumentConditionLink:
type: object
properties:
@@ -27242,6 +27274,8 @@ components:
- local-pdf
- local-ocr
- type: "null"
hasThumbnail:
type: boolean
createdAt:
type: string
updatedAt:
@@ -27268,6 +27302,7 @@ components:
- servingClass
- hasContentIndex
- contentIndexSource
- hasThumbnail
- createdAt
- updatedAt
- facts
+1 -25
View File
@@ -250,12 +250,6 @@ test.describe("document vault — AI assist + content search", () => {
await expect(card).toBeVisible({ timeout: 10_000 });
await expect(page).toHaveURL(new RegExp(`q=${CONTENT_BODY_WORD}`));
// The matched card advertises its searchable body.
const searchable = page
.locator('[data-slot="document-card"]', { hasText: "Radiology note" })
.locator('[data-slot="document-searchable"]');
await expect(searchable).toBeVisible();
// Sanity: the word really is absent from the short fields (server-side
// confirmation the union — not an ILIKE — did the work).
const viaApi = await page.request.get(
@@ -365,7 +359,7 @@ test.describe("document vault — AI assist + content search", () => {
test("a vision-indexed document surfaces the AI-read provenance", async ({
page,
}, testInfo) => {
}) => {
// CONTENT_DOC_ID is seeded with source "vision" (an AI provider read it).
// No mocks: the real list/detail GET threads the provenance through.
await page.goto(`/documents?doc=${CONTENT_DOC_ID}`);
@@ -377,24 +371,6 @@ test.describe("document vault — AI assist + content search", () => {
const status = sheet.locator('[data-slot="content-search-status"]');
await expect(status).toHaveAttribute("data-state", "ai-read");
await expect(status).toHaveText(/Read by AI/);
// The timeline card carries the same AI-read marker — desktop only. The
// vault timeline is virtualized; on the narrow mobile viewport the seeded
// card renders outside the initial virtual window (not in the DOM), so a
// card-level attribute read there tests the virtualizer, not the marker.
// The marker's render logic is viewport-independent and covered by the SSR
// card test + this desktop assertion; mobile provenance is already proven
// by the status pill above.
if (testInfo.project.name !== "chromium-mobile") {
await page.keyboard.press("Escape");
const marker = page
.locator('[data-slot="document-card"]', { hasText: "Radiology note" })
.locator('[data-slot="document-searchable"]')
.first();
await expect(marker).toHaveAttribute("data-source", "ai-read", {
timeout: 20_000,
});
}
});
// ── (f) Text-mode refuses a non-image before any OCR/upload ──────────────
+1 -4
View File
@@ -8198,9 +8198,7 @@
"openLabel": "{title} öffnen",
"attachmentBadge": "Nur Download",
"uploading": "Wird hochgeladen…",
"dismissFailed": "Fehlgeschlagenen Upload ausblenden",
"searchableBadge": "Inhalt durchsuchbar",
"aiReadBadge": "Von KI gelesen"
"dismissFailed": "Fehlgeschlagenen Upload ausblenden"
},
"error": {
"fileTooLarge": "Diese Datei ist größer als das Limit von {maxSize}.",
@@ -8330,7 +8328,6 @@
"readDone": "Die KI hat dein Dokument gelesen."
},
"contentIndex": {
"searchHint": "Die Suche durchsucht jetzt auch den Inhalt deiner Dokumente (ganze Wörter).",
"indexPrompt": "Dokumentinhalte durchsuchbar machen.",
"indexAll": "Alle für Suche indizieren",
"indexAllPending": "Wird gestartet…",
+1 -4
View File
@@ -8198,9 +8198,7 @@
"openLabel": "Open {title}",
"attachmentBadge": "Download only",
"uploading": "Uploading…",
"dismissFailed": "Dismiss failed upload",
"searchableBadge": "Contents searchable",
"aiReadBadge": "Read by AI"
"dismissFailed": "Dismiss failed upload"
},
"error": {
"fileTooLarge": "This file is larger than the {maxSize} limit.",
@@ -8330,7 +8328,6 @@
"readDone": "The AI read your document."
},
"contentIndex": {
"searchHint": "Search now looks inside your documents too (whole words).",
"indexPrompt": "Make document contents searchable.",
"indexAll": "Index all for search",
"indexAllPending": "Starting…",
+1 -4
View File
@@ -8198,9 +8198,7 @@
"openLabel": "Abrir {title}",
"attachmentBadge": "Solo descarga",
"uploading": "Subiendo…",
"dismissFailed": "Descartar subida fallida",
"searchableBadge": "Contenido buscable",
"aiReadBadge": "Leído por IA"
"dismissFailed": "Descartar subida fallida"
},
"error": {
"fileTooLarge": "Este archivo supera el límite de {maxSize}.",
@@ -8330,7 +8328,6 @@
"readDone": "La IA leyó tu documento."
},
"contentIndex": {
"searchHint": "La búsqueda ahora también mira dentro de tus documentos (palabras completas).",
"indexPrompt": "Haz que el contenido de los documentos sea buscable.",
"indexAll": "Indexar todo para búsqueda",
"indexAllPending": "Iniciando…",
+1 -4
View File
@@ -8198,9 +8198,7 @@
"openLabel": "Ouvrir {title}",
"attachmentBadge": "Téléchargement uniquement",
"uploading": "Téléversement…",
"dismissFailed": "Ignorer le téléversement échoué",
"searchableBadge": "Contenu consultable",
"aiReadBadge": "Lu par l’IA"
"dismissFailed": "Ignorer le téléversement échoué"
},
"error": {
"fileTooLarge": "Ce fichier dépasse la limite de {maxSize}.",
@@ -8330,7 +8328,6 @@
"readDone": "L’IA a lu votre document."
},
"contentIndex": {
"searchHint": "La recherche explore désormais aussi le contenu de vos documents (mots entiers).",
"indexPrompt": "Rendre le contenu des documents consultable.",
"indexAll": "Tout indexer pour la recherche",
"indexAllPending": "Démarrage…",
+1 -4
View File
@@ -8198,9 +8198,7 @@
"openLabel": "Apri {title}",
"attachmentBadge": "Solo download",
"uploading": "Caricamento…",
"dismissFailed": "Ignora caricamento non riuscito",
"searchableBadge": "Contenuto ricercabile",
"aiReadBadge": "Letto dall’IA"
"dismissFailed": "Ignora caricamento non riuscito"
},
"error": {
"fileTooLarge": "Questo file supera il limite di {maxSize}.",
@@ -8330,7 +8328,6 @@
"readDone": "L’IA ha letto il tuo documento."
},
"contentIndex": {
"searchHint": "La ricerca ora esamina anche il contenuto dei tuoi documenti (parole intere).",
"indexPrompt": "Rendi ricercabile il contenuto dei documenti.",
"indexAll": "Indicizza tutto per la ricerca",
"indexAllPending": "Avvio…",
+1 -4
View File
@@ -8198,9 +8198,7 @@
"openLabel": "Otwórz {title}",
"attachmentBadge": "Tylko do pobrania",
"uploading": "Przesyłanie…",
"dismissFailed": "Odrzuć nieudane przesyłanie",
"searchableBadge": "Treść przeszukiwalna",
"aiReadBadge": "Odczytane przez AI"
"dismissFailed": "Odrzuć nieudane przesyłanie"
},
"error": {
"fileTooLarge": "Ten plik przekracza limit {maxSize}.",
@@ -8330,7 +8328,6 @@
"readDone": "AI odczytało Twój dokument."
},
"contentIndex": {
"searchHint": "Wyszukiwanie przeszukuje teraz także treść Twoich dokumentów (całe słowa).",
"indexPrompt": "Udostępnij treść dokumentów do wyszukiwania.",
"indexAll": "Zindeksuj wszystko do wyszukiwania",
"indexAllPending": "Uruchamianie…",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.28.13",
"version": "1.28.15",
"description": "Self-hosted personal-health-tracking PWA with Withings integration, AI insights, and doctor-report PDF export.",
"license": "PolyForm-Noncommercial-1.0.0",
"homepage": "https://healthlog.dev",
@@ -0,0 +1,50 @@
-- Document vault: encrypted preview thumbnails.
--
-- `document_thumbnails` — a 1:1 sibling of `inbound_documents` holding a small
-- JPEG preview (~320px long edge, q70) rendered in the background from the
-- original: images are decoded + downscaled, a PDF's first page is rasterised.
-- The blob rides `thumbnail_encrypted` in the `encrypt()`-string-as-UTF-8
-- BYTEA shape (base64 of the JPEG) `document_content_index.text_encrypted`
-- uses — a scanned medical preview is PHI, never stored in the clear or
-- logged. The canvas re-encode strips EXIF/GPS from the source, so the
-- preview is metadata-free by construction.
--
-- A SIDE TABLE, not a column on `inbound_documents`, so the list/detail
-- queries that `omit` the original blob never drag this blob into the SELECT.
--
-- Both foreign keys cascade: a deleted document (or user) takes its thumbnail
-- with it. Additive; applies clean on a 0234 database and on a fresh database.
-- CreateTable
CREATE TABLE "document_thumbnails" (
"id" TEXT NOT NULL,
"document_id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"thumbnail_encrypted" BYTEA NOT NULL,
"width" INTEGER NOT NULL,
"height" INTEGER NOT NULL,
"byte_size" INTEGER NOT NULL,
"source_mime" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "document_thumbnails_pkey" PRIMARY KEY ("id")
);
-- 1:1 with the document.
CREATE UNIQUE INDEX "document_thumbnails_document_id_key"
ON "document_thumbnails" ("document_id");
-- Owner-scoped queries (the backfill discovery + list-page existence probe).
CREATE INDEX "document_thumbnails_user_id_idx"
ON "document_thumbnails" ("user_id");
ALTER TABLE "document_thumbnails"
ADD CONSTRAINT "document_thumbnails_document_id_fkey"
FOREIGN KEY ("document_id") REFERENCES "inbound_documents" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "document_thumbnails"
ADD CONSTRAINT "document_thumbnails_user_id_fkey"
FOREIGN KEY ("user_id") REFERENCES "users" ("id")
ON DELETE CASCADE ON UPDATE CASCADE;
+43
View File
@@ -814,6 +814,7 @@ model User {
extractedFacts ExtractedFact[]
documentConditionLinks DocumentConditionLink[]
documentContentIndexes DocumentContentIndex[]
documentThumbnails DocumentThumbnail[]
// v1.19.0 — full Withings ECG waveform recordings (encrypted at rest).
// The paired AFib verdict still lands as an IRREGULAR_RHYTHM_NOTIFICATION
// Measurement EVENT row (v1.18.11); this holds the raw signal samples.
@@ -5950,6 +5951,11 @@ model InboundDocument {
/// 1:1 blind content-search index (encrypted extracted text + HMAC token
/// array). Absent until the document is indexed; cascades on delete.
contentIndex DocumentContentIndex?
/// 1:1 encrypted preview thumbnail (small JPEG, AES-256-GCM at rest).
/// Absent until the background thumbnail job runs; a side table (not a
/// column) so the list/detail `omit: { contentEncrypted }` queries never
/// drag the blob into the SELECT. Cascades on delete.
thumbnail DocumentThumbnail?
/// v1.27.33 (Document vault P4) — "chat about a document" threads. Each is a
/// `CoachConversation` with `documentId` set to this document; cascades on
/// delete so a discarded document takes its chats with it.
@@ -6101,3 +6107,40 @@ model DocumentContentIndex {
@@index([userId])
@@map("document_content_index")
}
/// 1:1 encrypted preview thumbnail for a stored document. A small JPEG
/// (~320px long edge, q70) rendered in the background from the original —
/// images are decoded + downscaled, a PDF's first page is rasterised — then
/// stored encrypted at rest. Held in a side table (not a column on
/// `InboundDocument`) so the list/detail queries that `omit` the original
/// blob never drag this blob either. The re-encode strips EXIF/GPS from the
/// source, so the preview is metadata-free by construction. Owner-scoped
/// serve route only; cascades on delete.
model DocumentThumbnail {
id String @id @default(cuid())
documentId String @unique @map("document_id")
userId String @map("user_id")
/// AES-256-GCM ciphertext of the JPEG preview, stored as the
/// `encrypt()`-string-as-UTF-8 Bytes shape (base64 of the JPEG), the same
/// codec `DocumentContentIndex.textEncrypted` uses so the standard
/// key-rotation walk covers it. A scanned medical preview is PHI — never
/// stored in the clear or logged. The list query never selects it.
thumbnailEncrypted Bytes @map("thumbnail_encrypted")
/// Decoded thumbnail dimensions (px) — diagnostic + lets a future consumer
/// size the tile without decoding the blob.
width Int
height Int
/// Plaintext byte length of the JPEG (pre-encryption), for `Content-Length`.
byteSize Int @map("byte_size")
/// Source MIME the preview was rendered from (diagnostic only).
sourceMime String @map("source_mime")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
document InboundDocument @relation(fields: [documentId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@map("document_thumbnails")
}
+1 -1
View File
@@ -36,7 +36,7 @@ try {
// v1.4.38.4 → v1.4.42. Do not hand-edit; bump `package.json` and rebuild.
const CACHE_VERSION =
(typeof self !== "undefined" && self.__APP_VERSION__) ||
/* @sw-version-fallback */ "v1.28.13";
/* @sw-version-fallback */ "v1.28.15";
const STATIC_CACHE = `healthlog-static-${CACHE_VERSION}`;
const PAGE_CACHE = `healthlog-pages-${CACHE_VERSION}`;
// v1.18.6 — read-only data cache for a curated allowlist of safe GET `/api/*`
+13
View File
@@ -619,6 +619,19 @@ async function main() {
results.push(result);
}
// ───── Document preview thumbnails (Bytes column) ─────
// The small JPEG preview, stored as the `encrypt()`-string-as-UTF-8 Bytes
// shape (base64 of the JPEG) — the same codec as the content index — so the
// shared Bytes walk re-encrypts it under the active key. NOT NULL, so every
// thumbnail row rotates.
results.push(
await rotateBytesColumn(
"DocumentThumbnail",
"thumbnailEncrypted",
prisma.documentThumbnail,
),
);
console.log("\n=== Rotation summary ===");
let totalRotated = 0;
let totalErrors = 0;
@@ -25,6 +25,9 @@ vi.mock("@/lib/db", () => ({
documentContentIndex: {
findUnique: vi.fn(),
},
documentThumbnail: {
findUnique: vi.fn(),
},
illnessEpisode: {
findMany: vi.fn(),
},
@@ -137,6 +140,9 @@ beforeEach(() => {
vi.mocked(prisma.documentContentIndex.findUnique).mockResolvedValue(
null as never,
);
vi.mocked(prisma.documentThumbnail.findUnique).mockResolvedValue(
null as never,
);
});
describe("PATCH /api/documents/inbound/[id]", () => {
+12 -2
View File
@@ -79,12 +79,16 @@ export const GET = apiHandler(
});
}
const [links, contentIndex] = await Promise.all([
const [links, contentIndex, thumbnail] = await Promise.all([
loadConditionLinks(user.id, [document.id]),
prisma.documentContentIndex.findUnique({
where: { documentId: document.id },
select: { source: true },
}),
prisma.documentThumbnail.findUnique({
where: { documentId: document.id },
select: { id: true },
}),
]);
annotate({
@@ -99,6 +103,7 @@ export const GET = apiHandler(
links.get(document.id) ?? [],
contentIndex !== null,
toContentIndexSource(contentIndex?.source),
thumbnail !== null,
),
);
},
@@ -177,12 +182,16 @@ export const PATCH = apiHandler(
omit: { contentEncrypted: true },
include: { facts: { orderBy: { createdAt: "asc" } } },
});
const [links, contentIndex] = await Promise.all([
const [links, contentIndex, thumbnail] = await Promise.all([
loadConditionLinks(user.id, [document.id]),
prisma.documentContentIndex.findUnique({
where: { documentId: document.id },
select: { source: true },
}),
prisma.documentThumbnail.findUnique({
where: { documentId: document.id },
select: { id: true },
}),
]);
const touched = [
@@ -211,6 +220,7 @@ export const PATCH = apiHandler(
links.get(document.id) ?? [],
contentIndex !== null,
toContentIndexSource(contentIndex?.source),
thumbnail !== null,
),
);
},
@@ -0,0 +1,168 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { Buffer } from "node:buffer";
import { NextRequest } from "next/server";
/**
* `GET /api/documents/inbound/[id]/thumbnail`.
*
* The route decrypts and serves the small JPEG preview. Load-bearing behaviour
* under test: it is owner-scoped (the query narrows on the session userId, so
* another user's document is a 404), a document with no thumbnail row is a 404,
* a hit serves image/jpeg + nosniff + private no-store, and it fails closed on
* a decrypt error.
*/
// A reversible crypto stub so encryptThumbnail / decryptThumbnail round-trip
// through the shared string codec without real keys. The thumbnail helpers go
// through the bytes-codec (encryptToBytes / decryptFromBytes), which wraps
// encrypt() / decrypt() — stub those.
const decryptMock = vi.fn((s: string) => s.replace(/^v1\./, ""));
vi.mock("@/lib/crypto", () => ({
encrypt: (s: string) => `v1.${s}`,
decrypt: (s: string) => decryptMock(s),
encryptBytes: (b: Buffer) => b,
decryptBytes: (b: Buffer) => b,
}));
vi.mock("@/lib/db", () => ({
prisma: {
inboundDocument: { findFirst: vi.fn() },
},
}));
vi.mock("@/lib/modules/gate", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/modules/gate")>()),
requireModuleEnabled: vi.fn().mockResolvedValue({ enabled: true }),
resolveModuleMap: vi.fn().mockResolvedValue({}),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi
.fn()
.mockResolvedValue({ allowed: true, remaining: 2999, resetAt: Date.now() }),
rateLimitHeaders: () => ({}),
}));
vi.mock("@/lib/auth/session", () => ({ getSession: vi.fn() }));
vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/lib/logging/transports", () => ({ emitIfSampled: vi.fn() }));
vi.mock("@/lib/db-compat", () => ({
ensureDbCompatibility: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("next/headers", () => ({
headers: vi.fn(async () => ({ get: () => null })),
cookies: vi.fn(async () => ({
get: () => undefined,
set: () => {},
delete: () => {},
})),
}));
import { GET } from "../route";
import { getSession } from "@/lib/auth/session";
import { prisma } from "@/lib/db";
import { encryptThumbnail } from "@/lib/documents/store";
const SESSION_OK = {
session: { id: "sess-1", expiresAt: new Date(Date.now() + 3_600_000) },
user: {
id: "user-1",
username: "testuser",
role: "USER" as const,
locale: "en",
},
};
type Ctx = { params: Promise<{ id: string }> };
const callGet = GET as unknown as (
req: NextRequest,
ctx: Ctx,
) => Promise<Response>;
function makeReq(id: string): NextRequest {
return new NextRequest(
new URL(`http://localhost/api/documents/inbound/${id}/thumbnail`),
);
}
function ctx(id: string): Ctx {
return { params: Promise.resolve({ id }) };
}
const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0x00, 0x11, 0x22]);
beforeEach(() => {
vi.clearAllMocks();
decryptMock.mockImplementation((s: string) => s.replace(/^v1\./, ""));
vi.mocked(getSession).mockResolvedValue(SESSION_OK as never);
});
describe("GET /api/documents/inbound/[id]/thumbnail", () => {
it("serves the decrypted JPEG with nosniff + private no-store, owner-scoped", async () => {
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
id: "doc-1",
thumbnail: {
thumbnailEncrypted: encryptThumbnail(JPEG_BYTES),
byteSize: JPEG_BYTES.byteLength,
},
} as never);
const res = await callGet(makeReq("doc-1"), ctx("doc-1"));
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toBe("image/jpeg");
expect(res.headers.get("Content-Disposition")).toBe("inline");
expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(res.headers.get("Cache-Control")).toBe("private, no-store");
const out = Buffer.from(await res.arrayBuffer());
expect(out.equals(JPEG_BYTES)).toBe(true);
// The query is narrowed on the session userId + live rows only.
const where = vi.mocked(prisma.inboundDocument.findFirst).mock.calls[0][0]
?.where as { id: string; userId: string; deletedAt: null };
expect(where.userId).toBe("user-1");
expect(where.id).toBe("doc-1");
expect(where.deletedAt).toBeNull();
});
it("404s when the document has no thumbnail yet", async () => {
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
id: "doc-2",
thumbnail: null,
} as never);
const res = await callGet(makeReq("doc-2"), ctx("doc-2"));
expect(res.status).toBe(404);
});
it("404s for another user's document (the userId narrows it out)", async () => {
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue(
null as never,
);
const res = await callGet(makeReq("other-doc"), ctx("other-doc"));
expect(res.status).toBe(404);
const body = (await res.json()) as { error: string };
expect(body.error).toBeTruthy();
});
it("fails closed (500) on a decrypt error — never serves ciphertext", async () => {
vi.mocked(prisma.inboundDocument.findFirst).mockResolvedValue({
id: "doc-3",
thumbnail: {
thumbnailEncrypted: encryptThumbnail(JPEG_BYTES),
byteSize: JPEG_BYTES.byteLength,
},
} as never);
decryptMock.mockImplementation(() => {
throw new Error("unknown key id");
});
const res = await callGet(makeReq("doc-3"), ctx("doc-3"));
expect(res.status).toBe(500);
expect(res.headers.get("Content-Type")).toContain("application/json");
const body = (await res.json()) as { error: string };
expect(body.error).toBeTruthy();
});
});
@@ -0,0 +1,120 @@
/**
* Document vault: serve a document's encrypted preview thumbnail.
*
* The thumbnail is a small JPEG (~320px long edge) rendered in the background
* from the original and stored encrypted at rest
* (`DocumentThumbnail.thumbnailEncrypted`, AES-256-GCM). This route is the ONLY
* path that decrypts and serves it. Owner-scoped + module-gated; the bytes are
* PHI: never logged, never served cross-user, fail-closed on a decrypt error.
*
* Serving posture is fixed and singular: we only ever store JPEG, so the
* response is always `Content-Type: image/jpeg` + `nosniff` — zero
* misclassification surface, no `attachment` branch. It is loaded as an `<img>`
* subresource on the vault page (governed by the page CSP `img-src 'self'`), so
* it needs NO proxy carve-out — unlike `/original`, which is framed.
*
* A cache MISS (no thumbnail row yet — freshly uploaded, still rendering, or an
* unsupported type) is a 404; the card falls back to its kind icon. The route
* never generates synchronously, preserving the async never-block contract.
*/
import { NextResponse } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { apiError } from "@/lib/api-response";
import { prisma } from "@/lib/db";
import { decryptThumbnail } from "@/lib/documents/store";
import { annotate } from "@/lib/logging/context";
import { requireModuleEnabled } from "@/lib/modules/gate";
import { checkRateLimit, rateLimitHeaders } from "@/lib/rate-limit";
type RouteParams = { params: Promise<{ id: string }> };
export const dynamic = "force-dynamic";
/**
* Decrypting a thumbnail is cheap CPU work on PHI, but a virtualized grid
* legitimately fires many at once — so the per-user ceiling is higher than
* `/original`'s. It is a self-DoS backstop only (the route is owner-scoped, so
* there is no cross-user abuse vector).
*/
const THUMBNAIL_READ_LIMIT_PER_HOUR = 3000;
const THUMBNAIL_READ_WINDOW_MS = 60 * 60 * 1000;
export const GET = apiHandler(
async (_request: Request, { params }: RouteParams) => {
const { user } = await requireAuth();
const gate = await requireModuleEnabled(user.id, "inboundDocuments");
if (!gate.enabled) return gate.response;
const rl = await checkRateLimit(
`documents-thumbnail:${user.id}`,
THUMBNAIL_READ_LIMIT_PER_HOUR,
THUMBNAIL_READ_WINDOW_MS,
);
if (!rl.allowed) {
const response = apiError("Too many requests. Try again later.", 429, {
errorCode: "documents.inbound.rateLimited",
});
for (const [k, v] of Object.entries(rateLimitHeaders(rl))) {
response.headers.set(k, v);
}
return response;
}
const { id } = await params;
// Owner-scoped: the userId narrows the document so a cuid guess cannot
// reach another user's thumbnail. A cookie session and a Bearer token both
// resolve through requireAuth(); neither can cross the userId boundary.
// The thumbnail is loaded through the 1:1 relation on the owned document —
// both the document and its thumbnail are userId-scoped.
const document = await prisma.inboundDocument.findFirst({
where: { id, userId: user.id, deletedAt: null },
select: {
id: true,
thumbnail: { select: { thumbnailEncrypted: true, byteSize: true } },
},
});
if (!document || !document.thumbnail) {
// Missing document OR no thumbnail yet — both 404; the card shows its
// kind icon. Do not distinguish the two (no existence oracle).
return apiError("Thumbnail not found", 404, {
errorCode: "documents.inbound.notFound",
});
}
let bytes: Buffer;
try {
bytes = decryptThumbnail(document.thumbnail.thumbnailEncrypted);
} catch {
// Fail closed — never fall back to the raw ciphertext. The reason (bad /
// missing key id) is logged by the annotation, never the bytes.
annotate({
action: { name: "documents.inbound.thumbnail.decryptFailed" },
meta: { documentId: document.id },
});
return apiError("Could not read the stored thumbnail", 500, {
errorCode: "documents.inbound.decryptFailed",
});
}
annotate({
action: { name: "documents.inbound.thumbnail.get" },
meta: { documentId: document.id, bytes: bytes.byteLength },
});
return new NextResponse(bytes as unknown as BodyInit, {
status: 200,
headers: {
// We only ever store JPEG — the type is fixed and authoritative.
"Content-Type": "image/jpeg",
"Content-Length": String(bytes.byteLength),
"Content-Disposition": "inline",
// The type is authoritative — never MIME-sniffed.
"X-Content-Type-Options": "nosniff",
// Private PHI — never cache in a shared / disk cache.
"Cache-Control": "private, no-store",
},
});
},
);
@@ -42,6 +42,9 @@ vi.mock("@/lib/db", () => {
documentContentIndex: {
findMany: vi.fn(),
},
documentThumbnail: {
findMany: vi.fn(),
},
illnessEpisode: {
findMany: vi.fn(),
},
@@ -66,6 +69,10 @@ vi.mock("@/lib/jobs/document-index", () => ({
enqueueDocumentIndex: vi.fn().mockResolvedValue({ enqueued: true }),
}));
vi.mock("@/lib/jobs/document-thumbnail", () => ({
enqueueDocumentThumbnail: vi.fn().mockResolvedValue({ enqueued: true }),
}));
vi.mock("@/lib/rate-limit", () => ({
checkRateLimit: vi.fn().mockResolvedValue({
allowed: true,
@@ -163,6 +170,7 @@ beforeEach(() => {
vi.mocked(prisma.documentConditionLink.findMany).mockResolvedValue(
[] as never,
);
vi.mocked(prisma.documentThumbnail.findMany).mockResolvedValue([] as never);
vi.mocked(prisma.documentContentIndex.findMany).mockResolvedValue(
[] as never,
);
+19
View File
@@ -42,6 +42,7 @@ import {
type SerialisableDocument,
} from "@/lib/documents/store";
import { enqueueDocumentIndex } from "@/lib/jobs/document-index";
import { enqueueDocumentThumbnail } from "@/lib/jobs/document-thumbnail";
import {
detectDocumentType,
resolveDocumentLimits,
@@ -371,6 +372,12 @@ async function postUpload(request: Request): Promise<Response> {
// duplicate upload returns early above and never reaches here.
void enqueueDocumentIndex(user.id, document.id);
// Render a preview thumbnail in the background too (pure local compute — no
// egress). Same fire-and-forget contract: the upload never blocks on or fails
// because of it, and a dropped enqueue is recoverable via the boot backfill.
// Only fresh inserts reach here (a duplicate returns early above).
void enqueueDocumentThumbnail(user.id, document.id);
const links = await loadConditionLinks(user.id, [document.id]);
return apiSuccess(
serialiseDocument(
@@ -520,6 +527,17 @@ export const GET = apiHandler(async (request: Request) => {
for (const row of indexed) indexSources.set(row.documentId, row.source);
}
// Which of the page's documents have a preview thumbnail (gates the card's
// <img>). One grouped query on the 1:1 side table; never the blob column.
const thumbnailIds = new Set<string>();
if (page.length > 0) {
const thumbs = await prisma.documentThumbnail.findMany({
where: { userId: user.id, documentId: { in: page.map((d) => d.id) } },
select: { documentId: true },
});
for (const row of thumbs) thumbnailIds.add(row.documentId);
}
annotate({
action: { name: "documents.inbound.list" },
meta: {
@@ -538,6 +556,7 @@ export const GET = apiHandler(async (request: Request) => {
linkMap.get(doc.id) ?? [],
indexSources.has(doc.id),
toContentIndexSource(indexSources.get(doc.id)),
thumbnailIds.has(doc.id),
),
),
nextCursor,
@@ -33,6 +33,7 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
servingClass: "inline",
hasContentIndex: false,
contentIndexSource: null,
hasThumbnail: false,
createdAt: "2025-10-05T08:00:00.000Z",
updatedAt: "2025-10-05T08:00:00.000Z",
...overrides,
@@ -85,47 +86,33 @@ describe("<DocumentCard>", () => {
expect(html).toContain("befund.docx");
});
it("marks a content-indexed document as searchable", () => {
it("renders the preview thumbnail image when hasThumbnail is set", () => {
const html = render(
<DocumentCard
document={doc({ hasContentIndex: true })}
document={doc({ hasThumbnail: true })}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).toContain('data-slot="document-searchable"');
expect(html).toContain('data-source="local"');
expect(html).toContain("Contents searchable");
expect(html).toContain('data-slot="document-thumbnail"');
expect(html).toContain('src="/api/documents/inbound/doc-1/thumbnail"');
expect(html).toContain('loading="lazy"');
});
it("marks an AI-read document with the highlighted read-by-AI badge", () => {
it("shows the kind icon (no thumbnail image) when hasThumbnail is false", () => {
const html = render(
<DocumentCard
document={doc({ hasContentIndex: true, contentIndexSource: "vision" })}
document={doc({ hasThumbnail: false })}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).toContain('data-source="ai-read"');
expect(html).toContain("Read by AI");
expect(html).toContain("text-primary");
});
it("shows no searchable marker when the document is not indexed", () => {
const html = render(
<DocumentCard
document={doc({ hasContentIndex: false, conditionLinks: [] })}
selected={false}
onToggleSelected={noop}
onOpen={noop}
highlighted={false}
/>,
);
expect(html).not.toContain('data-slot="document-searchable"');
expect(html).not.toContain('data-slot="document-thumbnail"');
expect(html).not.toContain("/thumbnail");
});
it("falls back to the untitled label and rings when highlighted", () => {
@@ -53,6 +53,7 @@ function doc(id: string, title: string): InboundDocumentDto {
servingClass: "inline",
hasContentIndex: false,
contentIndexSource: null,
hasThumbnail: false,
createdAt: "2025-10-05T08:00:00.000Z",
updatedAt: "2025-10-05T08:00:00.000Z",
};
@@ -34,6 +34,7 @@ function doc(overrides: Partial<InboundDocumentDto> = {}): InboundDocumentDto {
servingClass: "inline",
hasContentIndex: false,
contentIndexSource: null,
hasThumbnail: false,
createdAt: "2026-03-11T08:00:00.000Z",
updatedAt: "2026-03-11T08:00:00.000Z",
...overrides,
+45 -49
View File
@@ -6,15 +6,17 @@
* with the translated §3.2 reason). All three share the card footprint so an
* optimistic entry morphs into the stored row without the grid jumping.
*
* Anatomy per the design standards: kind icon `text-foreground size-5`,
* title in foreground (`text-sm font-medium`, truncated), one muted meta
* line (date · size · filename), condition-tag pills, an attachment-class
* badge for download-only formats, and a selection checkbox that appears on
* hover / focus / while selected. The whole card is clickable through an
* invisible overlay button; the checkbox floats above it.
* Anatomy per the design standards: a leading edge that shows a small preview
* thumbnail tile when one has been rendered (`hasThumbnail`) and otherwise the
* kind icon `text-foreground size-5`, title in foreground (`text-sm
* font-medium`, truncated), one muted meta line (date · size · filename),
* condition-tag pills, an attachment-class badge for download-only formats,
* and a selection checkbox that appears on hover / focus / while selected. The
* whole card is clickable through an invisible overlay button; the checkbox
* floats above it.
*/
import { Download, ScanSearch, Sparkles, X } from "lucide-react";
import { useRef } from "react";
import { Download, X } from "lucide-react";
import { useRef, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
@@ -22,10 +24,7 @@ import { Card, CardContent } from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import { useFormatters, useTranslations } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import {
isAiReadSource,
type InboundDocumentDto,
} from "@/lib/validations/inbound-documents";
import type { InboundDocumentDto } from "@/lib/validations/inbound-documents";
import { DOCUMENT_KIND_ICONS } from "./document-kind-meta";
import type { UploadQueueItem } from "./use-document-upload";
import { documentDateKey, formatBytes } from "./vault-utils";
@@ -63,6 +62,10 @@ export function DocumentCard({
const { t, locale } = useTranslations();
const format = useFormatters();
// A preview thumbnail that fails to load (still rendering, decrypt error,
// 404) falls back to the kind icon — never a broken image.
const [thumbFailed, setThumbFailed] = useState(false);
// Touch long-press selects instead of opening; the click that the
// browser fires after the release is swallowed once.
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -94,10 +97,35 @@ export function DocumentCard({
>
<CardContent className="flex h-full flex-col gap-2 px-4">
<div className="flex items-start gap-2">
<Icon
className="text-foreground mt-0.5 size-5 shrink-0"
aria-hidden
/>
{document.hasThumbnail && !thumbFailed ? (
// Leading-edge preview tile. `loading="lazy"` + the timeline's
// virtualization means only cards near the viewport fetch their
// thumbnail. Decorative (alt="") — the title beside it names the
// document; an authed same-origin subresource, never cached
// cross-user. onError falls back to the kind icon.
<span
data-slot="document-thumbnail"
className="bg-muted mt-0.5 block size-12 shrink-0 overflow-hidden rounded-md"
>
{/* Authed, private (no-store) same-origin subresource — next/image
would try to proxy/optimize it, which is wrong for a PHI blob
we deliberately never cache. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`/api/documents/inbound/${document.id}/thumbnail`}
alt=""
loading="lazy"
decoding="async"
onError={() => setThumbFailed(true)}
className="size-full object-cover"
/>
</span>
) : (
<Icon
className="text-foreground mt-0.5 size-5 shrink-0"
aria-hidden
/>
)}
<p className="min-w-0 flex-1 truncate text-sm font-medium">{title}</p>
<Checkbox
checked={selected}
@@ -122,8 +150,7 @@ export function DocumentCard({
{showFilename ? ` · ${document.filename}` : ""}
</p>
{document.conditionLinks.length > 0 ||
document.servingClass === "attachment" ||
document.hasContentIndex ? (
document.servingClass === "attachment" ? (
<div className="mt-auto flex flex-wrap items-center gap-1.5 pt-1">
{document.conditionLinks.map((link) => (
<span
@@ -142,37 +169,6 @@ export function DocumentCard({
{t("documents.card.attachmentBadge")}
</Badge>
) : null}
{document.hasContentIndex ? (
// Subtle searchable marker — icon-only to keep the row calm; the
// label rides an accessible name. An AI-read document gets the
// highlighted Sparkles (primary) so the richer read is legible at
// a glance; a locally-indexed one keeps the quiet muted glass.
isAiReadSource(document.contentIndexSource) ? (
<span
data-slot="document-searchable"
data-source="ai-read"
className="text-primary inline-flex items-center"
title={t("documents.card.aiReadBadge")}
>
<Sparkles className="size-3.5" aria-hidden />
<span className="sr-only">
{t("documents.card.aiReadBadge")}
</span>
</span>
) : (
<span
data-slot="document-searchable"
data-source="local"
className="text-muted-foreground inline-flex items-center"
title={t("documents.card.searchableBadge")}
>
<ScanSearch className="size-3.5" aria-hidden />
<span className="sr-only">
{t("documents.card.searchableBadge")}
</span>
</span>
)
) : null}
</div>
) : null}
</CardContent>
@@ -10,9 +10,8 @@
* The controls sit on ONE row at every width: the search flexes and can
* shrink to nothing while each dropdown trigger stays a fixed compact
* control, so the bar never wraps to a second line and never grows a
* horizontal chip scroller. The content-search hint (indexed-content match
* + corpus backfill) rides its own helper line below, only when indexing is
* live.
* horizontal chip scroller. The corpus-backfill prompt rides its own helper
* line below, only while some documents remain un-indexed.
*
* Purely presentational — the filter state lives in the page URL and is
* owned by `documents-view.tsx`; this bar only renders the controls. It
@@ -82,7 +81,6 @@ export function DocumentFilterBar({
onToggleYear,
activeCount,
onClearAll,
contentSearchActive = false,
showIndexAll = false,
indexAllPending = false,
onIndexAll,
@@ -101,8 +99,6 @@ export function DocumentFilterBar({
onToggleYear: (year: number) => void;
activeCount: number;
onClearAll: () => void;
/** Search already matches indexed document CONTENT (whole words). */
contentSearchActive?: boolean;
/** Some documents are not yet indexed — offer the corpus backfill. */
showIndexAll?: boolean;
indexAllPending?: boolean;
@@ -323,32 +319,28 @@ export function DocumentFilterBar({
) : null}
</div>
{contentSearchActive || showIndexAll ? (
{showIndexAll ? (
<div
data-slot="content-search-hint"
className="text-muted-foreground mt-1.5 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs"
>
<span className="inline-flex items-center gap-1.5">
<ScanSearch className="size-3.5 shrink-0" aria-hidden />
{contentSearchActive
? t("documents.contentIndex.searchHint")
: t("documents.contentIndex.indexPrompt")}
{t("documents.contentIndex.indexPrompt")}
</span>
{showIndexAll ? (
<Button
type="button"
variant="link"
size="sm"
data-slot="content-index-all"
onClick={onIndexAll}
disabled={indexAllPending}
className="text-primary h-auto p-0 text-xs"
>
{indexAllPending
? t("documents.contentIndex.indexAllPending")
: t("documents.contentIndex.indexAll")}
</Button>
) : null}
<Button
type="button"
variant="link"
size="sm"
data-slot="content-index-all"
onClick={onIndexAll}
disabled={indexAllPending}
className="text-primary h-auto p-0 text-xs"
>
{indexAllPending
? t("documents.contentIndex.indexAllPending")
: t("documents.contentIndex.indexAll")}
</Button>
</div>
) : null}
</div>
@@ -257,8 +257,6 @@ export function DocumentsView() {
contentIndex !== undefined &&
contentIndex.totalCount > 0 &&
contentIndex.indexedCount < contentIndex.totalCount;
const contentSearchActive =
canIndexContent && (contentIndex?.indexedCount ?? 0) > 0;
const handleIndexAll = useCallback(() => {
reindexAll.mutate(undefined, {
onSuccess: (result) =>
@@ -586,7 +584,6 @@ export function DocumentsView() {
onToggleYear={toggleYear}
activeCount={activeCount}
onClearAll={clearFilters}
contentSearchActive={contentSearchActive}
showIndexAll={showIndexAll}
indexAllPending={reindexAll.isPending}
onIndexAll={handleIndexAll}
+5
View File
@@ -248,6 +248,11 @@ export const ENCRYPTED_COLUMNS: readonly EncryptedColumn[] = [
field: "verbatimTextEncrypted",
kind: "bytes",
},
// Document vault — the small JPEG preview thumbnail. Stored as the
// `encrypt()`-string-as-UTF-8 Bytes shape (base64 of the JPEG), the same
// codec `DocumentContentIndex.textEncrypted` uses. A scanned medical preview
// is PHI; rotation re-encrypts it on the standard Bytes walk.
{ model: "DocumentThumbnail", field: "thumbnailEncrypted", kind: "bytes" },
] as const;
/** Stable `Model.field` key for a registry entry. */
@@ -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,144 @@
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 });
});
it("refuses a decompression bomb before decoding (pixel cap)", async () => {
// A valid PNG signature + IHDR declaring 30000×30000 = 900 MP. The header
// sniff rejects it BEFORE `loadImage` would allocate a multi-GB bitmap, so
// the (otherwise incomplete) byte string never reaches the decoder.
const bomb = Buffer.alloc(24);
bomb.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], 0);
bomb.writeUInt32BE(13, 8); // IHDR length
bomb.write("IHDR", 12, "ascii");
bomb.writeUInt32BE(30_000, 16); // width
bomb.writeUInt32BE(30_000, 20); // height
await expect(generateThumbnail(bomb, "image/png")).resolves.toEqual({
ok: false,
});
});
});
+12 -6
View File
@@ -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++) {
+26
View File
@@ -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),
};
+273
View File
@@ -0,0 +1,273 @@
/**
* 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 };
}
}
+150
View File
@@ -0,0 +1,150 @@
/**
* Preview-thumbnail backfill for a user's EXISTING stored documents.
*
* For each live document whose type can carry a preview (image or PDF) and
* that has no `DocumentThumbnail` yet, enqueue a per-document thumbnail job.
* This covers pre-feature documents and any dropped upload-time enqueue — a
* missing thumbnail is always recoverable; the card just shows its kind icon
* meanwhile.
*
* Fired on boot (one per-user pass per account holding thumbnailable documents
* that lack a preview). Pure local compute downstream (no egress, no consent
* or budget gate), so — unlike the content-index backfill — it needs no
* consent receipt. Bounded + resumable: an id-cursor forward walk over the
* not-yet-thumbnailed set, capped at `MAX_ENQUEUES_PER_RUN` per job. A document
* drops out of the set the moment its thumbnail row lands, so the walk
* converges and a re-run picks up where the cap left off.
*
* The queue MUST be registered in the maintenance registrar
* (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it.
*/
import { prisma } from "@/lib/db";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { enqueueDocumentThumbnail } from "@/lib/jobs/document-thumbnail";
import { annotate } from "@/lib/logging/context";
export const DOCUMENT_THUMBNAIL_BACKFILL_QUEUE = "document-thumbnail-backfill";
/** Serial concurrency — cheap discovery + fan-out enqueues. */
export const DOCUMENT_THUMBNAIL_BACKFILL_CONCURRENCY = 1;
/** Discovery page size (id-only). */
const PAGE_SIZE = 100;
/** Max per-document jobs one backfill pass enqueues before yielding. */
const MAX_ENQUEUES_PER_RUN = 1000;
/** MIME types a preview can be rendered from (image + PDF). */
const THUMBNAILABLE_MIMES = [
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
"application/pdf",
] as const;
export interface ThumbnailBackfillPayload {
userId: string;
enqueuedAt?: string;
}
export interface ThumbnailBackfillSummary {
enqueued: number;
}
/**
* Enqueue thumbnail jobs for one user's not-yet-thumbnailed documents.
* Idempotent + resumable: an already-thumbnailed document drops out of the
* candidate set, and the pass stops at the per-run cap.
*/
export async function runThumbnailBackfillForUser(
userId: string,
): Promise<ThumbnailBackfillSummary> {
let enqueued = 0;
let cursor: string | null = null;
for (;;) {
if (enqueued >= MAX_ENQUEUES_PER_RUN) break;
const rows: { id: string }[] = await prisma.inboundDocument.findMany({
where: {
userId,
deletedAt: null,
mimeType: { in: [...THUMBNAILABLE_MIMES] },
thumbnail: { is: null },
},
select: { id: true },
orderBy: { id: "asc" },
take: PAGE_SIZE,
...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
});
if (rows.length === 0) break;
for (const { id } of rows) {
if (enqueued >= MAX_ENQUEUES_PER_RUN) break;
const { enqueued: ok } = await enqueueDocumentThumbnail(userId, id);
if (ok) enqueued += 1;
}
cursor = rows[rows.length - 1]!.id;
if (rows.length < PAGE_SIZE) break;
}
annotate({
action: { name: "documents.thumbnail.backfill" },
meta: { enqueued },
});
return { enqueued };
}
/**
* Boot-time discovery: enqueue one per-user backfill pass for every account
* that still holds a thumbnailable document without a preview. Coalesced by
* `singletonKey` per user. Fire-and-forget — a missing boss is a no-op.
*/
export async function enqueueBootTimeThumbnailBackfill(): Promise<{
enqueued: number;
}> {
const boss = getGlobalBoss();
if (!boss) return { enqueued: 0 };
// Distinct users with at least one thumbnailable, not-yet-thumbnailed doc.
const rows = await prisma.inboundDocument.findMany({
where: {
deletedAt: null,
mimeType: { in: [...THUMBNAILABLE_MIMES] },
thumbnail: { is: null },
},
select: { userId: true },
distinct: ["userId"],
});
let enqueued = 0;
for (const { userId } of rows) {
const payload: ThumbnailBackfillPayload = {
userId,
enqueuedAt: new Date().toISOString(),
};
try {
const jobId = await boss.send(
DOCUMENT_THUMBNAIL_BACKFILL_QUEUE,
payload,
{
retryLimit: 3,
retryDelay: 60,
retryBackoff: true,
singletonKey: `document-thumbnail-backfill|${userId}`,
},
);
if (jobId) enqueued += 1;
} catch {
// A transient send failure is a no-op — the next boot re-discovers the
// still-missing thumbnails.
}
}
annotate({
action: { name: "documents.thumbnail.backfill.boot" },
meta: { users: enqueued },
});
return { enqueued };
}
+163
View File
@@ -0,0 +1,163 @@
/**
* Automatic per-document preview thumbnail, enqueued the moment a document is
* stored (`POST /api/documents/inbound`). Fire-and-forget: the upload response
* never blocks on or fails because of thumbnail rendering.
*
* The job decrypts the stored original (owner-scoped), renders a small JPEG
* preview (`generateThumbnail` — images downscaled, a PDF's first page
* rasterised, every other type skipped), and upserts it encrypted into the 1:1
* `DocumentThumbnail` side table. Pure local compute — no egress, no consent
* or budget gate. Serial concurrency keeps the canvas/pdfjs decode off the
* request pool.
*
* NEVER fails a document for an expected outcome: an unsupported MIME or an
* unrenderable file leaves the document without a thumbnail (the card shows its
* kind icon), and `runDocumentThumbnail` returns cleanly so pg-boss does not
* retry it. Only an unexpected throw (a DB hiccup) propagates for retry.
*
* The queue MUST be registered in the maintenance registrar
* (`src/lib/jobs/reminder/register-maintenance.ts`) so pg-boss provisions it.
*/
import { prisma } from "@/lib/db";
import {
decryptDocumentContent,
encryptThumbnail,
} from "@/lib/documents/store";
import { generateThumbnail } from "@/lib/documents/thumbnail";
import { getGlobalBoss } from "@/lib/jobs/boss-instance";
import { annotate } from "@/lib/logging/context";
export const DOCUMENT_THUMBNAIL_QUEUE = "document-thumbnail";
/** Serial concurrency — canvas/pdfjs decode, kept off the request pool. */
export const DOCUMENT_THUMBNAIL_CONCURRENCY = 1;
export interface DocumentThumbnailPayload {
userId: string;
documentId: string;
enqueuedAt?: string;
}
/**
* Render + store one document's preview thumbnail (owner-scoped). Idempotent:
* a document that already has a thumbnail is skipped, and an unsupported /
* unrenderable document is left without one. Never throws for an expected
* outcome.
*/
export async function runDocumentThumbnail(
payload: DocumentThumbnailPayload,
): Promise<void> {
const { userId, documentId } = payload;
if (!userId || !documentId) return;
// Owner-scoped load; the blob column is selected ONLY here (the job that
// renders the preview), never in the list/detail queries. Skip a document
// that already has a thumbnail so a re-enqueue is a cheap no-op.
const document = await prisma.inboundDocument.findFirst({
where: { id: documentId, userId, deletedAt: null },
select: {
id: true,
mimeType: true,
contentEncrypted: true,
contentCodec: true,
thumbnail: { select: { id: true } },
},
});
if (!document || document.thumbnail) {
annotate({
action: { name: "documents.thumbnail.skipped" },
meta: { documentId, reason: !document ? "not-found" : "exists" },
});
return;
}
let bytes: Buffer;
try {
bytes = decryptDocumentContent(
document.contentEncrypted,
document.contentCodec,
);
} catch {
// Fail-closed decrypt: a bad / missing key id leaves the document without a
// thumbnail (the card shows its icon); never log the bytes.
annotate({
action: { name: "documents.thumbnail.decryptFailed" },
meta: { documentId },
});
return;
}
const result = await generateThumbnail(bytes, document.mimeType);
if (!result.ok) {
annotate({
action: { name: "documents.thumbnail.run" },
meta: { documentId, generated: false },
});
return;
}
const { jpeg, width, height } = result.thumbnail;
// Upsert (not create) so a racing re-enqueue cannot violate the 1:1 unique
// index; the last render wins. `userId` comes from the scoped load, never a
// body — no mass assignment.
await prisma.documentThumbnail.upsert({
where: { documentId: document.id },
create: {
documentId: document.id,
userId,
thumbnailEncrypted: encryptThumbnail(jpeg),
width,
height,
byteSize: jpeg.byteLength,
sourceMime: document.mimeType,
},
update: {
thumbnailEncrypted: encryptThumbnail(jpeg),
width,
height,
byteSize: jpeg.byteLength,
sourceMime: document.mimeType,
},
});
annotate({
action: { name: "documents.thumbnail.run" },
meta: { documentId, generated: true, byteSize: jpeg.byteLength },
});
}
/**
* Enqueue a per-document thumbnail job. Coalesced by `singletonKey` per
* document so a duplicate/idempotent re-upload cannot pile up parallel runs.
* Fire-and-forget — a missing boss (worker not up) is a no-op, and a
* `boss.send` failure (a transient DB hiccup) is swallowed to a no-op too, so
* enqueue can NEVER fail an already-stored upload. A dropped enqueue is
* recoverable via the corpus backfill.
*/
export async function enqueueDocumentThumbnail(
userId: string,
documentId: string,
): Promise<{ enqueued: boolean }> {
const boss = getGlobalBoss();
if (!boss) return { enqueued: false };
const payload: DocumentThumbnailPayload = {
userId,
documentId,
enqueuedAt: new Date().toISOString(),
};
try {
const jobId = await boss.send(DOCUMENT_THUMBNAIL_QUEUE, payload, {
retryLimit: 2,
retryDelay: 30,
retryBackoff: true,
singletonKey: `document-thumbnail|${documentId}`,
});
return { enqueued: Boolean(jobId) };
} catch (err) {
annotate({
action: { name: "documents.thumbnail.enqueueFailed" },
meta: { documentId, reason: err instanceof Error ? err.name : "unknown" },
});
return { enqueued: false };
}
}
@@ -81,6 +81,19 @@ import {
runDocumentIndex,
type DocumentIndexPayload,
} from "@/lib/jobs/document-index";
import {
DOCUMENT_THUMBNAIL_QUEUE,
DOCUMENT_THUMBNAIL_CONCURRENCY,
runDocumentThumbnail,
type DocumentThumbnailPayload,
} from "@/lib/jobs/document-thumbnail";
import {
DOCUMENT_THUMBNAIL_BACKFILL_QUEUE,
DOCUMENT_THUMBNAIL_BACKFILL_CONCURRENCY,
runThumbnailBackfillForUser,
enqueueBootTimeThumbnailBackfill,
type ThumbnailBackfillPayload,
} from "@/lib/jobs/document-thumbnail-backfill";
import {
ENCRYPTION_KEY_ROTATE_QUEUE,
ENCRYPTION_KEY_ROTATE_CONCURRENCY,
@@ -321,6 +334,16 @@ const allQueues = [
// extraction. Without this entry pg-boss never provisions the queue and every
// upload enqueue silently no-ops.
DOCUMENT_INDEX_QUEUE,
// Document vault — automatic per-document preview thumbnail, enqueued on
// upload. Pure local compute (canvas/pdfjs downscale), no egress. Without
// this entry pg-boss never provisions the queue and every upload enqueue
// silently no-ops.
DOCUMENT_THUMBNAIL_QUEUE,
// Document vault — boot-time preview-thumbnail backfill. Discovers accounts
// holding thumbnailable documents without a preview and fans out per-document
// thumbnail jobs. Without this entry pg-boss never provisions the queue and
// the boot discovery silently no-ops.
DOCUMENT_THUMBNAIL_BACKFILL_QUEUE,
];
const schedules: ScheduleEntry[] = [
@@ -719,6 +742,62 @@ export async function registerMaintenanceQueues(
},
);
// Document vault — automatic per-document preview thumbnail. Enqueued on
// upload (one job per stored document); decodes + downscales the original to
// a small encrypted JPEG. Pure local compute; serial concurrency so the
// canvas/pdfjs decode never crowds the request pool.
await boss.work<DocumentThumbnailPayload>(
DOCUMENT_THUMBNAIL_QUEUE,
{ localConcurrency: DOCUMENT_THUMBNAIL_CONCURRENCY },
async (jobs) => {
for (const job of jobs) {
const { userId, documentId } = job.data;
if (!userId || !documentId) continue;
try {
await runDocumentThumbnail(job.data);
} catch (err) {
recordError();
workerLog(
"error",
`[document-thumbnail] user=${userId} document=${documentId} failed`,
err,
);
throw err;
}
}
},
);
// Document vault — boot-time preview-thumbnail backfill worker. The boot
// discovery sends one per-user job; this handler fans out per-document
// thumbnail jobs for that user's not-yet-thumbnailed documents. Serial
// concurrency; cheap discovery + enqueue only.
await boss.work<ThumbnailBackfillPayload>(
DOCUMENT_THUMBNAIL_BACKFILL_QUEUE,
{ localConcurrency: DOCUMENT_THUMBNAIL_BACKFILL_CONCURRENCY },
async (jobs) => {
for (const job of jobs) {
const { userId } = job.data;
if (!userId) continue;
try {
const { enqueued } = await runThumbnailBackfillForUser(userId);
workerLog(
"info",
`[document-thumbnail-backfill] user=${userId} enqueued=${enqueued}`,
);
} catch (err) {
recordError();
workerLog(
"error",
`[document-thumbnail-backfill] user=${userId} failed`,
err,
);
throw err;
}
}
},
);
// v1.25 (W-ENV) — nightly environment fetch. The daily discovery tick (empty
// payload) fans out one per-user job per opted-in account; the queue also
// serves the on-demand backfill payloads from the settings surface. Serial
@@ -822,4 +901,22 @@ export async function enqueueMaintenanceBootDiscovery(): Promise<void> {
err,
);
}
// Document vault — preview-thumbnail backfill. Finds every account holding a
// thumbnailable document (image or PDF) without a preview and enqueues one
// per-user backfill pass. Idempotent across reboots: a rendered thumbnail
// drops its document off the discovery predicate.
try {
const { enqueued } = await enqueueBootTimeThumbnailBackfill();
workerLog(
"info",
`[document-thumbnail-backfill] boot discovery: enqueued=${enqueued}`,
);
} catch (err) {
workerLog(
"error",
"[document-thumbnail-backfill] boot discovery threw an unexpected error",
err,
);
}
}
+29 -1
View File
@@ -150,13 +150,14 @@ const inboundDocument = z
contentIndexSource: z
.enum(["vision", "text-ocr", "local-pdf", "local-ocr"])
.nullable(),
hasThumbnail: z.boolean(),
createdAt: z.string(),
updatedAt: z.string(),
})
.meta({
id: "InboundDocument",
description:
"A stored document. The raw bytes are stored encrypted at rest and never returned; this is the metadata + staging summary. `status` is STORED for a freshly uploaded file (no extraction run). `title` is the user label (plaintext); `documentDate` is the user filing date; `reportDate` is the model-transcribed date (null until extraction runs). `servingClass` says how `/original` delivers the file — render inline (`inline`) or download-only (`attachment`); render/download by it, never by MIME guess. `hasContentIndex` is true when the document has a content-search index (auto-indexed on upload); `contentIndexSource` says how that index was produced — `vision` means an AI provider read the original, the other values are provider-free local extractions, and it is null when `hasContentIndex` is false. Treat an unknown `kind` value as OTHER when decoding.",
"A stored document. The raw bytes are stored encrypted at rest and never returned; this is the metadata + staging summary. `status` is STORED for a freshly uploaded file (no extraction run). `title` is the user label (plaintext); `documentDate` is the user filing date; `reportDate` is the model-transcribed date (null until extraction runs). `servingClass` says how `/original` delivers the file — render inline (`inline`) or download-only (`attachment`); render/download by it, never by MIME guess. `hasContentIndex` is true when the document has a content-search index (auto-indexed on upload); `contentIndexSource` says how that index was produced — `vision` means an AI provider read the original, the other values are provider-free local extractions, and it is null when `hasContentIndex` is false. `hasThumbnail` is true when a preview thumbnail has been rendered in the background (fetch it from `/api/documents/inbound/{id}/thumbnail`); false means no preview yet or an unsupported type — show a placeholder. Treat an unknown `kind` value as OTHER when decoding.",
});
const inboundDocumentDetail = inboundDocument
@@ -641,6 +642,33 @@ export const inboundDocumentPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
},
},
"/api/documents/inbound/{id}/thumbnail": {
get: {
tags: ["Documents"],
summary: "View the document preview thumbnail",
description:
"Decrypts and serves the document's small JPEG preview thumbnail (~320px long edge), rendered in the background from the original and stored encrypted at rest under `DocumentThumbnail.thumbnailEncrypted`. Owner-scoped + gated on the opt-in `inboundDocuments` module. Always `Content-Type: image/jpeg` + `nosniff` + `Cache-Control: private, no-store`; loaded as an `<img>` subresource. 404 when no thumbnail exists yet (freshly uploaded / still rendering / unsupported type) — the caller shows a placeholder. Fails closed (500) on a decrypt error — it never returns the ciphertext.",
parameters: [
{
name: "id",
in: "path",
required: true,
schema: { type: "string" },
},
],
responses: {
"200": {
description: "The decrypted JPEG preview thumbnail bytes.",
content: {
"image/jpeg": {
schema: { type: "string", format: "binary" },
},
},
},
...stdResponses,
},
},
},
"/api/documents/inbound/{id}/extract": {
post: {
tags: ["Documents"],
+6
View File
@@ -280,6 +280,12 @@ export interface InboundDocumentDto {
* a locally-indexed one and offer a richer "Read with AI" pass on the latter.
*/
contentIndexSource: DocumentContentIndexSourceValue | null;
/**
* Whether the document has an encrypted preview thumbnail (rendered in the
* background after upload). Gates the card's `<img>`: when false the card
* shows its kind icon instead of fetching a thumbnail that 404s.
*/
hasThumbnail: boolean;
createdAt: string;
updatedAt: string;
}