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',