„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>
279 lines
12 KiB
C#
279 lines
12 KiB
C#
using System.Security.Claims;
|
|
using Flaro.Core.Metadata;
|
|
using Flaro.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
|
|
namespace Flaro.Api.Endpoints;
|
|
|
|
/// <summary>Das eigene Konto — für jeden, nicht nur für Administratoren.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Eine eigene Gruppe, weil die Rechte andere sind.</b> Alles unter
|
|
/// <c>/api/v1/users</c> war bisher Verwaltung und damit Administratoren
|
|
/// vorbehalten. Hier geht es um das eigene Konto: wer angemeldet ist, darf
|
|
/// sein Bild tauschen, und niemand darf das eines anderen anfassen. Die
|
|
/// Kennung kommt deshalb aus dem Token und nie aus der Adresse.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Es war bisher nicht erreichbar.</b> „Meine Sprache" stand mitten in der
|
|
/// Verwaltung, und die zeigt der Client nur Administratoren — der Endpunkt
|
|
/// erlaubte es längst jedem. Wer kein Admin war, konnte seine Sprache also
|
|
/// nirgends ändern.
|
|
/// </para>
|
|
/// </remarks>
|
|
public static class ProfilEndpoints
|
|
{
|
|
/// <summary>Höchstens so gross darf ein Profilbild sein.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Fünf Megabyte sind für ein Bild von 200 Pixeln reichlich. Dieselbe
|
|
/// Grenze wie bei Plakaten wäre hier zu grosszügig: ein Plakat sieht man
|
|
/// gross, einen Kreis von 28 Pixeln nicht.
|
|
/// </remarks>
|
|
private const long HoechstensBytes = 5 * 1024 * 1024;
|
|
|
|
public static void MapProfil(this IEndpointRouteBuilder app)
|
|
{
|
|
var gruppe = app.MapGroup("/api/v1/users/me").RequireAuthorization();
|
|
|
|
gruppe.MapGet("", async (ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
|
|
{
|
|
if (Kennung(wer) is not { } id) return Results.Unauthorized();
|
|
|
|
var benutzer = await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id, ct);
|
|
return benutzer is null
|
|
? Results.Unauthorized()
|
|
: Results.Ok(new AuthenticatedUser(
|
|
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
|
|
benutzer.Language, benutzer.AvatarImage));
|
|
});
|
|
|
|
gruppe.MapPost("/avatar", async (
|
|
HttpRequest anfrage, ClaimsPrincipal wer, FlaroContext db,
|
|
IImageStore ablage, CancellationToken ct) =>
|
|
{
|
|
if (Kennung(wer) is not { } id) return Results.Unauthorized();
|
|
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 5 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 benutzer = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
|
|
if (benutzer is null) return Results.Unauthorized();
|
|
|
|
benutzer.AvatarImage = schluessel;
|
|
await db.SaveChangesAsync(ct);
|
|
return Results.Ok(new { image = schluessel });
|
|
}).DisableAntiforgery();
|
|
|
|
// **Entfernen heisst vergessen, nicht löschen.** Die Datei bleibt in
|
|
// der Bildablage liegen — dort liegt sie unter dem Hash ihres Inhalts
|
|
// und könnte an einem anderen Konto hängen. Was dort aufräumt, ist
|
|
// „Bildspeicher leeren" in der Verwaltung, und das kennt alle Bezüge.
|
|
gruppe.MapDelete("/avatar", async (ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
|
|
{
|
|
if (Kennung(wer) is not { } id) return Results.Unauthorized();
|
|
|
|
var benutzer = await db.Users.FirstOrDefaultAsync(u => u.Id == id, ct);
|
|
if (benutzer is null) return Results.Unauthorized();
|
|
|
|
benutzer.AvatarImage = null;
|
|
await db.SaveChangesAsync(ct);
|
|
return Results.NoContent();
|
|
});
|
|
|
|
MapStatistik(gruppe);
|
|
}
|
|
|
|
/// <summary>Was jemand geschaut hat.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// <para>
|
|
/// <b>Jeder nur sich selbst, auch ein Administrator.</b> Das ist keine
|
|
/// Erfindung für diese Seite, sondern die Linie, die im Server schon
|
|
/// steht: wer wann welchen Film anschaut, ist eine Auskunft über Menschen
|
|
/// und nicht über den Server. Es gibt deshalb keinen Weg, ein fremdes
|
|
/// Konto zu meinen — die Kennung kommt aus dem Token.
|
|
/// </para>
|
|
/// <para>
|
|
/// <b>Wochen beginnen am Montag.</b> Nicht am Sonntag und nicht am Tag der
|
|
/// Anfrage: eine Kurve, deren Balken je nach Abfragezeitpunkt anders
|
|
/// zusammenfallen, lässt sich mit sich selbst nicht vergleichen.
|
|
/// </para>
|
|
/// </remarks>
|
|
private static void MapStatistik(RouteGroupBuilder gruppe)
|
|
{
|
|
gruppe.MapGet("/statistics", async (
|
|
ClaimsPrincipal wer, FlaroContext db, TimeProvider clock,
|
|
int wochen = 12, CancellationToken ct = default) =>
|
|
{
|
|
if (Kennung(wer) is not { } id) return Results.Unauthorized();
|
|
|
|
wochen = Math.Clamp(wochen, 1, 104);
|
|
var heute = DateOnly.FromDateTime(clock.GetUtcNow().ToLocalTime().DateTime);
|
|
var ersterMontag = Montag(heute).AddDays(-7 * (wochen - 1));
|
|
|
|
// ---- Das Protokoll ----
|
|
|
|
var tage = await db.Sehtage.AsNoTracking()
|
|
.Where(z => z.UserId == id)
|
|
.Select(z => new { z.Tag, z.Sekunden, z.MovieId, z.EpisodeId })
|
|
.ToListAsync(ct);
|
|
|
|
var gesamt = tage.Sum(z => z.Sekunden);
|
|
var seit = tage.Count == 0 ? (DateTimeOffset?)null
|
|
: new DateTimeOffset(tage.Min(z => z.Tag).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero);
|
|
|
|
var jeWoche = tage
|
|
.Where(z => z.Tag >= ersterMontag)
|
|
.GroupBy(z => Montag(z.Tag))
|
|
.ToDictionary(g => g.Key, g => g.Sum(x => x.Sekunden));
|
|
|
|
var kurve = Enumerable.Range(0, wochen)
|
|
.Select(n => ersterMontag.AddDays(7 * n))
|
|
.Select(montag => new Wochenwert(montag, jeWoche.GetValueOrDefault(montag)))
|
|
.ToList();
|
|
|
|
// ---- Was ohne Protokoll vollständig ist ----
|
|
|
|
var staende = await db.UserItemStates.AsNoTracking()
|
|
.Where(z => z.UserId == id && (z.MovieId != null || z.EpisodeId != null))
|
|
.Select(z => new
|
|
{
|
|
z.MovieId, z.EpisodeId, z.Watched, z.LastPlayedAt,
|
|
Laufzeit = z.Runtime,
|
|
})
|
|
.ToListAsync(ct);
|
|
|
|
var gesehene = staende.Where(z => z.Watched).ToList();
|
|
|
|
// ---- Zuletzt gesehen ----
|
|
|
|
var letzte = staende
|
|
.Where(z => z.LastPlayedAt != null)
|
|
.OrderByDescending(z => z.LastPlayedAt)
|
|
.Take(12)
|
|
.ToList();
|
|
|
|
var filmIds = letzte.Where(z => z.MovieId != null).Select(z => z.MovieId!.Value).ToList();
|
|
var folgenIds = letzte.Where(z => z.EpisodeId != null).Select(z => z.EpisodeId!.Value).ToList();
|
|
|
|
var filme = await db.Movies.AsNoTracking().Where(m => filmIds.Contains(m.Id))
|
|
.Select(m => new { m.Id, m.Title, m.PosterImage }).ToDictionaryAsync(m => m.Id, ct);
|
|
|
|
var folgen = await db.Episodes.AsNoTracking().Where(f => folgenIds.Contains(f.Id))
|
|
.Join(db.Series.AsNoTracking(), f => f.SeriesId, s => s.Id, (f, s) => new
|
|
{
|
|
f.Id, Folgentitel = f.Title, f.SeasonNumber, f.EpisodeNumber,
|
|
Serientitel = s.Title, s.PosterImage, SerienId = s.Id,
|
|
})
|
|
.ToDictionaryAsync(f => f.Id, ct);
|
|
|
|
var zuletzt = new List<Zuletztgesehen>();
|
|
foreach (var z in letzte)
|
|
{
|
|
if (z.MovieId is { } film && filme.TryGetValue(film, out var m))
|
|
{
|
|
zuletzt.Add(new Zuletztgesehen(
|
|
m.Id, "movie", m.Title, null, m.PosterImage, z.LastPlayedAt!.Value, z.Watched));
|
|
}
|
|
else if (z.EpisodeId is { } folge && folgen.TryGetValue(folge, out var f))
|
|
{
|
|
zuletzt.Add(new Zuletztgesehen(
|
|
f.SerienId, "episode", f.Serientitel,
|
|
$"S{f.SeasonNumber} · F{f.EpisodeNumber}" +
|
|
(f.Folgentitel is { Length: > 0 } t ? $" — {t}" : ""),
|
|
f.PosterImage, z.LastPlayedAt!.Value, z.Watched));
|
|
}
|
|
}
|
|
|
|
// ---- Genres, aus dem Protokoll ----
|
|
|
|
var genres = await GenresAus(db, tage.Select(t => (t.MovieId, t.EpisodeId, t.Sekunden)), ct);
|
|
|
|
return Results.Ok(new StatistikResponse(
|
|
gesamt,
|
|
gesehene.Count,
|
|
gesehene.Sum(z => z.Laufzeit?.TotalSeconds ?? 0),
|
|
seit,
|
|
kurve,
|
|
zuletzt,
|
|
genres));
|
|
});
|
|
}
|
|
|
|
/// <summary>Der Montag der Woche, in der dieser Tag liegt.</summary>
|
|
private static DateOnly Montag(DateOnly tag) =>
|
|
tag.AddDays(-((int)tag.DayOfWeek + 6) % 7);
|
|
|
|
/// <summary>Welche Genres die gesehene Zeit ausmachen.</summary>
|
|
///
|
|
/// <remarks>
|
|
/// Eine Folge hat keine Genres — ihre Serie hat sie. Deshalb wird jede
|
|
/// Folge erst zu ihrer Serie aufgelöst; sonst stünden Serien gar nicht in
|
|
/// der Auswertung, obwohl sie den grösseren Teil der Zeit ausmachen.
|
|
/// </remarks>
|
|
private static async Task<List<Genrewert>> GenresAus(
|
|
FlaroContext db,
|
|
IEnumerable<(Guid? Film, Guid? Folge, double Sekunden)> zeilen,
|
|
CancellationToken ct)
|
|
{
|
|
var liste = zeilen.ToList();
|
|
if (liste.Count == 0) return [];
|
|
|
|
var folgenIds = liste.Where(z => z.Folge != null).Select(z => z.Folge!.Value).Distinct().ToList();
|
|
var serieZuFolge = await db.Episodes.AsNoTracking()
|
|
.Where(f => folgenIds.Contains(f.Id))
|
|
.ToDictionaryAsync(f => f.Id, f => f.SeriesId, ct);
|
|
|
|
// Sekunden je Titel zusammenziehen, bevor die Genres dazukommen —
|
|
// sonst wird derselbe Tag so oft gezählt, wie der Titel Genres hat.
|
|
var jeFilm = new Dictionary<Guid, double>();
|
|
var jeSerie = new Dictionary<Guid, double>();
|
|
foreach (var (film, folge, sekunden) in liste)
|
|
{
|
|
if (film is { } f) jeFilm[f] = jeFilm.GetValueOrDefault(f) + sekunden;
|
|
else if (folge is { } e && serieZuFolge.TryGetValue(e, out var serie))
|
|
jeSerie[serie] = jeSerie.GetValueOrDefault(serie) + sekunden;
|
|
}
|
|
|
|
var filmIds = jeFilm.Keys.ToList();
|
|
var serienIds = jeSerie.Keys.ToList();
|
|
|
|
var verweise = await db.GenreLinks.AsNoTracking()
|
|
.Where(l => (l.MovieId != null && filmIds.Contains(l.MovieId!.Value))
|
|
|| (l.SeriesId != null && serienIds.Contains(l.SeriesId!.Value)))
|
|
.Join(db.Genres.AsNoTracking(), l => l.GenreId, g => g.Id,
|
|
(l, g) => new { l.MovieId, l.SeriesId, g.Name })
|
|
.ToListAsync(ct);
|
|
|
|
var summen = new Dictionary<string, double>();
|
|
foreach (var v in verweise)
|
|
{
|
|
var sekunden = v.MovieId is { } f ? jeFilm.GetValueOrDefault(f)
|
|
: v.SeriesId is { } s ? jeSerie.GetValueOrDefault(s)
|
|
: 0;
|
|
if (sekunden <= 0) continue;
|
|
summen[v.Name] = summen.GetValueOrDefault(v.Name) + sekunden;
|
|
}
|
|
|
|
return summen.OrderByDescending(p => p.Value).Take(6)
|
|
.Select(p => new Genrewert(p.Key, p.Value)).ToList();
|
|
}
|
|
|
|
/// <summary>Wer anfragt — aus dem Token, nie aus der Adresse.</summary>
|
|
private static Guid? Kennung(ClaimsPrincipal wer) =>
|
|
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
|
|
}
|