„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>
134 lines
6.0 KiB
C#
134 lines
6.0 KiB
C#
using Flaro.Core.Metadata;
|
|
using Flaro.Data;
|
|
using Flaro.Metadata;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Flaro.Api.Endpoints;
|
|
|
|
/// <summary>Plakat, Hintergrund und Schriftzug von Hand wählen.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Wofür.</b> Es gab genau einen Bild-Endpunkt, und der lieferte nur aus.
|
|
/// Wählte die Metadatenquelle ein schlechtes Plakat, blieb es — und die
|
|
/// einzige Abhilfe war, den ganzen Titel einem anderen Eintrag zuzuordnen.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Die Kandidaten kosten nichts.</b> TMDB liefert sie beim Einzelabruf
|
|
/// ohnehin mit; bisher wurde alles ausser dem Schriftzug weggeworfen.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class ArtworkEndpoints
|
|
{
|
|
/// <summary>Höchstens so gross darf ein eigenes Bild sein.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Zehn Megabyte reichen für jedes Plakat. Ohne Grenze lädt irgendwann
|
|
/// jemand ein Foto hoch, und der Cache eines NAS ist nicht dafür da.
|
|
/// </remarks>
|
|
private const long HoechstensBytes = 10 * 1024 * 1024;
|
|
|
|
public static void MapArtwork(this IEndpointRouteBuilder app)
|
|
{
|
|
var gruppe = app.MapGroup("/api/v1")
|
|
.RequireAuthorization(FlaroPolicies.Administrator);
|
|
|
|
gruppe.MapGet("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
|
string art, Guid id, FlaroContext db, TmdbClient tmdb, CancellationToken ct) =>
|
|
{
|
|
var serie = art == "series";
|
|
var kennung = serie
|
|
? await db.Series.Where(s => s.Id == id).Select(s => s.TmdbId).FirstOrDefaultAsync(ct)
|
|
: await db.Movies.Where(m => m.Id == id).Select(m => m.TmdbId).FirstOrDefaultAsync(ct);
|
|
|
|
var aktuell = serie
|
|
? await db.Series.Where(s => s.Id == id)
|
|
.Select(s => new Bildstand(s.PosterImage, s.BackdropImage, s.LogoImage,
|
|
s.PosterPinned, s.BackdropPinned, s.LogoPinned)).FirstOrDefaultAsync(ct)
|
|
: await db.Movies.Where(m => m.Id == id)
|
|
.Select(m => new Bildstand(m.PosterImage, m.BackdropImage, m.LogoImage,
|
|
m.PosterPinned, m.BackdropPinned, m.LogoPinned)).FirstOrDefaultAsync(ct);
|
|
|
|
if (aktuell is null) return Results.NotFound();
|
|
|
|
// Ohne Zuordnung gibt es nichts zu wählen — dann ist die
|
|
// Zuordnung das Problem, nicht das Bild.
|
|
var kandidaten = kennung is { } k
|
|
? await tmdb.BilderAsync(k, serie, ct)
|
|
: [];
|
|
|
|
return Results.Ok(new Bildauswahl(aktuell, kandidaten));
|
|
});
|
|
|
|
gruppe.MapPut("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
|
string art, Guid id, BildWahlRequest anfrage,
|
|
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
|
|
{
|
|
if (anfrage.Kind is not ("poster" or "backdrop" or "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.
|
|
var schluessel = string.IsNullOrWhiteSpace(anfrage.Url)
|
|
? null
|
|
: await ablage.StoreAsync(new RemoteImage(anfrage.Url), ct);
|
|
|
|
if (anfrage.Url is { Length: > 0 } && schluessel is null)
|
|
return Results.Problem("Das Bild liess sich nicht laden.", statusCode: 502);
|
|
|
|
var gesetzt = art == "series"
|
|
? await Setzen(db.Series.Where(s => s.Id == id), anfrage.Kind, schluessel, ct)
|
|
: await Setzen(db.Movies.Where(m => m.Id == id), anfrage.Kind, schluessel, ct);
|
|
|
|
return gesetzt ? Results.NoContent() : Results.NotFound();
|
|
});
|
|
|
|
gruppe.MapPost("/{art:regex(^movies|series$)}/{id:guid}/artwork", async (
|
|
string art, Guid id, string kind, HttpRequest anfrage,
|
|
FlaroContext db, IImageStore ablage, CancellationToken ct) =>
|
|
{
|
|
if (kind is not ("poster" or "backdrop" or "logo"))
|
|
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 Einwand.Bitte("Keine Datei dabei.");
|
|
if (datei.Length > HoechstensBytes)
|
|
return Einwand.Bitte("Höchstens 10 MB.");
|
|
|
|
await using var strom = datei.OpenReadStream();
|
|
var schluessel = await ablage.StoreStreamAsync(strom, datei.ContentType, ct);
|
|
if (schluessel is null) return Results.Problem("Das Bild liess sich nicht ablegen.", statusCode: 502);
|
|
|
|
var gesetzt = art == "series"
|
|
? await Setzen(db.Series.Where(s => s.Id == id), kind, schluessel, ct)
|
|
: await Setzen(db.Movies.Where(m => m.Id == id), kind, schluessel, ct);
|
|
|
|
return gesetzt ? Results.Ok(new { image = schluessel }) : Results.NotFound();
|
|
}).DisableAntiforgery();
|
|
}
|
|
|
|
/// <summary>Setzt ein Bild und merkt sich, dass es von Hand kam.</summary>
|
|
private static async Task<bool> Setzen<T>(
|
|
IQueryable<T> abfrage, string art, string? schluessel, CancellationToken ct)
|
|
where T : class, IBebildert
|
|
{
|
|
var betroffen = art switch
|
|
{
|
|
"poster" => await abfrage.ExecuteUpdateAsync(s => s
|
|
.SetProperty(x => x.PosterImage, schluessel)
|
|
.SetProperty(x => x.PosterPinned, schluessel != null), ct),
|
|
"backdrop" => await abfrage.ExecuteUpdateAsync(s => s
|
|
.SetProperty(x => x.BackdropImage, schluessel)
|
|
.SetProperty(x => x.BackdropPinned, schluessel != null), ct),
|
|
_ => await abfrage.ExecuteUpdateAsync(s => s
|
|
.SetProperty(x => x.LogoImage, schluessel)
|
|
.SetProperty(x => x.LogoPinned, schluessel != null), ct),
|
|
};
|
|
|
|
return betroffen > 0;
|
|
}
|
|
}
|