Compare commits

...

8 Commits

Author SHA1 Message Date
Marcel
688d38120a docs(caddy): note future CSP must allow wasm-unsafe-eval for pdf.js
Some checks failed
CI / Unit & Component Tests (pull_request) Failing after 2m57s
CI / OCR Service Tests (pull_request) Successful in 23s
CI / Backend Unit Tests (pull_request) Successful in 3m31s
CI / fail2ban Regex (pull_request) Successful in 44s
CI / Semgrep Security Scan (pull_request) Successful in 21s
CI / Compose Bucket Idempotency (pull_request) Successful in 1m5s
If a Content-Security-Policy is ever added, it must permit
'wasm-unsafe-eval' (script-src) and 'self' blob: (worker-src) or the
pdf.js wasm decoders and worker break and scanned PDFs render blank.
Forward-looking note so the future CSP author doesn't silently
reintroduce #708.

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:17:08 +02:00
Marcel
cf86019337 test(document): behavioral CCITT/DCT render fixtures prove the wasm path
Render committed synthetic fixtures through PdfViewer with the REAL
pdf.js loader and assert the canvas is non-blank (sampled dark-pixel
count). The CCITT (G4 fax) fixture exercises the shared jbig2.wasm
decode path — the same module pdf.js uses for JBIG2 — so it transitively
covers the JBIG2 acceptance criterion (the archive sample found zero
true JBIG2 docs and jbig2enc is unavailable to synthesize one). The
JPEG/DCTDecode fixture guards against regressing the natively-decoded
path. Verified the CCITT case goes red when wasmUrl is removed.

