Render invoice-editor images (mfr portal only) into the PDF

The invoice line editor already shows <img> pictures pulled from the mfr
portal, but the PDF pipeline's HTML-to-MigraDoc converter had no <img>
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 <noreply@anthropic.com>
This commit is contained in:
2026-09-10 19:36:31 +02:00
co-authored by Claude Sonnet 5
parent 1a8a14791d
commit 071982caec
15 changed files with 516 additions and 28 deletions
+5 -5
View File
@@ -34,7 +34,7 @@ public class InvoiceDraftServiceTests
public FdsInvoiceData? PreviewInvoice;
public bool? PreviewDraft;
public Task<FdsInvoiceData> 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<Document> GenerateInvoicePdfAsync(FdsInvoiceData i, bool d) { PreviewInvoice = i; PreviewDraft = d; return Task.FromResult(new Document()); }
public Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData i, bool d) => throw new System.NotSupportedException();
public Task<byte[]> StoreInvoiceDocumentFileAsync(FdsInvoiceData i, bool d, string u, DatabaseSecurity s) => throw new System.NotSupportedException();
public Task<byte[]?> 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]
+160
View File
@@ -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;
/// <summary>
/// Exercises <see cref="InvoiceService.ResolveInlineImagesAsync"/> — 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.
/// </summary>
public class InvoiceInlineImageTests
{
private sealed class StubHandler : HttpMessageHandler
{
public readonly List<string> RequestedUrls = new();
private readonly Func<HttpRequestMessage, HttpResponseMessage> _respond;
public StubHandler(Func<HttpRequestMessage, HttpResponseMessage> respond) => _respond = respond;
protected override Task<HttpResponseMessage> 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<IPdfService>(),
Mock.Of<IBlobStorageService>(),
Mock.Of<IEventService>(),
Mock.Of<IERechnungService>(),
new StubFactory(handler),
NullLogger<InvoiceService>.Instance);
private static FdsInvoiceData InvoiceWithDesc(params string[] descs)
{
var items = new List<Dictionary<string, object>>();
foreach (var d in descs) items.Add(new Dictionary<string, object> { ["type"] = "material", ["desc"] = d });
return new FdsInvoiceData
{
Req = new List<Dictionary<string, object>> { 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($"<p>Before</p><img src=\"{url}\"><p>After</p>");
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($"<img src=\"{url}\">");
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("<p>Just text, no picture.</p>");
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($"<img src=\"{url}\">");
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($"<img src=\"{url}\">");
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($"<img src=\"{url}\">");
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($"<img src=\"{url}\">", $"<img src=\"{url}\">");
var result = await MakeService(handler).ResolveInlineImagesAsync(invoice);
Assert.Single(result);
Assert.Single(handler.RequestedUrls);
}
}
+109
View File
@@ -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;
/// <summary>
/// Exercises the HTML→MigraDoc converter's &lt;img&gt; 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.
/// </summary>
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("<div>Text <img src=\"https://not-fetched.example/x.png\"> more</div>", null);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
Assert.DoesNotContain(para.Elements.Cast<DocumentObject>(), e => e is Image);
}
[Fact]
public void AddHtml_ResolvedImgSrc_RendersInlineAtHtmlPosition()
{
var cell = NewCell();
var images = new Dictionary<string, string> { ["x"] = DataSrc };
cell.AddHtml("<div>Before <img src=\"x\"> After</div>", images);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
var kinds = para.Elements.Cast<DocumentObject>().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<string, string> { ["x"] = DataSrc };
cell.AddHtml("<div><img src=\"x\" width=\"96\" height=\"48\"></div>", images);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
var img = para.Elements.Cast<DocumentObject>().OfType<Image>().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<string, string> { ["x"] = DataSrc };
cell.AddHtml("<div><img src=\"x\" style=\"width: 192px; height: 96px;\"></div>", images);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
var img = para.Elements.Cast<DocumentObject>().OfType<Image>().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<string, string> { ["x"] = DataSrc };
// A huge (or hand-edited/malformed) size must never blow out the invoice table cell.
cell.AddHtml("<div><img src=\"x\" width=\"4000\" height=\"4000\"></div>", images);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
var img = para.Elements.Cast<DocumentObject>().OfType<Image>().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<string, string> { ["x"] = DataSrc };
cell.AddHtml("<div><img src=\"x\"></div>", images);
var para = Assert.IsType<Paragraph>(cell.Elements[0]);
var img = para.Elements.Cast<DocumentObject>().OfType<Image>().Single();
Assert.True(img.LockAspectRatio);
}
}
@@ -75,7 +75,7 @@ public partial class IntranetController
private async Task<IActionResult> 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 });
@@ -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 });
}
@@ -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 `<img src="https://portal.mobilefieldreport.com/mfr/...">` 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 `<img>`, 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<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 —
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.
+4
View File
@@ -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<IComService, ProcessWebComService>();
// 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).
+2 -2
View File
@@ -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<string, string>? 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,
+1 -1
View File
@@ -51,7 +51,7 @@ public interface IInvoiceDraftService
Task<FdsInvoiceData?> FlushToDbAsync(string token, string userAccountId, DatabaseSecurity dbSec);
/// <summary>Renders a draft PDF straight from the cached session (no client upload). Null if token unknown.</summary>
Document? RenderPreview(string token);
Task<Document?> RenderPreviewAsync(string token);
/// <summary>Removes the session from the cache (explicit close/discard/finalise). Returns true if one was present.</summary>
bool Close(string token);
+2 -2
View File
@@ -17,8 +17,8 @@ public interface IInvoiceService
Task<FdsInvoiceData> RegisterInvoiceAsync(FdsInvoiceData invoice, bool change, string invId,
string userAccountId, DatabaseSecurity dbSec);
/// <summary>Generates a PDF document for an invoice.</summary>
Document GenerateInvoicePdf(FdsInvoiceData invoice, bool draft);
/// <summary>Generates a PDF document for an invoice, inlining its whitelisted-origin images (ADR 0018).</summary>
Task<Document> GenerateInvoicePdfAsync(FdsInvoiceData invoice, bool draft);
/// <summary>Renders invoice PDF to bytes.</summary>
Task<byte[]> RenderInvoicePdfBytesAsync(FdsInvoiceData invoice, bool draft);
+5 -1
View File
@@ -12,7 +12,11 @@ public interface IPdfService
Task<Document> WriteLetterAsync(FuchsPdf.FdsTextBlocks textBlocks, bool draft);
/// <summary>Applies invoice data to a letter document.</summary>
void ApplyInvoice(Document doc, FuchsPdf.FdsTextBlocks textBlocks, FdsInvoiceData invoice, bool draft = false);
/// <param name="inlineImages">Pre-fetched/vetted invoice-text images to inline into line
/// descriptions, keyed by their original &lt;img src&gt; (see InvoiceService.ResolveInlineImagesAsync
/// / ADR 0018). Null renders line descriptions without images, as before.</param>
void ApplyInvoice(Document doc, FuchsPdf.FdsTextBlocks textBlocks, FdsInvoiceData invoice, bool draft = false,
IReadOnlyDictionary<string, string>? inlineImages = null);
/// <summary>Applies reminder data to a letter document.</summary>
void ApplyReminder(Document doc, FuchsPdf.FdsTextBlocks textBlocks, FdsReminderData reminder, bool draft = false);
+2 -2
View File
@@ -527,14 +527,14 @@ public sealed class InvoiceDraftEditService : IInvoiceDraftService
return reg;
}
public Document? RenderPreview(string token)
public async Task<Document?> 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;
+85 -5
View File
@@ -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;
/// </summary>
public class InvoiceService : IInvoiceService
{
/// <summary>Named <see cref="IHttpClientFactory"/> client used to fetch invoice-text images.</summary>
public const string InlineImageHttpClientName = "InvoiceInlineImages";
/// <summary>
/// The only origin invoice-text &lt;img&gt; 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.
/// </summary>
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(@"<img[^>]+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<InvoiceService> _logger;
public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage,
IEventService events, IERechnungService erechnung, ILogger<InvoiceService> logger)
IEventService events, IERechnungService erechnung, IHttpClientFactory httpClientFactory,
ILogger<InvoiceService> 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<Document> 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<string, object?>("draft", draft));
return doc;
}
/// <summary>
/// Fetches the invoice-text &lt;img&gt; 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.
/// </summary>
internal async Task<Dictionary<string, string>> ResolveInlineImagesAsync(FdsInvoiceData invoice)
{
var result = new Dictionary<string, string>();
var urls = new List<string>();
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<byte[]> 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.
+3 -2
View File
@@ -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<string, string>? 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($"<div>{line.Desc}</div>");
if (!string.IsNullOrEmpty(line.Desc)) row.Cells[1].AddHtml($"<div>{line.Desc}</div>", 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";
+63 -3
View File
@@ -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());
/// <param name="inlineImages">Maps an &lt;img&gt; element's original <c>src</c> 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.</param>
public static Cell AddHtml(this Cell cell, string html,
IReadOnlyDictionary<string, string>? 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<string, Func<HtmlNode, DocumentObject, DocumentObject>> _handlers;
private readonly IReadOnlyDictionary<string, string>? _inlineImages;
public HtmlConverter()
public HtmlConverter(IReadOnlyDictionary<string, string>? inlineImages = null)
{
_inlineImages = inlineImages;
_handlers = new Dictionary<string, Func<HtmlNode, DocumentObject, DocumentObject>>();
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 <img> 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(@"(?<prop>width|height)\s*:\s*(?<val>[\d.]+)px", RegexOptions.IgnoreCase | RegexOptions.Compiled);
/// <summary>Reads a pixel width/height from the HTML <c>width</c>/<c>height</c> attribute
/// (as TinyMCE's image resize handles write it), falling back to an inline <c>style</c>
/// declaration. Percentage/unit-less-other sizes and missing values return null.</summary>
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);