Halo heisst ab hier Flaro — die Marke stand im Weg
Der Upload zu App Store Connect scheiterte am Namen. „Halo" ist eine eingetragene Marke von Microsoft; im App Store heisst zwar keine App exakt so, aber reservierte und markenrechtlich belegte Namen tauchen in keiner Suche auf. Der einzige verlässliche Test war die Eingabe dort, und die ging daneben. **Der Name wurde diesmal vorher geprüft.** Zwanzig Kandidaten gegen den deutschen und amerikanischen App Store, auf exakte Treffer und auf ähnliche Namen. „Kavo" wäre dieselbe Wand gewesen — exakt belegt in beiden Stores, in Deutschland von KaVo Dental GmbH. „Flare" ist mit 42 ähnlichen Apps je Store der am dichtesten besetzte Name der ganzen Runde. **Flaro** ist in beiden frei und liest sich als eigenes Wort, nicht als Flare mit Tippfehler. Das bleibt eine Store-Suche und keine Markenrecherche — reservierte Namen ohne veröffentlichte App sieht sie nicht. Aber es ist mehr Prüfung, als beim letzten Mal stattfand. --- 2160 Textvorkommen in 273 Dateien, 49 Pfade. Betroffen ist diesmal auch der Server: `Flaro.Api`, `Flaro.Core`, `Flaro.Data`, `Flaro.Media`, `Flaro.Metadata`, `FlaroContext`, `FlaroPaths`, `flaro.db`, die Variablen `FLARO_DATA`, `FLARO_CACHE`, `FLARO_TRANSCODE`, `FLARO_WRITE_SIDECARS`, und das Image. **Vor dem Ersetzen geprüft**, ob „halo" irgendwo als Teil eines anderen Wortes steckt — und ob es im TMDB-Token vorkommt. Tat es nicht; ein blindes Ersetzen hätte den sonst zerlegt. `docs/superpowers/specs/` bleibt wie beim letzten Mal unangetastet: datierte Entwurfsdokumente sind ein Protokoll getroffener Entscheidungen. Das Server-Dokument hat stattdessen einen Nachtrag bekommen, damit es niemanden in die Irre führt. **Die Bundle-ID wandert mit**, zum zweiten Mal an einem Tag: `com.scarriffleservices.flaro`. Wer die vorige Fassung installiert hat, hat jetzt zwei Karteileichen auf dem Gerät — beide löschen, dann ist Ruhe. 308 Server-Tests grün, 368 im Swift-Paket, null Warnungen. iOS und tvOS bauen, das Image baut, und ein frischer Container läuft durch Einrichtung und Anmeldung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f98b995050
commit
a7cf6c5def
@@ -0,0 +1,190 @@
|
||||
using System.Security.Claims;
|
||||
using Flaro.Api.Auth;
|
||||
using Flaro.Core.Auth;
|
||||
using Flaro.Core.Users;
|
||||
using Flaro.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api.Endpoints;
|
||||
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
/// <summary>Ein Hash, gegen den geprüft wird, wenn es den Benutzer gar nicht gibt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Gegen das Abfragen von Benutzernamen über die Uhr.</b> Ohne ihn
|
||||
/// antwortet der Server bei einem unbekannten Namen sofort und bei einem
|
||||
/// bekannten erst nach dem Argon2-Lauf — vierzig Millisekunden Unterschied,
|
||||
/// und damit lässt sich eine Liste gültiger Konten zusammentragen.
|
||||
/// </remarks>
|
||||
private static readonly Lazy<string> Strohmann =
|
||||
new(() => PasswordHasher.Hash(Secrets.Create()), LazyThreadSafetyMode.ExecutionAndPublication);
|
||||
|
||||
public static void MapAuth(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPost("/api/v1/auth/login", async (
|
||||
LoginRequest anfrage, FlaroContext db, TimeProvider clock) =>
|
||||
{
|
||||
var name = anfrage.UserName.Trim();
|
||||
var benutzer = await db.Users.FirstOrDefaultAsync(u => u.UserName == name);
|
||||
|
||||
if (benutzer is null)
|
||||
{
|
||||
// Dieselbe Arbeit wie bei einem echten Konto, dann ablehnen.
|
||||
PasswordHasher.Verify(anfrage.Password, Strohmann.Value);
|
||||
return Abgelehnt();
|
||||
}
|
||||
|
||||
if (!PasswordHasher.Verify(anfrage.Password, benutzer.PasswordHash)) return Abgelehnt();
|
||||
|
||||
// Gelegenheit genutzt: hier liegt das Passwort im Klartext vor, und
|
||||
// nur hier lässt sich ein nach alten Kennwerten gebildeter Hash
|
||||
// überhaupt erneuern.
|
||||
if (PasswordHasher.NeedsRehash(benutzer.PasswordHash))
|
||||
benutzer.PasswordHash = PasswordHasher.Hash(anfrage.Password);
|
||||
|
||||
var antwort = await TokenAusstellen(db, clock, benutzer, anfrage.DeviceName, anfrage.DeviceId);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(antwort);
|
||||
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
|
||||
|
||||
app.MapPost("/api/v1/auth/logout", async (ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
||||
{
|
||||
if (!Guid.TryParse(wer.FindFirstValue(TokenAuthenticationHandler.DeviceTokenIdClaim), out var id))
|
||||
return Results.Unauthorized();
|
||||
|
||||
var eintrag = await db.DeviceTokens.FindAsync(id);
|
||||
if (eintrag is not null)
|
||||
{
|
||||
eintrag.RevokedAt = clock.GetUtcNow();
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
return Results.NoContent();
|
||||
}).RequireAuthorization();
|
||||
|
||||
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();
|
||||
|
||||
MapCodeFlow(app);
|
||||
}
|
||||
|
||||
/// <summary>Anmelden mit einem Code auf dem Bildschirm.</summary>
|
||||
private static void MapCodeFlow(IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPost("/api/v1/auth/code", async (CodeRequest anfrage, FlaroContext db, TimeProvider clock) =>
|
||||
{
|
||||
var jetzt = clock.GetUtcNow();
|
||||
|
||||
// Beiläufig aufräumen. Ein eigener Hintergrunddienst für ein paar
|
||||
// Zeilen je Tag wäre Aufwand ohne Gegenwert.
|
||||
await db.AuthCodes.Where(c => c.ExpiresAt < jetzt).ExecuteDeleteAsync();
|
||||
|
||||
var abholschein = Secrets.Create();
|
||||
var code = new AuthCode
|
||||
{
|
||||
Code = AuthCode.NewCode(),
|
||||
HandleFingerprint = Secrets.Fingerprint(abholschein),
|
||||
DeviceName = Kürzen(anfrage.DeviceName, 128),
|
||||
DeviceId = Kürzen(anfrage.DeviceId, 128),
|
||||
CreatedAt = jetzt,
|
||||
ExpiresAt = jetzt + AuthCode.Lifetime,
|
||||
};
|
||||
db.AuthCodes.Add(code);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new CodeResponse(code.Code, abholschein, code.ExpiresAt));
|
||||
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.SignIn);
|
||||
|
||||
app.MapGet("/api/v1/auth/code/{handle}", async (
|
||||
string handle, FlaroContext db, TimeProvider clock) =>
|
||||
{
|
||||
var jetzt = clock.GetUtcNow();
|
||||
var fingerabdruck = Secrets.Fingerprint(handle);
|
||||
var code = await db.AuthCodes.FirstOrDefaultAsync(c => c.HandleFingerprint == fingerabdruck);
|
||||
|
||||
if (code is null || code.IsExpired(jetzt) || code.ConsumedAt is not null)
|
||||
return Results.Ok(new CodeState("expired", null));
|
||||
|
||||
if (code.ApprovedByUserId is not { } benutzerId)
|
||||
return Results.Ok(new CodeState("pending", null));
|
||||
|
||||
var benutzer = await db.Users.FindAsync(benutzerId);
|
||||
if (benutzer is null) return Results.Ok(new CodeState("expired", null));
|
||||
|
||||
// Einmal abgeholt ist abgeholt — vor dem Ausstellen gesetzt, damit
|
||||
// zwei gleichzeitige Abholungen nicht zwei Token ergeben.
|
||||
code.ConsumedAt = jetzt;
|
||||
var antwort = await TokenAusstellen(db, clock, benutzer, code.DeviceName, code.DeviceId);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new CodeState("approved", antwort));
|
||||
}).AllowAnonymous().RequireRateLimiting(FlaroRateLimits.Polling);
|
||||
|
||||
app.MapPost("/api/v1/auth/code/{code}", async (
|
||||
string code, ClaimsPrincipal wer, FlaroContext db, TimeProvider clock) =>
|
||||
{
|
||||
if (!Guid.TryParse(wer.FindFirstValue(ClaimTypes.NameIdentifier), out var benutzerId))
|
||||
return Results.Unauthorized();
|
||||
|
||||
var jetzt = clock.GetUtcNow();
|
||||
var offen = await db.AuthCodes.FirstOrDefaultAsync(c =>
|
||||
c.Code == code && c.ApprovedByUserId == null && c.ExpiresAt > jetzt);
|
||||
|
||||
if (offen is null)
|
||||
return Results.Problem("Dieser Code gilt nicht mehr.", statusCode: StatusCodes.Status404NotFound);
|
||||
|
||||
offen.ApprovedByUserId = benutzerId;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
// Damit am Telefon steht, was man da gerade hereinlässt.
|
||||
return Results.Ok(new { device = offen.DeviceName });
|
||||
}).RequireAuthorization().RequireRateLimiting(FlaroRateLimits.SignIn);
|
||||
}
|
||||
|
||||
/// <summary>Stellt ein Geräte-Token aus und löst ein älteres desselben Geräts ab.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Ohne das Ablösen füllt sich die Geräteliste mit Karteileichen: jede
|
||||
/// erneute Anmeldung derselben App hinterliesse einen weiteren Eintrag,
|
||||
/// und nach einem Jahr weiss niemand mehr, welches Telefon welches ist.
|
||||
/// </remarks>
|
||||
private static async Task<LoginResponse> TokenAusstellen(
|
||||
FlaroContext db, TimeProvider clock, User benutzer, string geräteName, string geräteKennung)
|
||||
{
|
||||
var jetzt = clock.GetUtcNow();
|
||||
var kennung = Kürzen(geräteKennung, 128);
|
||||
|
||||
await db.DeviceTokens
|
||||
.Where(t => t.UserId == benutzer.Id && t.DeviceId == kennung && t.RevokedAt == null)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(t => t.RevokedAt, jetzt));
|
||||
|
||||
var token = Secrets.Create();
|
||||
db.DeviceTokens.Add(new DeviceToken
|
||||
{
|
||||
UserId = benutzer.Id,
|
||||
TokenFingerprint = Secrets.Fingerprint(token),
|
||||
DeviceName = Kürzen(geräteName, 128),
|
||||
DeviceId = kennung,
|
||||
CreatedAt = jetzt,
|
||||
LastSeenAt = jetzt,
|
||||
});
|
||||
|
||||
return new LoginResponse(token, new AuthenticatedUser(benutzer.Id, benutzer.UserName, benutzer.IsAdministrator));
|
||||
}
|
||||
|
||||
/// <summary>Eine Antwort, die nicht verrät, welche Hälfte falsch war.</summary>
|
||||
private static IResult Abgelehnt() =>
|
||||
Results.Problem("Benutzername oder Passwort stimmt nicht.", statusCode: StatusCodes.Status401Unauthorized);
|
||||
|
||||
private static string Kürzen(string? wert, int höchstens)
|
||||
{
|
||||
var sauber = (wert ?? "").Trim();
|
||||
return sauber.Length <= höchstens ? sauber : sauber[..höchstens];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
namespace Flaro.Api.Endpoints;
|
||||
|
||||
// Was über die Leitung geht. Bewusst eigene Typen und nicht die der Domäne:
|
||||
// sonst wandert jede Umbenennung im Datenmodell ungefragt in die Schnittstelle
|
||||
// und bricht Clients, die niemand mehr aktualisiert.
|
||||
|
||||
public sealed record SystemInfo(
|
||||
string Version, bool NeedsSetup, IReadOnlyList<AttributionResponse> Attributions);
|
||||
|
||||
public sealed record SetupRequest(string UserName, string Password);
|
||||
|
||||
public sealed record LoginRequest(string UserName, string Password, string DeviceName, string DeviceId);
|
||||
|
||||
public sealed record AuthenticatedUser(Guid Id, string UserName, bool IsAdministrator);
|
||||
|
||||
public sealed record LoginResponse(string Token, AuthenticatedUser User);
|
||||
|
||||
public sealed record CodeRequest(string DeviceName, string DeviceId);
|
||||
|
||||
/// <summary>Was der Fernseher nach dem Anfordern bekommt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Der <c>Handle</c> ist das Geheimnis und wird <b>nicht</b> angezeigt — der
|
||||
/// <c>Code</c> ist das, was auf dem Bildschirm steht.
|
||||
/// </remarks>
|
||||
public sealed record CodeResponse(string Code, string Handle, DateTimeOffset ExpiresAt);
|
||||
|
||||
public sealed record CodeState(string State, LoginResponse? Login);
|
||||
|
||||
public sealed record CreateUserRequest(string UserName, string Password, bool IsAdministrator);
|
||||
|
||||
public sealed record LibraryResponse(Guid Id, string Name, string Kind, string Path);
|
||||
|
||||
public sealed record CreateLibraryRequest(string Name, string Kind, string Path);
|
||||
|
||||
// --- Stufe 2: Bibliothek -------------------------------------------------
|
||||
|
||||
public sealed record ScanProblem(string Path, string Reason);
|
||||
|
||||
public sealed record ScanStatus(
|
||||
string State,
|
||||
DateTimeOffset? StartedAt,
|
||||
DateTimeOffset? FinishedAt,
|
||||
int? FilesFound,
|
||||
int? Added,
|
||||
int? Updated,
|
||||
int? Removed,
|
||||
int? Unrecognised,
|
||||
int? Probed,
|
||||
IReadOnlyList<ScanProblem>? Problems,
|
||||
string? Error,
|
||||
MetadataProgress? Metadata);
|
||||
|
||||
/// <summary>Was das Nachschlagen bei der Metadatenquelle ergeben hat.</summary>
|
||||
public sealed record MetadataProgress(
|
||||
int MoviesMatched, int SeriesMatched, int EpisodesMatched, bool Skipped, string? Error);
|
||||
|
||||
/// <summary>Eine Spur in einer Datei.</summary>
|
||||
public sealed record MediaStreamResponse(
|
||||
int Index, string Kind, string? Codec, string? Language, string? Title,
|
||||
bool IsDefault, bool IsForced, int? Channels, int? Width, int? Height);
|
||||
|
||||
/// <summary>Eine Datei hinter einem Titel — eine Fassung also.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Nur der Dateiname, nicht der Pfad.</b> Der Pfad verriete jedem
|
||||
/// Angemeldeten den Aufbau des Servers, und gebraucht wird er nirgends: die
|
||||
/// App spricht Dateien über ihre Kennung an. Zum Unterscheiden zweier
|
||||
/// Fassungen genügt der Name.
|
||||
/// </remarks>
|
||||
public sealed record MediaFileResponse(
|
||||
Guid Id, string FileName, long SizeBytes, string? Container, double? DurationSeconds,
|
||||
IReadOnlyList<MediaStreamResponse> Streams);
|
||||
|
||||
public sealed record MovieResponse(
|
||||
Guid Id, string Title, int? Year, string? Overview,
|
||||
string? PosterImage, string? BackdropImage,
|
||||
IReadOnlyList<MediaFileResponse> Files);
|
||||
|
||||
public sealed record SeriesResponse(
|
||||
Guid Id, string Title, int? Year, string? Overview,
|
||||
string? PosterImage, string? BackdropImage,
|
||||
int EpisodeCount, IReadOnlyList<int> Seasons);
|
||||
|
||||
public sealed record EpisodeResponse(
|
||||
Guid Id, int Season, int Episode, string? Title, string? Overview, string? StillImage,
|
||||
IReadOnlyList<MediaFileResponse> Files);
|
||||
|
||||
public sealed record Page<T>(IReadOnlyList<T> Items, int Total);
|
||||
|
||||
public sealed record MetadataSettingsResponse(
|
||||
bool HasApiKey, string Language, bool UsingProjectKey);
|
||||
|
||||
/// <summary>Was ein Client über der Mediathek anzeigen muss.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Bedingung, nicht Höflichkeit.</b> TMDB verlangt in seinen
|
||||
/// Nutzungsbedingungen, dass das Logo gezeigt wird und dieser Satz dasteht.
|
||||
/// Der Server liefert beides, damit kein Client es abtippen muss — und keiner
|
||||
/// es vergisst.
|
||||
/// </remarks>
|
||||
public sealed record AttributionResponse(string Text, string LogoUrl, string Url);
|
||||
|
||||
public sealed record MetadataSettingsRequest(string? ApiKey, string? Language);
|
||||
|
||||
// --- Stufe 3: Ausliefern -------------------------------------------------
|
||||
|
||||
/// <summary>Was mit einer Datei geschehen wird, bevor sie hinausgeht.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <b>Damit vorher feststeht, was man bekommt.</b> Drei Gigabyte
|
||||
/// herunterzuladen und erst danach zu merken, dass die Untertitel fehlen oder
|
||||
/// die falsche Tonspur drin ist, war der Anlass für dieses ganze Projekt.
|
||||
/// </remarks>
|
||||
public sealed record DeliveryInfo(
|
||||
string Method,
|
||||
IReadOnlyList<string> Reasons,
|
||||
IReadOnlyList<int> AudioToEncode,
|
||||
IReadOnlyList<int> SubtitlesDropped,
|
||||
string PackageState,
|
||||
double Progress,
|
||||
string? Error);
|
||||
|
||||
// --- Zuordnung korrigieren -----------------------------------------------
|
||||
|
||||
/// <summary>Ein Vorschlag, aus dem gewählt werden kann.</summary>
|
||||
public sealed record MatchCandidate(
|
||||
int TmdbId, string Title, int? Year, string? Overview, string? PosterUrl);
|
||||
|
||||
public sealed record ApplyMatchRequest(int TmdbId);
|
||||
|
||||
/// <summary>Wie die Zuordnung jetzt aussieht.</summary>
|
||||
public sealed record MatchState(
|
||||
int? TmdbId, string? ImdbId, bool Locked, string Title, int? Year);
|
||||
@@ -0,0 +1,105 @@
|
||||
using Flaro.Core.Delivery;
|
||||
using Flaro.Core.Library;
|
||||
using Flaro.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api.Endpoints;
|
||||
|
||||
public static class DeliveryEndpoints
|
||||
{
|
||||
public static void MapDelivery(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/v1/files/{id:guid}/delivery", async (
|
||||
Guid id, FlaroContext db, PackageCoordinator packer) =>
|
||||
{
|
||||
var gefunden = await Laden(db, id);
|
||||
if (gefunden is not var (datei, plan)) return Results.NotFound();
|
||||
|
||||
return Results.Ok(Auskunft(plan, packer, datei.Id, datei.ModifiedAt));
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/v1/files/{id:guid}/download", async (
|
||||
Guid id, FlaroContext db, PackageCoordinator packer) =>
|
||||
{
|
||||
var gefunden = await Laden(db, id);
|
||||
if (gefunden is not var (datei, plan)) return Results.NotFound();
|
||||
|
||||
// Passt schon alles, geht die Datei unverändert hinaus — mit
|
||||
// Längenangabe und Bereichsanfragen, also mit Fortschritt und
|
||||
// Fortsetzen.
|
||||
if (!plan.NeedsWork)
|
||||
{
|
||||
return File.Exists(datei.Path)
|
||||
? Results.File(datei.Path, "video/mp4", Path.GetFileName(datei.Path),
|
||||
enableRangeProcessing: true)
|
||||
: Results.Problem($"„{Path.GetFileName(datei.Path)}“ liegt nicht mehr da.",
|
||||
statusCode: StatusCodes.Status410Gone);
|
||||
}
|
||||
|
||||
var fertig = packer.PathFor(datei.Id, datei.ModifiedAt);
|
||||
if (File.Exists(fertig))
|
||||
{
|
||||
return Results.File(fertig, "video/mp4",
|
||||
Path.ChangeExtension(Path.GetFileName(datei.Path), ".mp4"),
|
||||
enableRangeProcessing: true);
|
||||
}
|
||||
|
||||
var stand = packer.State(datei.Id);
|
||||
if (stand?.Status == "failed")
|
||||
{
|
||||
return Results.Problem(
|
||||
stand.Error ?? "Das Umpacken ist gescheitert.",
|
||||
statusCode: StatusCodes.Status500InternalServerError);
|
||||
}
|
||||
|
||||
packer.TryStart(datei.Id);
|
||||
|
||||
// **202 und kein Warten.** Das Umpacken eines grossen Films dauert
|
||||
// Minuten; eine so lange offene Anfrage läuft hinter jedem Reverse
|
||||
// Proxy ins Zeitlimit. Der Client fragt stattdessen nach und holt
|
||||
// die Datei, wenn sie fertig ist.
|
||||
return Results.Json(
|
||||
Auskunft(plan, packer, datei.Id, datei.ModifiedAt),
|
||||
statusCode: StatusCodes.Status202Accepted);
|
||||
}).RequireAuthorization();
|
||||
}
|
||||
|
||||
private static async Task<(MediaFile Datei, DeliveryPlan Plan)?> Laden(FlaroContext db, Guid id)
|
||||
{
|
||||
var datei = await db.MediaFiles.AsNoTracking().FirstOrDefaultAsync(f => f.Id == id);
|
||||
if (datei is null) return null;
|
||||
|
||||
var spuren = await db.MediaStreams.AsNoTracking()
|
||||
.Where(s => s.MediaFileId == id).OrderBy(s => s.Index).ToListAsync();
|
||||
|
||||
var gelesen = spuren.Select(s => new ProbedStream(
|
||||
s.Index, s.Kind, s.Codec, s.Language, s.Title,
|
||||
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height)).ToList();
|
||||
|
||||
return (datei, DeliveryDecider.ForDownload(datei.Container, gelesen));
|
||||
}
|
||||
|
||||
private static DeliveryInfo Auskunft(
|
||||
DeliveryPlan plan, PackageCoordinator packer, Guid dateiId, DateTimeOffset geändert)
|
||||
{
|
||||
var stand = !plan.NeedsWork ? "notNeeded"
|
||||
: packer.IsReady(dateiId, geändert) ? "ready"
|
||||
: packer.State(dateiId)?.Status ?? "idle";
|
||||
|
||||
return new DeliveryInfo(
|
||||
Method: plan.Method switch
|
||||
{
|
||||
DeliveryMethod.DirectFile => "directFile",
|
||||
DeliveryMethod.Remux => "remux",
|
||||
_ => "unknown",
|
||||
},
|
||||
Reasons: plan.Reasons.Select(g => Kleinbuchstabe(g.ToString())).ToList(),
|
||||
AudioToEncode: plan.AudioToEncode,
|
||||
SubtitlesDropped: plan.SubtitlesDropped,
|
||||
PackageState: stand,
|
||||
Progress: packer.State(dateiId)?.Progress ?? (stand == "ready" ? 1 : 0),
|
||||
Error: packer.State(dateiId)?.Error);
|
||||
}
|
||||
|
||||
private static string Kleinbuchstabe(string name) => char.ToLowerInvariant(name[0]) + name[1..];
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Flaro.Metadata;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Flaro.Api.Endpoints;
|
||||
|
||||
public static class ImageEndpoints
|
||||
{
|
||||
public static void MapImages(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/v1/images/{key}", (string key, ImageCache ablage) =>
|
||||
{
|
||||
// Gibt `null` zurück, sobald der Schlüssel kein reiner Hash ist —
|
||||
// ohne diese Prüfung wäre die Adresszeile ein Weg in jedes
|
||||
// Verzeichnis des Containers.
|
||||
var pfad = ablage.PfadFür(key);
|
||||
if (pfad is null || !File.Exists(pfad)) return Results.NotFound();
|
||||
|
||||
// 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);
|
||||
})
|
||||
.RequireAuthorization()
|
||||
.AddEndpointFilter(async (zusammenhang, weiter) =>
|
||||
{
|
||||
var ergebnis = await weiter(zusammenhang);
|
||||
zusammenhang.HttpContext.Response.Headers.CacheControl = "private, max-age=31536000, immutable";
|
||||
return ergebnis;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
using Flaro.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api.Endpoints;
|
||||
|
||||
public static class LibraryContentEndpoints
|
||||
{
|
||||
/// <summary>Mehr als das liefert keine Anfrage — auch wenn sie danach fragt.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Ohne Obergrenze holt ein <c>?take=100000</c> die ganze Mediathek in
|
||||
/// einem Zug, und zwar sowohl in den Arbeitsspeicher des Servers als auch
|
||||
/// über die Leitung. Eine Grenze, die der Aufrufer setzt, ist keine.
|
||||
/// </remarks>
|
||||
private const int HöchstensJeSeite = 200;
|
||||
|
||||
public static void MapLibraryContent(this IEndpointRouteBuilder app)
|
||||
{
|
||||
MapScan(app);
|
||||
MapBrowse(app);
|
||||
}
|
||||
|
||||
/// <summary>Holt die Spuren aller genannten Dateien in einer Abfrage.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Je Datei einzeln zu fragen hiesse: fünfzig Titel, fünfzig Abfragen —
|
||||
/// und SQLite führt die nacheinander aus.
|
||||
/// </remarks>
|
||||
private static async Task<ILookup<Guid, MediaStreamResponse>> SpurenZu(FlaroContext db, List<Guid> dateien)
|
||||
{
|
||||
if (dateien.Count == 0) return Array.Empty<(Guid, MediaStreamResponse)>().ToLookup(x => x.Item1, x => x.Item2);
|
||||
|
||||
var roh = await db.MediaStreams.AsNoTracking()
|
||||
.Where(s => dateien.Contains(s.MediaFileId))
|
||||
.OrderBy(s => s.Index)
|
||||
.ToListAsync();
|
||||
|
||||
return roh.ToLookup(s => s.MediaFileId, s => new MediaStreamResponse(
|
||||
s.Index, s.Kind.ToString(), s.Codec, s.Language, s.Title,
|
||||
s.IsDefault, s.IsForced, s.Channels, s.Width, s.Height));
|
||||
}
|
||||
|
||||
private static MediaFileResponse Datei(
|
||||
Guid id, string pfad, long bytes, string? schachtel, TimeSpan? dauer,
|
||||
ILookup<Guid, MediaStreamResponse> spuren) =>
|
||||
new(id, System.IO.Path.GetFileName(pfad), bytes, schachtel, dauer?.TotalSeconds, spuren[id].ToList());
|
||||
|
||||
private static void MapScan(IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapPost("/api/v1/libraries/{id:guid}/scan", async (
|
||||
Guid id, FlaroContext db, ScanCoordinator koordinator) =>
|
||||
{
|
||||
if (!await db.Libraries.AnyAsync(l => l.Id == id)) return Results.NotFound();
|
||||
|
||||
return koordinator.TryStart(id)
|
||||
? Results.AcceptedAtRoute("Scanstand", new { id })
|
||||
: Results.Problem(
|
||||
"Für diese Bibliothek läuft bereits ein Scan.",
|
||||
statusCode: StatusCodes.Status409Conflict);
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
app.MapGet("/api/v1/libraries/{id:guid}/scan", (Guid id, ScanCoordinator koordinator) =>
|
||||
{
|
||||
var zustand = koordinator.State(id);
|
||||
if (zustand is null)
|
||||
return Results.Ok(new ScanStatus("idle", null, null, null, null, null, null, null, null, null, null, null));
|
||||
|
||||
var bericht = zustand.Report;
|
||||
var metadaten = zustand.Enrichment;
|
||||
return Results.Ok(new ScanStatus(
|
||||
zustand.Status, zustand.StartedAt, zustand.FinishedAt,
|
||||
bericht?.FilesFound, bericht?.Added, bericht?.Updated, bericht?.Removed, bericht?.Unrecognised,
|
||||
bericht?.Probed,
|
||||
bericht?.Problems.Select(p => new ScanProblem(p.Path, p.Reason)).ToList(),
|
||||
zustand.Error,
|
||||
metadaten is null ? null : new MetadataProgress(
|
||||
metadaten.MoviesMatched, metadaten.SeriesMatched, metadaten.EpisodesMatched,
|
||||
metadaten.Skipped, metadaten.Error)));
|
||||
}).WithName("Scanstand").RequireAuthorization();
|
||||
}
|
||||
|
||||
private static void MapBrowse(IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/v1/movies", async (
|
||||
FlaroContext db, Guid? library, string? search, int skip = 0, int take = 50) =>
|
||||
{
|
||||
var abfrage = db.Movies.AsNoTracking();
|
||||
if (library is { } kennung) abfrage = abfrage.Where(m => m.LibraryId == kennung);
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
abfrage = abfrage.Where(m => EF.Functions.Like(m.Title, $"%{search.Trim()}%"));
|
||||
|
||||
var gesamt = await abfrage.CountAsync();
|
||||
var kennungen = await abfrage
|
||||
.OrderBy(m => m.SortTitleValue).ThenBy(m => m.Year)
|
||||
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
|
||||
.Select(m => new { m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage })
|
||||
.ToListAsync();
|
||||
|
||||
// Dateien in **einer** Abfrage nachholen statt je Film einer.
|
||||
// Andernfalls kostet eine Seite mit fünfzig Titeln einundfünfzig
|
||||
// Abfragen, und SQLite führt die nacheinander aus.
|
||||
var ids = kennungen.Select(m => m.Id).ToList();
|
||||
var dateien = await db.MediaFiles.AsNoTracking()
|
||||
.Where(f => f.MovieId != null && ids.Contains(f.MovieId!.Value))
|
||||
.Select(f => new { f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, f.MovieId })
|
||||
.ToListAsync();
|
||||
var spuren = await SpurenZu(db, dateien.Select(f => f.Id).ToList());
|
||||
|
||||
var items = kennungen.Select(m => new MovieResponse(
|
||||
m.Id, m.Title, m.Year, m.Overview, m.PosterImage, m.BackdropImage,
|
||||
dateien.Where(f => f.MovieId == m.Id)
|
||||
.Select(f => Datei(f.Id, f.Path, f.SizeBytes, f.Container, f.Duration, spuren)).ToList())).ToList();
|
||||
|
||||
return Results.Ok(new Page<MovieResponse>(items, gesamt));
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/v1/series", async (
|
||||
FlaroContext db, Guid? library, string? search, int skip = 0, int take = 50) =>
|
||||
{
|
||||
var abfrage = db.Series.AsNoTracking();
|
||||
if (library is { } kennung) abfrage = abfrage.Where(s => s.LibraryId == kennung);
|
||||
if (!string.IsNullOrWhiteSpace(search))
|
||||
abfrage = abfrage.Where(s => EF.Functions.Like(s.Title, $"%{search.Trim()}%"));
|
||||
|
||||
var gesamt = await abfrage.CountAsync();
|
||||
var serien = await abfrage
|
||||
.OrderBy(s => s.SortTitleValue)
|
||||
.Skip(Math.Max(0, skip)).Take(Math.Clamp(take, 1, HöchstensJeSeite))
|
||||
.Select(s => new { s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage })
|
||||
.ToListAsync();
|
||||
|
||||
var ids = serien.Select(s => s.Id).ToList();
|
||||
var folgen = await db.Episodes.AsNoTracking()
|
||||
.Where(f => ids.Contains(f.SeriesId))
|
||||
.Select(f => new { f.SeriesId, f.SeasonNumber })
|
||||
.ToListAsync();
|
||||
|
||||
var items = serien.Select(s =>
|
||||
{
|
||||
var meine = folgen.Where(f => f.SeriesId == s.Id).ToList();
|
||||
return new SeriesResponse(
|
||||
s.Id, s.Title, s.Year, s.Overview, s.PosterImage, s.BackdropImage, meine.Count,
|
||||
meine.Select(f => f.SeasonNumber).Distinct().Order().ToList());
|
||||
}).ToList();
|
||||
|
||||
return Results.Ok(new Page<SeriesResponse>(items, gesamt));
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/v1/series/{id:guid}/episodes", async (Guid id, FlaroContext db, int? season) =>
|
||||
{
|
||||
if (!await db.Series.AnyAsync(s => s.Id == id)) return Results.NotFound();
|
||||
|
||||
var abfrage = db.Episodes.AsNoTracking().Where(f => f.SeriesId == id);
|
||||
if (season is { } staffel) abfrage = abfrage.Where(f => f.SeasonNumber == staffel);
|
||||
|
||||
var folgen = await abfrage
|
||||
.OrderBy(f => f.SeasonNumber).ThenBy(f => f.EpisodeNumber)
|
||||
.Select(f => new { f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage })
|
||||
.ToListAsync();
|
||||
|
||||
var ids = folgen.Select(f => f.Id).ToList();
|
||||
var dateien = await db.MediaFiles.AsNoTracking()
|
||||
.Where(d => d.EpisodeId != null && ids.Contains(d.EpisodeId!.Value))
|
||||
.Select(d => new { d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, d.EpisodeId })
|
||||
.ToListAsync();
|
||||
var spuren = await SpurenZu(db, dateien.Select(d => d.Id).ToList());
|
||||
|
||||
var items = folgen.Select(f => new EpisodeResponse(
|
||||
f.Id, f.SeasonNumber, f.EpisodeNumber, f.Title, f.Overview, f.StillImage,
|
||||
dateien.Where(d => d.EpisodeId == f.Id)
|
||||
.Select(d => Datei(d.Id, d.Path, d.SizeBytes, d.Container, d.Duration, spuren)).ToList())).ToList();
|
||||
|
||||
return Results.Ok(new Page<EpisodeResponse>(items, items.Count));
|
||||
}).RequireAuthorization();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using Flaro.Api.Auth;
|
||||
using Flaro.Core.Libraries;
|
||||
using Flaro.Core.Users;
|
||||
using Flaro.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api.Endpoints;
|
||||
|
||||
public static class ManagementEndpoints
|
||||
{
|
||||
public static void MapManagement(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var benutzer = app.MapGroup("/api/v1/users").RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
benutzer.MapGet("/", async (FlaroContext db) =>
|
||||
Results.Ok(await db.Users
|
||||
.OrderBy(u => u.UserName)
|
||||
.Select(u => new AuthenticatedUser(u.Id, u.UserName, u.IsAdministrator))
|
||||
.ToListAsync()));
|
||||
|
||||
benutzer.MapPost("/", async (CreateUserRequest anfrage, FlaroContext db) =>
|
||||
{
|
||||
var name = anfrage.UserName.Trim();
|
||||
if (name.Length == 0 || anfrage.Password.Length < 8)
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["password"] = ["Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen."],
|
||||
});
|
||||
|
||||
if (await db.Users.AnyAsync(u => u.UserName == name))
|
||||
return Results.Problem($"„{name}“ gibt es schon.", statusCode: StatusCodes.Status409Conflict);
|
||||
|
||||
var neu = new User
|
||||
{
|
||||
UserName = name,
|
||||
PasswordHash = PasswordHasher.Hash(anfrage.Password),
|
||||
IsAdministrator = anfrage.IsAdministrator,
|
||||
};
|
||||
db.Users.Add(neu);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Created($"/api/v1/users/{neu.Id}",
|
||||
new AuthenticatedUser(neu.Id, neu.UserName, neu.IsAdministrator));
|
||||
});
|
||||
|
||||
var bibliotheken = app.MapGroup("/api/v1/libraries");
|
||||
|
||||
// Lesen darf jeder Angemeldete — ohne die Liste zeigt die App nichts an.
|
||||
bibliotheken.MapGet("/", async (FlaroContext db) =>
|
||||
Results.Ok(await db.Libraries
|
||||
.OrderBy(l => l.Name)
|
||||
.Select(l => new LibraryResponse(l.Id, l.Name, l.Kind.ToString(), l.Path))
|
||||
.ToListAsync()))
|
||||
.RequireAuthorization();
|
||||
|
||||
bibliotheken.MapPost("/", async (CreateLibraryRequest anfrage, FlaroContext db) =>
|
||||
{
|
||||
if (!Enum.TryParse<LibraryKind>(anfrage.Kind, ignoreCase: true, out var art))
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["kind"] = [$"Unbekannte Art „{anfrage.Kind}“. Möglich: Movies, Shows."],
|
||||
});
|
||||
|
||||
// **Jetzt prüfen, nicht beim ersten Scan.** Ein Tippfehler im Pfad
|
||||
// führte sonst zu einer Bibliothek, die dauerhaft leer bleibt, und
|
||||
// die Ursache stünde allenfalls im Protokoll.
|
||||
if (!Directory.Exists(anfrage.Path))
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["path"] = [$"„{anfrage.Path}“ gibt es im Container nicht. " +
|
||||
"Ist das Verzeichnis eingehängt? Gemeint ist der Pfad im Container, nicht der auf dem Wirt."],
|
||||
});
|
||||
|
||||
var neu = new Library { Name = anfrage.Name.Trim(), Kind = art, Path = anfrage.Path };
|
||||
db.Libraries.Add(neu);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Created($"/api/v1/libraries/{neu.Id}",
|
||||
new LibraryResponse(neu.Id, neu.Name, neu.Kind.ToString(), neu.Path));
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
}
|
||||
}
|
||||
|
||||
public static class FlaroPolicies
|
||||
{
|
||||
public const string Administrator = "administrator-only";
|
||||
}
|
||||
|
||||
public static class FlaroRateLimits
|
||||
{
|
||||
/// <summary>Für alles, was ein Geheimnis prüft.</summary>
|
||||
public const string SignIn = "sign-in";
|
||||
|
||||
/// <summary>Für das Nachfragen des Fernsehers — darf häufig sein.</summary>
|
||||
public const string Polling = "polling";
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Flaro.Core.Library;
|
||||
using Flaro.Core.Metadata;
|
||||
using Flaro.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api.Endpoints;
|
||||
|
||||
/// <summary>Eine falsche Zuordnung von Hand richtigstellen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Weil Raten manchmal danebengeht.</b> Die Suche nach „Dark" lieferte
|
||||
/// „Dark Shadows" von 1966 — ohne Jahresangabe gewinnt bei TMDB der
|
||||
/// beliebteste Treffer. Dagegen hilft keine bessere Heuristik, sondern nur
|
||||
/// die Möglichkeit, es richtigzustellen.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Eine Korrektur wird festgeschrieben.</b> In der Datenbank <i>und</i> in
|
||||
/// der NFO-Datei neben dem Film, mit <c>lockdata</c>. Danach sucht kein Lauf
|
||||
/// mehr danach — und ein Neuaufbau der Datenbank oder ein Umzug auf einen
|
||||
/// anderen Server findet die Entscheidung wieder.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class MatchEndpoints
|
||||
{
|
||||
public static void MapMatching(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/v1/movies/{id:guid}/matches", async (
|
||||
Guid id, FlaroContext db, IMetadataProvider anbieter, string? query, int? year) =>
|
||||
{
|
||||
var film = await db.Movies.AsNoTracking().FirstOrDefaultAsync(m => m.Id == id);
|
||||
if (film is null) return Results.NotFound();
|
||||
|
||||
// Ohne eigene Suchworte das, was aus dem Dateinamen gelesen wurde
|
||||
// — nicht der angezeigte Titel, der ja gerade der falsche ist.
|
||||
var vorschläge = await anbieter.SearchMoviesAsync(
|
||||
string.IsNullOrWhiteSpace(query) ? film.ParsedTitle : query.Trim(), year ?? film.Year);
|
||||
|
||||
return Results.Ok(vorschläge.Select(v =>
|
||||
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
app.MapPut("/api/v1/movies/{id:guid}/match", async (
|
||||
Guid id, ApplyMatchRequest anfrage, FlaroContext db,
|
||||
IMetadataProvider anbieter, MatchApplier anwender) =>
|
||||
{
|
||||
var film = await db.Movies.FirstOrDefaultAsync(m => m.Id == id);
|
||||
if (film is null) return Results.NotFound();
|
||||
|
||||
var gefunden = await anbieter.GetMovieAsync(anfrage.TmdbId);
|
||||
if (gefunden is null)
|
||||
return Results.Problem($"TMDB kennt den Film {anfrage.TmdbId} nicht.",
|
||||
statusCode: StatusCodes.Status404NotFound);
|
||||
|
||||
await anwender.ApplyAsync(film, gefunden);
|
||||
return Results.Ok(new MatchState(film.TmdbId, film.ImdbId, film.MatchLocked, film.Title, film.Year));
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
app.MapGet("/api/v1/series/{id:guid}/matches", async (
|
||||
Guid id, FlaroContext db, IMetadataProvider anbieter, string? query, int? year) =>
|
||||
{
|
||||
var serie = await db.Series.AsNoTracking().FirstOrDefaultAsync(s => s.Id == id);
|
||||
if (serie is null) return Results.NotFound();
|
||||
|
||||
var vorschläge = await anbieter.SearchSeriesAsync(
|
||||
string.IsNullOrWhiteSpace(query) ? serie.ParsedTitle : query.Trim(), year ?? serie.Year);
|
||||
|
||||
return Results.Ok(vorschläge.Select(v =>
|
||||
new MatchCandidate(v.ProviderId, v.Title, v.Year, v.Overview, v.Poster?.Url)).ToList());
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
app.MapPut("/api/v1/series/{id:guid}/match", async (
|
||||
Guid id, ApplyMatchRequest anfrage, FlaroContext db,
|
||||
IMetadataProvider anbieter, MatchApplier anwender) =>
|
||||
{
|
||||
var serie = await db.Series.FirstOrDefaultAsync(s => s.Id == id);
|
||||
if (serie is null) return Results.NotFound();
|
||||
|
||||
var gefunden = await anbieter.GetSeriesAsync(anfrage.TmdbId);
|
||||
if (gefunden is null)
|
||||
return Results.Problem($"TMDB kennt die Serie {anfrage.TmdbId} nicht.",
|
||||
statusCode: StatusCodes.Status404NotFound);
|
||||
|
||||
await anwender.ApplyAsync(serie, gefunden);
|
||||
return Results.Ok(new MatchState(serie.TmdbId, serie.ImdbId, serie.MatchLocked, serie.Title, serie.Year));
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using Flaro.Core;
|
||||
using Flaro.Data;
|
||||
using Flaro.Metadata;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api.Endpoints;
|
||||
|
||||
public static class SettingsEndpoints
|
||||
{
|
||||
public static void MapSettings(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/v1/settings/metadata", async (FlaroContext db) =>
|
||||
{
|
||||
var zeilen = await db.Settings.AsNoTracking()
|
||||
.Where(s => s.Key == DatabaseTmdbSettings.ApiKeyName || s.Key == DatabaseTmdbSettings.LanguageName)
|
||||
.ToDictionaryAsync(s => s.Key, s => s.Value);
|
||||
|
||||
// **Der Schlüssel selbst kommt nicht zurück.** Er gäbe jedem
|
||||
// Angemeldeten das TMDB-Kontingent des Betreibers in die Hand, und
|
||||
// zum Anzeigen genügt die Auskunft, dass einer da ist.
|
||||
var aufgelöst = TmdbSettings.Resolve(
|
||||
zeilen.GetValueOrDefault(DatabaseTmdbSettings.ApiKeyName),
|
||||
zeilen.GetValueOrDefault(DatabaseTmdbSettings.LanguageName));
|
||||
|
||||
return Results.Ok(new MetadataSettingsResponse(
|
||||
HasApiKey: !string.IsNullOrEmpty(aufgelöst.ApiKey),
|
||||
Language: aufgelöst.Language,
|
||||
// Damit in der Oberfläche steht, ob der mitgelieferte Schlüssel
|
||||
// benutzt wird — und dass ein eigener ihn ablösen würde.
|
||||
UsingProjectKey: aufgelöst.UsingProjectKey));
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
|
||||
app.MapPut("/api/v1/settings/metadata", async (
|
||||
MetadataSettingsRequest anfrage, FlaroContext db) =>
|
||||
{
|
||||
if (anfrage.ApiKey is not null) await Setzen(db, DatabaseTmdbSettings.ApiKeyName, anfrage.ApiKey.Trim());
|
||||
if (anfrage.Language is not null)
|
||||
{
|
||||
var sprache = anfrage.Language.Trim();
|
||||
// Form wie „de-DE". Alles andere nähme TMDB stillschweigend als
|
||||
// Englisch an, und niemand wüsste, warum.
|
||||
if (!System.Text.RegularExpressions.Regex.IsMatch(sprache, "^[a-z]{2}(-[A-Z]{2})?$"))
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["language"] = [$"„{sprache}“ ist keine Sprachkennung. Erwartet wird etwa „de-DE“ oder „en“."],
|
||||
});
|
||||
await Setzen(db, DatabaseTmdbSettings.LanguageName, sprache);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Results.NoContent();
|
||||
}).RequireAuthorization(FlaroPolicies.Administrator);
|
||||
}
|
||||
|
||||
private static async Task Setzen(FlaroContext db, string schlüssel, string wert)
|
||||
{
|
||||
var vorhanden = await db.Settings.FindAsync(schlüssel);
|
||||
if (vorhanden is null) db.Settings.Add(new ServerSetting { Key = schlüssel, Value = wert });
|
||||
else vorhanden.Value = wert;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using Flaro.Core.Users;
|
||||
using Flaro.Metadata;
|
||||
using Flaro.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Flaro.Api.Endpoints;
|
||||
|
||||
public static class SystemEndpoints
|
||||
{
|
||||
/// <summary>Verhindert, dass zwei gleichzeitige Anfragen zwei Administratoren anlegen.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Ein Prozess, ein Container — ein Semaphor reicht. Ohne ihn liegt
|
||||
/// zwischen „kein Benutzer vorhanden" und dem Anlegen ein Fenster, und
|
||||
/// genau in dieses Fenster passt eine zweite Anfrage.
|
||||
/// </remarks>
|
||||
private static readonly SemaphoreSlim EinrichtungsSchloss = new(1, 1);
|
||||
|
||||
public static void MapSystem(this IEndpointRouteBuilder app)
|
||||
{
|
||||
app.MapGet("/api/v1/system/info", async (FlaroContext db) =>
|
||||
Results.Ok(new SystemInfo(
|
||||
Version: typeof(SystemEndpoints).Assembly.GetName().Version?.ToString(3) ?? "0.0.0",
|
||||
NeedsSetup: !await db.Users.AnyAsync(),
|
||||
// Ohne Anmeldung abrufbar: ein Client muss den Hinweis auch
|
||||
// auf dem Anmeldebildschirm zeigen können.
|
||||
Attributions:
|
||||
[
|
||||
new AttributionResponse(Attribution.Tmdb, Attribution.TmdbLogoUrl, Attribution.TmdbUrl),
|
||||
])))
|
||||
.AllowAnonymous();
|
||||
|
||||
app.MapPost("/api/v1/system/setup", async (SetupRequest anfrage, FlaroContext db) =>
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(anfrage.UserName) || anfrage.Password.Length < 8)
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
["password"] = ["Benutzername darf nicht leer sein, das Passwort braucht mindestens 8 Zeichen."],
|
||||
});
|
||||
|
||||
await EinrichtungsSchloss.WaitAsync();
|
||||
try
|
||||
{
|
||||
// Noch einmal prüfen, innerhalb des Schlosses. Draussen wäre die
|
||||
// Antwort schon veraltet, wenn sie ankommt.
|
||||
if (await db.Users.AnyAsync())
|
||||
return Results.Problem(
|
||||
"Dieser Server ist bereits eingerichtet.", statusCode: StatusCodes.Status409Conflict);
|
||||
|
||||
var administrator = new User
|
||||
{
|
||||
UserName = anfrage.UserName.Trim(),
|
||||
PasswordHash = PasswordHasher.Hash(anfrage.Password),
|
||||
IsAdministrator = true,
|
||||
};
|
||||
db.Users.Add(administrator);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new AuthenticatedUser(administrator.Id, administrator.UserName, true));
|
||||
}
|
||||
finally { EinrichtungsSchloss.Release(); }
|
||||
}).AllowAnonymous();
|
||||
|
||||
// Ohne Anmeldung, damit Docker und Reverse Proxies danach fragen können.
|
||||
// Verrät nichts ausser der Tatsache, dass etwas antwortet.
|
||||
app.MapGet("/health", () => Results.Ok(new { status = "ok" })).AllowAnonymous();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user