feat(accounting): rasterise inbound PDFs server-side (never serve raw to browser)

Security hardening for inbound supplier-invoice previews. The admin UI no
longer renders raw PDFs — a malicious inbound PDF could otherwise run embedded
JS or phone home in the admin's session. Instead PDFs are rasterised to flat
PNGs server-side and only those images are shown.

- backend: new rasterizeService shells out to poppler `pdftoppm` (added to the
  Docker image via apk poppler-utils — an OS package, NOT a Node PDF lib, so it
  respects the pdfkit+pdf-lib "no third PDF lib" rule). pdftoppm executes no JS
  and fetches no remote resources, so it doubles as the SSRF/phone-home guard.
  Rendered pages cached under storage/business-docs/inbound/rendered/<id>/.
  - GET /inbound/:id/page/:n streams the rasterised PNG (CSP default-src 'none'
    + nosniff). GET /inbound/:id/file now serves PDFs as a DOWNLOAD only
    (Content-Disposition: attachment) — never inline; images still inline.
- frontend: triage preview switched from a raw-PDF <iframe> to rasterised page
  images (getInboundPageBlob), defaulting to the LAST page (QR-bill) with
  prev/next nav for multi-page PDFs; images stream as before.
- i18n: previewError / prevPage / nextPage / pageOf (EN + DE).

REQUIRES A BACKEND IMAGE REBUILD (Dockerfile adds poppler-utils) — a plain
`docker compose pull` of a stale image won't have pdftoppm; the route then
returns 503 RASTERIZER_UNAVAILABLE and the UI shows "preview unavailable".

