Files
Flaro/server/src/Flaro.Api/Endpoints/ProfilEndpoints.cs
T
ScarriffleandClaude Opus 5 a7a4419626 Adminrecht geben und nehmen — und einen, dem es niemand nimmt
Ein Konto liess sich anlegen und danach nie wieder ändern: kein Weg zum
Administrator, keiner zurück, keiner zum Löschen. Mit Einladungslinks
füllte sich der Server also in genau eine Richtung.

**Warum es einen Besitzer braucht.** Sobald Administratoren einander das
Recht entziehen können, gibt es einen Zustand ohne jeden Administrator —
und aus dem führt kein Weg zurück ausser dem an der Datenbank. Ein Konto,
das ausgenommen ist, schliesst ihn aus. Es ist das erste, also das, mit
dem eingerichtet wurde; die Migration trägt das auf bestehenden Servern
nach, und weil die Kennungen zeitlich geordnet vergeben werden, ist der
Älteste schlicht der mit der kleinsten.

Weitergeben lässt er sich: vom Besitzer, an einen Administrator, mit
Rückfrage. Nur an einen Administrator, sonst wäre der Server einen Klick
von einem Besitzer entfernt, der die Verwaltung gar nicht sieht.

**Drei Türen sind zugemauert**, und jede hat ihren Test: dem Besitzer das
Recht nehmen, den Besitzer löschen, sich selbst das eigene Recht nehmen.
Das letzte ist die unauffälligste — wer sich im Eifer entmachtet, steht
vor einer Verwaltung, die er nicht mehr öffnen kann, und das Zurücknehmen
braucht genau die.

Gemessen und als Test festgehalten: ein frisch vergebenes Adminrecht gilt
**sofort**, ohne neue Anmeldung. Die Rolle wird je Anfrage aus der
Datenbank gelesen und nicht aus dem Token — ich hatte das Gegenteil
befürchtet.

Löschen nimmt mit, was am Konto hing: Geräte, Sehstände, Sehprotokoll,
Meldungen, offene Einladungen. Alles hängt mit `Cascade` daran. Zahlen
über jemanden zu führen, den es nicht mehr gibt, wäre die schlechtere
Hälfte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-10-07 18:31:05 +02:00

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, benutzer.IsOwner));
});
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;
}