Das eigene Bild führte aus dem Server heraus

Ein Klick auf den Buchstabenkreis meldete ab. Keine Rückfrage, kein Menü,
kein Weg zurück — hinter einer Geste, die überall sonst „mein Konto"
heisst. Jetzt führt er auf `#/profil`; Abmelden steht dort als Knopf, mit
Nachfrage.

Auf der Seite: Bild wählen, tauschen, entfernen. Dazu „Meine Sprache" —
**umgezogen, nicht kopiert.** Sie stand mitten in der Verwaltung, und die
zeigt der Client nur Administratoren; der Endpunkt erlaubte sie längst
jedem. Wer kein Admin war, konnte seine eigene Sprache nirgends ändern.
Der Warnhinweis „weicht ab" bleibt in der Verwaltung, denn dort entsteht
die Verwirrung — er verweist jetzt aufs Profil.

**Vorher musste ein Fehler weg, der seit Monaten dasteht.**
`ImageCache.StoreStreamAsync` baute den Schlüssel aus 32 Hexzeichen plus
Dateiendung; `PfadFür` verlangt genau 64 Hexzeichen und nichts sonst. Es
gab keinen Wert, den beide gelten liessen: **jeder Bildupload gab `null`
zurück und endete in einem 502.** Kein Test fragte je danach — jetzt vier.
Damit geht nebenbei auch „eigenes Bild hochladen" für Filme und Serien
wieder.

Weil der Schlüssel keine Endung mehr trägt, kommt der Medientyp beim
Ausliefern aus den ersten Bytes der Datei. Vorher ging alles als
`image/jpeg` hinaus — bei TMDB stimmte das, bei einem hochgeladenen PNG
nicht.

