From 071982caec3d432b7565ea041fe7429f847cbf7e Mon Sep 17 00:00:00 2001 From: Stefan Date: Thu, 10 Sep 2026 19:36:31 +0200 Subject: [PATCH] Render invoice-editor images (mfr portal only) into the PDF The invoice line editor already shows pictures pulled from the mfr portal, but the PDF pipeline's HTML-to-MigraDoc converter had no handler, so they never appeared on the actual invoice. InvoiceService now fetches only https://portal.mobilefieldreport.com/mfr/* image sources before rendering (size/type/count capped, failures logged and skipped) and hands the resolved images down through ApplyInvoice into the HTML converter, which renders them inline at their original HTML position and size (px converted to cm, capped against layout overflow). See ADR 0018. Co-Authored-By: Claude Sonnet 5 --- Fuchs.Tests/InvoiceDraftServiceTests.cs | 10 +- Fuchs.Tests/InvoiceInlineImageTests.cs | 160 ++++++++++++++++++ Fuchs.Tests/MigraDocHtmlImageTests.cs | 109 ++++++++++++ .../IntranetController.InvoiceDraft.cs | 2 +- .../IntranetController.Requests.cs | 8 +- ...-invoice-editor-images-inlined-into-pdf.md | 70 ++++++++ Fuchs/Program.cs | 4 + Fuchs/Services/FuchsPdfService.cs | 4 +- Fuchs/Services/IInvoiceDraftService.cs | 2 +- Fuchs/Services/IInvoiceService.cs | 4 +- Fuchs/Services/IPdfService.cs | 6 +- Fuchs/Services/InvoiceDraftEditService.cs | 4 +- Fuchs/Services/InvoiceService.cs | 90 +++++++++- Fuchs/code/FuchsPdf.cs | 5 +- Fuchs/code/MigraDocExtensions.cs | 66 +++++++- 15 files changed, 516 insertions(+), 28 deletions(-) create mode 100644 Fuchs.Tests/InvoiceInlineImageTests.cs create mode 100644 Fuchs.Tests/MigraDocHtmlImageTests.cs create mode 100644 Fuchs/Docs/Decisions/0018-invoice-editor-images-inlined-into-pdf.md diff --git a/Fuchs.Tests/InvoiceDraftServiceTests.cs b/Fuchs.Tests/InvoiceDraftServiceTests.cs index 32ed20e..c4c67b3 100644 --- a/Fuchs.Tests/InvoiceDraftServiceTests.cs +++ b/Fuchs.Tests/InvoiceDraftServiceTests.cs @@ -34,7 +34,7 @@ public class InvoiceDraftServiceTests public FdsInvoiceData? PreviewInvoice; public bool? PreviewDraft; public Task LoadInvoiceAsync(string id, string u, DatabaseSecurity s) => throw new System.NotSupportedException(); - public Document GenerateInvoicePdf(FdsInvoiceData i, bool d) { PreviewInvoice = i; PreviewDraft = d; return new Document(); } + public Task GenerateInvoicePdfAsync(FdsInvoiceData i, bool d) { PreviewInvoice = i; PreviewDraft = d; return Task.FromResult(new Document()); } public Task RenderInvoicePdfBytesAsync(FdsInvoiceData i, bool d) => throw new System.NotSupportedException(); public Task StoreInvoiceDocumentFileAsync(FdsInvoiceData i, bool d, string u, DatabaseSecurity s) => throw new System.NotSupportedException(); public Task GetInvoiceFileAsync(FdsInvoiceData i, bool d, fds.IFdsMfr m) => throw new System.NotSupportedException(); @@ -592,12 +592,12 @@ public class InvoiceDraftServiceTests } [Fact] - public void RenderPreview_SynthesizesDraftRegistrationFromSession() + public async Task RenderPreview_SynthesizesDraftRegistrationFromSession() { var (svc, inv, _) = NewService(); var s = svc.OpenFromPayload(Payload(), "user1"); - var doc = svc.RenderPreview(s.Token); + var doc = await svc.RenderPreviewAsync(s.Token); Assert.NotNull(doc); Assert.True(inv.PreviewDraft); // always rendered as a draft @@ -610,10 +610,10 @@ public class InvoiceDraftServiceTests } [Fact] - public void RenderPreview_UnknownToken_ReturnsNull() + public async Task RenderPreview_UnknownToken_ReturnsNull() { var (svc, _, _) = NewService(); - Assert.Null(svc.RenderPreview("ghost")); + Assert.Null(await svc.RenderPreviewAsync("ghost")); } [Fact] diff --git a/Fuchs.Tests/InvoiceInlineImageTests.cs b/Fuchs.Tests/InvoiceInlineImageTests.cs new file mode 100644 index 0000000..4eda1f0 --- /dev/null +++ b/Fuchs.Tests/InvoiceInlineImageTests.cs @@ -0,0 +1,160 @@ +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Fuchs.intranet; +using Fuchs.Notifications; +using Fuchs.Services; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace Fuchs.Tests; + +/// +/// Exercises — the whitelist-restricted image +/// fetch that lets invoice-editor picture links (mfr portal only) appear in the rendered PDF +/// (ADR 0018). Covers the whitelist boundary, fetch failure modes (each must just drop that one +/// image, never throw), and the per-invoice fetch cap. +/// +public class InvoiceInlineImageTests +{ + private sealed class StubHandler : HttpMessageHandler + { + public readonly List RequestedUrls = new(); + private readonly Func _respond; + public StubHandler(Func respond) => _respond = respond; + protected override Task SendAsync(HttpRequestMessage request, CancellationToken ct) + { + RequestedUrls.Add(request.RequestUri!.ToString()); + return Task.FromResult(_respond(request)); + } + } + + private sealed class StubFactory : IHttpClientFactory + { + private readonly HttpMessageHandler _handler; + public StubFactory(HttpMessageHandler handler) => _handler = handler; + public HttpClient CreateClient(string name) => new(_handler, disposeHandler: false); + } + + private static InvoiceService MakeService(HttpMessageHandler handler) => new( + new Fuchs_intranet(new ConfigurationBuilder().Build()), + Mock.Of(), + Mock.Of(), + Mock.Of(), + Mock.Of(), + new StubFactory(handler), + NullLogger.Instance); + + private static FdsInvoiceData InvoiceWithDesc(params string[] descs) + { + var items = new List>(); + foreach (var d in descs) items.Add(new Dictionary { ["type"] = "material", ["desc"] = d }); + return new FdsInvoiceData + { + Req = new List> { new() { ["text"] = "Block", ["items"] = items } } + }; + } + + private static HttpResponseMessage ImageResponse(byte[] bytes) => + new(HttpStatusCode.OK) { Content = new ByteArrayContent(bytes) { Headers = { ContentType = new("image/png") } } }; + + [Fact] + public async Task ResolveInlineImagesAsync_WhitelistedUrl_ReturnsBase64DataForOriginalSrc() + { + byte[] bytes = { 1, 2, 3, 4 }; + var handler = new StubHandler(_ => ImageResponse(bytes)); + string url = InvoiceService.AllowedInlineImagePrefix + "pic1.png"; + var invoice = InvoiceWithDesc($"

