mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +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
@@ -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]", () => {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user