Der Server schrieb deutsche Sätze an eine Stelle, die niemand liest

„Die Anfrage war so nicht zu gebrauchen" — das ist der Ersatzsatz des
Browsers für eine Ablehnung ohne Begründung. Dabei stand die Begründung
da: „Benutzername darf nicht leer sein, das Passwort braucht mindestens 8
Zeichen." Nur eben in `errors`, und beide Clients lesen `detail`.

Zwei Formen waren betroffen. `Results.ValidationProblem` legt den Satz
unter `errors` ab, `Results.BadRequest(new { fehler = … })` unter
`fehler` — in beiden Fällen kommt er nirgends an. Zusammen 36 Stellen,
darunter jede Zeile, die ich gestern für die Meldungen geschrieben habe.
Dass die Clients `title` und `errors` auslassen, ist dabei richtig: dort
stehen sonst die englischen Prüftexte des Rahmenwerks, und „Not Found" ist
keine Auskunft.

Jetzt eine Form, hinter `Einwand.Bitte` / `.Fehlt` / `.Belegt`. Dass es
drei benannte Türen sind statt eines Aufrufs mit Zahl, hat einen Grund:
man sieht am Wort, ob der Server um eine bessere Eingabe bittet, etwas
nicht findet oder auf etwas Bestehendes stösst.

**Auffallen konnte es nicht**, und das ist der eigentliche Fund. Die
vorhandenen Tests prüften den Rumpf als Zeichenkette — der Satz *stand* ja
drin, nur im falschen Feld. Der neue Test prüft deshalb, in welchem Feld,
und ein Wächter am Quelltext schlägt an, wenn eine der beiden alten Formen
zurückkommt. Er hat beim ersten Lauf gleich eine Stelle gefunden, die mein
Suchmuster übersehen hatte.

Dazu drei Kleinigkeiten, die beim Lesen auffielen:

**Passwörter wurden getrimmt.** Der Dialog schnitt jedem Feld die Ränder
ab, auch dem Passwort — `login.ts` sendet es ungeschoren. Ein Leerzeichen
am Ende, und das angelegte Konto war für immer unbetretbar.

**Beim Einrichten stapelten sich die Fehlerzeilen**, weil `.error`
weggeräumt wurde und `.fehler` entsteht. Drei Fehlversuche, drei rote
Kästen.

