fix: detect no-op document stores via OUTPUT rowset; sanitise regenerated filename

Follow-up to 99bf533 from code review:

- The AffectedRows == 0 guard in Store{Invoice,Reminder}DocumentFileAsync
  was dead: fds__setInvoiceFile / fds__setReminderFile run SET NOCOUNT ON,
  so ExecuteNonQuery returns -1 whether or not the UPDATE ... WHERE [file]
  IS NULL matched. Both procs already return the updated Id through their
  OUTPUT clause, so read that result set (getSQLDatatable_async) instead:
  empty rowset = no-op store, logged, and the blob upload / FileCreated
  event are skipped rather than reporting unpersisted bytes as success.
- inv/rdoc's regenerate path built the download name from the raw
  DocumentName; a user-typed title with '/' truncated FileInfo.Name.
  SafeDocumentFileName strips editor markup and every invalid filename
  char, and the path no longer goes through FileInfo at all.
- Reminder counterpart tests for the store-file failure branches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan
2026-09-16 21:16:49 +02:00
co-authored by Claude Opus 5
parent 99bf5337f8
commit 4cac109283
6 changed files with 203 additions and 25 deletions
@@ -293,7 +293,13 @@ public partial class IntranetController
_logger.LogDebug("HandleReportDoc reportid={ReportId} typ={Typ} user={User}", reportid, Form("typ"), UserAccountID);
byte[]? content = null;
var file = _mfr.GetReportDoc(ref content, reportid);
if (file == null || content == null || content.Length == 0)
string fileName, mimeType;
if (file != null && content is { Length: > 0 })
{
fileName = file.Name;
mimeType = file.MimeType();
}
else
{
// Regenerate-on-miss (ADR 0025). Finalised, non-external only: fds__setInvoiceFile
// writes WHERE [file] IS NULL, so persisting a draft render here would permanently
@@ -310,18 +316,32 @@ public partial class IntranetController
_logger.LogWarning("HandleReportDoc: document not found reportid={ReportId} user={User}", reportid, UserAccountID);
return StatusCode(404, new { error = "Dokument wurde nicht gefunden" });
}
string regeneratedName = fdInv.InvoiceRegistration?.getString("DocumentName")
.ne($"Rechnung_{fdInv.Id}.pdf") ?? $"Rechnung_{reportid}.pdf";
file = new FileInfo(Path.Combine(Path.GetTempPath(), regeneratedName));
fileName = SafeDocumentFileName(
fdInv.InvoiceRegistration?.getString("DocumentName") ?? "", $"Rechnung_{fdInv.Id.ne(reportid)}.pdf");
mimeType = "application/pdf";
}
_logger.LogDebug("HandleReportDoc found reportid={ReportId} fileName={FileName} mimeType={MimeType} size={Size} user={User}",
reportid, file.Name, file.MimeType(), content?.Length ?? 0, UserAccountID);
reportid, fileName, mimeType, content.Length, UserAccountID);
if (Form("typ") != "img")
return await FileContentResultAsync(content!, file.MimeType(), file.Name);
return await FileContentResultAsync(content, mimeType, fileName);
return await PdfPreviewAsync("inv/rdoc", reportid, async () =>
await JSONAsync(new { id = reportid, img = await BuildPdfImageArray(content!) }));
}
/// <summary>
/// Download filename for a regenerated document. DocumentName is derived from the user-typed
/// invoice title, so it can carry path separators ("Wartung 2025/26") and stray editor markup;
/// mirrors the cleanup <c>FdsMfr.GetReportDoc</c> applies to stored documents.
/// </summary>
internal static string SafeDocumentFileName(string documentName, string fallback)
{
string name = (documentName ?? "").Replace("<p>", "").Replace("</p>", "").Trim();
if (name.Length == 0) name = fallback;
foreach (char c in Path.GetInvalidFileNameChars())
name = name.Replace(c, '_');
return name.Length == 0 ? fallback : name;
}
private async Task<IActionResult> HandleReportDocByName(string fn, string id, string code)
{
if (!HasForm("name")) { _logger.LogWarning("HandleReportDocByName: missing 'name' form field user={User}", UserAccountID); return BadRequest400(); }