**Was etwas anlegt oder ändert, steht jetzt in einem Dialog.** Das Formular zum Anlegen einer Mediathek nahm als Block in der Seite Platz für etwas weg, das man einmal im Monat braucht — und sah aus wie eine Eingabe, die noch aussteht. Dasselbe für Benutzer, Umbenennen, den Servernamen und die Metadaten-Einstellungen. Gebaut mit dem `<dialog>` des Browsers, nicht mit einem nachgebauten: Escape schliesst, der Hintergrund wird unbedienbar, der Tastaturfokus bleibt gefangen, Vorlesesoftware kündigt ihn als Dialog an. Jede Zeile, die das selbst baut, ist eine Zeile, die es schlechter kann. Scheitert das Speichern, bleibt der Dialog offen — ihn zu schliessen hiesse, die Eingabe wegzuwerfen und die Meldung dorthin zu schreiben, wo gerade niemand hinsieht. **Die Filter sehen nicht mehr nach Eingabe aus.** Bei Plex stehen dort Wörter mit einem kleinen Pfeil; ein umrandetes Auswahlfeld sagt „hier fehlt noch etwas", und das stimmt nicht. Gesetzte Filter werden farbig, daneben steht die Anzahl, und „Filter zurücksetzen" erscheint nur, wenn es etwas zurückzusetzen gibt. Dazu neu: gesehen / noch nicht gesehen. Das siebt vorerst in der Oberfläche, nicht am Server — er kann es noch nicht. Der Preis steht in der Zahl daneben: sie zeigt dann „3 von 7". --- **„Wird abgespielt" — wer gerade was schaut.** Abgeleitet aus den Fortschrittsmeldungen, nicht aus einer eigenen Anmeldung am Spieler: jeder Client meldet beim Abspielen ohnehin alle zehn Sekunden, wo er steht. Wer das als Lebenszeichen liest, braucht kein zweites Verfahren, das ein Client vergessen könnte — bleibt die Meldung aus, läuft der Eintrag nach einer Minute von selbst ab. Im Arbeitsspeicher, nicht in der Datenbank: „wer schaut gerade was" ist eine Frage über den Augenblick. Nach einem Neustart lautet die Antwort „niemand", und das zu speichern hiesse, Wiedergaben anzuzeigen, die es nicht gibt. Zwei Geräte desselben Menschen sind zwei Zeilen — derselbe Mensch kann auf dem Fernseher und auf dem Telefon gleichzeitig etwas laufen lassen. Geschlüsselt wird deshalb über das Gerätetoken, nicht über den Benutzer und nicht über den Gerätenamen: zwei iPhones heissen beide „iPhone". **Nur für Administratoren.** Wer wann welchen Film anschaut, ist eine Auskunft über Menschen, nicht über den Server. In einem Haushalt mag das harmlos sein; die Vorgabe eines offenen Projekts darf es trotzdem nicht sein. Der Spieler meldet beim Schliessen ausdrücklich „beendet". Ohne das stünde ein geschlossener Film noch eine Minute als „läuft" — und wer gerade den Spieler zugemacht hat, sähe sich selbst beim Schauen zu. --- **`docs/NAS-EINRICHTEN.md`** beantwortet die Frage, die offen war: **es ist ein einziges Image.** Die Weboberfläche wird beim Bauen hineingelegt und vom Server selbst ausgeliefert — ein Container, drei Volumes, kein Proxy, keine Datenbank daneben. Drei Wege aufs NAS: über die Container-Registry deines Gitea (die läuft — nachgeprüft), als Datei über `docker save`, oder dort bauen. Dazu der Schritt, den man am leichtesten überspringt und am teuersten bezahlt: die Architektur. Ein Mac baut ohne Angabe arm64, ein Intel-NAS braucht amd64, und `exec format error` sagt das nicht deutlich. Eine Zahl darin war geraten und ist korrigiert: `docker save | gzip` ergibt nicht 90 MB, sondern 230 — die Ebenen eines Images sind bereits gepackt. 378 Tests grün, sechs davon neu. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
215 lines
9.2 KiB
C#
215 lines
9.2 KiB
C#
using System.Threading.RateLimiting;
|
|
using Flaro.Api;
|
|
using Flaro.Api.Auth;
|
|
using Flaro.Api.Endpoints;
|
|
using Flaro.Core.Metadata;
|
|
using Flaro.Data;
|
|
using Flaro.Core.Library;
|
|
using Flaro.Media;
|
|
using Flaro.Metadata;
|
|
using Microsoft.AspNetCore.Authentication;
|
|
using Microsoft.AspNetCore.RateLimiting;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Net.Http.Headers;
|
|
|
|
var builder = WebApplication.CreateBuilder(args);
|
|
|
|
var pfade = FlaroPaths.FromEnvironment(builder.Environment.ContentRootPath);
|
|
pfade.EnsureExists();
|
|
builder.Services.AddSingleton(pfade);
|
|
builder.Services.AddSingleton(TimeProvider.System);
|
|
|
|
builder.Services.AddDbContext<FlaroContext>(o => o.UseSqlite($"Data Source={pfade.DatabaseFile}"));
|
|
|
|
builder.Services
|
|
.AddAuthentication(TokenAuthenticationHandler.SchemeName)
|
|
.AddScheme<AuthenticationSchemeOptions, TokenAuthenticationHandler>(
|
|
TokenAuthenticationHandler.SchemeName, _ => { });
|
|
|
|
builder.Services.AddAuthorizationBuilder()
|
|
// Jeder Endpunkt verlangt Anmeldung, sofern er sie nicht ausdrücklich
|
|
// abbestellt. Andersherum — jeden einzeln absichern — ist der Entwurf, bei
|
|
// dem irgendwann einer vergessen wird.
|
|
.SetFallbackPolicy(new Microsoft.AspNetCore.Authorization.AuthorizationPolicyBuilder()
|
|
.RequireAuthenticatedUser().Build())
|
|
.AddPolicy(FlaroPolicies.Administrator, p => p.RequireRole(FlaroRoles.Administrator));
|
|
|
|
// Einstellbar, weil die richtige Zahl vom Aufbau abhängt: steht Flaro hinter
|
|
// einem Reverse Proxy, erscheinen **alle** Benutzer unter derselben Adresse
|
|
// und teilen sich einen Eimer. Wer das nicht weiss, hält die eigene App für
|
|
// kaputt, während in Wahrheit der Nachbar im selben Haus gerade angemeldet hat.
|
|
//
|
|
// NOCH OFFEN: `X-Forwarded-For` auswerten, damit die Aufteilung auch hinter
|
|
// einem Proxy je Benutzer greift. Blind zu vertrauen wäre falsch — es braucht
|
|
// eine Liste vertrauenswürdiger Gegenstellen.
|
|
int grenze(string name, int voreinstellung) =>
|
|
int.TryParse(Environment.GetEnvironmentVariable(name), out var wert) && wert > 0 ? wert : voreinstellung;
|
|
|
|
builder.Services.AddRateLimiter(o =>
|
|
{
|
|
o.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
|
var beimAnmelden = grenze("FLARO_RATELIMIT_SIGNIN", 10);
|
|
var beimNachfragen = grenze("FLARO_RATELIMIT_POLLING", 120);
|
|
|
|
// Je Absender, nicht global: sonst sperrt ein einzelner Angreifer den
|
|
// ganzen Haushalt aus, und die Abwehr wäre selbst der Ausfall.
|
|
o.AddPolicy(FlaroRateLimits.SignIn, anfrage => RateLimitPartition.GetFixedWindowLimiter(
|
|
anfrage.Connection.RemoteIpAddress?.ToString() ?? "unbekannt",
|
|
_ => new FixedWindowRateLimiterOptions { PermitLimit = beimAnmelden, Window = TimeSpan.FromMinutes(1) }));
|
|
|
|
o.AddPolicy(FlaroRateLimits.Polling, anfrage => RateLimitPartition.GetFixedWindowLimiter(
|
|
anfrage.Connection.RemoteIpAddress?.ToString() ?? "unbekannt",
|
|
_ => new FixedWindowRateLimiterOptions { PermitLimit = beimNachfragen, Window = TimeSpan.FromMinutes(1) }));
|
|
});
|
|
|
|
// ffmpeg als eigener Prozess, nicht eingebunden: hält die Lizenzen getrennt
|
|
// und lässt einen Absturz dort den Server nicht mitnehmen.
|
|
builder.Services.AddSingleton(FfmpegOptions.FromEnvironment());
|
|
builder.Services.AddSingleton<IMediaProbe, FfprobeRunner>();
|
|
builder.Services.AddSingleton<FfmpegRemuxer>();
|
|
|
|
// Beiwerk im Medienordner — standardmässig aus. In fremde Medienordner zu
|
|
// schreiben ist nichts, was ein Server ungefragt tun sollte.
|
|
builder.Services.AddSingleton(SidecarOptions.FromEnvironment());
|
|
builder.Services.AddSingleton<ISidecarStore, SidecarWriter>();
|
|
|
|
builder.Services.AddScoped<LibraryScanner>();
|
|
|
|
// Metadaten. Der Anbieter steckt hinter einer Schnittstelle, damit alles, was
|
|
// ihn benutzt, ohne Netz und ohne Schlüssel geprüft werden kann.
|
|
builder.Services.AddScoped<ITmdbSettingsSource, DatabaseTmdbSettings>();
|
|
builder.Services.AddHttpClient<TmdbClient>(c =>
|
|
{
|
|
c.BaseAddress = new Uri(TmdbClient.BaseAddress);
|
|
// Ein hängender fremder Dienst darf einen Scan nicht festsetzen.
|
|
c.Timeout = TimeSpan.FromSeconds(20);
|
|
});
|
|
builder.Services.AddScoped<IMetadataProvider>(sp => sp.GetRequiredService<TmdbClient>());
|
|
builder.Services.AddSingleton(new ImageCacheOptions(pfade.Cache));
|
|
builder.Services.AddHttpClient<ImageCache>(c => c.Timeout = TimeSpan.FromSeconds(30));
|
|
builder.Services.AddScoped<IImageStore>(sp => sp.GetRequiredService<ImageCache>());
|
|
builder.Services.AddScoped<IImageReader>(sp => sp.GetRequiredService<ImageCache>());
|
|
builder.Services.AddScoped<MetadataEnricher>();
|
|
builder.Services.AddScoped<MatchApplier>();
|
|
builder.Services.AddSingleton<ScanCoordinator>();
|
|
builder.Services.AddSingleton<PackageCoordinator>();
|
|
// Im Arbeitsspeicher und nur dort: wer gerade was schaut, ist eine Frage
|
|
// ueber den Augenblick. Nach einem Neustart lautet die Antwort: niemand.
|
|
builder.Services.AddSingleton<PlaybackSessions>();
|
|
builder.Services.AddProblemDetails();
|
|
|
|
var app = builder.Build();
|
|
|
|
// Beim Start wandern, nicht über ein eigenes Kommando: wer den Container
|
|
// hochfährt, soll nicht erst eine Migration von Hand anstossen müssen.
|
|
using (var bereich = app.Services.CreateScope())
|
|
{
|
|
await bereich.ServiceProvider.GetRequiredService<FlaroContext>().Database.MigrateAsync();
|
|
}
|
|
|
|
app.UseExceptionHandler();
|
|
app.UseStatusCodePages();
|
|
app.UseRateLimiter();
|
|
|
|
// --- Die Oberfläche -------------------------------------------------------
|
|
//
|
|
// **Vom Server selbst ausgeliefert, nicht von einem Proxy davor.** Damit
|
|
// liegen Seite und Schnittstelle unter derselben Adresse: kein CORS, kein
|
|
// Mischmasch aus http und https, und der Zugang bleibt beim Server statt im
|
|
// Browser. Der Node-Proxy aus der Jellyfin-Zeit wird dadurch überflüssig.
|
|
//
|
|
// **Vor der Anmeldung, und das ist kein Versehen.** Die Rückfallregel weiter
|
|
// oben verlangt für jede Anfrage ohne eigenen Endpunkt einen angemeldeten
|
|
// Benutzer — und `/assets/index.js` hat keinen. Stünde das hier unter der
|
|
// Anmeldung, bekäme ein neuer Besucher 401 auf genau das Skript, das ihm die
|
|
// Anmeldemaske zeichnen soll. Die Seite selbst ist kein Geheimnis; sie zeigt
|
|
// ohne gültigen Zugang nichts als ein Anmeldefeld.
|
|
var oberfläche = Path.Combine(app.Environment.ContentRootPath, "wwwroot");
|
|
if (Directory.Exists(oberfläche))
|
|
{
|
|
app.UseDefaultFiles();
|
|
|
|
// **Zwei Lebensdauern, und die Unterscheidung ist nötig.**
|
|
//
|
|
// Die Dateien unter `assets/` tragen eine Prüfsumme im Namen: ändert
|
|
// sich ihr Inhalt, ändert sich der Name. Sie dürfen deshalb für immer im
|
|
// Zwischenspeicher liegen — und sollen es auch, sonst lädt jeder Besuch
|
|
// dasselbe Skript erneut.
|
|
//
|
|
// `index.html` trägt keine. Sie nennt die Namen der Skripte, und wer sie
|
|
// aus dem Zwischenspeicher nimmt, bekommt nach einem Server-Update
|
|
// weiter die alte App — nicht kaputt, sondern **veraltet**, und das
|
|
// fällt niemandem auf. Genau das ist hier passiert: der Browser lud
|
|
// `index-C0K3V8eg.js`, während der Server längst `index-DomHN4_b.js`
|
|
// auslieferte.
|
|
app.UseStaticFiles(new StaticFileOptions
|
|
{
|
|
OnPrepareResponse = zusammenhang =>
|
|
{
|
|
var name = zusammenhang.File.Name;
|
|
var kopf = zusammenhang.Context.Response.GetTypedHeaders();
|
|
|
|
if (name.EndsWith(".html", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
kopf.CacheControl = new CacheControlHeaderValue
|
|
{
|
|
NoCache = true,
|
|
MustRevalidate = true,
|
|
};
|
|
}
|
|
else
|
|
{
|
|
kopf.CacheControl = new CacheControlHeaderValue
|
|
{
|
|
Public = true,
|
|
MaxAge = TimeSpan.FromDays(365),
|
|
Extensions = { new NameValueHeaderValue("immutable") },
|
|
};
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
app.UseAuthentication();
|
|
app.UseAuthorization();
|
|
|
|
app.MapSystem();
|
|
app.MapAuth();
|
|
app.MapManagement();
|
|
app.MapLibraryContent();
|
|
app.MapSettings();
|
|
app.MapImages();
|
|
app.MapDelivery();
|
|
app.MapPlayback();
|
|
app.MapMatching();
|
|
|
|
// **Was unter /api nicht beantwortet wurde, ist nicht da.** Ohne diese Zeile
|
|
// fängt der Rückfall darunter jeden unbekannten Pfad ab und liefert die
|
|
// Webseite mit Status 200 — ein vertippter Aufruf sieht dann aus wie ein
|
|
// Erfolg, und der Fehler fällt erst dort auf, wo HTML verarbeitet wird.
|
|
// Buchstaben schlagen Platzhalter, also gewinnt jeder echte Endpunkt hier.
|
|
app.Map("/api/{*rest}", () => Results.NotFound()).AllowAnonymous();
|
|
|
|
// Alles Übrige bekommt die Seite — der Router läuft im Browser. Ohne das
|
|
// endet ein Neuladen auf /verwaltung in einem 404.
|
|
if (Directory.Exists(oberfläche))
|
|
{
|
|
// Auch hier `no-cache`: der Rückfall liefert dieselbe `index.html`, und
|
|
// sie über diesen Weg zwischenspeichern zu lassen hätte genau denselben
|
|
// Effekt wie oben.
|
|
app.MapFallback((HttpContext zusammenhang) =>
|
|
{
|
|
zusammenhang.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue
|
|
{
|
|
NoCache = true,
|
|
MustRevalidate = true,
|
|
};
|
|
return Results.File(Path.Combine(oberfläche, "index.html"), "text/html");
|
|
}).AllowAnonymous();
|
|
}
|
|
|
|
app.Run();
|
|
|
|
/// <summary>Damit die Tests den Server hochfahren können.</summary>
|
|
public partial class Program;
|