**Der Benutzerdialog versprach acht Zeichen und prüfte nichts.** Jetzt
sagt er es selbst, ohne den Umweg über die Leitung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-07 17:12:57 +02:00
co-authored by Claude Opus 5
parent 7cd48f5528
commit 8e9da1355c
15 changed files with 248 additions and 81 deletions
+48
View File
@@ -0,0 +1,48 @@
namespace Flaro.Api;
/// <summary>Ein abgelehnter Versuch — mit einem Satz, den die Clients auch lesen.</summary>
///
/// <remarks>
/// <para>
/// <b>Es gab drei Formen, und zwei davon kamen nie an.</b> Gemessen: die
/// Oberfläche las bei jedem abgelehnten Versuch „Die Anfrage war so nicht zu
/// gebrauchen" — ihren eigenen Ersatzsatz für eine 400 ohne Begründung. Dabei
/// hatte der Server sehr wohl einen deutschen Satz geschrieben, nur an eine
/// Stelle, an der niemand nachsieht.
/// </para>
/// <para>
/// <c>Results.ValidationProblem</c> legt ihn unter <c>errors</c> ab,
/// <c>Results.BadRequest(new { fehler = … })</c> unter <c>fehler</c>. Beide
/// Clients lesen <c>detail</c> — der Browser in <c>api.ts</c>, die Apps in
/// <c>FlaroClient.swift</c> —, und zwar aus gutem Grund: in <c>title</c> und in
/// <c>errors</c> stehen sonst die englischen Prüftexte des Rahmenwerks, und
/// „Not Found" ist keine Auskunft. Nur stand in <c>detail</c> an 28 Stellen
/// eben gar nichts.
/// </para>
/// <para>
/// <b>Also eine Form.</b> <c>Results.Problem(detail, …)</c> füllt genau das
/// Feld, das gelesen wird. Dass es hier durch drei benannte Türen geht statt
/// durch einen Aufruf mit Zahl, hat einen Grund: so lässt sich nachsehen, wo
/// ein Server etwas ablehnt, und man sieht am Wort, ob es eine Bitte um eine
/// bessere Eingabe war, etwas Fehlendes oder ein Zusammenstoss.
/// </para>
/// <para>
/// <b>Nicht für Ausnahmen.</b> Was unerwartet schiefgeht, übersetzt
/// <see cref="Fehlertext"/>. Hier geht es um Absichten, die der Server
/// zurückweist — nicht um Unfälle.
/// </para>
/// </remarks>
public static class Einwand
{
/// <summary>400 — so war die Eingabe nicht zu gebrauchen.</summary>
public static IResult Bitte(string satz) =>
Results.Problem(satz, statusCode: StatusCodes.Status400BadRequest);
/// <summary>404 — das, worauf es sich bezieht, gibt es nicht.</summary>
public static IResult Fehlt(string satz) =>
Results.Problem(satz, statusCode: StatusCodes.Status404NotFound);
/// <summary>409 — es gibt schon etwas, das im Weg steht.</summary>
public static IResult Belegt(string satz) =>
Results.Problem(satz, statusCode: StatusCodes.Status409Conflict);
}
@@ -65,7 +65,7 @@ public static class ArtworkEndpoints
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
{
if (anfrage.Kind is not ("poster" or "backdrop" or "logo"))
return Results.BadRequest(new { fehler = "Erwartet: poster, backdrop oder logo." });
return Einwand.Bitte("Erwartet: poster, backdrop oder logo.");
// Das Bild wandert in den Cache, bevor irgendetwas gesetzt wird —
// sonst zeigt der Eintrag auf etwas, das es hier nicht gibt.
@@ -88,15 +88,15 @@ public static class ArtworkEndpoints
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
{
if (kind is not ("poster" or "backdrop" or "logo"))
return Results.BadRequest(new { fehler = "Erwartet: poster, backdrop oder logo." });
if (!anfrage.HasFormContentType) return Results.BadRequest(new { fehler = "Kein Formular." });
return Einwand.Bitte("Erwartet: poster, backdrop oder logo.");
if (!anfrage.HasFormContentType) return Einwand.Bitte("Kein Formular.");
var form = await anfrage.ReadFormAsync(ct);
var datei = form.Files.FirstOrDefault();
if (datei is null || datei.Length == 0)
return Results.BadRequest(new { fehler = "Keine Datei dabei." });
return Einwand.Bitte("Keine Datei dabei.");
if (datei.Length > HoechstensBytes)
return Results.BadRequest(new { fehler = "Höchstens 10 MB." });
return Einwand.Bitte("Höchstens 10 MB.");
await using var strom = datei.OpenReadStream();
var schluessel = await ablage.StoreStreamAsync(strom, datei.ContentType, ct);
@@ -160,7 +160,7 @@ public static class AuthEndpoints
c.Code == code && c.ApprovedByUserId == null && c.ExpiresAt > jetzt);
if (offen is null)
return Results.Problem("Dieser Code gilt nicht mehr.", statusCode: StatusCodes.Status404NotFound);
return Einwand.Fehlt("Dieser Code gilt nicht mehr.");
offen.ApprovedByUserId = benutzerId;
await db.SaveChangesAsync();
@@ -183,7 +183,7 @@ public static class FreigabeEndpoints
FreigabeRequest anfrage, FlaroContext db, CancellationToken ct) =>
{
if (anfrage.Ids is null or { Count: 0 })
return Results.BadRequest(new { fehler = "Ohne Kennungen gibt es nichts zurückzuziehen." });
return Einwand.Bitte("Ohne Kennungen gibt es nichts zurückzuziehen.");
var ids = anfrage.Ids;
var filme = await db.Movies.Where(m => ids.Contains(m.Id))
@@ -23,13 +23,10 @@ public static class ManagementEndpoints
{
var name = anfrage.UserName.Trim();
if (name.Length == 0 || anfrage.Password.Length < 8)
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["password"] = ["Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen."],
});
return Einwand.Bitte("Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen.");
if (await db.Users.AnyAsync(u => u.UserName == name))
return Results.Problem($"„{name}“ gibt es schon.", statusCode: StatusCodes.Status409Conflict);
return Einwand.Belegt($"„{name}“ gibt es schon.");
var neu = new User
{
@@ -175,7 +172,7 @@ public static class ManagementEndpoints
ReihenfolgeRequest anfrage, FlaroContext db, CancellationToken ct) =>
{
if (anfrage.Ids is null or { Count: 0 })
return Results.BadRequest(new { fehler = "Ohne Reihenfolge gibt es nichts zu ordnen." });
return Einwand.Bitte("Ohne Reihenfolge gibt es nichts zu ordnen.");
// Ab 1 zählen: die 0 bleibt denen, die nie umsortiert wurden.
for (var i = 0; i < anfrage.Ids.Count; i++)
@@ -362,20 +359,14 @@ public static class ManagementEndpoints
bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) =>
{
if (!Enum.TryParse<LibraryKind>(anfrage.Kind, ignoreCase: true, out var art))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["kind"] = [$"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows."],
});
return Einwand.Bitte($"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows.");
// **Jetzt prüfen, nicht beim ersten Scan.** Ein Tippfehler im Pfad
// führte sonst zu einer Bibliothek, die dauerhaft leer bleibt, und
// die Ursache stünde allenfalls im Protokoll.
if (!Directory.Exists(anfrage.Path))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["path"] = [$"„{anfrage.Path}“ gibt es im Container nicht. " +
"Ist das Verzeichnis eingehängt? Gemeint ist der Pfad im Container, nicht der auf dem Wirt."],
});
return Einwand.Bitte($"„{anfrage.Path}“ gibt es im Container nicht. " +
"Ist das Verzeichnis eingehängt? Gemeint ist der Pfad im Container, nicht der auf dem Wirt.");
var neu = new Library
{
@@ -399,10 +390,7 @@ public static class ManagementEndpoints
{
var name = anfrage.Name.Trim();
if (name.Length == 0)
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["name"] = ["Ein Name darf nicht leer sein."],
});
return Einwand.Bitte("Ein Name darf nicht leer sein.");
var vorhanden = await db.Libraries.FindAsync(id);
if (vorhanden is null) return Results.NotFound();
@@ -40,16 +40,10 @@ public static class MatchEndpoints
var titel = anfrage.Title?.Trim();
if (string.IsNullOrEmpty(titel))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["title"] = ["Ein Titel darf nicht leer sein."],
});
return Einwand.Bitte("Ein Titel darf nicht leer sein.");
}
if (titel.Length > 512)
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["title"] = ["Höchstens 512 Zeichen."],
});
return Einwand.Bitte("Höchstens 512 Zeichen.");
// **Der Sortiertitel bleibt, wenn er von Hand gesetzt wurde.**
// Sonst wäre „Harry Potter 3" nach jeder Titeländerung wieder weg.
@@ -84,8 +78,7 @@ public static class MatchEndpoints
statusCode: StatusCodes.Status400BadRequest);
}
if (sortiert.Length > 512)
return Results.Problem("Höchstens 512 Zeichen.",
statusCode: StatusCodes.Status400BadRequest);
return Einwand.Bitte("Höchstens 512 Zeichen.");
var betroffen = art == "movies"
? await db.Movies.Where(m => m.Id == id).ExecuteUpdateAsync(s => s
@@ -283,8 +276,7 @@ public static class MatchEndpoints
var gefunden = await anbieter.GetMovieAsync(anfrage.TmdbId);
if (gefunden is null)
return Results.Problem($"TMDB kennt den Film {anfrage.TmdbId} nicht.",
statusCode: StatusCodes.Status404NotFound);
return Einwand.Fehlt($"TMDB kennt den Film {anfrage.TmdbId} nicht.");
// **Hat die Kennung schon jemand, ist es derselbe Film.**
//
@@ -343,8 +335,7 @@ public static class MatchEndpoints
var gefunden = await anbieter.GetSeriesAsync(anfrage.TmdbId);
if (gefunden is null)
return Results.Problem($"TMDB kennt die Serie {anfrage.TmdbId} nicht.",
statusCode: StatusCodes.Status404NotFound);
return Einwand.Fehlt($"TMDB kennt die Serie {anfrage.TmdbId} nicht.");
// Siehe bei den Filmen: dieselbe Kennung heisst dieselbe Serie,
// und zwei Ordner derselben Serie gehören zusammengelegt.
@@ -51,20 +51,17 @@ public static class MeldungEndpoints
var kommentar = anfrage.Comment?.Trim() ?? "";
if (kommentar.Length == 0)
return Results.BadRequest(new { fehler = "Ohne Beschreibung lässt sich nichts nachsehen." });
return Einwand.Bitte("Ohne Beschreibung lässt sich nichts nachsehen.");
if (kommentar.Length > HoechstensZeichen)
return Results.BadRequest(new { fehler = $"Höchstens {HoechstensZeichen} Zeichen." });
return Einwand.Bitte($"Höchstens {HoechstensZeichen} Zeichen.");
if (AlsArt(anfrage.Kind) is not { } art)
{
return Results.BadRequest(new
{
fehler = "Unbekannte Art. Möglich sind: bild, ton, untertitel, daten, sonstiges.",
});
return Einwand.Bitte("Unbekannte Art. Möglich sind: bild, ton, untertitel, daten, sonstiges.");
}
if (!AlsStelle(anfrage.Position, out var stelle))
return Results.BadRequest(new { fehler = "Die Stelle muss wie 0:34:12 aussehen." });
return Einwand.Bitte("Die Stelle muss wie 0:34:12 aussehen.");
// **Der Freigabefilter erledigt die Rechteprüfung.** Wer einen
// Titel nicht sehen darf, findet ihn hier nicht — ohne ein
@@ -74,15 +71,15 @@ public static class MeldungEndpoints
if (await db.Movies.AnyAsync(m => m.Id == id, ct)) film = id;
else if (await db.Episodes.AnyAsync(f => f.Id == id, ct)) folge = id;
else if (await db.Series.AnyAsync(s => s.Id == id, ct)) serie = id;
else return Results.BadRequest(new { fehler = "Diesen Titel gibt es nicht." });
else return Einwand.Bitte("Diesen Titel gibt es nicht.");
int? staffel = null;
if (anfrage.SeasonNumber is { } nummer)
{
if (serie is null)
return Results.BadRequest(new { fehler = "Eine Staffel gibt es nur zu einer Serie." });
return Einwand.Bitte("Eine Staffel gibt es nur zu einer Serie.");
if (!await db.Episodes.AnyAsync(f => f.SeriesId == serie && f.SeasonNumber == nummer, ct))
return Results.BadRequest(new { fehler = "Diese Staffel gibt es in der Serie nicht." });
return Einwand.Bitte("Diese Staffel gibt es in der Serie nicht.");
staffel = nummer;
}
@@ -157,7 +154,7 @@ public static class MeldungEndpoints
{
var meldung = await db.Meldungen.FirstOrDefaultAsync(m => m.Id == id, ct);
if (meldung is null)
return Results.NotFound(new { fehler = "Diese Meldung gibt es nicht mehr." });
return Einwand.Fehlt("Diese Meldung gibt es nicht mehr.");
meldung.ErledigtAm = wann;
meldung.ErledigtVon = wann is null ? null : Kennung(wer);
@@ -235,8 +235,7 @@ public static class PlaybackEndpoints
if (Benutzer(wer) is not { } benutzer) return Results.Unauthorized();
var regeln = await einstellungen.RegelnAsync();
if (anfrage.Rating is { } wert && wert is < 1 or > 10)
return Results.Problem("Eine Bewertung liegt zwischen 1 und 10.",
statusCode: StatusCodes.Status400BadRequest);
return Einwand.Bitte("Eine Bewertung liegt zwischen 1 und 10.");
var zustand = await Zustand(db, benutzer, id);
if (zustand is null) return Results.NotFound();
@@ -55,14 +55,14 @@ public static class ProfilEndpoints
{
if (Kennung(wer) is not { } id) return Results.Unauthorized();
if (!anfrage.HasFormContentType)
return Results.BadRequest(new { fehler = "Kein Formular." });
return Einwand.Bitte("Kein Formular.");
var form = await anfrage.ReadFormAsync(ct);
var datei = form.Files.FirstOrDefault();
if (datei is null || datei.Length == 0)
return Results.BadRequest(new { fehler = "Keine Datei dabei." });
return Einwand.Bitte("Keine Datei dabei.");
if (datei.Length > HoechstensBytes)
return Results.BadRequest(new { fehler = "Höchstens 5 MB." });
return Einwand.Bitte("Höchstens 5 MB.");
await using var strom = datei.OpenReadStream();
var schluessel = await ablage.StoreStreamAsync(strom, datei.ContentType, ct);
@@ -81,9 +81,7 @@ public static class SettingsEndpoints
return gestartet > 0
? Results.Accepted(value: new { started = gestartet, language = serversprache })
: Results.Problem(
"In jeder Mediathek läuft bereits etwas. Warte, bis es durch ist.",
statusCode: StatusCodes.Status409Conflict);
: Einwand.Belegt("In jeder Mediathek läuft bereits etwas. Warte, bis es durch ist.");
}).RequireAuthorization(FlaroPolicies.Administrator);
app.MapPut("/api/v1/settings/metadata", async (
@@ -96,10 +94,7 @@ public static class SettingsEndpoints
// Form wie „de-DE". Alles andere nähme TMDB stillschweigend als
// Englisch an, und niemand wüsste, warum.
if (!System.Text.RegularExpressions.Regex.IsMatch(sprache, "^[a-z]{2}(-[A-Z]{2})?$"))
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["language"] = [$"„{sprache}“ ist keine Sprachkennung. Erwartet wird etwa „de-DE“ oder „en“."],
});
return Einwand.Bitte($"„{sprache}“ ist keine Sprachkennung. Erwartet wird etwa „de-DE“ oder „en“.");
await Setzen(db, DatabaseTmdbSettings.LanguageName, sprache);
}
@@ -88,10 +88,7 @@ public static class SystemEndpoints
app.MapGet("/api/v1/system/folders", (string? path) =>
Ordnerbaum.Lesen(path) is { } baum
? Results.Ok(baum)
: Results.Problem(
title: "Ordner nicht gefunden",
detail: $"Den Ordner {path} gibt es nicht, oder es ist keiner.",
statusCode: StatusCodes.Status404NotFound))
: Einwand.Fehlt($"Den Ordner {path} gibt es nicht, oder es ist keiner."))
.RequireAuthorization(FlaroPolicies.Administrator);
// **Dubletten von Hand zusammenlegen.** Nach einem Scan passiert das
@@ -122,7 +119,7 @@ public static class SystemEndpoints
if (!DateOnly.TryParseExact(tag, "yyyy-MM-dd", null,
System.Globalization.DateTimeStyles.None, out var tagesdatum))
{
return Results.BadRequest(new { fehler = "Erwartet wird ein Datum als JJJJ-MM-TT." });
return Einwand.Bitte("Erwartet wird ein Datum als JJJJ-MM-TT.");
}
var datei = tagebuch.DateiFuer(tagesdatum);
@@ -170,7 +167,7 @@ public static class SystemEndpoints
{
var sprache = anfrage.Language?.Trim();
if (string.IsNullOrEmpty(sprache))
return Results.BadRequest(new { fehler = "Ohne Sprache gibt es nichts zu holen." });
return Einwand.Bitte("Ohne Sprache gibt es nichts zu holen.");
tagebuch.Schreiben($"Übersetzungen für {sprache} werden geholt.");
var bericht = await uebersetzer.HolenAsync(sprache, null, ct);
@@ -183,10 +180,7 @@ public static class SystemEndpoints
{
var name = anfrage.Name.Trim();
if (name.Length == 0)
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["name"] = ["Ein Name darf nicht leer sein."],
});
return Einwand.Bitte("Ein Name darf nicht leer sein.");
var vorhanden = await db.Settings.FirstOrDefaultAsync(e => e.Key == NameSchlüssel);
if (vorhanden is null) db.Settings.Add(new ServerSetting { Key = NameSchlüssel, Value = name });
@@ -199,10 +193,7 @@ public static class SystemEndpoints
app.MapPost("/api/v1/system/setup", async (SetupRequest anfrage, FlaroContext db) =>
{
if (string.IsNullOrWhiteSpace(anfrage.UserName) || anfrage.Password.Length < 8)
return Results.ValidationProblem(new Dictionary<string, string[]>
{
["password"] = ["Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen."],
});
return Einwand.Bitte("Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen.");
await EinrichtungsSchloss.WaitAsync();
try
@@ -210,8 +201,7 @@ public static class SystemEndpoints
// Noch einmal prüfen, innerhalb des Schlosses. Draussen wäre die
// Antwort schon veraltet, wenn sie ankommt.
if (await db.Users.AnyAsync())
return Results.Problem(
"Dieser Server ist bereits eingerichtet.", statusCode: StatusCodes.Status409Conflict);
return Einwand.Belegt("Dieser Server ist bereits eingerichtet.");
var administrator = new User
{