Compare commits
16 Commits
2c96752330
...
e16b7402bd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e16b7402bd | ||
|
|
229c1b0539 | ||
|
|
f24c415b04 | ||
|
|
4c57a2262f | ||
|
|
b8e01f997d | ||
|
|
e8e57d2712 | ||
|
|
817835fd6a | ||
|
|
c361b3cd45 | ||
|
|
5c8034d298 | ||
|
|
8b1b070254 | ||
|
|
4ca1c967d2 | ||
|
|
24d9d975d1 | ||
|
|
8a1cc2d1f0 | ||
|
|
d5bf401085 | ||
|
|
4944918692 | ||
|
|
bf90427bfa |
@@ -126,6 +126,7 @@
|
||||
"doc_label_summary": "Zusammenfassung",
|
||||
"doc_loading": "Lade Dokument...",
|
||||
"doc_download_link": "Direkter Download versuchen",
|
||||
"doc_render_failed": "Dieser Scan konnte nicht angezeigt werden.",
|
||||
"doc_no_scan": "Kein Scan vorhanden",
|
||||
"persons_heading": "Personenverzeichnis",
|
||||
"persons_subtitle": "Durchsuchen Sie den Index aller erfassten Personen im Familienarchiv.",
|
||||
@@ -302,7 +303,6 @@
|
||||
"date_season_summer": "Sommer",
|
||||
"date_season_autumn": "Herbst",
|
||||
"date_season_winter": "Winter",
|
||||
"date_original_label": "Originaltext:",
|
||||
"date_unknown_icon_label": "Datum unbekannt",
|
||||
"form_label_date_precision": "Datumsgenauigkeit",
|
||||
"form_label_date_end": "Enddatum",
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
"doc_label_summary": "Summary",
|
||||
"doc_loading": "Loading document...",
|
||||
"doc_download_link": "Try direct download",
|
||||
"doc_render_failed": "This scan could not be displayed.",
|
||||
"doc_no_scan": "No scan available",
|
||||
"persons_heading": "Person directory",
|
||||
"persons_subtitle": "Browse the index of all recorded persons in the family archive.",
|
||||
@@ -302,7 +303,6 @@
|
||||
"date_season_summer": "Summer",
|
||||
"date_season_autumn": "Autumn",
|
||||
"date_season_winter": "Winter",
|
||||
"date_original_label": "Original:",
|
||||
"date_unknown_icon_label": "Date unknown",
|
||||
"form_label_date_precision": "Date precision",
|
||||
"form_label_date_end": "End date",
|
||||
|
||||
@@ -126,6 +126,7 @@
|
||||
"doc_label_summary": "Resumen",
|
||||
"doc_loading": "Cargando documento...",
|
||||
"doc_download_link": "Intentar descarga directa",
|
||||
"doc_render_failed": "No se pudo mostrar este escaneo.",
|
||||
"doc_no_scan": "No hay escaneo disponible",
|
||||
"persons_heading": "Directorio de personas",
|
||||
"persons_subtitle": "Explore el índice de todas las personas registradas en el archivo familiar.",
|
||||
@@ -302,7 +303,6 @@
|
||||
"date_season_summer": "Verano",
|
||||
"date_season_autumn": "Otoño",
|
||||
"date_season_winter": "Invierno",
|
||||
"date_original_label": "Texto original:",
|
||||
"date_unknown_icon_label": "Fecha desconocida",
|
||||
"form_label_date_precision": "Precisión de la fecha",
|
||||
"form_label_date_end": "Fecha final",
|
||||
|
||||
164
frontend/package-lock.json
generated
164
frontend/package-lock.json
generated
@@ -54,6 +54,7 @@
|
||||
"typescript-eslint": "^8.47.0",
|
||||
"vite": "^7.3.3",
|
||||
"vite-plugin-devtools-json": "^1.0.0",
|
||||
"vite-plugin-static-copy": "^4.1.0",
|
||||
"vitest": "^4.0.10",
|
||||
"vitest-browser-svelte": "^2.0.1"
|
||||
}
|
||||
@@ -5078,6 +5079,33 @@
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
@@ -5189,6 +5217,19 @@
|
||||
"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": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
@@ -6723,6 +6764,19 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz",
|
||||
@@ -7721,6 +7775,16 @@
|
||||
"integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==",
|
||||
"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": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||
@@ -7861,6 +7925,19 @@
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||
@@ -9534,6 +9611,93 @@
|
||||
"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": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"postbuild": "node scripts/assert-pdfjs-wasm.mjs",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || true && git -C .. config core.hooksPath .husky 2>/dev/null || true",
|
||||
"postinstall": "patch-package",
|
||||
@@ -68,6 +69,7 @@
|
||||
"typescript-eslint": "^8.47.0",
|
||||
"vite": "^7.3.3",
|
||||
"vite-plugin-devtools-json": "^1.0.0",
|
||||
"vite-plugin-static-copy": "^4.1.0",
|
||||
"vitest": "^4.0.10",
|
||||
"vitest-browser-svelte": "^2.0.1"
|
||||
}
|
||||
|
||||
29
frontend/scripts/assert-pdfjs-wasm.mjs
Normal file
29
frontend/scripts/assert-pdfjs-wasm.mjs
Normal file
@@ -0,0 +1,29 @@
|
||||
// Build-time guard for issue #708. The pdf.js wasm image decoders are copied
|
||||
// into build/client/pdfjs-wasm/ by vite-plugin-static-copy. If a future
|
||||
// pdfjs-dist bump moves or renames the wasm, the glob could silently copy
|
||||
// nothing — and CCITT/JBIG2/JPEG2000 scans would render blank in production
|
||||
// again with no test catching it (the bug is invisible to unit tests). Fail
|
||||
// the build loudly instead. Runs after `npm run build` (incl. the Docker
|
||||
// build stage) via the `postbuild` npm script.
|
||||
import { existsSync, statSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const dir = join(process.cwd(), 'build', 'client', 'pdfjs-wasm');
|
||||
// jbig2.wasm decodes JBIG2 + CCITTFax; openjpeg.wasm decodes JPEG2000.
|
||||
const required = ['jbig2.wasm', 'openjpeg.wasm'];
|
||||
|
||||
const missing = required.filter((f) => {
|
||||
const p = join(dir, f);
|
||||
return !existsSync(p) || statSync(p).size === 0;
|
||||
});
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error(
|
||||
`\n[assert-pdfjs-wasm] MISSING from build output: ${missing.join(', ')}\n` +
|
||||
`Expected non-empty files in ${dir}.\n` +
|
||||
`The pdf.js wasm decoders did not ship — scanned PDFs would render blank.\n` +
|
||||
`Check the vite-plugin-static-copy target in vite.config.ts and that\n` +
|
||||
`node_modules/pdfjs-dist/wasm/ still contains these files. See issue #708.\n`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,30 +1,20 @@
|
||||
<script lang="ts">
|
||||
import { formatDocumentDate, type DatePrecision } from '$lib/shared/utils/documentDate';
|
||||
import { getLocale } from '$lib/paraglide/runtime.js';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
type Props = {
|
||||
iso?: string | null;
|
||||
precision?: DatePrecision | null;
|
||||
end?: string | null;
|
||||
/** Verbatim import cell — used only to derive the SEASON word, never displayed. */
|
||||
raw?: string | null;
|
||||
/** Show the verbatim "Originaltext: …" secondary line when raw is present. */
|
||||
showRaw?: boolean;
|
||||
};
|
||||
|
||||
let { iso = null, precision = null, end = null, raw = null, showRaw = true }: Props = $props();
|
||||
let { iso = null, precision = null, end = null, raw = null }: Props = $props();
|
||||
|
||||
const effectivePrecision = $derived<DatePrecision>(precision ?? (iso ? 'DAY' : 'UNKNOWN'));
|
||||
const label = $derived(formatDocumentDate(iso, effectivePrecision, end, raw, getLocale()));
|
||||
const isUnknown = $derived(effectivePrecision === 'UNKNOWN' || !iso);
|
||||
// Only show the verbatim raw line where it adds information the label can't: the
|
||||
// season word's source, or the original cell behind an "unknown"/approx date.
|
||||
const showRawLine = $derived(
|
||||
showRaw &&
|
||||
!!raw &&
|
||||
raw.trim().length > 0 &&
|
||||
(isUnknown || effectivePrecision === 'SEASON' || effectivePrecision === 'APPROX')
|
||||
);
|
||||
</script>
|
||||
|
||||
<span class="inline-flex flex-col">
|
||||
@@ -61,10 +51,4 @@ const showRawLine = $derived(
|
||||
{:else}
|
||||
<span>{label}</span>
|
||||
{/if}
|
||||
{#if showRawLine}
|
||||
<!-- Visible secondary line (WCAG 1.4.13 — not tooltip-only). raw is untrusted
|
||||
verbatim spreadsheet text; rendered via default Svelte interpolation, which
|
||||
HTML-escapes it (never {@html}; CWE-79). -->
|
||||
<span class="font-sans text-xs text-ink-2">{m.date_original_label()} {raw}</span>
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
@@ -17,19 +17,4 @@ describe('DocumentDate', () => {
|
||||
render(DocumentDate, { props: { iso: '1916-06-01', precision: 'MONTH', raw: 'Juni 1916' } });
|
||||
await expect.element(page.getByText('Juni 1916')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the verbatim raw cell as a visible secondary line for UNKNOWN (not tooltip-only)', async () => {
|
||||
render(DocumentDate, { props: { iso: null, precision: 'UNKNOWN', raw: 'Sommer?' } });
|
||||
// Real, visible text — not hidden behind a title attribute.
|
||||
await expect.element(page.getByText('Datum unbekannt')).toBeInTheDocument();
|
||||
await expect.element(page.getByText(/Sommer\?/)).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders a malicious raw value as inert escaped text (no element injected)', async () => {
|
||||
const malicious = '<img src=x onerror="alert(1)">';
|
||||
render(DocumentDate, { props: { iso: null, precision: 'UNKNOWN', raw: malicious } });
|
||||
// The payload appears as literal text, and no <img> is created in the DOM.
|
||||
await expect.element(page.getByText(/<img/)).toBeInTheDocument();
|
||||
expect(document.querySelector('img')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -209,7 +209,6 @@ async function handleReplaceFile(e: Event) {
|
||||
bind:dateIso={dateIso}
|
||||
bind:precision={datePrecision}
|
||||
bind:endDateIso={dateEndIso}
|
||||
rawDate={doc.metaDateRaw ?? ''}
|
||||
initialDateIso={doc.documentDate ?? ''}
|
||||
initialLocation={doc.location ?? ''}
|
||||
initialSenderName={doc.sender?.displayName ?? ''}
|
||||
|
||||
@@ -113,7 +113,7 @@ function getFullName(person: Person): string {
|
||||
<div>
|
||||
<dt class="font-sans text-xs font-medium text-ink-3">{m.doc_details_field_date()}</dt>
|
||||
<dd class="text-ink">
|
||||
{#if documentDate || metaDateRaw}
|
||||
{#if documentDate}
|
||||
<DocumentDate
|
||||
iso={documentDate}
|
||||
precision={metaDatePrecision}
|
||||
|
||||
@@ -58,6 +58,18 @@ describe('DocumentMetadataDrawer', () => {
|
||||
expect(dashTexts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('shows an em-dash and never the raw cell for an undated, raw-only document', async () => {
|
||||
render(DocumentMetadataDrawer, {
|
||||
props: { ...baseProps, documentDate: null, metaDateRaw: 'Sommer 1916' }
|
||||
});
|
||||
|
||||
await expect.element(page.getByText('Sommer 1916')).not.toBeInTheDocument();
|
||||
const dashTexts = Array.from(document.querySelectorAll('dd, p'))
|
||||
.map((el) => el.textContent?.trim())
|
||||
.filter((t) => t === '—');
|
||||
expect(dashTexts.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('renders the no-persons placeholder when sender and receivers are empty', async () => {
|
||||
render(DocumentMetadataDrawer, { props: baseProps });
|
||||
|
||||
|
||||
@@ -164,15 +164,10 @@ function safeTagColor(color: string | null | undefined): string {
|
||||
<!-- Mobile-only metadata -->
|
||||
<div class="mt-3 grid grid-cols-2 gap-x-4 gap-y-1 font-sans text-xs text-ink-2 sm:hidden">
|
||||
<div>
|
||||
<!-- Product decision (#666): raw provenance (meta_date_raw) is shown on the
|
||||
document DETAIL page, never in list/search rows — list rows surface only the
|
||||
honest label to keep scan-rows compact. showRaw={false} enforces this; the
|
||||
DocumentListItem payload also intentionally omits metaDateRaw. -->
|
||||
<DocumentDate
|
||||
iso={doc.documentDate}
|
||||
precision={doc.metaDatePrecision}
|
||||
end={doc.metaDateEnd}
|
||||
showRaw={false}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-start gap-2">
|
||||
@@ -194,7 +189,6 @@ function safeTagColor(color: string | null | undefined): string {
|
||||
iso={doc.documentDate}
|
||||
precision={doc.metaDatePrecision}
|
||||
end={doc.metaDateEnd}
|
||||
showRaw={false}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -72,6 +72,7 @@ let {
|
||||
<a
|
||||
href="/api/documents/{doc.id}/file"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="text-sm underline hover:text-white"
|
||||
>
|
||||
{m.doc_download_link()}
|
||||
|
||||
@@ -46,6 +46,20 @@ describe('DocumentViewer', () => {
|
||||
.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 () => {
|
||||
render(DocumentViewer, { props: { ...baseProps, error: 'Render failed' } });
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ let {
|
||||
dateIso = $bindable(''),
|
||||
precision = $bindable<DatePrecision>('DAY'),
|
||||
endDateIso = $bindable(''),
|
||||
rawDate = '',
|
||||
initialDateIso = '',
|
||||
initialLocation = '',
|
||||
initialSenderName = '',
|
||||
@@ -30,7 +29,6 @@ let {
|
||||
dateIso?: string;
|
||||
precision?: DatePrecision;
|
||||
endDateIso?: string;
|
||||
rawDate?: string;
|
||||
initialDateIso?: string;
|
||||
initialLocation?: string;
|
||||
initialSenderName?: string;
|
||||
@@ -179,15 +177,6 @@ $effect(() => {
|
||||
{/if}
|
||||
</div>
|
||||
<input type="hidden" name="metaDateEnd" value={showEndDate ? endDateIso : ''} />
|
||||
|
||||
<!-- Originaltext (read-only raw cell): labelled static text, not a disabled input. -->
|
||||
{#if rawDate && rawDate.trim().length > 0}
|
||||
<div data-testid="who-when-raw">
|
||||
<p class="mb-1 block text-sm font-medium text-ink-2">{m.date_original_label()}</p>
|
||||
<p class="font-sans text-sm text-ink">{rawDate}</p>
|
||||
<input type="hidden" name="metaDateRaw" value={rawDate} />
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Absender (required in upload mode — row 1, col 2) -->
|
||||
|
||||
@@ -93,13 +93,12 @@ describe('WhoWhenSection — precision controls', () => {
|
||||
expect(document.querySelector('input#metaDateEnd')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('renders the raw cell as static text (not an editable input) and escapes it', async () => {
|
||||
render(WhoWhenSection, { rawDate: '<b>Sommer</b> 1916' });
|
||||
const raw = document.querySelector('[data-testid="who-when-raw"]');
|
||||
expect(raw).not.toBeNull();
|
||||
// Verbatim shown as escaped text; no injected <b> element.
|
||||
expect(raw?.textContent).toContain('<b>Sommer</b> 1916');
|
||||
expect(raw?.querySelector('b')).toBeNull();
|
||||
it('never renders the raw cell, and never re-submits it via a hidden input', async () => {
|
||||
render(WhoWhenSection, {});
|
||||
// The confusing "Originaltext" line is gone …
|
||||
expect(document.querySelector('[data-testid="who-when-raw"]')).toBeNull();
|
||||
// … and editing no longer round-trips metaDateRaw to the backend.
|
||||
expect(document.querySelector('input[name="metaDateRaw"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -170,15 +170,22 @@ function handleAnnotationClick(id: string) {
|
||||
<p class="font-sans text-sm">Keine Datei vorhanden</p>
|
||||
</div>
|
||||
{: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">
|
||||
<p class="font-sans text-sm text-red-400">Fehler beim Laden der PDF</p>
|
||||
<!-- role="alert" announces the failure to screen readers; the message text
|
||||
(not colour alone) carries the meaning. The download link is the only
|
||||
recovery action, so it is sized as a comfortable tap/focus target for
|
||||
the archive's older readers. -->
|
||||
<div
|
||||
role="alert"
|
||||
class="flex h-full w-full flex-col items-center justify-center gap-3 bg-pdf-bg px-4 text-center text-ink-3"
|
||||
>
|
||||
<p class="font-sans text-base text-red-400">{m.doc_render_failed()}</p>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-sans text-xs text-primary underline hover:text-ink-2"
|
||||
class="inline-block py-2 font-sans text-sm text-primary underline hover:text-ink-2"
|
||||
>
|
||||
Direkt öffnen
|
||||
{m.doc_download_link()}
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
|
||||
@@ -3,9 +3,63 @@ import { cleanup, render } from 'vitest-browser-svelte';
|
||||
import { page } from 'vitest/browser';
|
||||
import PdfViewer from './PdfViewer.svelte';
|
||||
import { makeFakeLibLoader } from './testHelpers';
|
||||
import { m } from '$lib/paraglide/messages.js';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
// Document loads fine, but rendering the page rejects with a non-cancellation
|
||||
// error — exactly the wasm-decode failure class from #708. Exercises the real
|
||||
// renderCurrentPage path, not the load path.
|
||||
function makeRenderFailingLibLoader() {
|
||||
const page = {
|
||||
getViewport: vi.fn().mockReturnValue({ width: 100, height: 100 }),
|
||||
render: vi.fn().mockReturnValue({
|
||||
promise: Promise.reject(new Error('JBig2 failed to initialize')),
|
||||
cancel: vi.fn()
|
||||
}),
|
||||
streamTextContent: vi.fn().mockReturnValue(new ReadableStream())
|
||||
};
|
||||
const lib = {
|
||||
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');
|
||||
return vi.fn().mockResolvedValue([lib, { default: '' }] as const);
|
||||
}
|
||||
|
||||
describe('PdfViewer — render failure', () => {
|
||||
it('shows the localized failure message and a download link when the page render rejects', async () => {
|
||||
render(PdfViewer, {
|
||||
url: '/api/documents/test/file',
|
||||
documentId: 'test',
|
||||
libLoader: makeRenderFailingLibLoader()
|
||||
});
|
||||
|
||||
await expect.element(page.getByText(m.doc_render_failed())).toBeVisible();
|
||||
await expect.element(page.getByRole('link', { name: m.doc_download_link() })).toBeVisible();
|
||||
// Announced to assistive tech, not a silent visual-only failure.
|
||||
await expect.element(page.getByRole('alert')).toBeVisible();
|
||||
});
|
||||
|
||||
it('does not show the failure message when the page renders successfully', async () => {
|
||||
render(PdfViewer, {
|
||||
url: '/api/documents/test/file',
|
||||
documentId: 'test',
|
||||
libLoader: makeFakeLibLoader()
|
||||
});
|
||||
|
||||
await expect.element(page.getByRole('button', { name: 'Zurück' })).toBeVisible();
|
||||
expect(document.body.textContent).not.toContain(m.doc_render_failed());
|
||||
});
|
||||
});
|
||||
|
||||
describe('PdfViewer — empty / error states', () => {
|
||||
it('renders the no-file placeholder when url is empty', async () => {
|
||||
render(PdfViewer, { url: '', libLoader: makeFakeLibLoader() });
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { createPdfRenderer } from './usePdfRenderer.svelte';
|
||||
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).
|
||||
// These tests cover pure state logic only — bounds clamping and zoom limits.
|
||||
@@ -205,7 +226,58 @@ describe('createPdfRenderer', () => {
|
||||
expect(fakeLoader).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('loadDocument sets error and loading=false when getDocument().promise rejects', async () => {
|
||||
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 a localized error (not the raw pdf.js message) when getDocument rejects', async () => {
|
||||
const failingLib = {
|
||||
GlobalWorkerOptions: { workerSrc: '' },
|
||||
getDocument: vi.fn().mockReturnValue({
|
||||
@@ -222,6 +294,7 @@ describe('createPdfRenderer', () => {
|
||||
await r.init();
|
||||
await r.loadDocument('/bad/path');
|
||||
expect(r.loading).toBe(false);
|
||||
expect(r.error).toBe('PDF not found');
|
||||
expect(r.error).toBe(m.doc_render_failed());
|
||||
expect(r.error).not.toContain('PDF not found');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
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 }]>;
|
||||
|
||||
const defaultLibLoader: LibLoader = () =>
|
||||
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) {
|
||||
// Reactive state — exposed via getters
|
||||
let currentPage = $state(1);
|
||||
@@ -44,12 +51,14 @@ export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) {
|
||||
totalPages = 0;
|
||||
|
||||
try {
|
||||
const loadingTask = pdfjsLib.getDocument(src);
|
||||
const loadingTask = pdfjsLib.getDocument({ url: src, wasmUrl: WASM_URL });
|
||||
const doc = await loadingTask.promise;
|
||||
pdfDoc = doc;
|
||||
totalPages = doc.numPages;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to load PDF';
|
||||
} catch {
|
||||
// Never surface the raw pdf.js message — show a localized failure
|
||||
// that routes into the viewer's error UI (message + download link).
|
||||
error = m.doc_render_failed();
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
@@ -99,6 +108,11 @@ export function createPdfRenderer(libLoader: LibLoader = defaultLibLoader) {
|
||||
(e as { name: string }).name === 'RenderingCancelledException'
|
||||
)
|
||||
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.
|
||||
renderTask = null;
|
||||
error = m.doc_render_failed();
|
||||
return;
|
||||
}
|
||||
renderTask = null;
|
||||
|
||||
@@ -20,8 +20,7 @@ export type DatePrecision = 'DAY' | 'MONTH' | 'SEASON' | 'YEAR' | 'RANGE' | 'APP
|
||||
* {@code DocumentTitleFormatter}: both are asserted against
|
||||
* `docs/date-label-fixtures.json` so they cannot drift. The untrusted `raw`
|
||||
* cell is only used to derive a season word (a known German season token) — it
|
||||
* is otherwise rendered separately by the caller via Svelte default escaping,
|
||||
* never interpolated into HTML here.
|
||||
* is never displayed and never interpolated into HTML here.
|
||||
*
|
||||
* @param iso the sort/filter anchor day (`YYYY-MM-DD`), nullable for UNKNOWN rows
|
||||
* @param precision descriptive precision metadata
|
||||
@@ -82,8 +81,7 @@ function seasonLabel(
|
||||
): string {
|
||||
const month = Number(iso.slice(5, 7));
|
||||
// Prefer the season named in the raw cell; fall back to deriving it from the
|
||||
// anchor month. Either way the WORD is localized (Decision 4) — the verbatim
|
||||
// German raw cell is preserved separately as the visible secondary line.
|
||||
// anchor month. Either way the WORD is localized (Decision 4).
|
||||
const season = seasonFromRaw(raw) ?? seasonOfMonth(month);
|
||||
return `${seasonWord(season, locale)} ${year}`;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { viteStaticCopy } from 'vite-plugin-static-copy';
|
||||
|
||||
export default defineConfig({
|
||||
optimizeDeps: {
|
||||
@@ -42,6 +43,15 @@ export default defineConfig({
|
||||
tailwindcss(),
|
||||
sveltekit(),
|
||||
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({
|
||||
project: './project.inlang',
|
||||
outdir: './src/lib/paraglide'
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
# XSS landing in a privileged origin: a payload cannot silently turn
|
||||
# on the microphone or read 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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user