Files
Flaro/server/src/Flaro.Api/Endpoints/EinladungEndpoints.cs
T
ScarriffleandClaude Opus 5 2809821c9b Vier Fehler, und bei zweien log der Kommentar daneben
**Der Einstellungsknopf lag hinter der Abspielleiste.** Gemessen: `.pl`
steht auf Stufe 150, `#chrome` auf gar keiner, und die kleine Leiste spannte
über die ganze Breite — genau über die unteren 72 Bildpunkte der
Seitenleiste, wo „Einstellungen" klebt. Weil der Spieler bei jedem Wechsel
der Adresse minimiert, stand sie zuverlässig im Weg, sobald man dorthin
wollte.

Die Leiste beginnt jetzt hinter der Seitenleiste. Nicht über die Stufen
gelöst, und das ist der Punkt: `#chrome` höher zu setzen hiesse, die
Seitenleiste läge auch über dem grossen Spieler — es ist dieselbe Hülle.
Dass diese Leiste Platz wegnimmt statt sich darüberzulegen, stand für
Inhalt, Hinweise und Auswahlleiste ohnehin schon da; die Seitenleiste war
die vergessene vierte. Gemessen, 1400 Bildpunkte breit: Leiste ab 240,
Knopf bei 0, keine Überschneidung, und ein Treffertest in der Knopfmitte
trifft den Knopf.

**Ein Klick auf eine Kategorie kam nie an.** Dort sprang der Code in die
Mediathek und suchte danach `.filterzeile select`, um den Wert zu setzen.
Die Filterzeile hat kein `<select>` — `auswahl()` baut einen Knopf, mit
Absicht und begründet. Der Zweig lief also nie, und man landete in der
ungefilterten Mediathek. Der Kommentar daneben behauptete das Gegenteil.

Das Genre steht jetzt in der Adresse: `#/m/{id}/mediathek/Horror`. Das
löst zugleich das zweite Problem — die Mediathek wird unter ihrem Weg
gemerkt, und stünde das Genre nur im Kopf, zeigte ein zweiter Besuch die
Liste des ersten. Geprüft: Klick setzt den Weg und die Pille, Neuladen
behält beides, Zurück führt auf die Kategorien, Vorwärts wieder hinein.

**Zurückgezogene Einladungen blieben für immer stehen.** Der Knopfblock
steckte ganz in `if (!e.revokedAt)`, eine erledigte Zeile hatte also gar
keinen Knopf. Die Serverbegründung — eine Zeile wegzuwerfen hiesse,
hinterher nicht mehr zu wissen, dass es den Link je gab — bleibt richtig,
sie gilt nur nicht ewig: eine Liste, aus der nichts verschwindet, wird
unlesbar, und dann sieht man die eine Zeile nicht mehr, um die es ging.

Deshalb kein umgedeutetes DELETE, sondern `?purge=true`, und das greift nur
bei einem Link, der ohnehin keine Tür mehr öffnet. Ein gültiger wird mit
einem deutschen Satz abgewiesen.

**Poster in der falschen Sprache.** „365 Tage" stand mit polnischem Plakat
da. Kein fehlender Anbieter: für den Schriftzug gab es die sprachgewichtete
Wahl längst, fürs Plakat wurde das blanke `poster_path` der Hauptantwort
genommen — bei TMDB das Bild der Originalsprache, wenn in der gewünschten
keines eingetragen ist. Die sortierte Liste lag im selben Abruf schon vor
und wurde ausschliesslich für den Schriftzug benutzt.

Plakat und Hintergrund nehmen jetzt dieselbe Rangfolge: eingestellte
Sprache, dann sprachlos, dann Englisch. Sprachlos vor Englisch ist keine
Verlegenheit — ein Bild ohne Sprachkennung trägt keinen Text, und ein
Plakat ohne Titel ist für einen deutschen Bestand besser als dasselbe mit
englischem. Im Bilderdialog stehen die Kandidaten jetzt ebenso sortiert.
Bestehende Titel ändern sich beim nächsten Auffrischen; von Hand gesetzte
Plakate bleiben unangetastet.

676 Tests grün, davon zehn neue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-08 17:46:46 +02:00

221 lines
9.7 KiB
C#