Fixtures are hermetic, committed assets (~2-5 KB each), generated with
ImageMagick — never fetched from staging at test time. CI browser mode.

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:16:35 +02:00
Marcel
6690e1374d fix(document): add rel=noopener noreferrer to viewer download link (CWE-1022)
The error-state download link opened with target="_blank" but no rel,
exposing the opener to reverse tabnavbabbing. Add rel="noopener
noreferrer". Same-origin so low severity, but a one-token fix in a file
this issue already touches.

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:13:45 +02:00
Marcel
e0eedc70f9 fix(document): localize PdfViewer render-error message and download link
The error state showed a hardcoded German string ("Fehler beim Laden
der PDF" / "Direkt öffnen") to all users regardless of locale. Use the
localized doc_render_failed and doc_download_link messages so the
recovery path (message + working download link) is honest in de/en/es.

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:12:23 +02:00
Marcel
aa1e89c290 fix(document): surface PDF render failures instead of a silent blank canvas
renderCurrentPage swallowed every render rejection with a bare return,
so a decode failure left a blank white viewer with no feedback. Now a
non-cancellation rejection sets a localized doc_render_failed message,
which routes into the existing error UI (message + download link).
Cancellation (page-nav / zoom) still returns silently — no error.

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:10:26 +02:00
Marcel
5a4b55e366 i18n(document): add doc_render_failed message for blank-render fallback
Localized message shown when a PDF page cannot be rendered, so users
never see a blank canvas or a raw English pdf.js string. de/en/es.

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:08:44 +02:00
Marcel
be42e1f01f fix(document): pass wasmUrl to pdf.js getDocument so wasm decoders load
getDocument was called with a bare src string, so pdf.js 5.x had no
`wasmUrl` and could not initialise the JBIG2/CCITTFax wasm decoder —
CCITT (G4 fax) scans painted a blank canvas. Pass
{ url, wasmUrl: '/pdfjs-wasm/' }; the directory URL (trailing slash
required) is the single source of truth next to the worker config.

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:07:35 +02:00
Marcel
8d2ef97fe2 build(frontend): serve pdf.js wasm decoders at /pdfjs-wasm/ via static-copy
pdf.js 5.x moved the JBIG2/CCITTFax/JPEG2000 image decoders into
WebAssembly. The wasm lives in node_modules and was never web-served, so
those decoders failed to initialise and CCITT (G4 fax) scans painted
blank in production while rendering fine in dev.

Add vite-plugin-static-copy (devDependency) to copy
node_modules/pdfjs-dist/wasm/* into build/client/pdfjs-wasm/, so the
assets are emitted into the SvelteKit client build and survive the
production Docker image — not just `npm run dev`. Verified that
`node build` serves /pdfjs-wasm/jbig2.wasm with 200 + application/wasm.

Refs #708

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 20:05:34 +02:00
16 changed files with 365 additions and 3 deletions

View File

@@ -126,6 +126,7 @@
"doc_label_summary": "Zusammenfassung", "doc_label_summary": "Zusammenfassung",
"doc_loading": "Lade Dokument...", "doc_loading": "Lade Dokument...",
"doc_download_link": "Direkter Download versuchen", "doc_download_link": "Direkter Download versuchen",
"doc_render_failed": "Dieser Scan konnte nicht angezeigt werden.",
"doc_no_scan": "Kein Scan vorhanden", "doc_no_scan": "Kein Scan vorhanden",
"persons_heading": "Personenverzeichnis", "persons_heading": "Personenverzeichnis",
"persons_subtitle": "Durchsuchen Sie den Index aller erfassten Personen im Familienarchiv.", "persons_subtitle": "Durchsuchen Sie den Index aller erfassten Personen im Familienarchiv.",

View File

@@ -126,6 +126,7 @@
"doc_label_summary": "Summary", "doc_label_summary": "Summary",
"doc_loading": "Loading document...", "doc_loading": "Loading document...",
"doc_download_link": "Try direct download", "doc_download_link": "Try direct download",
"doc_render_failed": "This scan could not be displayed.",
"doc_no_scan": "No scan available", "doc_no_scan": "No scan available",
"persons_heading": "Person directory", "persons_heading": "Person directory",
"persons_subtitle": "Browse the index of all recorded persons in the family archive.", "persons_subtitle": "Browse the index of all recorded persons in the family archive.",

View File

@@ -126,6 +126,7 @@
"doc_label_summary": "Resumen", "doc_label_summary": "Resumen",
"doc_loading": "Cargando documento...", "doc_loading": "Cargando documento...",
"doc_download_link": "Intentar descarga directa", "doc_download_link": "Intentar descarga directa",
"doc_render_failed": "No se pudo mostrar este escaneo.",
"doc_no_scan": "No hay escaneo disponible", "doc_no_scan": "No hay escaneo disponible",
"persons_heading": "Directorio de personas", "persons_heading": "Directorio de personas",
"persons_subtitle": "Explore el índice de todas las personas registradas en el archivo familiar.", "persons_subtitle": "Explore el índice de todas las personas registradas en el archivo familiar.",

View File

@@ -54,6 +54,7 @@
"typescript-eslint": "^8.47.0", "typescript-eslint": "^8.47.0",
"vite": "^7.3.3", "vite": "^7.3.3",
"vite-plugin-devtools-json": "^1.0.0", "vite-plugin-devtools-json": "^1.0.0",
"vite-plugin-static-copy": "^4.1.0",
"vitest": "^4.0.10", "vitest": "^4.0.10",
"vitest-browser-svelte": "^2.0.1" "vitest-browser-svelte": "^2.0.1"
} }
@@ -5078,6 +5079,33 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1" "url": "https://github.com/chalk/ansi-styles?sponsor=1"
} }
}, },
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/anymatch/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/argparse": { "node_modules/argparse": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -5189,6 +5217,19 @@
"require-from-string": "^2.0.2" "require-from-string": "^2.0.2"
} }
}, },
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.6", "version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
@@ -6723,6 +6764,19 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-bun-module": { "node_modules/is-bun-module": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz",
@@ -7721,6 +7775,16 @@
"integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==", "integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-keys": { "node_modules/object-keys": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
@@ -7861,6 +7925,19 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/p-map": {
"version": "7.0.4",
"resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz",
"integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/parent-module": { "node_modules/parent-module": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -9534,6 +9611,93 @@
"uuid": "dist/esm/bin/uuid" "uuid": "dist/esm/bin/uuid"
} }
}, },
"node_modules/vite-plugin-static-copy": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/vite-plugin-static-copy/-/vite-plugin-static-copy-4.1.0.tgz",
"integrity": "sha512-9XOarNV7LgP0KBB7AApxdgFikLXx3daZdqjC3AevYsL6MrUH62zphonLUs2a6LZc1HN1GY+vQdheZ8VVJb6dQQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"chokidar": "^3.6.0",
"p-map": "^7.0.4",
"picocolors": "^1.1.1",
"tinyglobby": "^0.2.15"
},
"engines": {
"node": "^22.0.0 || >=24.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/sapphi-red"
},
"peerDependencies": {
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/vite-plugin-static-copy/node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/vite-plugin-static-copy/node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/vite-plugin-static-copy/node_modules/picomatch": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/vite-plugin-static-copy/node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/vite/node_modules/fsevents": { "node_modules/vite/node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",

View File

@@ -68,6 +68,7 @@
"typescript-eslint": "^8.47.0", "typescript-eslint": "^8.47.0",
"vite": "^7.3.3", "vite": "^7.3.3",
"vite-plugin-devtools-json": "^1.0.0", "vite-plugin-devtools-json": "^1.0.0",
"vite-plugin-static-copy": "^4.1.0",
"vitest": "^4.0.10", "vitest": "^4.0.10",
"vitest-browser-svelte": "^2.0.1" "vitest-browser-svelte": "^2.0.1"
} }

View File

@@ -72,6 +72,7 @@ let {
<a <a
href="/api/documents/{doc.id}/file" href="/api/documents/{doc.id}/file"
target="_blank" target="_blank"
rel="noopener noreferrer"
class="text-sm underline hover:text-white" class="text-sm underline hover:text-white"
> >
{m.doc_download_link()} {m.doc_download_link()}

View File

@@ -46,6 +46,20 @@ describe('DocumentViewer', () => {
.toHaveAttribute('href', '/api/documents/d1/file'); .toHaveAttribute('href', '/api/documents/d1/file');
}); });
it('hardens the target=_blank download link with rel=noopener noreferrer (CWE-1022)', async () => {
render(DocumentViewer, {
props: {
...baseProps,
doc: { ...baseProps.doc, filePath: 'docs/scan.pdf' },
error: 'Render failed'
}
});
await expect
.element(page.getByRole('link', { name: /direkter download/i }))
.toHaveAttribute('rel', 'noopener noreferrer');
});
it('omits the direct-download link in the error state when filePath is null', async () => { it('omits the direct-download link in the error state when filePath is null', async () => {
render(DocumentViewer, { props: { ...baseProps, error: 'Render failed' } }); render(DocumentViewer, { props: { ...baseProps, error: 'Render failed' } });

View File

@@ -171,14 +171,14 @@ function handleAnnotationClick(id: string) {
</div> </div>
{:else if renderer.error} {:else if renderer.error}
<div class="flex h-full w-full flex-col items-center justify-center gap-3 bg-pdf-bg text-ink-3"> <div class="flex h-full w-full flex-col items-center justify-center gap-3 bg-pdf-bg text-ink-3">
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p> <p class="font-sans text-sm text-red-400">{m.doc_render_failed()}</p>
<a <a
href={url} href={url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="font-sans text-xs text-primary underline hover:text-ink-2" class="font-sans text-xs text-primary underline hover:text-ink-2"
> >
Direkt öffnen {m.doc_download_link()}
</a> </a>
</div> </div>
{:else} {:else}

View File

@@ -3,9 +3,39 @@ import { cleanup, render } from 'vitest-browser-svelte';
import { page } from 'vitest/browser'; import { page } from 'vitest/browser';
import PdfViewer from './PdfViewer.svelte'; import PdfViewer from './PdfViewer.svelte';
import { makeFakeLibLoader } from './testHelpers'; import { makeFakeLibLoader } from './testHelpers';
import { m } from '$lib/paraglide/messages.js';
afterEach(cleanup); afterEach(cleanup);
function makeFailingLibLoader() {
const lib = {
GlobalWorkerOptions: { workerSrc: '' },
getDocument: vi.fn().mockReturnValue({
promise: Promise.reject(new Error('JBig2 failed to initialize'))
}),
TextLayer: class {
render() {
return Promise.resolve();
}
cancel() {}
}
} as unknown as typeof import('pdfjs-dist');
return vi.fn().mockResolvedValue([lib, { default: '' }] as const);
}
describe('PdfViewer — render failure', () => {
it('shows the localized failure message and a download link, not a blank canvas', async () => {
render(PdfViewer, {
url: '/api/documents/test/file',
documentId: 'test',
libLoader: makeFailingLibLoader()
});
await expect.element(page.getByText(m.doc_render_failed())).toBeVisible();
await expect.element(page.getByRole('link', { name: m.doc_download_link() })).toBeVisible();
});
});
describe('PdfViewer — empty / error states', () => { describe('PdfViewer — empty / error states', () => {
it('renders the no-file placeholder when url is empty', async () => { it('renders the no-file placeholder when url is empty', async () => {
render(PdfViewer, { url: '', libLoader: makeFakeLibLoader() }); render(PdfViewer, { url: '', libLoader: makeFakeLibLoader() });

View File

@@ -0,0 +1,52 @@
import { describe, it, expect, afterEach, vi } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
import PdfViewer from './PdfViewer.svelte';
import ccittUrl from './fixtures/ccitt-g4.pdf?url';
import jpegUrl from './fixtures/jpeg-dct.pdf?url';
// Behavioral, real-render coverage of the wasm decode path. Unlike the rest of
// the viewer tests, these use the REAL pdf.js loader (no libLoader prop) so the
// page is actually decoded and painted, and the wasm is fetched from
// /pdfjs-wasm/ exactly as in production. CI runs this in a real Chromium.
// See issue #708.
afterEach(cleanup);
// A blank page is a uniform white canvas. A rendered page has dark glyph pixels.
function countNonBackgroundPixels(canvas: HTMLCanvasElement): number {
const ctx = canvas.getContext('2d');
if (!ctx || canvas.width === 0 || canvas.height === 0) return 0;
const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
let count = 0;
for (let i = 0; i < data.length; i += 4) {
const r = data[i];
const g = data[i + 1];
const b = data[i + 2];
const a = data[i + 3];
if (a > 0 && (r < 250 || g < 250 || b < 250)) count++;
}
return count;
}
async function expectNonBlankRender(url: string): Promise<void> {
render(PdfViewer, { url, documentId: 'fixture' });
await vi.waitFor(
() => {
const canvas = document.querySelector('canvas');
expect(canvas).not.toBeNull();
expect((canvas as HTMLCanvasElement).width).toBeGreaterThan(0);
expect(countNonBackgroundPixels(canvas as HTMLCanvasElement)).toBeGreaterThan(50);
},
{ timeout: 20000, interval: 250 }
);
}
describe('PdfViewer — real codec fixtures (wasm decode path)', () => {
it('renders a CCITT (G4 fax) scan as a non-blank page — same jbig2.wasm path JBIG2 uses', async () => {
await expectNonBlankRender(ccittUrl);
});
it('renders a DCTDecode (JPEG) PDF as a non-blank page — no regression', async () => {
await expectNonBlankRender(jpegUrl);
});
});

Binary file not shown.

Binary file not shown.

View File

@@ -1,6 +1,27 @@
import { describe, it, expect, vi } from 'vitest'; import { describe, it, expect, vi } from 'vitest';
import { createPdfRenderer } from './usePdfRenderer.svelte'; import { createPdfRenderer } from './usePdfRenderer.svelte';
import { makeFakeLibLoader } from './testHelpers'; import { makeFakeLibLoader } from './testHelpers';
import { m } from '$lib/paraglide/messages.js';
function makeRenderingLib(renderPromise: Promise<void>): typeof import('pdfjs-dist') {
const page = {
getViewport: vi.fn().mockReturnValue({ width: 100, height: 100 }),
render: vi.fn().mockReturnValue({ promise: renderPromise, cancel: vi.fn() }),
streamTextContent: vi.fn().mockReturnValue(new ReadableStream())
};
return {
GlobalWorkerOptions: { workerSrc: '' },
getDocument: vi.fn().mockReturnValue({
promise: Promise.resolve({ numPages: 1, getPage: vi.fn().mockResolvedValue(page) })
}),
TextLayer: class {
render() {
return Promise.resolve();
}
cancel() {}
}
} as unknown as typeof import('pdfjs-dist');
}
// Note: init() and loadDocument() require pdfjsLib (browser module). // Note: init() and loadDocument() require pdfjsLib (browser module).
// These tests cover pure state logic only — bounds clamping and zoom limits. // These tests cover pure state logic only — bounds clamping and zoom limits.
@@ -205,6 +226,57 @@ describe('createPdfRenderer', () => {
expect(fakeLoader).toHaveBeenCalledOnce(); expect(fakeLoader).toHaveBeenCalledOnce();
}); });
it('passes a non-null wasmUrl directory (ending in /) to getDocument, not a bare src string', async () => {
const getDocument = vi.fn().mockReturnValue({
promise: Promise.resolve({ numPages: 1, getPage: vi.fn() })
});
const lib = {
GlobalWorkerOptions: { workerSrc: '' },
getDocument,
TextLayer: class {
render() {
return Promise.resolve();
}
cancel() {}
}
} as unknown as typeof import('pdfjs-dist');
const r = createPdfRenderer(vi.fn().mockResolvedValue([lib, { default: '' }] as const));
await r.init();
await r.loadDocument('/api/documents/abc/file');
expect(getDocument).toHaveBeenCalledTimes(1);
const arg = getDocument.mock.calls[0][0] as { url?: string; wasmUrl?: string };
expect(arg.url).toBe('/api/documents/abc/file');
expect(typeof arg.wasmUrl).toBe('string');
expect(arg.wasmUrl).not.toBe('');
expect(arg.wasmUrl?.endsWith('/')).toBe(true);
});
it('renderCurrentPage sets a localized error when the render rejects (not silently blank)', async () => {
const lib = makeRenderingLib(Promise.reject(new Error('JBig2 failed to initialize')));
const r = createPdfRenderer(vi.fn().mockResolvedValue([lib, { default: '' }] as const));
await r.init();
r.setElements(document.createElement('canvas'), document.createElement('div'));
await r.loadDocument('/x');
await r.renderCurrentPage();
expect(r.error).toBe(m.doc_render_failed());
});
it('renderCurrentPage does NOT set an error when the render is cancelled', async () => {
const cancelled = Object.assign(new Error('cancelled'), {
name: 'RenderingCancelledException'
});
const lib = makeRenderingLib(Promise.reject(cancelled));
const r = createPdfRenderer(vi.fn().mockResolvedValue([lib, { default: '' }] as const));
await r.init();
r.setElements(document.createElement('canvas'), document.createElement('div'));
await r.loadDocument('/x');
await r.renderCurrentPage();
expect(r.error).toBeNull();
});
it('loadDocument sets error and loading=false when getDocument().promise rejects', async () => { it('loadDocument sets error and loading=false when getDocument().promise rejects', async () => {
const failingLib = { const failingLib = {
GlobalWorkerOptions: { workerSrc: '' }, GlobalWorkerOptions: { workerSrc: '' },

View File

@@ -1,10 +1,17 @@
import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist'; import type { PDFDocumentProxy, RenderTask } from 'pdfjs-dist';
import { m } from '$lib/paraglide/messages.js';
export type LibLoader = () => Promise<readonly [typeof import('pdfjs-dist'), { default: string }]>; export type LibLoader = () => Promise<readonly [typeof import('pdfjs-dist'), { default: string }]>;
const defaultLibLoader: LibLoader = () => const defaultLibLoader: LibLoader = () =>
Promise.all([import('pdfjs-dist'), import('pdfjs-dist/build/pdf.worker.min.mjs?url')]); Promise.all([import('pdfjs-dist'), import('pdfjs-dist/build/pdf.worker.min.mjs?url')]);
// pdf.js 5.x decodes JBIG2 / CCITTFax / JPEG2000 images via WebAssembly and
// needs to know where the .wasm modules are served. Must be a directory URL
// with a trailing slash — pdf.js appends `jbig2.wasm` etc. Served from our own
// origin by vite-plugin-static-copy (see vite.config.ts). See issue #708.
const WASM_URL = '/pdfjs-wasm/';
export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) { export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) {
// Reactive state — exposed via getters // Reactive state — exposed via getters
let currentPage = $state(1); let currentPage = $state(1);
@@ -44,7 +51,7 @@ export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) {
totalPages = 0; totalPages = 0;
try { try {
const loadingTask = pdfjsLib.getDocument(src); const loadingTask = pdfjsLib.getDocument({ url: src, wasmUrl: WASM_URL });
const doc = await loadingTask.promise; const doc = await loadingTask.promise;
pdfDoc = doc; pdfDoc = doc;
totalPages = doc.numPages; totalPages = doc.numPages;
@@ -99,6 +106,10 @@ export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) {
(e as { name: string }).name === 'RenderingCancelledException' (e as { name: string }).name === 'RenderingCancelledException'
) )
return; return;
// A real decode/render failure (e.g. a wasm decoder that could not
// initialise) — surface a localized message instead of leaving a
// silent blank canvas. Never leak the raw pdf.js error text.
error = m.doc_render_failed();
return; return;
} }
renderTask = null; renderTask = null;

View File

@@ -5,6 +5,7 @@ import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import { playwright } from '@vitest/browser-playwright'; import { playwright } from '@vitest/browser-playwright';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import { viteStaticCopy } from 'vite-plugin-static-copy';
export default defineConfig({ export default defineConfig({
optimizeDeps: { optimizeDeps: {
@@ -42,6 +43,15 @@ export default defineConfig({
tailwindcss(), tailwindcss(),
sveltekit(), sveltekit(),
devtoolsJson(), devtoolsJson(),
// pdf.js 5.x decodes JBIG2 / CCITTFax / JPEG2000 images in WebAssembly.
// Serve the wasm from our own origin at /pdfjs-wasm/ (referenced by
// getDocument's wasmUrl) — emitted into build/client/ so it survives the
// production Docker image, not just `npm run dev`. See issue #708.
viteStaticCopy({
targets: [
{ src: 'node_modules/pdfjs-dist/wasm/*', dest: 'pdfjs-wasm', rename: { stripBase: true } }
]
}),
paraglideVitePlugin({ paraglideVitePlugin({
project: './project.inlang', project: './project.inlang',
outdir: './src/lib/paraglide' outdir: './src/lib/paraglide'

View File

@@ -22,6 +22,10 @@
# XSS landing in a privileged origin: a payload cannot silently turn # XSS landing in a privileged origin: a payload cannot silently turn
# on the microphone or read geolocation. # on the microphone or read geolocation.
Permissions-Policy "camera=(), microphone=(), geolocation=()" Permissions-Policy "camera=(), microphone=(), geolocation=()"
# No Content-Security-Policy is set yet. When one is added, it MUST
# include `script-src 'wasm-unsafe-eval'` and `worker-src 'self' blob:`
# or the pdf.js WebAssembly image decoders (JBIG2/CCITTFax/JPEG2000)
# and worker will be blocked and scanned PDFs render blank. See #708.
-Server -Server
} }
} }