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:
co-authored by
Claude Opus 5
parent
9622da0284
commit
d8a1633cf4
@@ -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;
|
||||
|
||||
@@ -82,7 +82,16 @@ public sealed record LoginRequest(
|
||||
public sealed record AuthenticatedUser(
|
||||
Guid Id, string UserName, bool IsAdministrator,
|
||||
/// <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);
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -30,5 +30,19 @@ public sealed class User
|
||||
/// </remarks>
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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.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<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()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AvatarImage")
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("CreatedAt")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@@ -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.
|
||||
///
|
||||
/// <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>
|
||||
public async Task<string?> 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");
|
||||
}
|
||||
|
||||
/// <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>
|
||||
///
|
||||
/// <remarks>
|
||||
|
||||
Reference in New Issue
Block a user