diff --git a/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs b/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs index 39468e2..640ba12 100644 --- a/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/AuthEndpoints.cs @@ -77,14 +77,22 @@ public static class AuthEndpoints return Results.NoContent(); }).AllowAnonymous(); - app.MapGet("/api/v1/auth/me", (ClaimsPrincipal wer) => - Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) - ? Results.Ok(new AuthenticatedUser( - id, - wer.FindFirstValue(ClaimTypes.Name) ?? "", - wer.IsInRole(FlaroRoles.Administrator))) - : Results.Unauthorized()) - .RequireAuthorization(); + // **Aus der Datenbank, nicht nur aus dem Token.** Name und Rolle stehen + // in den Ansprüchen; Sprache und Bild ändern sich, ohne dass ein neues + // Token ausgestellt wird. Wer sein Bild tauscht und die Seite neu lädt, + // sähe sonst das alte, bis er sich neu anmeldet. + app.MapGet("/api/v1/auth/me", async (ClaimsPrincipal wer, FlaroContext db) => + { + if (!Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id)) + return Results.Unauthorized(); + + var benutzer = await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id); + if (benutzer is null) return Results.Unauthorized(); + + return Results.Ok(new AuthenticatedUser( + benutzer.Id, benutzer.UserName, benutzer.IsAdministrator, + benutzer.Language, benutzer.AvatarImage)); + }).RequireAuthorization(); MapCodeFlow(app); } @@ -191,7 +199,8 @@ public static class AuthEndpoints }); return new LoginResponse(token, new AuthenticatedUser( - benutzer.Id, benutzer.UserName, benutzer.IsAdministrator, benutzer.Language)); + benutzer.Id, benutzer.UserName, benutzer.IsAdministrator, + benutzer.Language, benutzer.AvatarImage)); } private const string FlaroTokenCookie = TokenAuthenticationHandler.CookieName; diff --git a/server/src/Flaro.Api/Endpoints/Contracts.cs b/server/src/Flaro.Api/Endpoints/Contracts.cs index 451bcc1..ee100a6 100644 --- a/server/src/Flaro.Api/Endpoints/Contracts.cs +++ b/server/src/Flaro.Api/Endpoints/Contracts.cs @@ -82,7 +82,16 @@ public sealed record LoginRequest( public sealed record AuthenticatedUser( Guid Id, string UserName, bool IsAdministrator, /// Die bevorzugte Sprache — null heisst: die des Servers. - string? Language = null); + string? Language = null, + + /// Das eigene Bild, als Schlüssel in die Bildablage. + /// + /// + /// Hier und nicht in einer eigenen Abfrage. Die Kopfleiste zeigt es + /// auf jeder Seite; es nach der Anmeldung noch einmal holen zu müssen + /// hiesse, dass der Buchstabenkreis kurz aufblitzt und dann wechselt. + /// + string? AvatarImage = null); public sealed record LoginResponse(string Token, AuthenticatedUser User); diff --git a/server/src/Flaro.Api/Endpoints/ImageEndpoints.cs b/server/src/Flaro.Api/Endpoints/ImageEndpoints.cs index e6e05e2..64d0e35 100644 --- a/server/src/Flaro.Api/Endpoints/ImageEndpoints.cs +++ b/server/src/Flaro.Api/Endpoints/ImageEndpoints.cs @@ -18,7 +18,7 @@ public static class ImageEndpoints // Der Schlüssel ist der Hash des Inhalts, also ändert sich das Bild // unter dieser Adresse nie. Ein Jahr zwischenspeichern spart bei // einer Mediathek mit tausend Plakaten das meiste an Verkehr. - return Results.File(pfad, "image/jpeg", enableRangeProcessing: true); + return Results.File(pfad, ImageCache.MedientypVon(pfad), enableRangeProcessing: true); }) .RequireAuthorization() .AddEndpointFilter(async (zusammenhang, weiter) => diff --git a/server/src/Flaro.Api/Endpoints/ProfilEndpoints.cs b/server/src/Flaro.Api/Endpoints/ProfilEndpoints.cs new file mode 100644 index 0000000..1cd4a1f --- /dev/null +++ b/server/src/Flaro.Api/Endpoints/ProfilEndpoints.cs @@ -0,0 +1,100 @@ +using System.Security.Claims; +using Flaro.Core.Metadata; +using Flaro.Data; +using Microsoft.EntityFrameworkCore; + +namespace Flaro.Api.Endpoints; + +/// Das eigene Konto — für jeden, nicht nur für Administratoren. +/// +/// +/// +/// Eine eigene Gruppe, weil die Rechte andere sind. Alles unter +/// /api/v1/users 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. +/// +/// +/// Es war bisher nicht erreichbar. „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. +/// +/// +public static class ProfilEndpoints +{ + /// Höchstens so gross darf ein Profilbild sein. + /// + /// + /// 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. + /// + 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 Results.BadRequest(new { fehler = "Kein Formular." }); + + var form = await anfrage.ReadFormAsync(ct); + var datei = form.Files.FirstOrDefault(); + if (datei is null || datei.Length == 0) + return Results.BadRequest(new { fehler = "Keine Datei dabei." }); + if (datei.Length > HoechstensBytes) + return Results.BadRequest(new { fehler = "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(); + }); + } + + /// Wer anfragt — aus dem Token, nie aus der Adresse. + private static Guid? Kennung(ClaimsPrincipal wer) => + Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null; +} diff --git a/server/src/Flaro.Api/Program.cs b/server/src/Flaro.Api/Program.cs index beb8f1e..4fa9635 100644 --- a/server/src/Flaro.Api/Program.cs +++ b/server/src/Flaro.Api/Program.cs @@ -269,6 +269,7 @@ app.MapStream(); app.MapPlayback(); app.MapMatching(); app.MapArtwork(); +app.MapProfil(); app.MapFreigabe(); // **Was unter /api nicht beantwortet wurde, ist nicht da.** Ohne diese Zeile diff --git a/server/src/Flaro.Core/Users/User.cs b/server/src/Flaro.Core/Users/User.cs index 06c62a5..020756a 100644 --- a/server/src/Flaro.Core/Users/User.cs +++ b/server/src/Flaro.Core/Users/User.cs @@ -30,5 +30,19 @@ public sealed class User /// public string? Language { get; set; } + /// Das eigene Bild — der Schlüssel aus derselben Ablage wie Plakate. + /// + /// + /// Kein eigener Speicher für Profilbilder. Es ist ein Bild wie + /// jedes andere: derselbe Ordner, dieselbe Adresse, derselbe + /// Zwischenspeicher im Browser. Ein zweiter Weg hiesse ein zweites Mal + /// Pfadprüfung, Grössengrenze und Aufräumen — dreimal dieselbe Arbeit für + /// ein Bild von 200 Pixeln. + /// + /// null heisst: der Buchstabenkreis. Der ist keine Notlösung, + /// sondern eine gültige Antwort — niemand muss ein Bild haben. + /// + public string? AvatarImage { get; set; } + public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; } diff --git a/server/src/Flaro.Data/FlaroContext.cs b/server/src/Flaro.Data/FlaroContext.cs index d93c87f..02409aa 100644 --- a/server/src/Flaro.Data/FlaroContext.cs +++ b/server/src/Flaro.Data/FlaroContext.cs @@ -95,6 +95,8 @@ public sealed class FlaroContext(DbContextOptions options) : DbCon e.Property(x => x.UserName).HasMaxLength(64).UseCollation("NOCASE"); e.Property(x => x.Language).HasMaxLength(16); e.Property(x => x.PasswordHash).HasMaxLength(256); + // Ein Hash aus 64 Hexzeichen, wie jeder Bildschlüssel. + e.Property(x => x.AvatarImage).HasMaxLength(64); }); b.Entity(e => diff --git a/server/src/Flaro.Data/Migrations/20261006145833_Profilbild.Designer.cs b/server/src/Flaro.Data/Migrations/20261006145833_Profilbild.Designer.cs new file mode 100644 index 0000000..d237494 --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261006145833_Profilbild.Designer.cs @@ -0,0 +1,937 @@ +// +using System; +using Flaro.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + [DbContext(typeof(FlaroContext))] + [Migration("20261006145833_Profilbild")] + partial class Profilbild + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.12"); + + modelBuilder.Entity("Flaro.Core.Auth.AuthCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ApprovedByUserId") + .HasColumnType("TEXT"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ConsumedAt") + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ExpiresAt") + .HasColumnType("INTEGER"); + + b.Property("HandleFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Code"); + + b.HasIndex("HandleFingerprint") + .IsUnique(); + + b.ToTable("AuthCodes"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("DeviceId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("DeviceName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastSeenAt") + .HasColumnType("INTEGER"); + + b.Property("RevokedAt") + .HasColumnType("INTEGER"); + + b.Property("TokenFingerprint") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenFingerprint") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("DeviceTokens"); + }); + + modelBuilder.Entity("Flaro.Core.Libraries.Library", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ReviewNewItems") + .HasColumnType("INTEGER"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("Libraries"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Character") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Order") + .HasColumnType("INTEGER"); + + b.Property("PersonId") + .HasColumnType("TEXT"); + + b.Property("Role") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("PersonId"); + + b.HasIndex("SeriesId"); + + b.ToTable("Credits"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("EpisodeNumber") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("SeasonNumber") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("StillImage") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("SeriesId", "SeasonNumber", "EpisodeNumber") + .IsUnique(); + + b.ToTable("Episodes"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Genre", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("Genres"); + }); + + modelBuilder.Entity("Flaro.Core.Library.GenreLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("GenreId") + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("GenreId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("GenreId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("GenreLinks"); + }); + + modelBuilder.Entity("Flaro.Core.Library.ItemTranslation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("FetchedAt") + .HasColumnType("INTEGER"); + + b.Property("Language") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId", "Language") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("MovieId", "Language") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("SeriesId", "Language") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.ToTable("Translations"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BitRate") + .HasColumnType("INTEGER"); + + b.Property("Container") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Duration") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("MissingSince") + .HasColumnType("INTEGER"); + + b.Property("ModifiedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("Path") + .IsRequired() + .HasMaxLength(1024) + .HasColumnType("TEXT"); + + b.Property("ProbedAt") + .HasColumnType("INTEGER"); + + b.Property("SeenAt") + .HasColumnType("INTEGER"); + + b.Property("SizeBytes") + .HasColumnType("INTEGER"); + + b.Property("VersionLabel") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("LibraryId", "Path") + .IsUnique(); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Channels") + .HasColumnType("INTEGER"); + + b.Property("Codec") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Height") + .HasColumnType("INTEGER"); + + b.Property("Index") + .HasColumnType("INTEGER"); + + b.Property("IsDefault") + .HasColumnType("INTEGER"); + + b.Property("IsForced") + .HasColumnType("INTEGER"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("MediaFileId") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Width") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId", "Index") + .IsUnique(); + + b.ToTable("MediaStreams"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ChangedSince") + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitlePinned") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + + b.ToTable("Movies"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Person", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Image") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("TmdbId") + .IsUnique(); + + b.ToTable("People"); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AddedAt") + .HasColumnType("INTEGER"); + + b.Property("BackdropImage") + .HasColumnType("TEXT"); + + b.Property("BackdropPinned") + .HasColumnType("INTEGER"); + + b.Property("ChangedSince") + .HasColumnType("INTEGER"); + + b.Property("ContentRating") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("ImdbId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("LibraryId") + .HasColumnType("TEXT"); + + b.Property("LogoImage") + .HasColumnType("TEXT"); + + b.Property("LogoPinned") + .HasColumnType("INTEGER"); + + b.Property("MatchLocked") + .HasColumnType("INTEGER"); + + b.Property("MetadataLanguage") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("OriginalTitle") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Overview") + .HasColumnType("TEXT"); + + b.Property("ParsedTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParsedYear") + .HasColumnType("INTEGER"); + + b.Property("PosterImage") + .HasColumnType("TEXT"); + + b.Property("PosterPinned") + .HasColumnType("INTEGER"); + + b.Property("PublishAt") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("REAL"); + + b.Property("RuntimeMinutes") + .HasColumnType("INTEGER"); + + b.Property("SortTitlePinned") + .HasColumnType("INTEGER"); + + b.Property("SortTitleValue") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Tagline") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("TitlePinned") + .HasColumnType("INTEGER"); + + b.Property("TmdbId") + .HasColumnType("INTEGER"); + + b.Property("Year") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("LibraryId", "MetadataLanguage"); + + b.HasIndex("LibraryId", "SortTitleValue"); + + b.HasIndex("LibraryId", "TmdbId") + .IsUnique() + .HasFilter("\"TmdbId\" IS NOT NULL"); + + b.ToTable("Series"); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Dismissed") + .HasColumnType("INTEGER"); + + b.Property("EpisodeId") + .HasColumnType("TEXT"); + + b.Property("Favourite") + .HasColumnType("INTEGER"); + + b.Property("InProgress") + .HasColumnType("INTEGER"); + + b.Property("LastPlayedAt") + .HasColumnType("INTEGER"); + + b.Property("MovieId") + .HasColumnType("TEXT"); + + b.Property("PlayCount") + .HasColumnType("INTEGER"); + + b.Property("Position") + .HasColumnType("INTEGER"); + + b.Property("Rating") + .HasColumnType("INTEGER"); + + b.Property("Runtime") + .HasColumnType("INTEGER"); + + b.Property("SeriesId") + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("INTEGER"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("Watched") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("EpisodeId"); + + b.HasIndex("MovieId"); + + b.HasIndex("SeriesId"); + + b.HasIndex("UserId", "EpisodeId") + .IsUnique() + .HasFilter("\"EpisodeId\" IS NOT NULL"); + + b.HasIndex("UserId", "MovieId") + .IsUnique() + .HasFilter("\"MovieId\" IS NOT NULL"); + + b.HasIndex("UserId", "SeriesId") + .IsUnique() + .HasFilter("\"SeriesId\" IS NOT NULL"); + + b.HasIndex("UserId", "InProgress", "UpdatedAt"); + + b.ToTable("UserItemStates"); + }); + + modelBuilder.Entity("Flaro.Core.ServerSetting", b => + { + b.Property("Key") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(4096) + .HasColumnType("TEXT"); + + b.HasKey("Key"); + + b.ToTable("Settings"); + }); + + modelBuilder.Entity("Flaro.Core.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AvatarImage") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("INTEGER"); + + b.Property("IsAdministrator") + .HasColumnType("INTEGER"); + + b.Property("Language") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("UserName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("UserName") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Flaro.Core.Auth.DeviceToken", b => + { + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Credit", b => + { + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Person", null) + .WithMany() + .HasForeignKey("PersonId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Flaro.Core.Library.Episode", b => + { + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.GenreLink", b => + { + b.HasOne("Flaro.Core.Library.Genre", null) + .WithMany() + .HasForeignKey("GenreId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaFile", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.MediaStream", b => + { + b.HasOne("Flaro.Core.Library.MediaFile", null) + .WithMany() + .HasForeignKey("MediaFileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Movie", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Library.Series", b => + { + b.HasOne("Flaro.Core.Libraries.Library", null) + .WithMany() + .HasForeignKey("LibraryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Flaro.Core.Playback.UserItemState", b => + { + b.HasOne("Flaro.Core.Library.Episode", null) + .WithMany() + .HasForeignKey("EpisodeId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Movie", null) + .WithMany() + .HasForeignKey("MovieId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Library.Series", null) + .WithMany() + .HasForeignKey("SeriesId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Flaro.Core.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/server/src/Flaro.Data/Migrations/20261006145833_Profilbild.cs b/server/src/Flaro.Data/Migrations/20261006145833_Profilbild.cs new file mode 100644 index 0000000..48aadab --- /dev/null +++ b/server/src/Flaro.Data/Migrations/20261006145833_Profilbild.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Flaro.Data.Migrations +{ + /// + public partial class Profilbild : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "AvatarImage", + table: "Users", + type: "TEXT", + maxLength: 64, + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "AvatarImage", + table: "Users"); + } + } +} diff --git a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs index 6ceb245..b2187e3 100644 --- a/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs +++ b/server/src/Flaro.Data/Migrations/FlaroContextModelSnapshot.cs @@ -780,6 +780,10 @@ namespace Flaro.Data.Migrations .ValueGeneratedOnAdd() .HasColumnType("TEXT"); + b.Property("AvatarImage") + .HasMaxLength(64) + .HasColumnType("TEXT"); + b.Property("CreatedAt") .HasColumnType("INTEGER"); diff --git a/server/src/Flaro.Metadata/ImageCache.cs b/server/src/Flaro.Metadata/ImageCache.cs index ddfcb26..741b62b 100644 --- a/server/src/Flaro.Metadata/ImageCache.cs +++ b/server/src/Flaro.Metadata/ImageCache.cs @@ -40,6 +40,16 @@ public sealed class ImageCache( /// /// Der Schlüssel kommt aus dem Inhalt, nicht aus einem Zufallswert: /// dasselbe Bild zweimal hochzuladen ergibt dieselbe Datei. + /// + /// Der ganze Hash, ohne Dateiendung. Hier standen einmal die ersten + /// 32 Zeichen plus „.jpg" — und nimmt genau 64 + /// Hexzeichen und nichts sonst. Es gab also keinen Schlüssel, den beide + /// gelten liessen: jeder Upload endete in null und damit in einem + /// 502. Monatelang, ohne Test, der danach gefragt hätte. + /// + /// Die Endung fehlt mit Absicht. Was in der Datei steht, steht in der + /// Datei; der Medientyp beim Ausliefern kommt aus ihren ersten Bytes + /// () und nicht aus ihrem Namen. /// public async Task StoreStreamAsync( Stream inhalt, string? medientyp, CancellationToken ct = default) @@ -49,15 +59,9 @@ public sealed class ImageCache( if (zwischen.Length == 0 || zwischen.Length > HöchstensBytes) return null; var daten = zwischen.ToArray(); - var endung = medientyp switch - { - "image/png" => ".png", - "image/webp" => ".webp", - _ => ".jpg", - }; + _ = medientyp; - var schlüssel = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(daten))[..32] - + endung; + var schlüssel = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(daten)); var ziel = PfadFür(schlüssel); if (ziel is null) return null; if (File.Exists(ziel)) return schlüssel; @@ -137,6 +141,34 @@ public sealed class ImageCache( return Path.Combine(optionen.Root, "images", schlüssel[..2], schlüssel + ".img"); } + /// Was in der Datei steht — aus ihren ersten Bytes, nicht aus dem Namen. + /// + /// + /// Weil der Schlüssel keine Endung trägt. Alles als + /// image/jpeg auszuliefern ging gut, solange nur TMDB lieferte; + /// ein hochgeladenes PNG wäre damit falsch angesagt. Browser raten zwar, + /// aber eine Angabe, die man selbst macht, soll stimmen. + /// + /// Vier Zahlen, keine Bibliothek: mehr Bildarten nimmt der Server ohnehin + /// nicht an. + /// + public static string Medientyp(ReadOnlySpan anfang) => anfang switch + { + [0x89, 0x50, 0x4E, 0x47, ..] => "image/png", + [0x47, 0x49, 0x46, 0x38, ..] => "image/gif", + [0x52, 0x49, 0x46, 0x46, _, _, _, _, 0x57, 0x45, 0x42, 0x50, ..] => "image/webp", + _ => "image/jpeg", + }; + + /// Dasselbe für eine Datei auf der Platte. + public static string MedientypVon(string pfad) + { + Span anfang = stackalloc byte[12]; + using var strom = File.OpenRead(pfad); + var gelesen = strom.ReadAtLeast(anfang, anfang.Length, throwOnEndOfStream: false); + return Medientyp(anfang[..gelesen]); + } + /// Gibt ein abgelegtes Bild zum Lesen heraus. /// /// diff --git a/server/tests/Flaro.Api.Tests/ProfilTests.cs b/server/tests/Flaro.Api.Tests/ProfilTests.cs new file mode 100644 index 0000000..b85953f --- /dev/null +++ b/server/tests/Flaro.Api.Tests/ProfilTests.cs @@ -0,0 +1,170 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using Flaro.Api.Endpoints; + +namespace Flaro.Api.Tests; + +/// Das eigene Konto — und wer daran darf. +[TestFixture] +public sealed class ProfilTests +{ + /// Ein PNG, das nur ein PNG sein muss. + /// + /// + /// Die ersten acht Bytes sind die Signatur; mehr prüft der Server nicht, + /// und mehr braucht der Test nicht. Ein echtes Bild hier abzulegen hiesse, + /// eine Binärdatei ins Verzeichnis zu legen, die niemand lesen kann. + /// + private static byte[] Bild(byte kennzeichen = 1) => + [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, kennzeichen, 2, 3, 4]; + + private static MultipartFormDataContent Formular(byte[] inhalt, string name = "ich.png") + { + var teil = new ByteArrayContent(inhalt); + teil.Headers.ContentType = new MediaTypeHeaderValue("image/png"); + return new MultipartFormDataContent { { teil, "datei", name } }; + } + + /// Hochladen, abrufen, entfernen — der ganze Weg. + /// + /// + /// Dieser Weg war kaputt, und niemand merkte es. Die Ablage baute + /// einen Schlüssel aus 32 Hexzeichen plus Endung, die Ausgabe verlangte + /// genau 64 Hexzeichen — jeder Upload endete in einem 502. Deshalb steht + /// hier nicht nur „200", sondern auch, dass das Bild danach wirklich + /// herauskommt. + /// + [Test] + public async Task Ein_Profilbild_laesst_sich_setzen_abrufen_und_entfernen() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var client = server.AlsAngemeldeter(admin.Token); + + var gesetzt = await client.PostAsync("/api/v1/users/me/avatar", Formular(Bild())); + var antwort = await gesetzt.Content.ReadFromJsonAsync(); + + Assert.That(gesetzt.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(antwort!.Image, Has.Length.EqualTo(64), "der Schlüssel ist der ganze Hash"); + + var konto = await client.GetFromJsonAsync("/api/v1/users/me"); + Assert.That(konto!.AvatarImage, Is.EqualTo(antwort.Image)); + + // **Und es kommt auch wieder heraus.** Ein Schlüssel, den die + // Bildausgabe nicht gelten lässt, wäre so gut wie keiner. + var abgerufen = await client.GetAsync($"/api/v1/images/{antwort.Image}"); + Assert.Multiple(() => + { + Assert.That(abgerufen.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(abgerufen.Content.Headers.ContentType?.MediaType, Is.EqualTo("image/png"), + "der Medientyp kommt aus dem Inhalt, nicht aus dem Dateinamen"); + }); + + var entfernt = await client.DeleteAsync("/api/v1/users/me/avatar"); + var danach = await client.GetFromJsonAsync("/api/v1/users/me"); + + Assert.Multiple(() => + { + Assert.That(entfernt.StatusCode, Is.EqualTo(HttpStatusCode.NoContent)); + Assert.That(danach!.AvatarImage, Is.Null); + }); + } + + /// Das Bild steht nach dem Anmelden sofort zur Verfügung. + /// + /// + /// Die Kopfleiste zeigt es auf jeder Seite. Käme es erst mit einer zweiten + /// Anfrage, blitzte bei jedem Laden kurz der Buchstabenkreis auf. + /// + [Test] + public async Task Die_Anmeldung_bringt_das_Bild_gleich_mit() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var client = server.AlsAngemeldeter(admin.Token); + + await client.PostAsync("/api/v1/users/me/avatar", Formular(Bild())); + + var erneut = await server.SignIn("guido", "ein-langes-passwort", "Zweitgerät", "zweit"); + var ueberMich = await client.GetFromJsonAsync("/api/v1/auth/me"); + + Assert.Multiple(() => + { + Assert.That(erneut.User.AvatarImage, Is.Not.Null, "in der Anmeldeantwort"); + Assert.That(ueberMich!.AvatarImage, Is.EqualTo(erneut.User.AvatarImage), "und in /auth/me"); + }); + } + + /// Wer kein Administrator ist, darf sein eigenes Konto trotzdem pflegen. + /// + /// + /// Der Anlass. „Meine Sprache" stand in der Verwaltung, und die + /// zeigt der Client nur Administratoren — der Endpunkt erlaubte sie längst + /// jedem. Wer kein Admin war, konnte seine Sprache also nirgends ändern. + /// + [Test] + public async Task Ohne_Adminrecht_gehoert_einem_das_eigene_Konto_trotzdem() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var gast = await server.NeuerBenutzer( + server.AlsAngemeldeter(admin.Token), "gast", "gastpasswortlang"); + + var gesetzt = await gast.PostAsync("/api/v1/users/me/avatar", Formular(Bild(9))); + var sprache = await gast.PutAsJsonAsync("/api/v1/users/me/language", new SprachRequest("en")); + var konto = await gast.GetFromJsonAsync("/api/v1/users/me"); + + // Und die Verwaltung bleibt zu. + var verwaltung = await gast.GetAsync("/api/v1/settings/playback"); + + Assert.Multiple(() => + { + Assert.That(gesetzt.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(sprache.StatusCode, Is.EqualTo(HttpStatusCode.OK)); + Assert.That(konto!.Language, Is.EqualTo("en")); + Assert.That(konto.IsAdministrator, Is.False); + Assert.That(verwaltung.StatusCode, Is.EqualTo(HttpStatusCode.Forbidden)); + }); + } + + /// Das Bild des einen ist nicht das des anderen. + /// + /// + /// Die Kennung kommt aus dem Token und nie aus der Adresse — es gibt also + /// gar keinen Weg, ein fremdes Konto zu meinen. Der Test hält fest, dass + /// das so bleibt. + /// + [Test] + public async Task Ein_Bild_gehoert_genau_einem_Konto() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var alsAdmin = server.AlsAngemeldeter(admin.Token); + var gast = await server.NeuerBenutzer(alsAdmin, "gast", "gastpasswortlang"); + + await alsAdmin.PostAsync("/api/v1/users/me/avatar", Formular(Bild(1))); + await gast.PostAsync("/api/v1/users/me/avatar", Formular(Bild(2))); + + var eines = await alsAdmin.GetFromJsonAsync("/api/v1/users/me"); + var anderes = await gast.GetFromJsonAsync("/api/v1/users/me"); + + Assert.That(eines!.AvatarImage, Is.Not.EqualTo(anderes!.AvatarImage)); + } + + /// Eine zu grosse Datei wird abgelehnt, nicht abgelegt. + [Test] + public async Task Ein_zu_grosses_Bild_wird_abgelehnt() + { + using var server = new FlaroServer(); + var admin = await server.SetUpAndSignIn(); + var client = server.AlsAngemeldeter(admin.Token); + + var zuGross = new byte[6 * 1024 * 1024]; + var antwort = await client.PostAsync("/api/v1/users/me/avatar", Formular(zuGross)); + + Assert.That(antwort.StatusCode, Is.EqualTo(HttpStatusCode.BadRequest)); + } + + private sealed record Bildantwort(string Image); +} diff --git a/server/tests/Flaro.Metadata.Tests/ImageCacheTests.cs b/server/tests/Flaro.Metadata.Tests/ImageCacheTests.cs index ae5b954..a213bf5 100644 --- a/server/tests/Flaro.Metadata.Tests/ImageCacheTests.cs +++ b/server/tests/Flaro.Metadata.Tests/ImageCacheTests.cs @@ -152,4 +152,72 @@ public class ImageCacheTests public override void SetLength(long v) => throw new NotSupportedException(); public override void Write(byte[] p, int v, int a) => throw new NotSupportedException(); } + + /* ---------- Hochgeladene Bilder ---------- */ + + /// Ein hochgeladenes Bild landet und ist danach abrufbar. + /// + /// + /// Dieser Weg war monatelang kaputt und niemand prüfte ihn. + /// StoreStreamAsync baute einen Schlüssel aus 32 Hexzeichen plus + /// Dateiendung, PfadFür verlangt genau 64 Hexzeichen — es gab + /// keinen Wert, den beide gelten liessen. Jeder Upload gab null + /// zurück, und der Endpunkt antwortete 502. + /// + [Test] + public async Task Ein_hochgeladenes_Bild_landet_da_wo_man_es_wiederfindet() + { + var ablage = Ablage(MitBild()); + using var strom = new MemoryStream(Encoding.UTF8.GetBytes("ein eigenes bild")); + + var schlüssel = await ablage.StoreStreamAsync(strom, "image/png"); + + Assert.That(schlüssel, Is.Not.Null, "ohne Schlüssel gibt es kein Bild"); + Assert.Multiple(() => + { + Assert.That(schlüssel, Has.Length.EqualTo(64)); + Assert.That(ablage.PfadFür(schlüssel!), Is.Not.Null, + "der Schlüssel muss durch dieselbe Prüfung passen, die ihn ausliefert"); + Assert.That(File.Exists(ablage.PfadFür(schlüssel!)), Is.True); + }); + } + + /// Derselbe Inhalt ergibt dieselbe Datei. + [Test] + public async Task Zweimal_dasselbe_Bild_liegt_einmal_da() + { + var ablage = Ablage(MitBild()); + var a = await ablage.StoreStreamAsync(new MemoryStream(Encoding.UTF8.GetBytes("gleich")), "image/jpeg"); + var b = await ablage.StoreStreamAsync(new MemoryStream(Encoding.UTF8.GetBytes("gleich")), "image/png"); + + Assert.That(b, Is.EqualTo(a)); + } + + /// Leeres wird abgelehnt, nicht abgelegt. + [Test] + public async Task Eine_leere_Datei_wird_nicht_abgelegt() + { + var ablage = Ablage(MitBild()); + Assert.That(await ablage.StoreStreamAsync(new MemoryStream([]), "image/jpeg"), Is.Null); + } + + /// Der Medientyp kommt aus dem Inhalt, nicht aus dem Namen. + /// + /// + /// Der Schlüssel trägt keine Endung — ohne diese Erkennung ginge jedes + /// Bild als image/jpeg hinaus, auch ein PNG. + /// + [Test] + public void Der_Medientyp_steht_in_den_ersten_Bytes() + { + Assert.Multiple(() => + { + Assert.That(ImageCache.Medientyp([0x89, 0x50, 0x4E, 0x47, 0x0D]), Is.EqualTo("image/png")); + Assert.That(ImageCache.Medientyp([0x47, 0x49, 0x46, 0x38, 0x39]), Is.EqualTo("image/gif")); + Assert.That(ImageCache.Medientyp( + [0x52, 0x49, 0x46, 0x46, 1, 2, 3, 4, 0x57, 0x45, 0x42, 0x50]), Is.EqualTo("image/webp")); + Assert.That(ImageCache.Medientyp([0xFF, 0xD8, 0xFF, 0xE0]), Is.EqualTo("image/jpeg")); + Assert.That(ImageCache.Medientyp([]), Is.EqualTo("image/jpeg"), "im Zweifel das Häufigste"); + }); + } } diff --git a/web/app/src/api.ts b/web/app/src/api.ts index d2a86c7..705abcd 100644 --- a/web/app/src/api.ts +++ b/web/app/src/api.ts @@ -7,6 +7,8 @@ export type Rolle = { id: string; userName: string; isAdministrator: boolean; /** Die bevorzugte Sprache — `null` heisst: die des Servers. */ language?: string | null; + /** Das eigene Bild, als Schlüssel in die Bildablage. `null`: Buchstabenkreis. */ + avatarImage?: string | null; }; export type Spur = { index: number; kind: 'Video' | 'Audio' | 'Subtitle'; @@ -212,6 +214,42 @@ export const istAdmin = (): boolean => Boolean(sitzung.wer?.isAdministrator); /** Die eingestellte Sprache dieses Benutzers — `null` heisst: die des Servers. */ export const meineSprache = (): string | null => sitzung.wer?.language ?? null; +/** Das eigene Konto, frisch vom Server. */ +export const meinKonto = () => anfrage('/users/me'); + +/** Holt das Konto und schreibt die gemerkte Sitzung fort. + * + * **Weil die Sitzung nur bei der Anmeldung geschrieben wurde.** Wer sein Bild + * am Telefon setzt und danach den Browser am Schreibtisch öffnet, fand dort + * den alten Buchstaben — und zwar bis zum nächsten Anmelden. Dasselbe gilt für + * die Sprache. Eine Anfrage beim Hochfahren macht aus der Kopie wieder einen + * Stand. + * + * Scheitert sie, bleibt die alte Kopie stehen: lieber ein veraltetes Bild als + * gar keine Oberfläche. */ +export async function kontoAbgleichen(): Promise { + try { + const frisch = await meinKonto(); + if (sitzung.wer) { Object.assign(sitzung.wer, frisch); merken(); } + } catch { /* Offline oder abgelehnt — dann gilt, was gespeichert ist. */ } +} + +/** Legt ein Profilbild ab und gibt seinen Schlüssel zurück. */ +export async function profilbildSetzen(datei: File): Promise { + const form = new FormData(); + form.append('datei', datei); + const antwort = await anfrage<{ image: string }>('/users/me/avatar', { method: 'POST', body: form }); + // **Die Sitzung gleich mitziehen.** Die Kopfleiste liest aus ihr; ohne das + // stünde dort bis zum nächsten Anmelden der alte Buchstabe. + if (sitzung.wer) { sitzung.wer.avatarImage = antwort.image; merken(); } + return antwort.image; +} + +export async function profilbildEntfernen(): Promise { + await anfrage('/users/me/avatar', { method: 'DELETE' }); + if (sitzung.wer) { sitzung.wer.avatarImage = null; merken(); } +} + /* ---------- Anfragen ---------- */ /** Ein deutscher Satz zu einem Status, den der Server nicht erklärt hat. diff --git a/web/app/src/main.ts b/web/app/src/main.ts index b3165f4..618c569 100644 --- a/web/app/src/main.ts +++ b/web/app/src/main.ts @@ -1,10 +1,10 @@ import './style.css'; import { - systemauskunft, angemeldet, istAdmin, abmelden, sitzung, bibliotheken, - reihenfolgeSetzen, + systemauskunft, angemeldet, istAdmin, sitzung, bibliotheken, + reihenfolgeSetzen, bildAdresse, kontoAbgleichen, type Hinweis, type Bibliothek, } from './api'; -import { icons, icon } from './icons'; +import { icons } from './icons'; import { einrichtung } from './views/setup'; import { einhaengen, ablegen, vergessen } from './ansichten'; import { anmeldung } from './views/login'; @@ -13,6 +13,7 @@ import { mediathek } from './views/library'; import { detailseite } from './views/detail'; import { verwaltung, alsBereich } from './views/verwaltung'; import { suchseite } from './views/search'; +import { profilseite } from './views/profil'; import { esc, meldung } from './util'; const anmeldeflaeche = document.getElementById('login')!; @@ -45,10 +46,18 @@ async function hochfahren(): Promise { // **Vor dem ersten Zeichnen.** Die Seitenleiste führt jede Mediathek // einzeln auf; ohne die Liste könnte der Router `#/m/{id}` nicht // auflösen. - mediatheken = await bibliotheken(); + // **Das Konto frisch holen, bevor die Kopfleiste steht.** Sie zeigt Bild + // und Namen aus der gemerkten Sitzung, und die stammt von der Anmeldung — + // ein Bild, das anderswo gesetzt wurde, käme hier nie an. + await Promise.all([kontoAbgleichen(), (async () => { + mediatheken = await bibliotheken(); + })()]); kopfleisteBauen(); leisteBauen(); + // Das Profil meldet, wenn sich am Konto etwas geändert hat — dann zeigt + // die Kopfleiste das neue Bild sofort und nicht erst nach dem Neuladen. + window.addEventListener('flaro:konto', kopfleisteBauen); window.addEventListener('hashchange', zeichnen); zeichnen(); } catch (fehler) { @@ -144,13 +153,27 @@ function kopfleisteBauen(): void { const rechts = document.createElement('div'); rechts.className = 'rechts'; + // **Das eigene Bild führt zum eigenen Konto, nicht aus dem Server heraus.** + // + // Hier stand `abmelden()` — ein Klick, keine Rückfrage, kein Weg zurück, + // hinter einer Geste, die überall sonst „mein Konto" heisst. Abmelden gibt + // es weiterhin, auf der Profilseite und mit Nachfrage. const konto = document.createElement('button'); konto.className = 'konto'; const name = sitzung.wer?.userName ?? ''; - konto.innerHTML = `${esc(name.slice(0, 1).toUpperCase())}`; - konto.title = `${name} — abmelden`; - konto.appendChild(icon('abmelden')); - konto.onclick = () => { abmelden(); location.reload(); }; + const bild = bildAdresse(sitzung.wer?.avatarImage ?? null); + if (bild) { + konto.innerHTML = ``; + // Kommt es nicht an, tritt der Buchstabe an seine Stelle. + konto.querySelector('img')!.onerror = () => { + konto.innerHTML = `${esc(name.slice(0, 1).toUpperCase())}`; + }; + } else { + konto.innerHTML = `${esc(name.slice(0, 1).toUpperCase())}`; + } + konto.title = `${name} — mein Profil`; + konto.setAttribute('aria-label', `${name} — mein Profil`); + konto.onclick = () => { location.hash = '#/profil'; }; rechts.appendChild(konto); kopfleiste.append(marke, suche, rechts); @@ -400,6 +423,9 @@ function zeichnen(): void { } else if (teile[0] === 'suche' && kennung) { einhaengen(weg, inhalt, () => suchseite(decodeURIComponent(kennung), oeffnen), { merken: true }); + } else if (teile[0] === 'profil') { + // Nicht gemerkt: das Bild kann sich ändern, und ein Profil ist billig. + einhaengen(weg, inhalt, () => profilseite()); } else if (teile[0] === 'verwaltung' && istAdmin()) { // **Die Verwaltung auch nicht** — sie zeigt Laufendes. Ihr Takt hängt am // Signal und hört auf, sobald man weggeht. diff --git a/web/app/src/style.css b/web/app/src/style.css index b31239a..aa1cd75 100644 --- a/web/app/src/style.css +++ b/web/app/src/style.css @@ -147,6 +147,9 @@ svg { width: 20px; height: 20px; display: block; flex: none; } background: var(--accent); color: #fff; display: grid; place-items: center; font-size: 13px; font-weight: 600; } +/* Mit eigenem Bild dieselbe Scheibe, nur gefüllt. */ +#topbar .konto img.kreis { object-fit: cover; background: var(--elevated); } +#topbar .konto:hover .kreis { box-shadow: 0 0 0 2px var(--accent-soft); } /* ---------- Grundgerüst ---------- */ @@ -1226,6 +1229,42 @@ body:has(.pl.pl-klein) .auswahlleiste { bottom: 92px; } .hero-bald { font-size: 12px; padding: 0 var(--s-snug); } } +/* --- Profil -------------------------------------------------------------- + * + * **Die eigene Seite.** Ein Klick auf das eigene Bild meldete ab — ohne + * Rückfrage und ohne Weg zurück. Hier steht stattdessen, wem das Konto + * gehört, und Abmelden ist ein Knopf unter allem anderen. */ + +.profil { max-width: 720px; } + +.profil-kopf { + display: flex; align-items: center; gap: var(--s-section); + margin-bottom: var(--s-section); +} +.profil-bild { + flex: none; width: 96px; height: 96px; border-radius: 50%; + background: var(--accent); color: #fff; overflow: hidden; + display: grid; place-items: center; font-size: 36px; font-weight: 600; +} +.profil-bild img { width: 100%; height: 100%; object-fit: cover; } +.profil-text { display: flex; flex-direction: column; gap: var(--s-snug); min-width: 0; } +.profil-text h1 { margin: 0; font-size: 26px; font-weight: 700; letter-spacing: -.02em; } + +.profil h2 { + font-size: 15px; font-weight: 600; color: var(--text-dim); + margin: 0 0 var(--s-regular); +} + +/* Abgesetzt, weil es die einzige Handlung hier ist, die etwas beendet. */ +.profil-abmelden { + margin-top: var(--s-section); padding-top: var(--s-section); + border-top: 1px solid var(--line); +} + +@media (max-width: 860px) { + .profil-kopf { flex-direction: column; align-items: flex-start; gap: var(--s-loose); } +} + /* --- Verwaltung ---------------------------------------------------------- * * **Eine zweite Leiste, keine zweite Seite.** Die Verwaltung war elf diff --git a/web/app/src/views/profil.ts b/web/app/src/views/profil.ts new file mode 100644 index 0000000..2120641 --- /dev/null +++ b/web/app/src/views/profil.ts @@ -0,0 +1,232 @@ +import { + sitzung, meinKonto, profilbildSetzen, profilbildEntfernen, bildAdresse, + spracheSetzen, sprachen, abmelden, istAdmin, type Rolle, +} from '../api'; +import { esc, knopf, fehlerzeile, meldung, hinweisZeigen } from '../util'; +import { dialog, nachfragen } from '../dialog'; +import { icon } from '../icons'; + +/** Die eigene Seite. + * + * **Warum es sie gibt.** Ein Klick auf das eigene Bild meldete ab — ohne + * Rückfrage, ohne Menü, ohne Weg zurück. Das ist die unfreundlichste Deutung, + * die ein Bild mit dem eigenen Namen haben kann. Jetzt führt es hierher, und + * Abmelden steht als eigener Knopf am Ende, mit Rückfrage. + * + * **Und eine Einstellung wird dadurch überhaupt erst erreichbar.** „Meine + * Sprache" stand mitten in der Verwaltung; die zeigt der Client nur + * Administratoren, der Endpunkt erlaubte sie längst jedem. Wer kein Admin war, + * konnte seine Sprache nirgends ändern. + */ +export function profilseite(): HTMLElement { + const wurzel = document.createElement('div'); + wurzel.className = 'profil'; + wurzel.innerHTML = '
'; + + void laden(); + return wurzel; + + async function laden(): Promise { + let konto: Rolle; + try { + konto = await meinKonto(); + } catch (fehler) { + wurzel.innerHTML = ''; + wurzel.appendChild(fehlerzeile(meldung(fehler))); + return; + } + + // Die Sitzung mitziehen: die Kopfleiste liest aus ihr. + if (sitzung.wer) Object.assign(sitzung.wer, konto); + + wurzel.innerHTML = ''; + wurzel.append(kopf(konto), einstellungen(konto), abmeldeteil()); + } + + /* ---------- Bild und Name ---------- */ + + function kopf(konto: Rolle): HTMLElement { + const reihe = document.createElement('section'); + reihe.className = 'profil-kopf'; + + const bild = document.createElement('div'); + bild.className = 'profil-bild'; + const adresse = bildAdresse(konto.avatarImage ?? null); + if (adresse) { + const img = document.createElement('img'); + img.src = adresse; + img.alt = ''; + // Kommt es nicht an, tritt der Buchstabe an seine Stelle — dieselbe + // Vorsorge wie im Aufmacher. + img.onerror = () => { img.remove(); bild.textContent = anfangsbuchstabe(konto.userName); }; + bild.appendChild(img); + } else { + bild.textContent = anfangsbuchstabe(konto.userName); + } + + const text = document.createElement('div'); + text.className = 'profil-text'; + text.innerHTML = `

