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>
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, 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;
|
|
}
|