Before

After

"); + + var result = await MakeService(handler).ResolveInlineImagesAsync(invoice); + + Assert.True(result.ContainsKey(url)); + Assert.Equal("base64:" + Convert.ToBase64String(bytes), result[url]); + Assert.Single(handler.RequestedUrls); + } + + [Theory] + [InlineData("https://evil.example.com/mfr/pic1.png")] + [InlineData("https://portal.mobilefieldreport.com.evil.com/mfr/pic1.png")] + [InlineData("http://portal.mobilefieldreport.com/mfr/pic1.png")] + [InlineData("https://portal.mobilefieldreport.com/other/pic1.png")] + public async Task ResolveInlineImagesAsync_NonWhitelistedSrc_NeverFetched(string url) + { + var handler = new StubHandler(_ => ImageResponse(new byte[] { 1 })); + var invoice = InvoiceWithDesc($""); + + var result = await MakeService(handler).ResolveInlineImagesAsync(invoice); + + Assert.Empty(result); + Assert.Empty(handler.RequestedUrls); + } + + [Fact] + public async Task ResolveInlineImagesAsync_NoImages_SkipsHttpEntirely() + { + var handler = new StubHandler(_ => ImageResponse(new byte[] { 1 })); + var invoice = InvoiceWithDesc("

Just text, no picture.

