Refactor document name sanitization for consistent filename generation

- Introduced FdsDocumentNames class to centralize the logic for sanitizing document names used in downloads.
- Updated FdsMfr to utilize the new SafeFileName method for generating safe filenames.
- Enhanced documentation for FdsDocumentNames and its methods to clarify usage and purpose.
- Added global.json file to configure testing framework settings.
This commit is contained in:
Stefan
2026-09-16 22:18:42 +02:00
parent a60d4c2eaf
commit 944a5bb442
18 changed files with 245 additions and 34 deletions
+1 -1
View File
@@ -14,7 +14,7 @@
- Routes: `/{fn?}/{id?}/{code?}` → `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` → `IntranetController.Do`. - Routes: `/{fn?}/{id?}/{code?}` → `IntranetController.Index`; `/do/{fn?}/{id?}/{code?}` → `IntranetController.Do`.
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`. - Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves. - Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`. - Test: `dotnet test --project Fuchs.Tests/Fuchs.Tests.csproj -c Debug` (the root `global.json` opts `dotnet test` into the Microsoft.Testing.Platform runner that xunit.v3 needs on the .NET 10 SDK; the positional-path form no longer works).
- Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures. - Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures.
- Submodules include the OCORE projects and `eRechnungLib` (ZUGFeRD/Factur-X + XRechnung generation) — invoices are moving to eRechnung output. - Submodules include the OCORE projects and `eRechnungLib` (ZUGFeRD/Factur-X + XRechnung generation) — invoices are moving to eRechnung output.
- Project structure (relative to `Fuchs/`): - Project structure (relative to `Fuchs/`):
+1 -1
View File
@@ -16,7 +16,7 @@
## Build & Test (workflow) ## Build & Test (workflow)
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`. - Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves. - Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`. - Test: `dotnet test --project Fuchs.Tests/Fuchs.Tests.csproj -c Debug` (the root `global.json` opts `dotnet test` into the Microsoft.Testing.Platform runner that xunit.v3 needs on the .NET 10 SDK; the positional-path form no longer works).
- Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures. - Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures.
- Commit only when asked. Co-author trailer: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`. - Commit only when asked. Co-author trailer: `Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>`.
- The working tree may contain an untracked `Fuchs_Database/` SQL project — it is **not** part of app changes; never `git add -A` it into an unrelated commit. Stage explicit paths. - The working tree may contain an untracked `Fuchs_Database/` SQL project — it is **not** part of app changes; never `git add -A` it into an unrelated commit. Stage explicit paths.
+1 -1
View File
@@ -16,7 +16,7 @@
## Build & Test (workflow) ## Build & Test (workflow)
- Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`. - Build app: `dotnet build Fuchs/Fuchs.csproj -c Debug`. Build all: `dotnet build Fuchs_Intranet.slnx -c Debug`.
- Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves. - Frontend assets are source-built: run the gulp tasks in `Fuchs/` (`npx gulp min`, or `npx gulp all` when copied/static assets also need refreshing) whenever JS or SCSS/CSS sources change. The generated files under `Fuchs/wwwroot/web/` are what the app serves.
- Test: `dotnet test Fuchs.Tests/Fuchs.Tests.csproj -c Debug`. - Test: `dotnet test --project Fuchs.Tests/Fuchs.Tests.csproj -c Debug` (the root `global.json` opts `dotnet test` into the Microsoft.Testing.Platform runner that xunit.v3 needs on the .NET 10 SDK; the positional-path form no longer works).
- Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures. - Always build **and** run the test suite before committing. CA1416 (platform-compatibility) is suppressed solution-wide via the root `.editorconfig` — the app is Windows-only. The build may still emit other pre-existing analyzer warnings; only treat `: error` lines as failures.
- Commit only when asked. Co-author trailer, when requested: `Co-Authored-By: Codex <noreply@openai.com>`. - Commit only when asked. Co-author trailer, when requested: `Co-Authored-By: Codex <noreply@openai.com>`.
- The working tree may contain an untracked `Fuchs_Database/` SQL project — it is **not** part of app changes; never `git add -A` it into an unrelated commit. Stage explicit paths. - The working tree may contain an untracked `Fuchs_Database/` SQL project — it is **not** part of app changes; never `git add -A` it into an unrelated commit. Stage explicit paths.
+51
View File
@@ -26,8 +26,59 @@ public class ReportDocFileNameTests
[InlineData("")] [InlineData("")]
[InlineData(" ")] [InlineData(" ")]
[InlineData("<p></p>")] [InlineData("<p></p>")]
[InlineData("<p> </p>")]
public void SafeDocumentFileName_EmptyDocumentName_UsesFallback(string? documentName) public void SafeDocumentFileName_EmptyDocumentName_UsesFallback(string? documentName)
{ {
Assert.Equal("Rechnung_W7VAL.pdf", IntranetController.SafeDocumentFileName(documentName!, "Rechnung_W7VAL.pdf")); Assert.Equal("Rechnung_W7VAL.pdf", IntranetController.SafeDocumentFileName(documentName!, "Rechnung_W7VAL.pdf"));
} }
/// <summary>
/// The stored-document path (<c>FdsMfr.GetReportDoc</c>) replaced exactly these characters
/// before the sanitiser was shared: <c>= &gt; | ! &lt; /</c>. The shared helper must still
/// produce the identical result for them — unifying the two paths may not loosen either.
/// </summary>
[Theory]
[InlineData("a>b|c!d<e/f=g.pdf", "a_b_c_d_e_f_g.pdf")]
[InlineData("<p>Summe=100!.pdf</p>", "Summe_100_.pdf")]
public void SafeFileName_KeepsEveryReplacementTheStoredDocumentPathMade(string documentName, string expected)
{
Assert.Equal(expected, fds.FdsDocumentNames.SafeFileName(documentName, "X.pdf"));
}
/// <summary>
/// Characters the old stored-document path did NOT catch but Windows rejects — the reason
/// the sanitiser was unified (code review on ADR 0025): a stored and a regenerated document
/// must never diverge in filename safety.
/// </summary>
[Theory]
[InlineData("Angebot A\\B.pdf", "Angebot A_B.pdf")]
[InlineData("Re: 2026.pdf", "Re_ 2026.pdf")]
[InlineData("x*y?z\".pdf", "x_y_z_.pdf")]
[InlineData("tab\there.pdf", "tab_here.pdf")]
public void SafeFileName_ReplacesInvalidCharsTheStoredDocumentPathMissed(string documentName, string expected)
{
Assert.Equal(expected, fds.FdsDocumentNames.SafeFileName(documentName, "X.pdf"));
}
[Theory]
[InlineData("Rechnung R2026-0258.pdf")]
[InlineData("Wartung 2025/26 R2026-0001.pdf")]
[InlineData("a>b|c!d<e/f=g.pdf")]
[InlineData("Angebot A\\B: \"x\" <y>|z?*.pdf")]
[InlineData("<p></p>")]
[InlineData(null)]
public void SafeDocumentFileName_MatchesSharedSanitiserForEveryInput(string? documentName)
{
Assert.Equal(
fds.FdsDocumentNames.SafeFileName(documentName, "Rechnung_X.pdf"),
IntranetController.SafeDocumentFileName(documentName!, "Rechnung_X.pdf"));
}
[Fact]
public void SafeFileName_ReturnsFallbackVerbatim_WhenNothingUsableRemains()
{
// The fallback is the caller's responsibility and is not sanitised or second-guessed.
Assert.Equal("W7VAL", fds.FdsDocumentNames.SafeFileName("<p></p>", "W7VAL"));
Assert.Equal("", fds.FdsDocumentNames.SafeFileName("", ""));
}
} }
@@ -329,18 +329,12 @@ public partial class IntranetController
} }
/// <summary> /// <summary>
/// Download filename for a regenerated document. DocumentName is derived from the user-typed /// Download filename for a regenerated document — the one shared sanitiser
/// invoice title, so it can carry path separators ("Wartung 2025/26") and stray editor markup; /// (<see cref="fds.FdsDocumentNames.SafeFileName"/>) that <c>FdsMfr.GetReportDoc</c> also
/// mirrors the cleanup <c>FdsMfr.GetReportDoc</c> applies to stored documents. /// applies to stored documents, so both paths name the same DocumentName identically.
/// </summary> /// </summary>
internal static string SafeDocumentFileName(string documentName, string fallback) internal static string SafeDocumentFileName(string documentName, string fallback) =>
{ fds.FdsDocumentNames.SafeFileName(documentName, 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) private async Task<IActionResult> HandleReportDocByName(string fn, string id, string code)
{ {
@@ -36,6 +36,28 @@ public partial class IntranetController
{ {
var result = await render(); var result = await render();
sw.Stop(); sw.Stop();
// The client (postXT) gives up after a fixed timeout and aborts the connection while a
// slow render/regenerate-on-miss (ADR 0025) is still running server-side; that work is
// deliberately NOT cancelled — the regenerated document must still be stored so the
// next click is fast (ADR 0025) — but writing the now-finished result back to a socket
// the client already closed would only surface as a low-level framework/Kestrel
// exception that never reaches an EventId'd `_logger` call: it is either below the SQL
// sink's Warning floor or purely a debugger first-chance notification that never goes
// through ILogger at all — exactly the "exception in the debug console but nothing in
// fds__debuglog" gap. Recognise the disconnect here instead and log it deliberately, at
// Warning, so it is guaranteed to land in fds__debuglog (ADR 0024) — and skip the write.
if (HttpContext.RequestAborted.IsCancellationRequested)
{
FuchsTelemetry.PdfPreviews.Add(1,
new KeyValuePair<string, object?>("what", what),
new KeyValuePair<string, object?>("outcome", "client-disconnected"));
_logger.LogWarning(
"PDF preview {What} ref={RefId} user={User} finished in {Ms} ms but the client had already disconnected (timeout) — result discarded",
what, refId, UserAccountID, sw.ElapsedMilliseconds);
return new EmptyResult();
}
// A draft session that expired between page load and preview click is an expected // A draft session that expired between page load and preview click is an expected
// client-driven outcome (the browser reopens the draft), not a render failure — keep // client-driven outcome (the browser reopens the draft), not a render failure — keep
// it out of the failure telemetry/logging this guard exists to produce. // it out of the failure telemetry/logging this guard exists to produce.
@@ -51,6 +73,20 @@ public partial class IntranetController
{ {
sw.Stop(); sw.Stop();
act?.SetStatus(ActivityStatusCode.Error, ex.Message); act?.SetStatus(ActivityStatusCode.Error, ex.Message);
// Same client-gone rationale as above, but the render itself threw (e.g. it tried to
// write partial output to the aborted connection). Still an expected outcome of a
// client timeout, not a render defect — logged at Warning, not Error, but still
// guaranteed to reach fds__debuglog (ADR 0024) rather than only a debug console.
if (HttpContext.RequestAborted.IsCancellationRequested)
{
FuchsTelemetry.PdfPreviews.Add(1,
new KeyValuePair<string, object?>("what", what),
new KeyValuePair<string, object?>("outcome", "client-disconnected"));
_logger.LogWarning(ex,
"PDF preview {What} ref={RefId} user={User} failed after {Ms} ms because the client had already disconnected",
what, refId, UserAccountID, sw.ElapsedMilliseconds);
return new EmptyResult();
}
FuchsTelemetry.PdfPreviews.Add(1, FuchsTelemetry.PdfPreviews.Add(1,
new KeyValuePair<string, object?>("what", what), new KeyValuePair<string, object?>("what", what),
new KeyValuePair<string, object?>("outcome", "failure")); new KeyValuePair<string, object?>("outcome", "failure"));
+32
View File
@@ -236,6 +236,38 @@ public class Program
// calls silently no-op instead of reaching Debug output / ErrorLog.txt. // calls silently no-op instead of reaching Debug output / ErrorLog.txt.
OCORE.Logging.Logger = app.Logger; OCORE.Logging.Logger = app.Logger;
// Exceptions that never pass through a request (Do()'s catch-all, PdfPreviewAsync,
// UseExceptionHandler) or a hosted service's own try/catch — a faulted fire-and-forget
// Task nobody awaited, or a crash on a non-request thread — are otherwise visible only
// as a debugger notification or a process exit: they never reach ILogger and therefore
// never reach fds__debuglog (ADR 0024). Route them through the app logger so the SQL
// sink and ErrorLog.txt both see them. On a terminating crash the fire-and-forget SQL
// write may not complete; the synchronous file sink (0003) is the record then.
TaskScheduler.UnobservedTaskException += (_, e) =>
{
app.Logger.LogError(new EventId(0, "Unobserved task exception"), e.Exception,
"Unobserved task exception: {Message}", e.Exception.GetBaseException().Message);
e.SetObserved();
};
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
{
var ex = e.ExceptionObject as Exception;
app.Logger.LogCritical(new EventId(0, "Unhandled exception"), ex,
"Unhandled exception on a non-request thread (terminating={IsTerminating}): {Message}",
e.IsTerminating, ex?.Message ?? e.ExceptionObject?.ToString());
};
// One deliberate Critical entry per application start. Critical is the one level no
// provider filters (file sink, console, SQL sink at Warning+), so a fds__debuglog row
// with CodeReference 'Application started' proves on every restart that the whole
// log pipeline (ADR 0024) is working end to end — and its absence is itself the alarm.
// Registered on ApplicationStarted so it is written once the host and every sink are up.
app.Lifetime.ApplicationStarted.Register(() =>
app.Logger.LogCritical(new EventId(0, "Application started"),
"Fuchs started: version={Version} environment={Environment} machine={Machine} pid={Pid}",
typeof(Program).Assembly.GetName().Version?.ToString() ?? "?",
app.Environment.EnvironmentName, Environment.MachineName, Environment.ProcessId));
if (!app.Environment.IsDevelopment()) if (!app.Environment.IsDevelopment())
{ {
app.UseExceptionHandler("/error"); app.UseExceptionHandler("/error");
+20 -4
View File
@@ -133,13 +133,27 @@ $inv.pdfPreviewFailed = function (jqXHR, textStatus) {
alert(($ict.pvf || $t.f1) + (desc !== '' ? '\n' + desc : '')); alert(($ict.pvf || $t.f1) + (desc !== '' ? '\n' + desc : ''));
}; };
/* postXT never calls `error` for a timeout (it returns early), so the timeout is only /* postXT never calls `error` for a timeout (it returns early), so the timeout is only
observable from `complete`; this builds the complete handler for a preview call. */ observable from `complete`; this builds the complete handler for a preview call.
$inv.pdfPreviewComplete = function (layout) { `wait`, if given, is the "please wait" popup from `$inv.pdfWaitDlg` — it must close on
every outcome (success, error, timeout), so `complete` is the only place that reliably
covers all three. */
$inv.pdfPreviewComplete = function (layout, wait) {
return (jqXHR, textStatus) => { return (jqXHR, textStatus) => {
if (wait) { wait.c.trigger('modal_close'); }
if (layout) { layout.rC('freeze'); } if (layout) { layout.rC('freeze'); }
if (textStatus === 'timeout') { $inv.pdfPreviewFailed(jqXHR, textStatus); } if (textStatus === 'timeout') { $inv.pdfPreviewFailed(jqXHR, textStatus); }
}; };
}; };
/* A preview render can take anywhere from a couple of seconds to the full 120s timeout (a
cold miss regenerates + stores the PDF before rasterising it, ADR 0025); postXT's generic
`body.ldng` class has no CSS behind it, so nothing tells the user the click was received.
Same popup the MFR data fetch shows (see $inv.cInv2): spinner frame in a modal with the
explanation in the footer. Non-exclusive so it can sit on top of an open editor. */
$inv.pdfWaitDlg = function () {
let o = $ocms.dlg($$.dc('rfrm').ldng(1), { exclusive: false, zindex_min: 50 });
if (o) { o.ft.rwText($ict.pvw); }
return o;
};
/* ── Backend-authoritative draft editing controller (ADR 0006/0007) ─────────── /* ── Backend-authoritative draft editing controller (ADR 0006/0007) ───────────
The server holds the truth for an invoice draft in an in-memory session; this The server holds the truth for an invoice draft in an in-memory session; this
object seeds it, sends single edits as deltas, and renders the totals footer + object seeds it, sends single edits as deltas, and renders the totals footer +
@@ -1563,6 +1577,7 @@ $inv.disp = (id, typ) => {
break; break;
} }
if (u !== '') { if (u !== '') {
let wait = $inv.pdfWaitDlg();
$ocms.postXT({ $ocms.postXT({
url: $ocms.url(u), data: { id: id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => { url: $ocms.url(u), data: { id: id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id; let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
@@ -1572,11 +1587,12 @@ $inv.disp = (id, typ) => {
$ocms.dlg(c, { $ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false
}); });
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete() }, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(null, wait)
}); });
} }
}; };
$inv.jdbn = function (ev) { $inv.jdbn = function (ev) {
let wait = $inv.pdfWaitDlg();
$ocms.postXT({ $ocms.postXT({
url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => { url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id; let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
@@ -1586,7 +1602,7 @@ $inv.jdbn = function (ev) {
$ocms.dlg(c, { $ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false
}); });
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete() }, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(null, wait)
}); });
}; };
$inv.sp13b = () => { $inv.sp13b = () => {
@@ -38,6 +38,7 @@
pna: 'Diese Seite ist in der Vorschau nicht verfügbar', pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt', tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.', eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
pvw: 'Das Dokument wird abgerufen und für die Vorschau aufbereitet.\nDer Vorgang kann bis zu 2 Minuten dauern.',
pvf: 'Die PDF-Vorschau konnte nicht erstellt werden.', pvf: 'Die PDF-Vorschau konnte nicht erstellt werden.',
pvto: 'Der Server hat nicht rechtzeitig geantwortet (Zeitüberschreitung). Bitte versuchen Sie es erneut.', pvto: 'Der Server hat nicht rechtzeitig geantwortet (Zeitüberschreitung). Bitte versuchen Sie es erneut.',
pvgone: 'Der Entwurf ist auf dem Server nicht mehr vorhanden (Sitzung abgelaufen). Bitte öffnen Sie den Entwurf erneut.', pvgone: 'Der Entwurf ist auf dem Server nicht mehr vorhanden (Sitzung abgelaufen). Bitte öffnen Sie den Entwurf erneut.',
+21 -4
View File
@@ -162,6 +162,7 @@ let $ict = {
pna: 'Diese Seite ist in der Vorschau nicht verfügbar', pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt', tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.', eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
pvw: 'Das Dokument wird abgerufen und für die Vorschau aufbereitet.\nDer Vorgang kann bis zu 2 Minuten dauern.',
pvf: 'Die PDF-Vorschau konnte nicht erstellt werden.', pvf: 'Die PDF-Vorschau konnte nicht erstellt werden.',
pvto: 'Der Server hat nicht rechtzeitig geantwortet (Zeitüberschreitung). Bitte versuchen Sie es erneut.', pvto: 'Der Server hat nicht rechtzeitig geantwortet (Zeitüberschreitung). Bitte versuchen Sie es erneut.',
pvgone: 'Der Entwurf ist auf dem Server nicht mehr vorhanden (Sitzung abgelaufen). Bitte öffnen Sie den Entwurf erneut.', pvgone: 'Der Entwurf ist auf dem Server nicht mehr vorhanden (Sitzung abgelaufen). Bitte öffnen Sie den Entwurf erneut.',
@@ -712,13 +713,27 @@ $inv.pdfPreviewFailed = function (jqXHR, textStatus) {
alert(($ict.pvf || $t.f1) + (desc !== '' ? '\n' + desc : '')); alert(($ict.pvf || $t.f1) + (desc !== '' ? '\n' + desc : ''));
}; };
/* postXT never calls `error` for a timeout (it returns early), so the timeout is only /* postXT never calls `error` for a timeout (it returns early), so the timeout is only
observable from `complete`; this builds the complete handler for a preview call. */ observable from `complete`; this builds the complete handler for a preview call.
$inv.pdfPreviewComplete = function (layout) { `wait`, if given, is the "please wait" popup from `$inv.pdfWaitDlg` — it must close on
every outcome (success, error, timeout), so `complete` is the only place that reliably
covers all three. */
$inv.pdfPreviewComplete = function (layout, wait) {
return (jqXHR, textStatus) => { return (jqXHR, textStatus) => {
if (wait) { wait.c.trigger('modal_close'); }
if (layout) { layout.rC('freeze'); } if (layout) { layout.rC('freeze'); }
if (textStatus === 'timeout') { $inv.pdfPreviewFailed(jqXHR, textStatus); } if (textStatus === 'timeout') { $inv.pdfPreviewFailed(jqXHR, textStatus); }
}; };
}; };
/* A preview render can take anywhere from a couple of seconds to the full 120s timeout (a
cold miss regenerates + stores the PDF before rasterising it, ADR 0025); postXT's generic
`body.ldng` class has no CSS behind it, so nothing tells the user the click was received.
Same popup the MFR data fetch shows (see $inv.cInv2): spinner frame in a modal with the
explanation in the footer. Non-exclusive so it can sit on top of an open editor. */
$inv.pdfWaitDlg = function () {
let o = $ocms.dlg($$.dc('rfrm').ldng(1), { exclusive: false, zindex_min: 50 });
if (o) { o.ft.rwText($ict.pvw); }
return o;
};
/* ── Backend-authoritative draft editing controller (ADR 0006/0007) ─────────── /* ── Backend-authoritative draft editing controller (ADR 0006/0007) ───────────
The server holds the truth for an invoice draft in an in-memory session; this The server holds the truth for an invoice draft in an in-memory session; this
object seeds it, sends single edits as deltas, and renders the totals footer + object seeds it, sends single edits as deltas, and renders the totals footer +
@@ -2142,6 +2157,7 @@ $inv.disp = (id, typ) => {
break; break;
} }
if (u !== '') { if (u !== '') {
let wait = $inv.pdfWaitDlg();
$ocms.postXT({ $ocms.postXT({
url: $ocms.url(u), data: { id: id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => { url: $ocms.url(u), data: { id: id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id; let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
@@ -2151,11 +2167,12 @@ $inv.disp = (id, typ) => {
$ocms.dlg(c, { $ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false
}); });
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete() }, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(null, wait)
}); });
} }
}; };
$inv.jdbn = function (ev) { $inv.jdbn = function (ev) {
let wait = $inv.pdfWaitDlg();
$ocms.postXT({ $ocms.postXT({
url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => { url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id; let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
@@ -2165,7 +2182,7 @@ $inv.jdbn = function (ev) {
$ocms.dlg(c, { $ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false
}); });
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete() }, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(null, wait)
}); });
}; };
$inv.sp13b = () => { $inv.sp13b = () => {
File diff suppressed because one or more lines are too long
+21 -4
View File
@@ -162,6 +162,7 @@ let $ict = {
pna: 'Diese Seite ist in der Vorschau nicht verfügbar', pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt', tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.', eis: 'Der Rechnungsentwurf konnte nicht gespeichert werden.',
pvw: 'Das Dokument wird abgerufen und für die Vorschau aufbereitet.\nDer Vorgang kann bis zu 2 Minuten dauern.',
pvf: 'Die PDF-Vorschau konnte nicht erstellt werden.', pvf: 'Die PDF-Vorschau konnte nicht erstellt werden.',
pvto: 'Der Server hat nicht rechtzeitig geantwortet (Zeitüberschreitung). Bitte versuchen Sie es erneut.', pvto: 'Der Server hat nicht rechtzeitig geantwortet (Zeitüberschreitung). Bitte versuchen Sie es erneut.',
pvgone: 'Der Entwurf ist auf dem Server nicht mehr vorhanden (Sitzung abgelaufen). Bitte öffnen Sie den Entwurf erneut.', pvgone: 'Der Entwurf ist auf dem Server nicht mehr vorhanden (Sitzung abgelaufen). Bitte öffnen Sie den Entwurf erneut.',
@@ -693,13 +694,27 @@ $inv.pdfPreviewFailed = function (jqXHR, textStatus) {
alert(($ict.pvf || $t.f1) + (desc !== '' ? '\n' + desc : '')); alert(($ict.pvf || $t.f1) + (desc !== '' ? '\n' + desc : ''));
}; };
/* postXT never calls `error` for a timeout (it returns early), so the timeout is only /* postXT never calls `error` for a timeout (it returns early), so the timeout is only
observable from `complete`; this builds the complete handler for a preview call. */ observable from `complete`; this builds the complete handler for a preview call.
$inv.pdfPreviewComplete = function (layout) { `wait`, if given, is the "please wait" popup from `$inv.pdfWaitDlg` — it must close on
every outcome (success, error, timeout), so `complete` is the only place that reliably
covers all three. */
$inv.pdfPreviewComplete = function (layout, wait) {
return (jqXHR, textStatus) => { return (jqXHR, textStatus) => {
if (wait) { wait.c.trigger('modal_close'); }
if (layout) { layout.rC('freeze'); } if (layout) { layout.rC('freeze'); }
if (textStatus === 'timeout') { $inv.pdfPreviewFailed(jqXHR, textStatus); } if (textStatus === 'timeout') { $inv.pdfPreviewFailed(jqXHR, textStatus); }
}; };
}; };
/* A preview render can take anywhere from a couple of seconds to the full 120s timeout (a
cold miss regenerates + stores the PDF before rasterising it, ADR 0025); postXT's generic
`body.ldng` class has no CSS behind it, so nothing tells the user the click was received.
Same popup the MFR data fetch shows (see $inv.cInv2): spinner frame in a modal with the
explanation in the footer. Non-exclusive so it can sit on top of an open editor. */
$inv.pdfWaitDlg = function () {
let o = $ocms.dlg($$.dc('rfrm').ldng(1), { exclusive: false, zindex_min: 50 });
if (o) { o.ft.rwText($ict.pvw); }
return o;
};
/* ── Backend-authoritative draft editing controller (ADR 0006/0007) ─────────── /* ── Backend-authoritative draft editing controller (ADR 0006/0007) ───────────
The server holds the truth for an invoice draft in an in-memory session; this The server holds the truth for an invoice draft in an in-memory session; this
object seeds it, sends single edits as deltas, and renders the totals footer + object seeds it, sends single edits as deltas, and renders the totals footer +
@@ -2123,6 +2138,7 @@ $inv.disp = (id, typ) => {
break; break;
} }
if (u !== '') { if (u !== '') {
let wait = $inv.pdfWaitDlg();
$ocms.postXT({ $ocms.postXT({
url: $ocms.url(u), data: { id: id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => { url: $ocms.url(u), data: { id: id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id; let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
@@ -2132,11 +2148,12 @@ $inv.disp = (id, typ) => {
$ocms.dlg(c, { $ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false size: [vhr, Math.round(vw() * 0.88)], zindex_min: 50, form: false, exclusive:false
}); });
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete() }, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(null, wait)
}); });
} }
}; };
$inv.jdbn = function (ev) { $inv.jdbn = function (ev) {
let wait = $inv.pdfWaitDlg();
$ocms.postXT({ $ocms.postXT({
url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => { url: $ocms.url('inv/rdocn'), data: { name: ev.data.id || '', typ: 'img' }, timeout: $inv.pdfPreviewTimeout, success: (response) => {
let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id; let c = $$.dc('imagecollection pdfpreview'), vhr = Math.round(vh() * 0.88), invid = response.id;
@@ -2146,7 +2163,7 @@ $inv.jdbn = function (ev) {
$ocms.dlg(c, { $ocms.dlg(c, {
size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false size: [vhr, Math.round(vw() * 0.88)], zindex: 50, form: false
}); });
}, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete() }, error: $inv.pdfPreviewFailed, complete: $inv.pdfPreviewComplete(null, wait)
}); });
}; };
$inv.sp13b = () => { $inv.sp13b = () => {
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+6 -3
View File
@@ -205,9 +205,12 @@ public class FdsMfr : IFdsMfr
if (fl != null) if (fl != null)
{ {
file = fl; file = fl;
string safeName = fln.Replace("<p>", "").Replace("</p>", "").Replace("=", "_") // Same sanitiser as the regenerate-on-miss path (ADR 0025) so a stored and a
.Replace(">", "_").Replace("|", "_").Replace("!", "_").Replace("<", "_").Replace("/", "_"); // regenerated document with the same DocumentName download under the same name.
return new FileInfo(Path.GetTempPath() + safeName); // Fallback to the report id: an empty DocumentName used to yield the temp-dir
// segment ("Temp") as the download name.
string safeName = FdsDocumentNames.SafeFileName(fln, reportid);
return new FileInfo(Path.Combine(Path.GetTempPath(), safeName));
} }
} }
return null; return null;
+27
View File
@@ -40,6 +40,33 @@ public static class FdsConfig
Current["Fds:MFR_host"] ?? ""; Current["Fds:MFR_host"] ?? "";
} }
/// <summary>
/// Download-filename sanitising shared by every path that hands a document to the browser —
/// stored documents (<see cref="FdsMfr.GetReportDoc"/>) and documents regenerated on a miss
/// (<c>IntranetController.HandleReportDoc</c>, ADR 0025) — so both produce the same name for the
/// same DocumentName. DocumentName is derived from the user-typed invoice title, so it can carry
/// path separators ("Wartung 2025/26"), stray editor markup and characters Windows rejects.
/// </summary>
public static class FdsDocumentNames
{
// '=' and '!' are legal filename characters, but the stored-document path replaced them
// long before the regenerate path existed; kept so unifying the two never loosens either.
private static readonly char[] Replaced =
[.. Path.GetInvalidFileNameChars(), '=', '!'];
/// <param name="documentName">Raw DocumentName (may be null, empty, or wrapped in &lt;p&gt;).</param>
/// <param name="fallback">Used when nothing usable remains after cleanup.</param>
public static string SafeFileName(string? documentName, string fallback)
{
string name = (documentName ?? "").Replace("<p>", "").Replace("</p>", "").Trim();
if (name.Length == 0) return fallback;
foreach (char c in Replaced)
name = name.Replace(c, '_');
name = name.Trim();
return name.Length == 0 ? fallback : name;
}
}
internal static class FdsShared internal static class FdsShared
{ {
internal static string FDSConnectionString() => FdsConfig.FDSConnectionString(); internal static string FDSConnectionString() => FdsConfig.FDSConnectionString();
+13
View File
@@ -37,5 +37,18 @@
which <c>Program.cs</c> calls once at startup before any <see cref="T:fds.FdsMfr"/> use. which <c>Program.cs</c> calls once at startup before any <see cref="T:fds.FdsMfr"/> use.
</summary> </summary>
</member> </member>
<member name="T:fds.FdsDocumentNames">
<summary>
Download-filename sanitising shared by every path that hands a document to the browser —
stored documents (<see cref="M:fds.FdsMfr.GetReportDoc(System.Byte[]@,System.String,System.Boolean)"/>) and documents regenerated on a miss
(<c>IntranetController.HandleReportDoc</c>, ADR 0025) — so both produce the same name for the
same DocumentName. DocumentName is derived from the user-typed invoice title, so it can carry
path separators ("Wartung 2025/26"), stray editor markup and characters Windows rejects.
</summary>
</member>
<member name="M:fds.FdsDocumentNames.SafeFileName(System.String,System.String)">
<param name="documentName">Raw DocumentName (may be null, empty, or wrapped in &lt;p&gt;).</param>
<param name="fallback">Used when nothing usable remains after cleanup.</param>
</member>
</members> </members>
</doc> </doc>
+5
View File
@@ -0,0 +1,5 @@
{
"test": {
"runner": "Microsoft.Testing.Platform"
}
}