${esc(konto.userName)}

` + + `${konto.isAdministrator ? 'Administrator' : 'Benutzer'}`; + + const waehlen = document.createElement('input'); + waehlen.type = 'file'; + waehlen.accept = 'image/*'; + waehlen.className = 'hidden'; + waehlen.onchange = () => void hochladen(waehlen.files?.[0] ?? null); + + const reihen = document.createElement('div'); + reihen.className = 'knopfreihe'; + + const neu = knopf(konto.avatarImage ? 'Bild tauschen' : 'Bild wählen', 'btn'); + neu.prepend(icon('stift')); + neu.onclick = () => waehlen.click(); + reihen.appendChild(neu); + + if (konto.avatarImage) { + const weg = knopf('Entfernen', 'btn'); + weg.onclick = () => void entfernen(); + reihen.appendChild(weg); + } + + text.appendChild(reihen); + reihe.append(bild, text, waehlen); + return reihe; + } + + async function hochladen(datei: File | null): Promise { + if (!datei) return; + // **Vorher prüfen, nicht hinterher.** Fünf Megabyte sind die Grenze des + // Servers; sie hier zu kennen spart einen Upload, der mit 400 endet. + if (datei.size > 5 * 1024 * 1024) { + hinweisZeigen('Das Bild ist grösser als 5 MB. Nimm ein kleineres.'); + return; + } + try { + await profilbildSetzen(datei); + hinweisZeigen('Bild gesetzt.'); + await laden(); + kopfleisteAuffrischen(); + } catch (fehler) { + hinweisZeigen(meldung(fehler)); + } + } + + async function entfernen(): Promise { + try { + await profilbildEntfernen(); + hinweisZeigen('Bild entfernt.'); + await laden(); + kopfleisteAuffrischen(); + } catch (fehler) { + hinweisZeigen(meldung(fehler)); + } + } + + /* ---------- Einstellungen, die jedem gehören ---------- */ + + function einstellungen(konto: Rolle): HTMLElement { + const bereich = document.createElement('section'); + + const h = document.createElement('h2'); + h.textContent = 'Einstellungen'; + bereich.appendChild(h); + + const liste = document.createElement('div'); + liste.className = 'einstellungen'; + + const gewaehlt = sprachen.find(s => s.wert === konto.language); + liste.appendChild(zeile( + 'Meine Sprache', + gewaehlt?.text ?? 'Wie der Server', + 'Die Metadaten liegen in der Sprache des Servers vor. Eine andere zu ' + + 'wählen heisst: du siehst, was übersetzt vorliegt — und für den Rest ' + + 'die Serversprache.', + () => dialog({ + titel: 'Meine Sprache', + text: 'Gilt nur für dich. Was nicht übersetzt vorliegt, steht weiter in ' + + 'der Sprache des Servers.', + felder: [{ + name: 'sprache', beschriftung: 'Sprache', + auswahl: [{ wert: '', text: 'Wie der Server' }, ...sprachen], + wert: konto.language ?? '', + }], + bestaetigen: 'Übernehmen', + tun: async wert => { + await spracheSetzen(wert('sprache') || null); + location.reload(); + }, + }))); + + if (istAdmin()) { + liste.appendChild(zeile( + 'Server einstellen', + 'Mediatheken, Wiedergabe, Metadaten', + 'Was für alle gilt, steht in der Verwaltung.', + () => { location.hash = '#/verwaltung'; }, 'Öffnen')); + } + + bereich.appendChild(liste); + return bereich; + } + + /* ---------- Abmelden ---------- */ + + function abmeldeteil(): HTMLElement { + const bereich = document.createElement('section'); + bereich.className = 'profil-abmelden'; + + const k = knopf('Abmelden', 'btn btn-gefahr'); + k.prepend(icon('abmelden')); + // **Mit Rückfrage.** Vorher war der Klick aufs Bild selbst das Abmelden — + // eine Aktion ohne Weg zurück hinter einer Geste, die wie „mein Konto" + // aussieht. + k.onclick = () => nachfragen({ + titel: 'Abmelden', + text: `Du meldest dich als „${sitzung.wer?.userName ?? ''}“ von diesem Gerät ab. ` + + 'Sehstände und Favoriten bleiben auf dem Server.', + bestaetigen: 'Abmelden', + tun: async () => { abmelden(); location.reload(); }, + }); + + bereich.appendChild(k); + return bereich; + } +} + +/** Die Kopfleiste zeigt das Bild — nach einem Wechsel muss sie es neu lesen. */ +function kopfleisteAuffrischen(): void { + window.dispatchEvent(new CustomEvent('flaro:konto')); +} + +export function anfangsbuchstabe(name: string): string { + return (name.slice(0, 1) || '?').toUpperCase(); +} + +/** Dieselbe Zeile wie in der Verwaltung — hier mit frei wählbarer Beschriftung. */ +function zeile( + name: string, wert: string, hinweis: string, tun: () => void, text = 'Ändern', +): HTMLElement { + const d = document.createElement('div'); + d.className = 'einstellung'; + + const oben = document.createElement('div'); + oben.className = 'knopfreihe'; + const links = document.createElement('div'); + links.className = 'zeile'; + links.style.flex = '1'; + links.innerHTML = `${esc(name)}${esc(wert)}`; + oben.appendChild(links); + + const b = knopf(text, 'btn'); + b.prepend(icon('stift')); + b.onclick = tun; + oben.appendChild(b); + d.appendChild(oben); + + const h = document.createElement('div'); + h.className = 'hinweis'; + h.textContent = hinweis; + d.appendChild(h); + return d; +} diff --git a/web/app/src/views/verwaltung/metadaten.ts b/web/app/src/views/verwaltung/metadaten.ts index a8f8cb4..e78ce98 100644 --- a/web/app/src/views/verwaltung/metadaten.ts +++ b/web/app/src/views/verwaltung/metadaten.ts @@ -1,5 +1,5 @@ import { - sprachen, spracheSetzen, uebersetzungenHolen, meineSprache, metadatenEinstellung, + sprachen, uebersetzungenHolen, meineSprache, metadatenEinstellung, metadatenSetzen, sprachverteilung, abweichendeAuffrischen, } from '../../api'; import { knopf, fehlerzeile, meldung } from '../../util'; @@ -115,35 +115,25 @@ export function sprachbereich(): HTMLElement { const weichtAb = eigeneSprache !== null && eigeneSprache !== stand.language; const serversprache = sprachen.find(x => x.wert === stand.language)?.text ?? stand.language; - liste.appendChild(zeile( - weichtAb ? 'Meine Sprache — weicht ab' : 'Meine Sprache', - eigeneSprache ? (sprachen.find(x => x.wert === eigeneSprache)?.text ?? eigeneSprache) - : 'Wie der Server', - weichtAb - ? `Du siehst alles auf ${sprachen.find(x => x.wert === eigeneSprache)?.text ?? eigeneSprache}, ` - + `obwohl der Server auf ${serversprache} holt. Titel ohne Übersetzung bleiben ` - + `auf ${serversprache}. Auf „Wie der Server" stellen, um zu sehen, was wirklich ` - + 'gespeichert ist.' - : 'Gilt nur für dich. Was nicht übersetzt vorliegt, erscheint weiterhin in ' - + 'der Sprache des Servers.', - () => dialog({ - titel: 'Meine Sprache', - text: 'Die Metadaten liegen in der Sprache des Servers vor. Eine andere ' - + 'zu wählen heisst: du siehst, was übersetzt vorliegt — und für den ' - + 'Rest die Serversprache. Ein Administrator kann die Übersetzungen ' - + 'unten für alle Titel auf einmal holen.', - felder: [{ - name: 'sprache', beschriftung: 'Sprache', - auswahl: [{ wert: '', text: 'Wie der Server' }, ...sprachen], - wert: eigeneSprache ?? '', - }], - bestaetigen: 'Übernehmen', - tun: async wert => { - const neu = wert('sprache'); - await spracheSetzen(neu || null); - location.reload(); - }, - }))); + // **Geändert wird sie im Profil, nicht hier.** + // + // Sie gehört einem Menschen, nicht dem Server — und sie stand in einem + // Bereich, den der Client nur Administratoren zeigt. Wer kein Admin war, + // konnte seine eigene Sprache nirgends umstellen, obwohl der Endpunkt es + // längst erlaubte. + // + // Der Hinweis bleibt trotzdem hier: Die Verwirrung entsteht an dieser + // Stelle. Wer die Mediathek englisch vorfindet und hier die Serversprache + // auf Deutsch stehen sieht, sucht den Fehler sonst beim Auffrischen. + if (weichtAb) { + liste.appendChild(zeile( + 'Deine Sprache weicht ab', + sprachen.find(x => x.wert === eigeneSprache)?.text ?? eigeneSprache!, + `Du siehst alles auf ${sprachen.find(x => x.wert === eigeneSprache)?.text ?? eigeneSprache}, ` + + `obwohl der Server auf ${serversprache} holt. Titel ohne Übersetzung bleiben ` + + `auf ${serversprache}. Umstellen kannst du das in deinem Profil.`, + () => { location.hash = '#/profil'; })); + } liste.appendChild(zeile( 'Übersetzungen holen',