using System.Security.Claims;
using Flaro.Core.Auth;
using Flaro.Core.Users;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
/// <summary>Jemanden über einen Link einladen, statt ein Konto für ihn zu erfinden.</summary>
///
/// <remarks>
/// <para>
/// <b>Warum.</b> Ein Konto von Hand anzulegen heisst, sich ein Passwort für
/// einen anderen auszudenken und es ihm durchzusagen — über denselben Kanal,
/// über den er den Link bekommen hätte. Wer eingeladen wird, soll sich Namen
/// und Passwort selbst geben.
/// </para>
/// <para>
/// <b>Zwei Welten in einer Datei.</b> Erstellen, auflisten und zurückziehen
/// ist Verwaltung. Ansehen und Einlösen muss jemand können, der noch gar kein
/// Konto hat — also anonym, und damit gedrosselt: es gibt keine globale
/// Drossel in diesem Server, ein anonymer Endpunkt ohne eigene wäre
/// ungebremst.
/// </para>
/// </remarks>
public static class EinladungEndpoints
{
private const int HoechstensTage = 365;
public static void MapEinladungen(this IEndpointRouteBuilder app)
{
var verwalten = app.MapGroup("/api/v1/invites")
.RequireAuthorization(FlaroPolicies.Administrator);
verwalten.MapPost("", async (
EinladungRequest anfrage, ClaimsPrincipal wer,
FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
{
if (Kennung(wer) is not { } ich) return Results.Unauthorized();
var tage = Math.Clamp(anfrage.Days, 1, HoechstensTage);
if (anfrage.MaxUses is { } anzahl && anzahl < 1)
return Einwand.Bitte("Ein Link, der nullmal gilt, ist keiner.");
// **Das Geheimnis verlässt den Server genau hier.** Gespeichert
// wird nur sein Fingerabdruck; aus der Datenbank lässt sich kein
// Link mehr bauen, und eine verlorene Einladung wird deshalb nicht
// wiedergefunden, sondern neu gemacht.
var token = Secrets.Create();
var jetzt = uhr.GetUtcNow();
var einladung = new Einladung
{
TokenFingerprint = Secrets.Fingerprint(token),
CreatedByUserId = ich,
CreatedAt = jetzt,
ExpiresAt = jetzt.AddDays(tage),
MaxUses = anfrage.MaxUses,
};
db.Einladungen.Add(einladung);
await db.SaveChangesAsync(ct);
return Results.Ok(new EinladungErstellt(
einladung.Id, token, einladung.ExpiresAt, einladung.MaxUses));
});
verwalten.MapGet("", async (FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
{
var jetzt = uhr.GetUtcNow();
var roh = await db.Einladungen.AsNoTracking()
.OrderByDescending(e => e.CreatedAt)
.Join(db.Users.AsNoTracking(), e => e.CreatedByUserId, u => u.Id,
(e, u) => new { E = e, u.UserName })
.ToListAsync(ct);
return Results.Ok(roh.Select(z => new Einladungseintrag(
z.E.Id, z.UserName, z.E.CreatedAt, z.E.ExpiresAt,
z.E.MaxUses, z.E.UsedCount, z.E.RevokedAt, z.E.Gilt(jetzt))).ToList());
});
// **Zurückziehen, nicht löschen.** Eine Zeile wegzuwerfen hiesse,
// hinterher nicht mehr zu wissen, dass es den Link je gab — und genau
// das will man wissen, wenn jemand Unerwartetes im Haushalt auftaucht.
//
// **Und mit `purge=true` doch.** Die Begründung oben bleibt richtig,
// sie gilt nur nicht ewig: eine Liste, aus der nichts je verschwindet,
// wird irgendwann unlesbar, und dann sieht man die eine Zeile nicht
// mehr, um die es ging. Weggeworfen wird deshalb nur, was ohnehin
// keine Tür mehr öffnet — zurückgezogen, abgelaufen oder
// aufgebraucht. Ein gültiger Link lässt sich nicht wegwischen; erst
// zurückziehen, dann entfernen, und das ist ein Handgriff mehr mit
// Absicht.
verwalten.MapDelete("/{id:guid}", async (
Guid id, FlaroContext db, TimeProvider uhr, CancellationToken ct,
bool purge = false) =>
{
var einladung = await db.Einladungen.FirstOrDefaultAsync(e => e.Id == id, ct);
if (einladung is null) return Einwand.Fehlt("Diese Einladung gibt es nicht mehr.");
var jetzt = uhr.GetUtcNow();
if (purge)
{
if (einladung.Gilt(jetzt))
{
return Einwand.Bitte(
"Dieser Einladungslink gilt noch. Zieh ihn erst zurück, dann lässt er sich entfernen.");
}
db.Einladungen.Remove(einladung);
await db.SaveChangesAsync(ct);
return Results.NoContent();
}
einladung.RevokedAt ??= jetzt;
await db.SaveChangesAsync(ct);
return Results.NoContent();
});
/* ---------- Was ohne Konto erreichbar sein muss ---------- */
app.MapGet("/api/v1/invites/{token}", async (
string token, FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
{
var gefunden = await Suchen(db, token, ct);
if (Pruefen(gefunden, uhr.GetUtcNow()) is { } einwand) return einwand;
var name = await db.Users.AsNoTracking()
.Where(u => u.Id == gefunden!.CreatedByUserId)
.Select(u => u.UserName).FirstOrDefaultAsync(ct) ?? "";
var servername = await db.Settings.AsNoTracking()
.Where(s => s.Key == SystemEndpoints.NameSchlüssel)
.Select(s => s.Value).FirstOrDefaultAsync(ct);
return Results.Ok(new Einladungsschau(
string.IsNullOrWhiteSpace(servername) ? "Flaro" : servername,
name, gefunden!.ExpiresAt));
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
app.MapPost("/api/v1/invites/{token}/redeem", async (
string token, EinloesenRequest anfrage,
FlaroContext db, TimeProvider uhr, CancellationToken ct) =>
{
var name = anfrage.UserName?.Trim() ?? "";
if (name.Length == 0 || (anfrage.Password?.Length ?? 0) < 8)
{
return Einwand.Bitte(
"Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen.");
}
var jetzt = uhr.GetUtcNow();
var gefunden = await Suchen(db, token, ct);
if (Pruefen(gefunden, jetzt) is { } einwand) return einwand;
// **Verbrauchen und anlegen gehören zusammen.**
//
// Zwei Leute, die gleichzeitig denselben einmaligen Link öffnen,
// läsen beide `UsedCount = 0` und schrieben beide `1` — und hätten
// beide ein Konto. Der Zähler wird deshalb mit einer Bedingung
// hochgesetzt, die die Gültigkeit noch einmal prüft; wer dabei
// keine Zeile trifft, war der Zweite.
//
// Und das Ganze in einer Transaktion: scheitert das Anlegen am
// Namen, rollt der Verbrauch mit zurück. Sonst wäre ein einmaliger
// Link an einem Tippfehler verbrannt.
await using var geschaeft = await db.Database.BeginTransactionAsync(ct);
var verbraucht = await db.Einladungen
.Where(e => e.Id == gefunden!.Id
&& e.RevokedAt == null
&& e.ExpiresAt > jetzt
&& (e.MaxUses == null || e.UsedCount < e.MaxUses))
.ExecuteUpdateAsync(s => s.SetProperty(e => e.UsedCount, e => e.UsedCount + 1), ct);
if (verbraucht != 1)
return Einwand.Bitte("Dieser Einladungslink wurde schon benutzt.");
if (await db.Users.AnyAsync(u => u.UserName == name, ct))
return Einwand.Belegt($"„{name}“ gibt es schon. Nimm einen anderen.");
db.Users.Add(new User
{
UserName = name,
PasswordHash = PasswordHasher.Hash(anfrage.Password!),
IsAdministrator = false,
});
await db.SaveChangesAsync(ct);
await geschaeft.CommitAsync(ct);
return Results.NoContent();
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
}
private static Task<Einladung?> Suchen(FlaroContext db, string token, CancellationToken ct) =>
db.Einladungen.FirstOrDefaultAsync(
e => e.TokenFingerprint == Secrets.Fingerprint(token ?? ""), ct);
/// <summary>Der Grund, warum ein Link nicht gilt — oder <c>null</c>.</summary>
///
/// <remarks>
/// <b>Unterschiedliche Sätze, mit Absicht.</b> Alles in ein „gilt nicht"
/// zusammenzufassen wäre verschwiegener, aber hier ist Verschwiegenheit
/// am falschen Platz: wer einen Link bekommen hat und abgewiesen wird,
/// muss wissen, ob er zu spät ist oder ob jemand ihn zurückgezogen hat —
/// sonst fragt er nach, und der Gastgeber rät mit.
/// </remarks>
private static IResult? Pruefen(Einladung? einladung, DateTimeOffset jetzt) => einladung switch
{
null => Einwand.Fehlt("Diesen Einladungslink gibt es nicht."),
{ RevokedAt: not null } => Einwand.Bitte("Dieser Einladungslink wurde zurückgezogen."),
var e when jetzt >= e.ExpiresAt => Einwand.Bitte("Dieser Einladungslink ist abgelaufen."),
var e when e.MaxUses is { } h && e.UsedCount >= h =>
Einwand.Bitte("Dieser Einladungslink wurde schon benutzt."),
_ => null,
};
private static Guid? Kennung(ClaimsPrincipal wer) =>
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
}