Verified: node -c, a pdfkit->pdftoppm rasterise smoke test (renders + caches),
en/de JSON valid, npm run build green.
This commit is contained in:
Luca
2026-06-11 00:51:10 +02:00
parent 0c35ac43e6
commit e111522415
7 changed files with 157 additions and 32 deletions
+5 -1
View File
@@ -3430,7 +3430,11 @@
"noAmount": "Betrag nicht erfasst",
"rebillHint": "Erstellt eine bearbeitbare geplante Rechnung beim Kunden. MwSt-/Steuerbehandlung ist v1 — mit Treuhänder prüfen.",
"previewLoading": "Vorschau wird geladen…",
"qrHint": "An der letzten Seite geöffnet — der Schweizer QR-Einzahlschein sitzt meist unten.",
"previewError": "Vorschau nicht verfügbar — Felder manuell erfassen.",
"qrHint": "Letzte Seite — der Schweizer QR-Einzahlschein sitzt meist unten.",
"prevPage": "Zurück",
"nextPage": "Weiter",
"pageOf": "Seite {{n}} / {{total}}",
"status": {
"unsorted": "Neu",
"categorized": "Kategorisiert",
+5 -1
View File
@@ -3430,7 +3430,11 @@
"noAmount": "amount not entered",
"rebillHint": "Creates an editable scheduled invoice on the client. VAT/tax handling is v1 — verify with your Treuhänder.",
"previewLoading": "Loading preview…",
"qrHint": "Opened at the last page — the Swiss QR-bill usually sits at the bottom.",
"previewError": "Preview unavailable — enter the fields manually.",
"qrHint": "Showing the last page — the Swiss QR-bill usually sits at the bottom.",
"prevPage": "Prev",
"nextPage": "Next",
"pageOf": "Page {{n}} / {{total}}",
"status": {
"unsorted": "New",
"categorized": "Categorized",
@@ -57,24 +57,28 @@ const TriageModal: React.FC<{
const totalMinor = Number.isFinite(amountMajor) ? Math.round(amountMajor * 100) : null;
// Authenticated file preview: fetch as a blob (Bearer auth) and embed.
// PDFs open at the last page scrolled into the QR-bill area (no OCR — the
// admin reads the payment slip and types the fields).
// Authenticated preview. PDFs are shown as SERVER-RASTERISED page images
// (the raw PDF never reaches the browser); images stream directly. Start on
// the LAST page — the Swiss QR-bill payment part sits at its bottom (no OCR;
// the admin reads the slip and types the fields).
const isPdf = (doc.mimeType || '').includes('pdf');
const [fileUrl, setFileUrl] = useState<string | null>(null);
const pageCount = doc.pageCount || 1;
const [page, setPage] = useState(pageCount);
const [imgUrl, setImgUrl] = useState<string | null>(null);
const [previewError, setPreviewError] = useState(false);
useEffect(() => {
let url: string | null = null;
let cancelled = false;
accountingService.getInboundFileBlob(doc.id)
.then((blob) => { if (!cancelled) { url = URL.createObjectURL(blob); setFileUrl(url); } })
.catch(() => { if (!cancelled) setFileUrl(null); });
setImgUrl(null);
setPreviewError(false);
const fetcher = isPdf
? accountingService.getInboundPageBlob(doc.id, page)
: accountingService.getInboundFileBlob(doc.id);
fetcher
.then((blob) => { if (!cancelled) { url = URL.createObjectURL(blob); setImgUrl(url); } })
.catch(() => { if (!cancelled) setPreviewError(true); });
return () => { cancelled = true; if (url) URL.revokeObjectURL(url); };
}, [doc.id]);
// #page=<last> jumps to the last page; view=FitH,300 fits the width and
// positions ~300pt from the bottom — the Swiss QR-bill payment part.
const previewSrc = fileUrl
? (isPdf ? `${fileUrl}#page=${doc.pageCount || 1}&view=FitH,300` : fileUrl)
: null;
}, [doc.id, isPdf, page]);
const save = useMutation({
mutationFn: async () => {
@@ -134,20 +138,29 @@ const TriageModal: React.FC<{
<div className="px-5 py-4 grid grid-cols-1 lg:grid-cols-2 gap-5">
{/* Document preview — PDFs open at the last page (QR-bill area). */}
<div className="order-2 lg:order-1">
{previewSrc ? (
isPdf ? (
<iframe title="document preview" src={previewSrc} className="w-full h-[60vh] rounded-md border border-neutral-200 dark:border-neutral-700" />
<div className="overflow-auto rounded-md border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800" style={{ maxHeight: '60vh' }}>
{previewError ? (
<div className="flex h-[60vh] items-center justify-center px-3 text-center text-sm text-neutral-500">
{t('accounting.inbox.previewError', 'Preview unavailable — enter the fields manually.')}
</div>
) : imgUrl ? (
<img src={imgUrl} alt="document page" className="w-full h-auto" />
) : (
<img src={previewSrc} alt="document" className="w-full max-h-[60vh] object-contain rounded-md border border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800" />
)
) : (
<div className="flex h-[60vh] items-center justify-center rounded-md border border-dashed border-neutral-300 dark:border-neutral-700 text-sm text-neutral-500">
{t('accounting.inbox.previewLoading', 'Loading preview…')}
<div className="flex h-[60vh] items-center justify-center text-sm text-neutral-500">
{t('accounting.inbox.previewLoading', 'Loading preview…')}
</div>
)}
</div>
{isPdf && pageCount > 1 && (
<div className="mt-2 flex items-center justify-center gap-3 text-sm">
<Button size="sm" variant="outline" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>{t('accounting.inbox.prevPage', 'Prev')}</Button>
<span className="text-neutral-600 dark:text-neutral-400">{t('accounting.inbox.pageOf', 'Page {{n}} / {{total}}', { n: page, total: pageCount })}</span>
<Button size="sm" variant="outline" onClick={() => setPage((p) => Math.min(pageCount, p + 1))} disabled={page >= pageCount}>{t('accounting.inbox.nextPage', 'Next')}</Button>
</div>
)}
{isPdf && (
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
{t('accounting.inbox.qrHint', 'Opened at the last page — the Swiss QR-bill usually sits at the bottom.')}
<p className="mt-1 text-center text-xs text-neutral-500 dark:text-neutral-400">
{t('accounting.inbox.qrHint', 'Showing the last page — the Swiss QR-bill usually sits at the bottom.')}
</p>
)}
</div>
@@ -120,6 +120,12 @@ export const accountingService = {
return data;
},
// Rasterised PDF page (PNG) — the raw PDF is never sent to the browser.
async getInboundPageBlob(id: number, page: number): Promise<Blob> {
const { data } = await api.get(`/admin/expenses/inbound/${id}/page/${page}`, { responseType: 'blob' });
return data;
},
async updateInbound(id: number, fields: Partial<Pick<InboundDocument,
'supplierName' | 'invoiceNumber' | 'invoiceDate' | 'dueDate' | 'currency' |
'netAmountMinor' | 'vatAmountMinor' | 'totalAmountMinor' | 'iban' | 'paymentReference'>>): Promise<InboundDocument> {