Fix invoice-image code-review findings: aspect ratio, whitelist config, stale refs

- MigraDocExtensions: fix a bug where capping an oversized explicit
  width+height image re-derived the other dimension from the image's
  native pixel ratio instead of scaling both sides uniformly, silently
  distorting a deliberately non-native-ratio (e.g. stretched) image.
- InvoiceService: derive the mfr-portal image whitelist prefix from the
  existing Fds:MFR_host config setting (via MFRClientConfig.RestRoot)
  instead of a second hardcoded literal, so it can't drift from the
  actual configured host; parse <img> sources with HtmlAgilityPack
  (already used by the PDF's own HTML converter) instead of a regex, so
  a decoy attribute like data-src can't be picked up in place of the
  real src; dedup fetch URLs with a HashSet instead of O(n^2) List
  scanning; reuse FuchsPdf.MigraDocFilenameFromByteArray instead of
  re-spelling the "base64:" prefix.
- Fix a stale log message and comment left over from the
  GenerateInvoicePdf -> GenerateInvoicePdfAsync rename, and update
  INVOICE_LIFECYCLE.md's references to the old sync method name.
- Add tests for the config-derived whitelist, a decoy-attribute src,
  and the 20-image-per-invoice fetch cap boundary.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 21:03:09 +02:00
co-authored by Claude Sonnet 5
parent c182d4fd69
commit 406c6f2860
6 changed files with 146 additions and 47 deletions
@@ -28,17 +28,24 @@ user-supplied URL server-side is also an SSRF surface — the invoice text is us
so a naive "download whatever `src` says" would let it reach internal/private network addresses.
## Decision
- Invoice images are inlined into the PDF only when their `src` starts with the literal prefix
`https://portal.mobilefieldreport.com/mfr/` (`InvoiceService.AllowedInlineImagePrefix`) — the
only origin the invoice editor's picture links come from. Anything else (any other host, `http://`,
a different path) is left unrendered, exactly as before this feature — no generic remote fetch.
- Invoice images are inlined into the PDF only when their `src` starts with the mfr REST root
(`InvoiceService.AllowedInlineImagePrefix`, e.g. `https://portal.mobilefieldreport.com/mfr/`) —
the only origin the invoice editor's picture links come from. This is derived from the same
`Fds:MFR_host` configuration entry the MFR REST client itself uses (via
`MFR_RESTClient.MFRClientConfig.RestRoot`), not a second hardcoded literal, so it can't drift if
the configured mfr host ever changes per environment/tenant. Anything else (any other host,
`http://`, a different path) is left unrendered, exactly as before this feature — no generic
remote fetch.
- The fetch happens once per invoice render, in `InvoiceService.ResolveInlineImagesAsync`
(async, off a named `IHttpClientFactory` client `"InvoiceInlineImages"`, 10s timeout), *before*
the synchronous MigraDoc render starts. `InvoiceService.GenerateInvoicePdf` therefore became
`GenerateInvoicePdfAsync` — every caller already ran in an async context. Each fetched image is
capped at 5MB, must report an `image/*` content-type, and the invoice fetches at most 20 distinct
images; failures (unreachable, wrong type, oversized) are logged and just leave that one image out
— they never fail the whole render.
`GenerateInvoicePdfAsync` — every caller already ran in an async context. `<img>` sources are
found by parsing the description HTML with HtmlAgilityPack (`ResolveInlineImagesAsync`) — the
same library the `HtmlConverter` itself parses the identical text with — rather than a regex, so
a decoy attribute next to the real `src` (e.g. `data-src="..."`) can't be picked up in its place.
Each fetched image is capped at 5MB, must report an `image/*` content-type, and the invoice
fetches at most 20 distinct images; failures (unreachable, wrong type, oversized) are logged and
just leave that one image out — they never fail the whole render.
- The resolved `{src → "base64:..."}` map flows down as a plain `IReadOnlyDictionary<string,string>?`
parameter through `IPdfService.ApplyInvoice` → `FuchsPdf.ApplyInvoice` → `Cell.AddHtml` into the
`HtmlConverter`, whose new `img` handler only emits an image for a `src` present in that map —
+4 -3
View File
@@ -116,7 +116,7 @@ vat }` plus set header/member tagging) that is posted to the server.
| User action | JS entry point | Endpoint | Controller handler | Effect |
|---|---|---|---|---|
| First save without preview | `$inv.ssave` | `req/save` | `Do_Process_Requests` case `save` | `RegisterInvoiceAsync(change: id present)` — creates or updates the draft row, **no PDF rendered** |
| Preview a **new** invoice | `$inv.sprev(false)` | `req/sprep` | case `sprep` | Registers draft, then `GenerateInvoicePdf` → renders preview images |
| Preview a **new** invoice | `$inv.sprev(false)` | `req/sprep` | case `sprep` | Registers draft, then `GenerateInvoicePdfAsync` → renders preview images |
| Preview an **existing** draft | `$inv.sprev(true)` (aka `$inv.sedit`) | `req/sedit` | case `sedit` | Same as `sprep` but requires `id`, updates existing draft |
| Cancel out of preview | dialog `cancel` handler | `req/sdel` | case `sdel` | `fds__remInvoice` — deletes the (unfinalised) draft row |
@@ -134,7 +134,8 @@ JSON into SQL parameters and calls, in one batch:
- **Existing draft**: `fds__setInvoice` (same parameter set, updates in place)
→ `fds__createInvoice_Details` again.
`GenerateInvoicePdf` (still in `InvoiceService`) builds `FuchsPdf.FdsTextBlocks`
`GenerateInvoicePdfAsync` (still in `InvoiceService`) builds `FuchsPdf.FdsTextBlocks`,
resolves any whitelisted mfr-portal images from the line-item text (ADR 0018),
and calls `IPdfService.WriteLetterAsync` + `ApplyInvoice`, which internally
uses `InvoiceSetPricing.Build(...)` to turn the posted item contract into
ordered, priced/unpriced lines. While the invoice is **not yet final**
@@ -160,7 +161,7 @@ sequenceDiagram
C->>S: RegisterInvoiceAsync(invoice, change, invId)
S->>DB: fds__createInvoice / fds__setInvoice + fds__createInvoice_Details
DB-->>S: registered invoice row (Id, ...)
S->>S: GenerateInvoicePdf (draft=true -> watermark, no GiroCode)
S->>S: GenerateInvoicePdfAsync (draft=true -> watermark, no GiroCode)
S-->>C: MigraDoc Document
C-->>JS: {id, img[] (base64 pages), total}
JS->>U: show PDF preview modal (confirm / cancel)