**Und die gemerkte Sitzung war eine Kopie, die nie nachzog.** Sie wurde
bei der Anmeldung geschrieben und danach nie wieder. Wer sein Bild am
Telefon setzt und den Browser am Schreibtisch öffnet, fand dort den alten
Buchstaben — bis zum nächsten Anmelden. Gemessen und behoben: beim
Hochfahren wird das Konto abgeglichen, und das Profil meldet Änderungen
an die Kopfleiste, damit sie ohne Neuladen nachzieht.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-10-06 17:07:40 +02:00
co-authored by Claude Opus 5
parent 9622da0284
commit d8a1633cf4
18 changed files with 1757 additions and 57 deletions
@@ -77,14 +77,22 @@ public static class AuthEndpoints
return Results.NoContent(); return Results.NoContent();
}).AllowAnonymous(); }).AllowAnonymous();
app.MapGet("/api/v1/auth/me", (ClaimsPrincipal wer) => // **Aus der Datenbank, nicht nur aus dem Token.** Name und Rolle stehen
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) // in den Ansprüchen; Sprache und Bild ändern sich, ohne dass ein neues
? Results.Ok(new AuthenticatedUser( // Token ausgestellt wird. Wer sein Bild tauscht und die Seite neu lädt,
id, // sähe sonst das alte, bis er sich neu anmeldet.
wer.FindFirstValue(ClaimTypes.Name) ?? "", app.MapGet("/api/v1/auth/me", async (ClaimsPrincipal wer, FlaroContext db) =>
wer.IsInRole(FlaroRoles.Administrator))) {
: Results.Unauthorized()) if (!Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id))
.RequireAuthorization(); 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); MapCodeFlow(app);
} }
@@ -191,7 +199,8 @@ public static class AuthEndpoints
}); });
return new LoginResponse(token, new AuthenticatedUser( 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; private const string FlaroTokenCookie = TokenAuthenticationHandler.CookieName;
+10 -1
View File
@@ -82,7 +82,16 @@ public sealed record LoginRequest(
public sealed record AuthenticatedUser( public sealed record AuthenticatedUser(
Guid Id, string UserName, bool IsAdministrator, Guid Id, string UserName, bool IsAdministrator,
/// <summary>Die bevorzugte Sprache — <c>null</c> heisst: die des Servers.</summary> /// <summary>Die bevorzugte Sprache — <c>null</c> heisst: die des Servers.</summary>
string? Language = null); string? Language = null,
/// <summary>Das eigene Bild, als Schlüssel in die Bildablage.</summary>
///
/// <remarks>
/// <b>Hier und nicht in einer eigenen Abfrage.</b> 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.
/// </remarks>
string? AvatarImage = null);
public sealed record LoginResponse(string Token, AuthenticatedUser User); public sealed record LoginResponse(string Token, AuthenticatedUser User);
@@ -18,7 +18,7 @@ public static class ImageEndpoints
// Der Schlüssel ist der Hash des Inhalts, also ändert sich das Bild // Der Schlüssel ist der Hash des Inhalts, also ändert sich das Bild
// unter dieser Adresse nie. Ein Jahr zwischenspeichern spart bei // unter dieser Adresse nie. Ein Jahr zwischenspeichern spart bei
// einer Mediathek mit tausend Plakaten das meiste an Verkehr. // 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() .RequireAuthorization()
.AddEndpointFilter(async (zusammenhang, weiter) => .AddEndpointFilter(async (zusammenhang, weiter) =>
@@ -0,0 +1,100 @@
using System.Security.Claims;
using Flaro.Core.Metadata;
using Flaro.Data;
using Microsoft.EntityFrameworkCore;
namespace Flaro.Api.Endpoints;
/// <summary>Das eigene Konto — für jeden, nicht nur für Administratoren.</summary>
///
/// <remarks>
/// <para>
/// <b>Eine eigene Gruppe, weil die Rechte andere sind.</b> Alles unter
/// <c>/api/v1/users</c> war bisher Verwaltung und damit Administratoren
/// vorbehalten. Hier geht es um das eigene Konto: wer angemeldet ist, darf
/// sein Bild tauschen, und niemand darf das eines anderen anfassen. Die
/// Kennung kommt deshalb aus dem Token und nie aus der Adresse.
/// </para>
/// <para>
/// <b>Es war bisher nicht erreichbar.</b> „Meine Sprache" stand mitten in der
/// Verwaltung, und die zeigt der Client nur Administratoren — der Endpunkt
/// erlaubte es längst jedem. Wer kein Admin war, konnte seine Sprache also
/// nirgends ändern.
/// </para>
/// </remarks>
public static class ProfilEndpoints
{
/// <summary>Höchstens so gross darf ein Profilbild sein.</summary>
///
/// <remarks>
/// Fünf Megabyte sind für ein Bild von 200 Pixeln reichlich. Dieselbe
/// Grenze wie bei Plakaten wäre hier zu grosszügig: ein Plakat sieht man
/// gross, einen Kreis von 28 Pixeln nicht.
/// </remarks>
private const long HoechstensBytes = 5 * 1024 * 1024;
public static void MapProfil(this IEndpointRouteBuilder app)
{
var gruppe = app.MapGroup("/api/v1/users/me").RequireAuthorization();
gruppe.MapGet("", async (ClaimsPrincipal wer, FlaroContext db, CancellationToken ct) =>
{
if (Kennung(wer) is not { } id) return Results.Unauthorized();
var benutzer = await db.Users.AsNoTracking().FirstOrDefaultAsync(u => u.Id == id, ct);
return benutzer is null
? Results.Unauthorized()
: Results.Ok(new AuthenticatedUser(
benutzer.Id, benutzer.UserName, benutzer.IsAdministrator,
benutzer.Language, benutzer.AvatarImage));
});
gruppe.MapPost("/avatar", async (
HttpRequest anfrage, ClaimsPrincipal wer, FlaroContext db,
IImageStore ablage, CancellationToken ct) =>
{
if (Kennung(wer) is not { } id) return Results.Unauthorized();
if (!anfrage.HasFormContentType)
return 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();
});
}
/// <summary>Wer anfragt — aus dem Token, nie aus der Adresse.</summary>
private static Guid? Kennung(ClaimsPrincipal wer) =>
Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var id) ? id : null;
}
+1
View File
@@ -269,6 +269,7 @@ app.MapStream();
app.MapPlayback(); app.MapPlayback();
app.MapMatching(); app.MapMatching();
app.MapArtwork(); app.MapArtwork();
app.MapProfil();
app.MapFreigabe(); app.MapFreigabe();
// **Was unter /api nicht beantwortet wurde, ist nicht da.** Ohne diese Zeile // **Was unter /api nicht beantwortet wurde, ist nicht da.** Ohne diese Zeile
+14
View File
@@ -30,5 +30,19 @@ public sealed class User
/// </remarks> /// </remarks>
public string? Language { get; set; } public string? Language { get; set; }
/// <summary>Das eigene Bild — der Schlüssel aus derselben Ablage wie Plakate.</summary>
///
/// <remarks>
/// <b>Kein eigener Speicher für Profilbilder.</b> 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.
///
/// <c>null</c> heisst: der Buchstabenkreis. Der ist keine Notlösung,
/// sondern eine gültige Antwort — niemand muss ein Bild haben.
/// </remarks>
public string? AvatarImage { get; set; }
public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow; public DateTimeOffset CreatedAt { get; init; } = DateTimeOffset.UtcNow;
} }
+2
View File
@@ -95,6 +95,8 @@ public sealed class FlaroContext(DbContextOptions<FlaroContext> options) : DbCon
e.Property(x => x.UserName).HasMaxLength(64).UseCollation("NOCASE"); e.Property(x => x.UserName).HasMaxLength(64).UseCollation("NOCASE");
e.Property(x => x.Language).HasMaxLength(16); e.Property(x => x.Language).HasMaxLength(16);
e.Property(x => x.PasswordHash).HasMaxLength(256); 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<DeviceToken>(e => b.Entity<DeviceToken>(e =>
@@ -0,0 +1,937 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("ApprovedByUserId")
.HasColumnType("TEXT");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<long?>("ConsumedAt")
.HasColumnType("INTEGER");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("ExpiresAt")
.HasColumnType("INTEGER");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("DeviceId")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("DeviceName")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<long>("LastSeenAt")
.HasColumnType("INTEGER");
b.Property<long?>("RevokedAt")
.HasColumnType("INTEGER");
b.Property<string>("TokenFingerprint")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<bool>("ReviewNewItems")
.HasColumnType("INTEGER");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.ToTable("Libraries");
});
modelBuilder.Entity("Flaro.Core.Library.Credit", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Character")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("Order")
.HasColumnType("INTEGER");
b.Property<Guid>("PersonId")
.HasColumnType("TEXT");
b.Property<string>("Role")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<int>("EpisodeNumber")
.HasColumnType("INTEGER");
b.Property<string>("MetadataLanguage")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<int>("SeasonNumber")
.HasColumnType("INTEGER");
b.Property<Guid>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("StillImage")
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("GenreId")
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<Guid?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<long>("FetchedAt")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long?>("BitRate")
.HasColumnType("INTEGER");
b.Property<string>("Container")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<long?>("Duration")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<long?>("MissingSince")
.HasColumnType("INTEGER");
b.Property<long>("ModifiedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<string>("Path")
.IsRequired()
.HasMaxLength(1024)
.HasColumnType("TEXT");
b.Property<long?>("ProbedAt")
.HasColumnType("INTEGER");
b.Property<long>("SeenAt")
.HasColumnType("INTEGER");
b.Property<long>("SizeBytes")
.HasColumnType("INTEGER");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int?>("Channels")
.HasColumnType("INTEGER");
b.Property<string>("Codec")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int?>("Height")
.HasColumnType("INTEGER");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsDefault")
.HasColumnType("INTEGER");
b.Property<bool>("IsForced")
.HasColumnType("INTEGER");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<Guid>("MediaFileId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int?>("Width")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("MediaFileId", "Index")
.IsUnique();
b.ToTable("MediaStreams");
});
modelBuilder.Entity("Flaro.Core.Library.Movie", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<bool>("BackdropPinned")
.HasColumnType("INTEGER");
b.Property<long?>("ChangedSince")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("LogoPinned")
.HasColumnType("INTEGER");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("MetadataLanguage")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("OriginalTitle")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<bool>("PosterPinned")
.HasColumnType("INTEGER");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<bool>("SortTitlePinned")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<bool>("TitlePinned")
.HasColumnType("INTEGER");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Image")
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<int>("TmdbId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("TmdbId")
.IsUnique();
b.ToTable("People");
});
modelBuilder.Entity("Flaro.Core.Library.Series", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<long>("AddedAt")
.HasColumnType("INTEGER");
b.Property<string>("BackdropImage")
.HasColumnType("TEXT");
b.Property<bool>("BackdropPinned")
.HasColumnType("INTEGER");
b.Property<long?>("ChangedSince")
.HasColumnType("INTEGER");
b.Property<string>("ContentRating")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("ImdbId")
.HasMaxLength(32)
.HasColumnType("TEXT");
b.Property<Guid>("LibraryId")
.HasColumnType("TEXT");
b.Property<string>("LogoImage")
.HasColumnType("TEXT");
b.Property<bool>("LogoPinned")
.HasColumnType("INTEGER");
b.Property<bool>("MatchLocked")
.HasColumnType("INTEGER");
b.Property<string>("MetadataLanguage")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("OriginalTitle")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Overview")
.HasColumnType("TEXT");
b.Property<string>("ParsedTitle")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int?>("ParsedYear")
.HasColumnType("INTEGER");
b.Property<string>("PosterImage")
.HasColumnType("TEXT");
b.Property<bool>("PosterPinned")
.HasColumnType("INTEGER");
b.Property<long?>("PublishAt")
.HasColumnType("INTEGER");
b.Property<double?>("Rating")
.HasColumnType("REAL");
b.Property<int?>("RuntimeMinutes")
.HasColumnType("INTEGER");
b.Property<bool>("SortTitlePinned")
.HasColumnType("INTEGER");
b.Property<string>("SortTitleValue")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Tagline")
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(512)
.HasColumnType("TEXT");
b.Property<bool>("TitlePinned")
.HasColumnType("INTEGER");
b.Property<int?>("TmdbId")
.HasColumnType("INTEGER");
b.Property<int?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("Dismissed")
.HasColumnType("INTEGER");
b.Property<Guid?>("EpisodeId")
.HasColumnType("TEXT");
b.Property<bool>("Favourite")
.HasColumnType("INTEGER");
b.Property<bool>("InProgress")
.HasColumnType("INTEGER");
b.Property<long?>("LastPlayedAt")
.HasColumnType("INTEGER");
b.Property<Guid?>("MovieId")
.HasColumnType("TEXT");
b.Property<int>("PlayCount")
.HasColumnType("INTEGER");
b.Property<long>("Position")
.HasColumnType("INTEGER");
b.Property<int?>("Rating")
.HasColumnType("INTEGER");
b.Property<long?>("Runtime")
.HasColumnType("INTEGER");
b.Property<Guid?>("SeriesId")
.HasColumnType("TEXT");
b.Property<long>("UpdatedAt")
.HasColumnType("INTEGER");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.Property<bool>("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<string>("Key")
.HasMaxLength(128)
.HasColumnType("TEXT");
b.Property<string>("Value")
.IsRequired()
.HasMaxLength(4096)
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("Settings");
});
modelBuilder.Entity("Flaro.Core.Users.User", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AvatarImage")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt")
.HasColumnType("INTEGER");
b.Property<bool>("IsAdministrator")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasMaxLength(256)
.HasColumnType("TEXT");
b.Property<string>("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
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Flaro.Data.Migrations
{
/// <inheritdoc />
public partial class Profilbild : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "AvatarImage",
table: "Users",
type: "TEXT",
maxLength: 64,
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "AvatarImage",
table: "Users");
}
}
}
@@ -780,6 +780,10 @@ namespace Flaro.Data.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("AvatarImage")
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<long>("CreatedAt") b.Property<long>("CreatedAt")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
+40 -8
View File
@@ -40,6 +40,16 @@ public sealed class ImageCache(
/// ///
/// Der Schlüssel kommt aus dem Inhalt, nicht aus einem Zufallswert: /// Der Schlüssel kommt aus dem Inhalt, nicht aus einem Zufallswert:
/// dasselbe Bild zweimal hochzuladen ergibt dieselbe Datei. /// dasselbe Bild zweimal hochzuladen ergibt dieselbe Datei.
///
/// <b>Der ganze Hash, ohne Dateiendung.</b> Hier standen einmal die ersten
/// 32 Zeichen plus „.jpg" — und <see cref="PfadFür"/> nimmt genau 64
/// Hexzeichen und nichts sonst. Es gab also keinen Schlüssel, den beide
/// gelten liessen: jeder Upload endete in <c>null</c> 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
/// (<see cref="Medientyp"/>) und nicht aus ihrem Namen.
/// </remarks> /// </remarks>
public async Task<string?> StoreStreamAsync( public async Task<string?> StoreStreamAsync(
Stream inhalt, string? medientyp, CancellationToken ct = default) 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; if (zwischen.Length == 0 || zwischen.Length > HöchstensBytes) return null;
var daten = zwischen.ToArray(); var daten = zwischen.ToArray();
var endung = medientyp switch _ = medientyp;
{
"image/png" => ".png",
"image/webp" => ".webp",
_ => ".jpg",
};
var schlüssel = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(daten))[..32] var schlüssel = Convert.ToHexStringLower(System.Security.Cryptography.SHA256.HashData(daten));
+ endung;
var ziel = PfadFür(schlüssel); var ziel = PfadFür(schlüssel);
if (ziel is null) return null; if (ziel is null) return null;
if (File.Exists(ziel)) return schlüssel; 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"); return Path.Combine(optionen.Root, "images", schlüssel[..2], schlüssel + ".img");
} }
/// <summary>Was in der Datei steht — aus ihren ersten Bytes, nicht aus dem Namen.</summary>
///
/// <remarks>
/// <b>Weil der Schlüssel keine Endung trägt.</b> Alles als
/// <c>image/jpeg</c> 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.
/// </remarks>
public static string Medientyp(ReadOnlySpan<byte> 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",
};
/// <summary>Dasselbe für eine Datei auf der Platte.</summary>
public static string MedientypVon(string pfad)
{
Span<byte> anfang = stackalloc byte[12];
using var strom = File.OpenRead(pfad);
var gelesen = strom.ReadAtLeast(anfang, anfang.Length, throwOnEndOfStream: false);
return Medientyp(anfang[..gelesen]);
}
/// <summary>Gibt ein abgelegtes Bild zum Lesen heraus.</summary> /// <summary>Gibt ein abgelegtes Bild zum Lesen heraus.</summary>
/// ///
/// <remarks> /// <remarks>
+170
View File
@@ -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;
/// <summary>Das eigene Konto — und wer daran darf.</summary>
[TestFixture]
public sealed class ProfilTests
{
/// <summary>Ein PNG, das nur ein PNG sein muss.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
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 } };
}
/// <summary>Hochladen, abrufen, entfernen — der ganze Weg.</summary>
///
/// <remarks>
/// <b>Dieser Weg war kaputt, und niemand merkte es.</b> 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.
/// </remarks>
[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<Bildantwort>();
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<AuthenticatedUser>("/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<AuthenticatedUser>("/api/v1/users/me");
Assert.Multiple(() =>
{
Assert.That(entfernt.StatusCode, Is.EqualTo(HttpStatusCode.NoContent));
Assert.That(danach!.AvatarImage, Is.Null);
});
}
/// <summary>Das Bild steht nach dem Anmelden sofort zur Verfügung.</summary>
///
/// <remarks>
/// Die Kopfleiste zeigt es auf jeder Seite. Käme es erst mit einer zweiten
/// Anfrage, blitzte bei jedem Laden kurz der Buchstabenkreis auf.
/// </remarks>
[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<AuthenticatedUser>("/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");
});
}
/// <summary>Wer kein Administrator ist, darf sein eigenes Konto trotzdem pflegen.</summary>
///
/// <remarks>
/// <b>Der Anlass.</b> „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.
/// </remarks>
[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<AuthenticatedUser>("/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));
});
}
/// <summary>Das Bild des einen ist nicht das des anderen.</summary>
///
/// <remarks>
/// 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.
/// </remarks>
[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<AuthenticatedUser>("/api/v1/users/me");
var anderes = await gast.GetFromJsonAsync<AuthenticatedUser>("/api/v1/users/me");
Assert.That(eines!.AvatarImage, Is.Not.EqualTo(anderes!.AvatarImage));
}
/// <summary>Eine zu grosse Datei wird abgelehnt, nicht abgelegt.</summary>
[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);
}
@@ -152,4 +152,72 @@ public class ImageCacheTests
public override void SetLength(long v) => throw new NotSupportedException(); public override void SetLength(long v) => throw new NotSupportedException();
public override void Write(byte[] p, int v, int a) => throw new NotSupportedException(); public override void Write(byte[] p, int v, int a) => throw new NotSupportedException();
} }
/* ---------- Hochgeladene Bilder ---------- */
/// <summary>Ein hochgeladenes Bild landet und ist danach abrufbar.</summary>
///
/// <remarks>
/// <b>Dieser Weg war monatelang kaputt und niemand prüfte ihn.</b>
/// <c>StoreStreamAsync</c> baute einen Schlüssel aus 32 Hexzeichen plus
/// Dateiendung, <c>PfadFür</c> verlangt genau 64 Hexzeichen — es gab
/// keinen Wert, den beide gelten liessen. Jeder Upload gab <c>null</c>
/// zurück, und der Endpunkt antwortete 502.
/// </remarks>
[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);
});
}
/// <summary>Derselbe Inhalt ergibt dieselbe Datei.</summary>
[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));
}
/// <summary>Leeres wird abgelehnt, nicht abgelegt.</summary>
[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);
}
/// <summary>Der Medientyp kommt aus dem Inhalt, nicht aus dem Namen.</summary>
///
/// <remarks>
/// Der Schlüssel trägt keine Endung — ohne diese Erkennung ginge jedes
/// Bild als <c>image/jpeg</c> hinaus, auch ein PNG.
/// </remarks>
[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");
});
}
} }
+38
View File
@@ -7,6 +7,8 @@ export type Rolle = {
id: string; userName: string; isAdministrator: boolean; id: string; userName: string; isAdministrator: boolean;
/** Die bevorzugte Sprache — `null` heisst: die des Servers. */ /** Die bevorzugte Sprache — `null` heisst: die des Servers. */
language?: string | null; language?: string | null;
/** Das eigene Bild, als Schlüssel in die Bildablage. `null`: Buchstabenkreis. */
avatarImage?: string | null;
}; };
export type Spur = { export type Spur = {
index: number; kind: 'Video' | 'Audio' | 'Subtitle'; 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. */ /** Die eingestellte Sprache dieses Benutzers — `null` heisst: die des Servers. */
export const meineSprache = (): string | null => sitzung.wer?.language ?? null; export const meineSprache = (): string | null => sitzung.wer?.language ?? null;
/** Das eigene Konto, frisch vom Server. */
export const meinKonto = () => anfrage<Rolle>('/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<void> {
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<string> {
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<void> {
await anfrage<void>('/users/me/avatar', { method: 'DELETE' });
if (sitzung.wer) { sitzung.wer.avatarImage = null; merken(); }
}
/* ---------- Anfragen ---------- */ /* ---------- Anfragen ---------- */
/** Ein deutscher Satz zu einem Status, den der Server nicht erklärt hat. /** Ein deutscher Satz zu einem Status, den der Server nicht erklärt hat.
+32 -6
View File
@@ -1,10 +1,10 @@
import './style.css'; import './style.css';
import { import {
systemauskunft, angemeldet, istAdmin, abmelden, sitzung, bibliotheken, systemauskunft, angemeldet, istAdmin, sitzung, bibliotheken,
reihenfolgeSetzen, reihenfolgeSetzen, bildAdresse, kontoAbgleichen,
type Hinweis, type Bibliothek, type Hinweis, type Bibliothek,
} from './api'; } from './api';
import { icons, icon } from './icons'; import { icons } from './icons';
import { einrichtung } from './views/setup'; import { einrichtung } from './views/setup';
import { einhaengen, ablegen, vergessen } from './ansichten'; import { einhaengen, ablegen, vergessen } from './ansichten';
import { anmeldung } from './views/login'; import { anmeldung } from './views/login';
@@ -13,6 +13,7 @@ import { mediathek } from './views/library';
import { detailseite } from './views/detail'; import { detailseite } from './views/detail';
import { verwaltung, alsBereich } from './views/verwaltung'; import { verwaltung, alsBereich } from './views/verwaltung';
import { suchseite } from './views/search'; import { suchseite } from './views/search';
import { profilseite } from './views/profil';
import { esc, meldung } from './util'; import { esc, meldung } from './util';
const anmeldeflaeche = document.getElementById('login')!; const anmeldeflaeche = document.getElementById('login')!;
@@ -45,10 +46,18 @@ async function hochfahren(): Promise<void> {
// **Vor dem ersten Zeichnen.** Die Seitenleiste führt jede Mediathek // **Vor dem ersten Zeichnen.** Die Seitenleiste führt jede Mediathek
// einzeln auf; ohne die Liste könnte der Router `#/m/{id}` nicht // einzeln auf; ohne die Liste könnte der Router `#/m/{id}` nicht
// auflösen. // auflösen.
// **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(); mediatheken = await bibliotheken();
})()]);
kopfleisteBauen(); kopfleisteBauen();
leisteBauen(); 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); window.addEventListener('hashchange', zeichnen);
zeichnen(); zeichnen();
} catch (fehler) { } catch (fehler) {
@@ -144,13 +153,27 @@ function kopfleisteBauen(): void {
const rechts = document.createElement('div'); const rechts = document.createElement('div');
rechts.className = 'rechts'; 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'); const konto = document.createElement('button');
konto.className = 'konto'; konto.className = 'konto';
const name = sitzung.wer?.userName ?? ''; const name = sitzung.wer?.userName ?? '';
const bild = bildAdresse(sitzung.wer?.avatarImage ?? null);
if (bild) {
konto.innerHTML = `<img class="kreis" src="${bild}" alt="">`;
// Kommt es nicht an, tritt der Buchstabe an seine Stelle.
konto.querySelector('img')!.onerror = () => {
konto.innerHTML = `<span class="kreis">${esc(name.slice(0, 1).toUpperCase())}</span>`; konto.innerHTML = `<span class="kreis">${esc(name.slice(0, 1).toUpperCase())}</span>`;
konto.title = `${name} — abmelden`; };
konto.appendChild(icon('abmelden')); } else {
konto.onclick = () => { abmelden(); location.reload(); }; konto.innerHTML = `<span class="kreis">${esc(name.slice(0, 1).toUpperCase())}</span>`;
}
konto.title = `${name} — mein Profil`;
konto.setAttribute('aria-label', `${name} — mein Profil`);
konto.onclick = () => { location.hash = '#/profil'; };
rechts.appendChild(konto); rechts.appendChild(konto);
kopfleiste.append(marke, suche, rechts); kopfleiste.append(marke, suche, rechts);
@@ -400,6 +423,9 @@ function zeichnen(): void {
} else if (teile[0] === 'suche' && kennung) { } else if (teile[0] === 'suche' && kennung) {
einhaengen(weg, inhalt, () => suchseite(decodeURIComponent(kennung), oeffnen), einhaengen(weg, inhalt, () => suchseite(decodeURIComponent(kennung), oeffnen),
{ merken: true }); { 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()) { } else if (teile[0] === 'verwaltung' && istAdmin()) {
// **Die Verwaltung auch nicht** — sie zeigt Laufendes. Ihr Takt hängt am // **Die Verwaltung auch nicht** — sie zeigt Laufendes. Ihr Takt hängt am
// Signal und hört auf, sobald man weggeht. // Signal und hört auf, sobald man weggeht.
+39
View File
@@ -147,6 +147,9 @@ svg { width: 20px; height: 20px; display: block; flex: none; }
background: var(--accent); color: #fff; background: var(--accent); color: #fff;
display: grid; place-items: center; font-size: 13px; font-weight: 600; 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 ---------- */ /* ---------- Grundgerüst ---------- */
@@ -1226,6 +1229,42 @@ body:has(.pl.pl-klein) .auswahlleiste { bottom: 92px; }
.hero-bald { font-size: 12px; padding: 0 var(--s-snug); } .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 ---------------------------------------------------------- /* --- Verwaltung ----------------------------------------------------------
* *
* **Eine zweite Leiste, keine zweite Seite.** Die Verwaltung war elf * **Eine zweite Leiste, keine zweite Seite.** Die Verwaltung war elf
+232
View File
@@ -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 = '<div class="spinner"></div>';
void laden();
return wurzel;
async function laden(): Promise<void> {
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 = `<h1>${esc(konto.userName)}</h1>`
+ `<span class="hinweis">${konto.isAdministrator ? 'Administrator' : 'Benutzer'}</span>`;
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<void> {
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<void> {
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 = `<strong>${esc(name)}</strong><span class="winzig">${esc(wert)}</span>`;
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;
}
+18 -28
View File
@@ -1,5 +1,5 @@
import { import {
sprachen, spracheSetzen, uebersetzungenHolen, meineSprache, metadatenEinstellung, sprachen, uebersetzungenHolen, meineSprache, metadatenEinstellung,
metadatenSetzen, sprachverteilung, abweichendeAuffrischen, metadatenSetzen, sprachverteilung, abweichendeAuffrischen,
} from '../../api'; } from '../../api';
import { knopf, fehlerzeile, meldung } from '../../util'; import { knopf, fehlerzeile, meldung } from '../../util';
@@ -115,35 +115,25 @@ export function sprachbereich(): HTMLElement {
const weichtAb = eigeneSprache !== null && eigeneSprache !== stand.language; const weichtAb = eigeneSprache !== null && eigeneSprache !== stand.language;
const serversprache = sprachen.find(x => x.wert === stand.language)?.text ?? stand.language; const serversprache = sprachen.find(x => x.wert === stand.language)?.text ?? stand.language;
// **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( liste.appendChild(zeile(
weichtAb ? 'Meine Sprache — weicht ab' : 'Meine Sprache', 'Deine Sprache weicht ab',
eigeneSprache ? (sprachen.find(x => x.wert === eigeneSprache)?.text ?? eigeneSprache) sprachen.find(x => x.wert === eigeneSprache)?.text ?? eigeneSprache!,
: 'Wie der Server', `Du siehst alles auf ${sprachen.find(x => x.wert === eigeneSprache)?.text ?? eigeneSprache}, `
weichtAb
? `Du siehst alles auf ${sprachen.find(x => x.wert === eigeneSprache)?.text ?? eigeneSprache}, `
+ `obwohl der Server auf ${serversprache} holt. Titel ohne Übersetzung bleiben ` + `obwohl der Server auf ${serversprache} holt. Titel ohne Übersetzung bleiben `
+ `auf ${serversprache}. Auf „Wie der Server" stellen, um zu sehen, was wirklich ` + `auf ${serversprache}. Umstellen kannst du das in deinem Profil.`,
+ 'gespeichert ist.' () => { location.hash = '#/profil'; }));
: '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();
},
})));
liste.appendChild(zeile( liste.appendChild(zeile(
'Übersetzungen holen', 'Übersetzungen holen',