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:
@@ -329,18 +329,12 @@ public partial class IntranetController
|
||||
}
|
||||
|
||||
/// <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.
|
||||
/// Download filename for a regenerated document — the one shared sanitiser
|
||||
/// (<see cref="fds.FdsDocumentNames.SafeFileName"/>) that <c>FdsMfr.GetReportDoc</c> also
|
||||
/// applies to stored documents, so both paths name the same DocumentName identically.
|
||||
/// </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;
|
||||
}
|
||||
internal static string SafeDocumentFileName(string documentName, string fallback) =>
|
||||
fds.FdsDocumentNames.SafeFileName(documentName, fallback);
|
||||
|
||||
private async Task<IActionResult> HandleReportDocByName(string fn, string id, string code)
|
||||
{
|
||||
|
||||
@@ -36,6 +36,28 @@ public partial class IntranetController
|
||||
{
|
||||
var result = await render();
|
||||
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
|
||||
// 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.
|
||||
@@ -51,6 +73,20 @@ public partial class IntranetController
|
||||
{
|
||||
sw.Stop();
|
||||
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,
|
||||
new KeyValuePair<string, object?>("what", what),
|
||||
new KeyValuePair<string, object?>("outcome", "failure"));
|
||||
|
||||
@@ -236,6 +236,38 @@ public class Program
|
||||
// calls silently no-op instead of reaching Debug output / ErrorLog.txt.
|
||||
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())
|
||||
{
|
||||
app.UseExceptionHandler("/error");
|
||||
|
||||
@@ -133,13 +133,27 @@ $inv.pdfPreviewFailed = function (jqXHR, textStatus) {
|
||||
alert(($ict.pvf || $t.f1) + (desc !== '' ? '\n' + desc : ''));
|
||||
};
|
||||
/* 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. */
|
||||
$inv.pdfPreviewComplete = function (layout) {
|
||||
observable from `complete`; this builds the complete handler for a preview call.
|
||||
`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) => {
|
||||
if (wait) { wait.c.trigger('modal_close'); }
|
||||
if (layout) { layout.rC('freeze'); }
|
||||
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) ───────────
|
||||
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 +
|
||||
@@ -1563,6 +1577,7 @@ $inv.disp = (id, typ) => {
|
||||
break;
|
||||
}
|
||||
if (u !== '') {
|
||||
let wait = $inv.pdfWaitDlg();
|
||||
$ocms.postXT({
|
||||
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;
|
||||
@@ -1572,11 +1587,12 @@ $inv.disp = (id, typ) => {
|
||||
$ocms.dlg(c, {
|
||||
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) {
|
||||
let wait = $inv.pdfWaitDlg();
|
||||
$ocms.postXT({
|
||||
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;
|
||||
@@ -1586,7 +1602,7 @@ $inv.jdbn = function (ev) {
|
||||
$ocms.dlg(c, {
|
||||
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 = () => {
|
||||
|
||||
@@ -38,6 +38,7 @@
|
||||
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
|
||||
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
|
||||
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.',
|
||||
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.',
|
||||
|
||||
@@ -162,6 +162,7 @@ let $ict = {
|
||||
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
|
||||
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
|
||||
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.',
|
||||
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.',
|
||||
@@ -712,13 +713,27 @@ $inv.pdfPreviewFailed = function (jqXHR, textStatus) {
|
||||
alert(($ict.pvf || $t.f1) + (desc !== '' ? '\n' + desc : ''));
|
||||
};
|
||||
/* 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. */
|
||||
$inv.pdfPreviewComplete = function (layout) {
|
||||
observable from `complete`; this builds the complete handler for a preview call.
|
||||
`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) => {
|
||||
if (wait) { wait.c.trigger('modal_close'); }
|
||||
if (layout) { layout.rC('freeze'); }
|
||||
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) ───────────
|
||||
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 +
|
||||
@@ -2142,6 +2157,7 @@ $inv.disp = (id, typ) => {
|
||||
break;
|
||||
}
|
||||
if (u !== '') {
|
||||
let wait = $inv.pdfWaitDlg();
|
||||
$ocms.postXT({
|
||||
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;
|
||||
@@ -2151,11 +2167,12 @@ $inv.disp = (id, typ) => {
|
||||
$ocms.dlg(c, {
|
||||
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) {
|
||||
let wait = $inv.pdfWaitDlg();
|
||||
$ocms.postXT({
|
||||
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;
|
||||
@@ -2165,7 +2182,7 @@ $inv.jdbn = function (ev) {
|
||||
$ocms.dlg(c, {
|
||||
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 = () => {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
@@ -162,6 +162,7 @@ let $ict = {
|
||||
pna: 'Diese Seite ist in der Vorschau nicht verfügbar',
|
||||
tpe: 'Die Anzahl von {0} Seiten wird aktuell nicht unterstützt',
|
||||
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.',
|
||||
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.',
|
||||
@@ -693,13 +694,27 @@ $inv.pdfPreviewFailed = function (jqXHR, textStatus) {
|
||||
alert(($ict.pvf || $t.f1) + (desc !== '' ? '\n' + desc : ''));
|
||||
};
|
||||
/* 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. */
|
||||
$inv.pdfPreviewComplete = function (layout) {
|
||||
observable from `complete`; this builds the complete handler for a preview call.
|
||||
`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) => {
|
||||
if (wait) { wait.c.trigger('modal_close'); }
|
||||
if (layout) { layout.rC('freeze'); }
|
||||
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) ───────────
|
||||
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 +
|
||||
@@ -2123,6 +2138,7 @@ $inv.disp = (id, typ) => {
|
||||
break;
|
||||
}
|
||||
if (u !== '') {
|
||||
let wait = $inv.pdfWaitDlg();
|
||||
$ocms.postXT({
|
||||
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;
|
||||
@@ -2132,11 +2148,12 @@ $inv.disp = (id, typ) => {
|
||||
$ocms.dlg(c, {
|
||||
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) {
|
||||
let wait = $inv.pdfWaitDlg();
|
||||
$ocms.postXT({
|
||||
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;
|
||||
@@ -2146,7 +2163,7 @@ $inv.jdbn = function (ev) {
|
||||
$ocms.dlg(c, {
|
||||
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 = () => {
|
||||
|
||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user