Fix invoice-image code-review findings: aspect ratio, whitelist config, stale refs
- MigraDocExtensions: fix a bug where capping an oversized explicit width+height image re-derived the other dimension from the image's native pixel ratio instead of scaling both sides uniformly, silently distorting a deliberately non-native-ratio (e.g. stretched) image. - InvoiceService: derive the mfr-portal image whitelist prefix from the existing Fds:MFR_host config setting (via MFRClientConfig.RestRoot) instead of a second hardcoded literal, so it can't drift from the actual configured host; parse <img> sources with HtmlAgilityPack (already used by the PDF's own HTML converter) instead of a regex, so a decoy attribute like data-src can't be picked up in place of the real src; dedup fetch URLs with a HashSet instead of O(n^2) List scanning; reuse FuchsPdf.MigraDocFilenameFromByteArray instead of re-spelling the "base64:" prefix. - Fix a stale log message and comment left over from the GenerateInvoicePdf -> GenerateInvoicePdfAsync rename, and update INVOICE_LIFECYCLE.md's references to the old sync method name. - Add tests for the config-derived whitelist, a decoy-attribute src, and the 20-image-per-invoice fetch cap boundary. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
using System.Data;
|
||||
using System.Diagnostics;
|
||||
using System.Text.RegularExpressions;
|
||||
using Fuchs.intranet;
|
||||
using Fuchs.Notifications;
|
||||
using Fuchs.Observability;
|
||||
using HtmlAgilityPack;
|
||||
using MFR_RESTClient;
|
||||
using Microsoft.Data.SqlClient;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MigraDoc.DocumentObjectModel;
|
||||
using Newtonsoft.Json.Linq;
|
||||
@@ -26,19 +28,9 @@ 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 <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.
|
||||
/// </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;
|
||||
@@ -47,9 +39,19 @@ public class InvoiceService : IInvoiceService
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly ILogger<InvoiceService> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// Derived from the same <c>Fds:MFR_host</c> setting the MFR REST client itself uses (via
|
||||
/// <see cref="MFRClientConfig.RestRoot"/>), not a separately-hardcoded host, so it can't drift
|
||||
/// from the actual configured mfr portal.
|
||||
/// </summary>
|
||||
internal readonly string AllowedInlineImagePrefix;
|
||||
|
||||
public InvoiceService(Fuchs_intranet intranet, IPdfService pdf, IBlobStorageService blobStorage,
|
||||
IEventService events, IERechnungService erechnung, IHttpClientFactory httpClientFactory,
|
||||
ILogger<InvoiceService> logger)
|
||||
IConfiguration configuration, ILogger<InvoiceService> logger)
|
||||
{
|
||||
_intranet = intranet;
|
||||
_pdf = pdf;
|
||||
@@ -58,6 +60,11 @@ public class InvoiceService : IInvoiceService
|
||||
_erechnung = erechnung;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_logger = logger;
|
||||
|
||||
string? mfrHost = configuration["Fds:MFR_host"];
|
||||
AllowedInlineImagePrefix = string.IsNullOrWhiteSpace(mfrHost)
|
||||
? MFRClientConfig.DefaultHost + "/mfr/"
|
||||
: new MFRClientConfig(mfrHost).RestRoot;
|
||||
}
|
||||
|
||||
private string Conn => _intranet.Intranet__SQLConnectionString;
|
||||
@@ -284,7 +291,7 @@ public class InvoiceService : IInvoiceService
|
||||
using var act = FuchsTelemetry.StartActivity("invoice.render");
|
||||
act?.SetTag("fuchs.invoice.id", invoice.Id);
|
||||
act?.SetTag("fuchs.invoice.draft", draft);
|
||||
_logger.LogDebug("GenerateInvoicePdf id={Id} draft={Draft}", invoice.Id, draft);
|
||||
_logger.LogDebug("GenerateInvoicePdfAsync id={Id} draft={Draft}", invoice.Id, draft);
|
||||
var reg = invoice.InvoiceRegistration;
|
||||
var tb = new FuchsPdf.FdsTextBlocks
|
||||
{
|
||||
@@ -297,7 +304,6 @@ public class InvoiceService : IInvoiceService
|
||||
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 = await _pdf.WriteLetterAsync(tb, draft);
|
||||
doc.Info.Title = reg?.getString("InvoiceTitle") ?? "";
|
||||
_pdf.ApplyInvoice(doc, tb, invoice, draft, inlineImages);
|
||||
@@ -315,17 +321,24 @@ public class InvoiceService : IInvoiceService
|
||||
internal async Task<Dictionary<string, string>> ResolveInlineImagesAsync(FdsInvoiceData invoice)
|
||||
{
|
||||
var result = new Dictionary<string, string>();
|
||||
var urls = new List<string>();
|
||||
// Parsed with HtmlAgilityPack (already a project dependency, and what the PDF's own HTML
|
||||
// converter parses this same text with) rather than a regex, so a decoy attribute like
|
||||
// data-src="..." next to the real src can never be picked up in its place.
|
||||
var urls = new HashSet<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))
|
||||
var doc = new HtmlDocument();
|
||||
doc.LoadHtml(desc);
|
||||
var imgNodes = doc.DocumentNode.SelectNodes("//img");
|
||||
if (imgNodes == null) continue;
|
||||
foreach (var node in imgNodes)
|
||||
{
|
||||
string src = m.Groups[1].Value;
|
||||
if (src.StartsWith(AllowedInlineImagePrefix, StringComparison.Ordinal)
|
||||
&& !urls.Contains(src) && urls.Count < MaxInlineImagesPerInvoice)
|
||||
string src = node.GetAttributeValue("src", "");
|
||||
if (src.Length > 0 && src.StartsWith(AllowedInlineImagePrefix, StringComparison.Ordinal)
|
||||
&& urls.Count < MaxInlineImagesPerInvoice)
|
||||
urls.Add(src);
|
||||
}
|
||||
}
|
||||
@@ -354,7 +367,7 @@ public class InvoiceService : IInvoiceService
|
||||
_logger.LogWarning("ResolveInlineImagesAsync: {Url} image size {Size} out of bounds", url, bytes.Length);
|
||||
continue;
|
||||
}
|
||||
result[url] = "base64:" + Convert.ToBase64String(bytes);
|
||||
result[url] = FuchsPdf.MigraDocFilenameFromByteArray(bytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user