"); + + var result = await MakeService(handler).ResolveInlineImagesAsync(invoice); + + Assert.Empty(result); + Assert.Empty(handler.RequestedUrls); + } + + [Fact] + public async Task ResolveInlineImagesAsync_HttpErrorStatus_DropsImageWithoutThrowing() + { + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.NotFound)); + string url = InvoiceService.AllowedInlineImagePrefix + "missing.png"; + var invoice = InvoiceWithDesc($""); + + var result = await MakeService(handler).ResolveInlineImagesAsync(invoice); + + Assert.Empty(result); + } + + [Fact] + public async Task ResolveInlineImagesAsync_NonImageContentType_DropsImage() + { + var handler = new StubHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(new byte[] { 1, 2 }) { Headers = { ContentType = new("text/html") } } + }); + string url = InvoiceService.AllowedInlineImagePrefix + "notreally.png"; + var invoice = InvoiceWithDesc($""); + + var result = await MakeService(handler).ResolveInlineImagesAsync(invoice); + + Assert.Empty(result); + } + + [Fact] + public async Task ResolveInlineImagesAsync_ThrowingHandler_DropsImageWithoutThrowing() + { + var handler = new StubHandler(_ => throw new HttpRequestException("network down")); + string url = InvoiceService.AllowedInlineImagePrefix + "boom.png"; + var invoice = InvoiceWithDesc($""); + + var result = await MakeService(handler).ResolveInlineImagesAsync(invoice); + + Assert.Empty(result); + } + + [Fact] + public async Task ResolveInlineImagesAsync_DuplicateSrcAcrossLines_FetchedOnce() + { + var handler = new StubHandler(_ => ImageResponse(new byte[] { 9 })); + string url = InvoiceService.AllowedInlineImagePrefix + "shared.png"; + var invoice = InvoiceWithDesc($"", $""); + + var result = await MakeService(handler).ResolveInlineImagesAsync(invoice); + + Assert.Single(result); + Assert.Single(handler.RequestedUrls); + } +} diff --git a/Fuchs.Tests/MigraDocHtmlImageTests.cs b/Fuchs.Tests/MigraDocHtmlImageTests.cs new file mode 100644 index 0000000..3d2995d --- /dev/null +++ b/Fuchs.Tests/MigraDocHtmlImageTests.cs @@ -0,0 +1,109 @@ +using System.Collections.Generic; +using System.Linq; +using MigraDoc.DocumentObjectModel; +using MigraDoc.DocumentObjectModel.Shapes; +using MigraDoc.DocumentObjectModel.Tables; +using MigraDoc.Extensions; +using Xunit; + +namespace Fuchs.Tests; + +/// +/// Exercises the HTML→MigraDoc converter's <img> handling (added alongside the invoice-text +/// image feature, ADR 0018): only pre-resolved sources render, they land inline at the position the +/// HTML puts them (not appended/floating), and their size mirrors the HTML width/height, capped +/// against layout-breaking values. +/// +public class MigraDocHtmlImageTests +{ + private const string DataSrc = "base64:AAAA"; + + private static Cell NewCell() + { + var doc = new Document(); + var sec = doc.AddSection(); + var table = sec.AddTable(); + table.AddColumn(); + var row = table.AddRow(); + return row.Cells[0]; + } + + [Fact] + public void AddHtml_UnresolvedImgSrc_IsDropped() + { + var cell = NewCell(); + cell.AddHtml("
Text more
", null); + + var para = Assert.IsType(cell.Elements[0]); + Assert.DoesNotContain(para.Elements.Cast(), e => e is Image); + } + + [Fact] + public void AddHtml_ResolvedImgSrc_RendersInlineAtHtmlPosition() + { + var cell = NewCell(); + var images = new Dictionary { ["x"] = DataSrc }; + cell.AddHtml("
Before After
", images); + + var para = Assert.IsType(cell.Elements[0]); + var kinds = para.Elements.Cast().Select(e => e.GetType().Name).ToList(); + int imgIdx = kinds.IndexOf(nameof(Image)); + Assert.NotEqual(-1, imgIdx); + // The image must sit between the "Before " and " After" text runs, not appended after them. + Assert.True(imgIdx > 0 && imgIdx < kinds.Count - 1, + $"expected image between surrounding text, got element order: {string.Join(",", kinds)}"); + } + + [Fact] + public void AddHtml_WidthAndHeightAttributes_SetExplicitSizeFromPx() + { + var cell = NewCell(); + var images = new Dictionary { ["x"] = DataSrc }; + cell.AddHtml("
", images); + + var para = Assert.IsType(cell.Elements[0]); + var img = para.Elements.Cast().OfType().Single(); + // 96px / 48px at 96dpi → 2.54cm / 1.27cm. + Assert.Equal(2.54, img.Width.Centimeter, 2); + Assert.Equal(1.27, img.Height.Centimeter, 2); + } + + [Fact] + public void AddHtml_StyleWidthHeight_SetExplicitSizeFromPx() + { + var cell = NewCell(); + var images = new Dictionary { ["x"] = DataSrc }; + cell.AddHtml("
", images); + + var para = Assert.IsType(cell.Elements[0]); + var img = para.Elements.Cast().OfType().Single(); + Assert.Equal(5.08, img.Width.Centimeter, 2); + Assert.Equal(2.54, img.Height.Centimeter, 2); + } + + [Fact] + public void AddHtml_HugeHtmlSize_IsCappedToLayoutSafeMaximum() + { + var cell = NewCell(); + var images = new Dictionary { ["x"] = DataSrc }; + // A huge (or hand-edited/malformed) size must never blow out the invoice table cell. + cell.AddHtml("
", images); + + var para = Assert.IsType(cell.Elements[0]); + var img = para.Elements.Cast().OfType().Single(); + Assert.True(img.Width.Centimeter <= 7.0); + Assert.True(img.Height.Centimeter <= 10.0); + } + + [Fact] + public void AddHtml_NoSizeGiven_KeepsAspectRatioLocked() + { + var cell = NewCell(); + var images = new Dictionary { ["x"] = DataSrc }; + cell.AddHtml("
", images); + + var para = Assert.IsType(cell.Elements[0]); + var img = para.Elements.Cast().OfType().Single(); + Assert.True(img.LockAspectRatio); + } +} diff --git a/Fuchs/Controllers/IntranetController.InvoiceDraft.cs b/Fuchs/Controllers/IntranetController.InvoiceDraft.cs index f139827..731e199 100644 --- a/Fuchs/Controllers/IntranetController.InvoiceDraft.cs +++ b/Fuchs/Controllers/IntranetController.InvoiceDraft.cs @@ -75,7 +75,7 @@ public partial class IntranetController private async Task HandleDraftPreview(string fn, string id, string code) { if (!HasForm("token")) return BadRequest400(); - var doc = _invoiceDrafts.RenderPreview(Form("token")); + var doc = await _invoiceDrafts.RenderPreviewAsync(Form("token")); if (doc == null) return DraftGone(); var imgcol = await _pdf.DocToImageCollectionAsync(doc); return await JSONAsync(new { img = imgcol.ImgB64Array, total = imgcol.TotalPages }); diff --git a/Fuchs/Controllers/IntranetController.Requests.cs b/Fuchs/Controllers/IntranetController.Requests.cs index ac0479e..aae34ba 100644 --- a/Fuchs/Controllers/IntranetController.Requests.cs +++ b/Fuchs/Controllers/IntranetController.Requests.cs @@ -75,7 +75,7 @@ public partial class IntranetController { _logger.LogInformation("Requests sprep: invoice draft {InvId} created, user={User}", fdInv.Id, UserAccountID); await _events.InvoiceDraftRegisteredAsync(fdInv, changed: false, userAccountId: UserAccountID); - var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft)); + var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft)); return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages }); } return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht erstellt werden."); @@ -92,7 +92,7 @@ public partial class IntranetController { _logger.LogInformation("Requests sedit: invoice draft {InvId} updated, user={User}", fdInv.Id, UserAccountID); await _events.InvoiceDraftRegisteredAsync(fdInv, changed: true, userAccountId: UserAccountID); - var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft)); + var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft)); return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages }); } return await InvoiceIssueResult("Die Rechnung konnte aufgrund eines Fehlers nicht aktualisiert werden."); @@ -396,7 +396,7 @@ public partial class IntranetController { byte[]? ct = Form("create", "0") != "1" ? await _invoices.GetInvoiceFileAsync(fdInv, fdInv.IsDraft, _mfr) is { Length: > 0 } f1 ? f1 : await _invoices.StoreInvoiceDocumentFileAsync(fdInv, fdInv.IsDraft, UserAccountID, DbSec) - : _pdf.DocToPdfBytes(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft)); + : _pdf.DocToPdfBytes(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft)); if (ct == null) return await InvoiceIssueResult("Die Rechnungs-PDF konnte aufgrund eines Fehlers nicht erstellt werden.", fdInv.Id); // Serve inline for the in-browser viewer, but carry the real DocumentName so the browser's @@ -405,7 +405,7 @@ public partial class IntranetController SetInlinePdfFilename(filename); return File(ct, "application/pdf"); } - var imgcol = await _pdf.DocToImageCollectionAsync(_invoices.GenerateInvoicePdf(fdInv, fdInv.IsDraft)); + var imgcol = await _pdf.DocToImageCollectionAsync(await _invoices.GenerateInvoicePdfAsync(fdInv, fdInv.IsDraft)); return await JSONAsync(new { id = fdInv.Id, img = imgcol.ImgB64Array, total = imgcol.TotalPages }); } diff --git a/Fuchs/Docs/Decisions/0018-invoice-editor-images-inlined-into-pdf.md b/Fuchs/Docs/Decisions/0018-invoice-editor-images-inlined-into-pdf.md new file mode 100644 index 0000000..932b923 --- /dev/null +++ b/Fuchs/Docs/Decisions/0018-invoice-editor-images-inlined-into-pdf.md @@ -0,0 +1,70 @@ +--- +status: Accepted +date: 2026-09-10 +applyTo: + - "Fuchs/code/MigraDocExtensions.cs" + - "Fuchs/code/FuchsPdf.cs" + - "Fuchs/Services/InvoiceService.cs" + - "Fuchs/Services/IInvoiceService.cs" + - "Fuchs/Services/IPdfService.cs" + - "Fuchs/Services/FuchsPdfService.cs" +supersededBy: "" +--- + +# 0018 — Invoice-text images are fetched at render time from a domain whitelist, not embedded as data URIs + +## Context +The invoice line-item description is TinyMCE-authored HTML (`FdsInvoiceData`/`InvoiceBlock.Items` +item `desc`), and can contain `` picture +links pulled from the mfr ERP — the browser resolves and displays them in the editor. The PDF +pipeline, however, renders through a hand-rolled HTML→MigraDoc subset converter +(`MigraDoc.Extensions.Html.HtmlConverter` in `MigraDocExtensions.cs`), which had no `img` handler +at all: every ``, remote or embedded, was silently dropped, so images the editor showed never +appeared on the actual invoice. + +MigraDoc/PdfSharp does not fetch remote URLs itself, and the converter runs synchronously deep +inside the largely-synchronous `FuchsPdf` static rendering pipeline. Fetching an arbitrary +user-supplied URL server-side is also an SSRF surface — the invoice text is user/editor-controlled, +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. +- 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. +- The resolved `{src → "base64:..."}` map flows down as a plain `IReadOnlyDictionary?` + 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 — + it does no fetching itself and never sees the network. Image sizing mirrors the HTML: the + handler reads `width`/`height` (attribute or inline `style`, as TinyMCE's resize handles write + them) and converts CSS px → cm, capped to 7×10cm so a malformed/huge size can't blow out the + invoice table layout. + +## Consequences +- Any future host serving invoice-editor images must be added to the whitelist deliberately (a + one-line prefix, or a small allowed-prefixes list if a second origin appears) — this is not + meant to grow into a generic "fetch any URL" facility. +- `IInvoiceService.GenerateInvoicePdf` → `GenerateInvoicePdfAsync`, and + `IInvoiceDraftService.RenderPreview` → `RenderPreviewAsync` (same signature change, same reason) + are breaking signature changes; anything implementing/mocking these interfaces needs updating. +- Image src values not covered by the whitelist — including a stray non-mfr remote URL, or a raw + `data:` URI a user might paste in — still render as nothing, matching the pre-existing (silent) + drop behaviour. If embedded `data:` images need support later, that's a separate, simpler + decision (no network fetch involved) and can reuse the same `img` handler contract. + +## Alternatives considered +- **Fetch inside the HTML converter itself (sync-over-async `HttpClient.GetAsync(...).Result`)** — + rejected: violates the project's no-sync-over-async rule and keeps SSRF-relevant network code + inside a generic, reusable HTML converter instead of a single audited call site. +- **Fully embed images as `data:` base64 URIs at editor save time** — rejected: doubles storage + size per invoice edit, and the image is already resolved by the mfr portal itself; re-fetching a + small number of images once at render time is simpler and keeps the source of truth external. +- **Allow any remote `http(s)://` host** — rejected on SSRF grounds; every invoice image the editor + can currently produce is from the mfr portal, so a whitelist costs nothing today. diff --git a/Fuchs/Program.cs b/Fuchs/Program.cs index 85604a7..ea0f2f5 100644 --- a/Fuchs/Program.cs +++ b/Fuchs/Program.cs @@ -128,6 +128,10 @@ public class Program builder.Services.AddHttpClient(ProcessWebERechnungValidator.HttpClientName, c => c.Timeout = TimeSpan.FromSeconds(90)); builder.Services.AddHttpClient("ProcessWebMailer"); + // Fetches invoice-editor images (mfr:// picture links) for inline PDF rendering; whitelist- + // restricted to the mfr portal (see InvoiceService.AllowedInlineImagePrefix / ADR 0018). + builder.Services.AddHttpClient(InvoiceService.InlineImageHttpClientName, + c => c.Timeout = TimeSpan.FromSeconds(10)); builder.Services.AddScoped(); // Holds the one-shot startup self-test result for the lifetime of the process so the Admin // module can display it as a non-refreshable widget (must be registered before the service). diff --git a/Fuchs/Services/FuchsPdfService.cs b/Fuchs/Services/FuchsPdfService.cs index 124e2b8..f6d9d12 100644 --- a/Fuchs/Services/FuchsPdfService.cs +++ b/Fuchs/Services/FuchsPdfService.cs @@ -107,10 +107,10 @@ public class FuchsPdfService : IPdfService } public void ApplyInvoice(Document doc, FuchsPdf.FdsTextBlocks textBlocks, - FdsInvoiceData invoice, bool draft = false) + FdsInvoiceData invoice, bool draft = false, IReadOnlyDictionary? inlineImages = null) { _logger.LogDebug("ApplyInvoice id={Id} draft={Draft}", invoice.Id, draft); - FuchsPdf.ApplyInvoice(doc, textBlocks, invoice, draft); + FuchsPdf.ApplyInvoice(doc, textBlocks, invoice, draft, inlineImages); } public void ApplyReminder(Document doc, FuchsPdf.FdsTextBlocks textBlocks, diff --git a/Fuchs/Services/IInvoiceDraftService.cs b/Fuchs/Services/IInvoiceDraftService.cs index a94ee0c..8339587 100644 --- a/Fuchs/Services/IInvoiceDraftService.cs +++ b/Fuchs/Services/IInvoiceDraftService.cs @@ -51,7 +51,7 @@ public interface IInvoiceDraftService Task FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec); /// Renders a draft PDF straight from the cached session (no client upload). Null if token unknown. - Document? RenderPreview(string token); + Task RenderPreviewAsync(string token); /// Removes the session from the cache (explicit close/discard/finalise). Returns true if one was present. bool Close(string token); diff --git a/Fuchs/Services/IInvoiceService.cs b/Fuchs/Services/IInvoiceService.cs index 23b8668..7ac5a80 100644 --- a/Fuchs/Services/IInvoiceService.cs +++ b/Fuchs/Services/IInvoiceService.cs @@ -17,8 +17,8 @@ public interface IInvoiceService Task RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId, string userAccountId, DatabaseSecurity dbSec); - /// Generates a PDF document for an invoice. - Document GenerateInvoicePdf(FdsInvoiceData invoice, bool draft); + /// Generates a PDF document for an invoice, inlining its whitelisted-origin images (ADR 0018). + Task GenerateInvoicePdfAsync(FdsInvoiceData invoice, bool draft); /// Renders invoice PDF to bytes. Task RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft); diff --git a/Fuchs/Services/IPdfService.cs b/Fuchs/Services/IPdfService.cs index b486933..b2b48d6 100644 --- a/Fuchs/Services/IPdfService.cs +++ b/Fuchs/Services/IPdfService.cs @@ -12,7 +12,11 @@ public interface IPdfService Task WriteLetterAsync(FuchsPdf.FdsTextBlocks textBlocks, bool draft); /// Applies invoice data to a letter document. - void ApplyInvoice(Document doc, FuchsPdf.FdsTextBlocks textBlocks, FdsInvoiceData invoice, bool draft = false); + /// Pre-fetched/vetted invoice-text images to inline into line + /// descriptions, keyed by their original <img src> (see InvoiceService.ResolveInlineImagesAsync + /// / ADR 0018). Null renders line descriptions without images, as before. + void ApplyInvoice(Document doc, FuchsPdf.FdsTextBlocks textBlocks, FdsInvoiceData invoice, bool draft = false, + IReadOnlyDictionary? inlineImages = null); /// Applies reminder data to a letter document. void ApplyReminder(Document doc, FuchsPdf.FdsTextBlocks textBlocks, FdsReminderData reminder, bool draft = false); diff --git a/Fuchs/Services/InvoiceDraftEditService.cs b/Fuchs/Services/InvoiceDraftEditService.cs index c9ef2d8..2f8a550 100644 --- a/Fuchs/Services/InvoiceDraftEditService.cs +++ b/Fuchs/Services/InvoiceDraftEditService.cs @@ -527,14 +527,14 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService return reg; } - public Document? RenderPreview(string token) + public async Task RenderPreviewAsync(string token) { var session = _cache.Get(token); if (session == null) return null; var fds = BuildFdsData(session); fds.InvoiceRegistration = SynthesizeRegistration(session); fds.IsDraft = true; - return _invoices.GenerateInvoicePdf(fds, draft: true); + return await _invoices.GenerateInvoicePdfAsync(fds, draft: true); } public bool Close(string token) => _cache.Remove(token) != null; diff --git a/Fuchs/Services/InvoiceService.cs b/Fuchs/Services/InvoiceService.cs index c86a117..b58c18b 100644 --- a/Fuchs/Services/InvoiceService.cs +++ b/Fuchs/Services/InvoiceService.cs @@ -1,5 +1,6 @@ using System.Data; using System.Diagnostics; +using System.Text.RegularExpressions; using Fuchs.intranet; using Fuchs.Notifications; using Fuchs.Observability; @@ -22,21 +23,40 @@ namespace Fuchs.Services; /// public class InvoiceService : IInvoiceService { + /// Named client used to fetch invoice-text images. + public const string InlineImageHttpClientName = "InvoiceInlineImages"; + + /// + /// The only origin invoice-text <img> sources are fetched from for inline PDF rendering + /// (ADR 0018) — the mfr portal picture links the invoice editor already displays. Any other + /// src (including other remote hosts) is left unrendered, exactly as before this feature. + /// + internal const string AllowedInlineImagePrefix = "https://portal.mobilefieldreport.com/mfr/"; + + private const int MaxInlineImageBytes = 5 * 1024 * 1024; + private const int MaxInlineImagesPerInvoice = 20; + + private static readonly Regex ImgSrcRegex = + new(@"]+src\s*=\s*[""']([^""']+)[""']", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private readonly Fuchs_intranet _intranet; private readonly IPdfService _pdf; private readonly IBlobStorageService _blobStorage; private readonly IEventService _events; private readonly IERechnungService _erechnung; + private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger _logger; public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage, - IEventService events, IERechnungService erechnung, ILogger logger) + IEventService events, IERechnungService erechnung, IHttpClientFactory httpClientFactory, + ILogger logger) { _intranet = intranet; _pdf = pdf; _blobStorage = blobStorage; _events = events; _erechnung = erechnung; + _httpClientFactory = httpClientFactory; _logger = logger; } @@ -259,7 +279,7 @@ public class InvoiceService : IInvoiceService } } - public Document GenerateInvoicePdf(FdsInvoiceData invoice, bool draft) + public async Task GenerateInvoicePdfAsync(FdsInvoiceData invoice, bool draft) { using var act = FuchsTelemetry.StartActivity("invoice.render"); act?.SetTag("fuchs.invoice.id", invoice.Id); @@ -276,17 +296,77 @@ public class InvoiceService : IInvoiceService AdminUserEmail = reg?.getString("UserEmailFinalized") ?? "", AdminDatumValue = reg?.getString("DateCreated") is { Length: > 0 } dc ? DateTime.Parse(dc) : DateTime.Now }; + var inlineImages = await ResolveInlineImagesAsync(invoice); // WriteLetter is effectively synchronous; no Task.Run thread-hop (safe: no sync context in ASP.NET Core). - var doc = _pdf.WriteLetterAsync(tb, draft).GetAwaiter().GetResult(); + var doc = await _pdf.WriteLetterAsync(tb, draft); doc.Info.Title = reg?.getString("InvoiceTitle") ?? ""; - _pdf.ApplyInvoice(doc, tb, invoice, draft); + _pdf.ApplyInvoice(doc, tb, invoice, draft, inlineImages); FuchsTelemetry.InvoicesRendered.Add(1, new KeyValuePair("draft", draft)); return doc; } + /// + /// Fetches the invoice-text <img> sources that come from the mfr portal (the only origin + /// the invoice editor's images can come from) so they can be inlined into the PDF, which — unlike + /// the browser-rendered editor — cannot itself resolve remote URLs. See ADR 0018. + /// Fetch failures (unreachable, wrong content-type, oversized) are logged and simply leave that + /// image out of the PDF rather than failing the whole render. + /// + internal async Task> ResolveInlineImagesAsync(FdsInvoiceData invoice) + { + var result = new Dictionary(); + var urls = new List(); + foreach (var block in invoice.InvoiceBlocks) + foreach (var item in block.Items) + { + string desc = item.nz("desc", ""); + if (desc.Length == 0) continue; + foreach (Match m in ImgSrcRegex.Matches(desc)) + { + string src = m.Groups[1].Value; + if (src.StartsWith(AllowedInlineImagePrefix, StringComparison.Ordinal) + && !urls.Contains(src) && urls.Count < MaxInlineImagesPerInvoice) + urls.Add(src); + } + } + if (urls.Count == 0) return result; + + var client = _httpClientFactory.CreateClient(InlineImageHttpClientName); + foreach (var url in urls) + { + try + { + using var resp = await client.GetAsync(url, HttpCompletionOption.ResponseHeadersRead); + if (!resp.IsSuccessStatusCode) + { + _logger.LogWarning("ResolveInlineImagesAsync: {Url} returned {Status}", url, (int)resp.StatusCode); + continue; + } + string contentType = resp.Content.Headers.ContentType?.MediaType ?? ""; + if (!contentType.StartsWith("image/", StringComparison.OrdinalIgnoreCase)) + { + _logger.LogWarning("ResolveInlineImagesAsync: {Url} returned non-image content-type '{ContentType}'", url, contentType); + continue; + } + var bytes = await resp.Content.ReadAsByteArrayAsync(); + if (bytes.Length == 0 || bytes.Length > MaxInlineImageBytes) + { + _logger.LogWarning("ResolveInlineImagesAsync: {Url} image size {Size} out of bounds", url, bytes.Length); + continue; + } + result[url] = "base64:" + Convert.ToBase64String(bytes); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "ResolveInlineImagesAsync: failed to fetch invoice image {Url}", url); + } + } + return result; + } + public async Task RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft) { - var doc = GenerateInvoicePdf(invoice, draft); + var doc = await GenerateInvoicePdfAsync(invoice, draft); // Finalized invoices are emitted as a ZUGFeRD/Factur-X hybrid when eRechnung is enabled: // eRechnungLib embeds the CII XML into the render-only visual PDF and owns the single // PDF/A-3 layer (ADR 0012). Drafts/previews keep the plain PDF/A. Any failure falls back. diff --git a/Fuchs/code/FuchsPdf.cs b/Fuchs/code/FuchsPdf.cs index 5b14264..977ddf1 100644 --- a/Fuchs/code/FuchsPdf.cs +++ b/Fuchs/code/FuchsPdf.cs @@ -709,7 +709,8 @@ public static class FuchsPdf } // ── ApplyInvoice ────────────────────────────────────────────────────────── - public static void ApplyInvoice(Document doc, FdsTextBlocks tb, FdsInvoiceData inv, bool draft = false) + public static void ApplyInvoice(Document doc, FdsTextBlocks tb, FdsInvoiceData inv, bool draft = false, + IReadOnlyDictionary? inlineImages = null) { string invoiceType = inv.InvoiceType; bool p13b = inv.InvoiceRegistration?.getString("InvoiceOptions") @@ -813,7 +814,7 @@ public static class FuchsPdf if (line.IsSetHeader) titleCell.AddFormattedText(line.Title, TextFormat.Bold); else titleCell.AddText(line.Title); } - if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"
{line.Desc}
"); + if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"
{line.Desc}
", inlineImages); row.Cells[2].AddParagraph(line.Qty).Style = "TblCell_Base"; row.Cells[3].AddParagraph(line.ShowPrice ? Currency(line.PriceNet) : "").Style = "TblCell_Base"; row.Cells[4].AddParagraph(line.ShowPrice ? Currency(line.TotalNet) : "").Style = "TblCell_RSum"; diff --git a/Fuchs/code/MigraDocExtensions.cs b/Fuchs/code/MigraDocExtensions.cs index 3592df6..6afb29a 100644 --- a/Fuchs/code/MigraDocExtensions.cs +++ b/Fuchs/code/MigraDocExtensions.cs @@ -2,7 +2,9 @@ using MigraDoc.DocumentObjectModel; using MigraDoc.DocumentObjectModel.Shapes; using MigraDoc.DocumentObjectModel.Tables; +using System.Globalization; using System.Net; +using System.Text.RegularExpressions; // Ported from migradoc_Extensions.vb + migradoc_HtmlConverter.vb @@ -24,8 +26,13 @@ namespace MigraDoc.Extensions public static Section AddHtml(this Section section, string html) => section.Add(html, new MigraDoc.Extensions.Html.HtmlConverter()); - public static Cell AddHtml(this Cell cell, string html) - => cell.Add(html, new MigraDoc.Extensions.Html.HtmlConverter()); + /// Maps an <img> element's original src to a MigraDoc + /// image source ("base64:...") for images already fetched/vetted by the caller (see + /// InvoiceService.ResolveInlineImagesAsync / ADR 0018). Src values not present here are + /// dropped, matching the converter's long-standing behaviour for unsupported elements. + public static Cell AddHtml(this Cell cell, string html, + IReadOnlyDictionary? inlineImages = null) + => cell.Add(html, new MigraDoc.Extensions.Html.HtmlConverter(inlineImages)); public static TextFrame AddHtml(this TextFrame frame, string html) => frame.Add(html, new MigraDoc.Extensions.Html.HtmlConverter()); @@ -59,9 +66,11 @@ namespace MigraDoc.Extensions.Html public class HtmlConverter : MigraDoc.Extensions.IConverter { private readonly IDictionary> _handlers; + private readonly IReadOnlyDictionary? _inlineImages; - public HtmlConverter() + public HtmlConverter(IReadOnlyDictionary? inlineImages = null) { + _inlineImages = inlineImages; _handlers = new Dictionary>(); AddDefaultHandlers(); } @@ -103,6 +112,32 @@ namespace MigraDoc.Extensions.Html }; _handlers["hr"] = (n, p) => { var para = GetPara(p); if (para != null) para.Style = "HorizontalRule"; return (DocumentObject?)para ?? p; }; + // Image — only src values pre-resolved by the caller (see ctor) are rendered; anything + // else (including any remote URL the caller didn't fetch/vet) is silently dropped, same + // as every other unsupported element. Size mirrors what the editor shows (the width/height + // TinyMCE writes onto the from its resize handles), capped so a stray huge size from + // hand-edited HTML can't blow out the invoice table layout. + _handlers["img"] = (n, p) => + { + string src = n.GetAttributeValue("src", ""); + if (src.Length == 0 || _inlineImages == null || !_inlineImages.TryGetValue(src, out var source)) + return p; + var para = GetPara(p); + if (para == null) return p; + var img = para.AddImage(source); + img.LockAspectRatio = true; + + double? wPx = ReadPxDimension(n, "width"); + double? hPx = ReadPxDimension(n, "height"); + if (wPx is > 0 && hPx is > 0) { img.LockAspectRatio = false; img.Width = PxToUnit(wPx.Value); img.Height = PxToUnit(hPx.Value); } + else if (wPx is > 0) img.Width = PxToUnit(wPx.Value); + else if (hPx is > 0) img.Height = PxToUnit(hPx.Value); + + if (img.Width > MaxInlineImageWidth) { img.LockAspectRatio = true; img.Width = MaxInlineImageWidth; } + if (img.Height > MaxInlineImageHeight) { img.LockAspectRatio = true; img.Height = MaxInlineImageHeight; } + return para; + }; + // Headings foreach (int i in Enumerable.Range(1, 6)) { @@ -148,6 +183,31 @@ namespace MigraDoc.Extensions.Html } // ── Helpers ─────────────────────────────────────────────────────────── + private const double CssPxPerCm = 96 / 2.54; // CSS px (96dpi) → cm, matches browser rendering + private static readonly Unit MaxInlineImageWidth = Unit.FromCentimeter(7); + private static readonly Unit MaxInlineImageHeight = Unit.FromCentimeter(10); + private static readonly Regex StyleSizeRegex = + new(@"(?width|height)\s*:\s*(?[\d.]+)px", RegexOptions.IgnoreCase | RegexOptions.Compiled); + + /// Reads a pixel width/height from the HTML width/height attribute + /// (as TinyMCE's image resize handles write it), falling back to an inline style + /// declaration. Percentage/unit-less-other sizes and missing values return null. + private static double? ReadPxDimension(HtmlNode n, string attr) + { + string raw = n.GetAttributeValue(attr, ""); + if (double.TryParse(raw, NumberStyles.Any, CultureInfo.InvariantCulture, out var px) && px > 0) + return px; + string style = n.GetAttributeValue("style", ""); + foreach (Match m in StyleSizeRegex.Matches(style)) + if (string.Equals(m.Groups["prop"].Value, attr, StringComparison.OrdinalIgnoreCase) + && double.TryParse(m.Groups["val"].Value, NumberStyles.Any, CultureInfo.InvariantCulture, out var spx) + && spx > 0) + return spx; + return null; + } + + private static Unit PxToUnit(double px) => Unit.FromCentimeter(px / CssPxPerCm); + private static DocumentObject Fmt(DocumentObject parent, TextFormat fmt) { if (parent is FormattedText ft) return ft.AddFormattedText(fmt);