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);