using Microsoft.AspNetCore.DataProtection; 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); // **Mit Wartezeit statt sofortigem Abbruch.** // // SQLite lässt genau einen Schreiber zu. Ohne `Default Timeout` scheitert // jeder, der dazwischenkommt, auf der Stelle mit „database is locked" — // während eines Scans über tausende Dateien ist das jede zweite Anfrage aus // der Oberfläche. Dreissig Sekunden sind grosszügig; gebraucht werden // Millisekunden. builder.Services.AddDbContext(o => o.UseSqlite($"Data Source={pfade.DatabaseFile};Default Timeout=30")); builder.Services .AddAuthentication(TokenAuthenticationHandler.SchemeName) .AddScheme( 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(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // 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(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // 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(); builder.Services.AddScoped(); builder.Services.AddHttpClient(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(sp => sp.GetRequiredService()); // **Eine zweite Bildquelle, und ausdrücklich keine zweite Metadatenquelle.** // fanart.tv liefert Plakate mit Sprachangabe — genau das, was TMDB für // manche Filme nicht hat. Titel, Beschreibung und Besetzung kommen // weiterhin von einer Stelle; zwei Anbieter für dieselbe Angabe wären zwei // Wahrheiten. builder.Services.AddScoped(); builder.Services.AddHttpClient(c => { c.BaseAddress = new Uri(FanartClient.BaseAddress); c.Timeout = TimeSpan.FromSeconds(20); }); builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddSingleton(new ImageCacheOptions(pfade.Cache)); builder.Services.AddHttpClient(c => c.Timeout = TimeSpan.FromSeconds(30)); builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // **Neben dem Umpacker, nicht an seiner Stelle.** Der baut eine fertige Datei // zum Mitnehmen; dieser lässt sie laufen, während sie entsteht. builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // Der Kratzplatz räumt sich auf — kein Dienst, kein Takt, vier Anlässe. builder.Services.AddScoped(); builder.Services.AddSingleton(); // **Der einzige Hintergrunddienst, und mit Bedacht.** Eine Sicherung hängt // an keinem Aufruf: sie soll alle drei Tage geschehen, auch wenn niemand // etwas anklickt. Alles andere Wiederkehrende bleibt beiläufig. builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); // 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(); builder.Services.AddSingleton(); // **Die Schlüssel der Datenverschlüsselung gehören ins Volume.** // // Ohne diese Zeile legt ASP.NET sie unter `~/.aspnet/DataProtection-Keys` ab — // also im Container, der beim nächsten Update weg ist. Heute fällt das nicht // auf, weil Flaros Sitzungs-Cookie das Gerätetoken selbst trägt und nichts // davon verschlüsselt wird. Es fällt erst auf, wenn irgendwann etwas doch // darauf baut, und dann sieht es aus wie ein Fehler anderswo. // // Nebenbei verschwindet damit die Warnung, die bei jedem Start im Protokoll // stand und nach einem Problem aussah, das keines war. var schluesselOrdner = Path.Combine(pfade.Data, "keys"); Directory.CreateDirectory(schluesselOrdner); builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(schluesselOrdner)) .SetApplicationName("Flaro"); 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()) { var db = bereich.ServiceProvider.GetRequiredService(); // **WAL, und zwar vor der Migration.** // // In der Vorgabe („delete") sperrt ein Schreiber die ganze Datei: solange // der Scan schreibt, kommt *keine* Leseanfrage durch. Genau so sah es in // der Oberfläche aus — Bilddialog und Protokoll liefen während eines // Laufs sporadisch ins Leere. Mit WAL lesen beliebig viele weiter, // während einer schreibt. // // Die Einstellung hält sich in der Datei; einmal gesetzt bleibt sie. Sie // trotzdem bei jedem Start zu setzen kostet nichts und deckt den Fall ab, // dass jemand mit einer frischen Datei kommt. await db.Database.ExecuteSqlRawAsync("PRAGMA journal_mode=WAL;"); // Ohne das wartet SQLite nach jeder Schreibung auf die Platte. Mit WAL // ist „normal" die übliche Wahl: ein Absturz des Prozesses kostet nichts, // nur ein Stromausfall mitten im Schreiben könnte die letzte Transaktion // kosten — und die ist bei einem Medienserver ein Scanfortschritt. await db.Database.ExecuteSqlRawAsync("PRAGMA synchronous=NORMAL;"); await db.Database.MigrateAsync(); } // **Auch das Letzte, was durchrutscht, spricht Deutsch.** // // Die Vorgabe von ASP.NET antwortet mit „An error occurred while processing // your request." — englisch, ohne Grund, ohne nächsten Schritt. Genau das // stand im Zuordnungsdialog, als eine Kennung doppelt vergeben werden // sollte. Hier bekommt jede Ausnahme, die es bis nach oben schafft, einen // deutschen Satz; die Einzelheiten gehen ins Tagesprotokoll, wo man sie // sucht. app.UseExceptionHandler(zweig => zweig.Run(async zusammenhang => { var fehler = zusammenhang.Features .Get()?.Error; if (fehler is not null) { zusammenhang.RequestServices.GetRequiredService().Schreiben( $"{zusammenhang.Request.Method} {zusammenhang.Request.Path}: " + Fehlertext.FuersProtokoll(fehler), Tagebuch.Art.Fehler); } zusammenhang.Response.StatusCode = StatusCodes.Status500InternalServerError; await Results.Problem( fehler is null ? "Etwas ist schiefgegangen." : Fehlertext.FuerMenschen(fehler), statusCode: StatusCodes.Status500InternalServerError) .ExecuteAsync(zusammenhang); })); 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(); // **Nach der Anmeldung, vor den Endpunkten.** Davor stünde im Principal noch // nichts, und ein Administrator sähe seine eigene Prüfliste nicht. app.UseFreigabefilter(); app.MapSystem(); app.MapAuth(); app.MapManagement(); app.MapLibraryContent(); app.MapSettings(); app.MapImages(); app.MapDelivery(); app.MapStream(); app.MapPlayback(); app.MapMatching(); app.MapArtwork(); app.MapProfil(); app.MapFreigabe(); app.MapMeldungen(); app.MapEinladungen(); // **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(); } // **Start und Ende gehören ins Protokoll.** // // Ohne das war nicht zu unterscheiden, ob der Server während eines langen // Scans nur beschäftigt oder zwischendurch neu gestartet war — und genau das // macht den Unterschied zwischen „dauert eben" und „stürzt ab". Steht mitten // im Scan ein Start, weiss man es beim nächsten Blick ins Protokoll. { var tagebuch = app.Services.GetRequiredService(); var leben = app.Services.GetRequiredService(); leben.ApplicationStarted.Register(() => { tagebuch.Schreiben($"Server gestartet — {Buildkennung.Beides}"); // **Die Untertitel holen, bevor jemand sie einschaltet.** // // Eine Spur aus einer Datei zu ziehen heisst, die Datei ganz zu // lesen — bei einem Dreistünder auf einem NAS sind das Minuten. // Das im Augenblick des Einschaltens zu tun hiess: zwei Minuten // „wird geholt", und noch immer kein Text. Hier läuft es einmal je // Datei durch, eine nach der anderen, während niemand wartet. // // **Und nicht sofort.** Der Lauf liest die ganze Mediathek von // derselben Platte, von der die Startseite ihre Bilder holt. Wer in // dem Augenblick aufmacht, in dem der Server hochkommt — und das ist // der häufige Fall, denn man startet ihn ja, um etwas zu schauen —, // wartete auf beides zugleich. Eine Minute reicht, damit die erste // Seite steht. // **Zuerst aufräumen.** Nach einem harten Ende liegt jeder // Stromordner noch da und jedes Teilstück eines abgebrochenen // Umpackens — und beim Start ist sicher, dass niemand davon noch // gebraucht wird. _ = app.Services.GetRequiredService() .LaufenAsync(Kratzplatzpflege.Anlass.Start, leben.ApplicationStopping); _ = Task.Run(async () => { try { await Task.Delay(TimeSpan.FromMinutes(1), leben.ApplicationStopping); await app.Services.GetRequiredService() .NachtragenAsync(leben.ApplicationStopping); } catch (OperationCanceledException) { /* Der Server fährt herunter. */ } }); }); leben.ApplicationStopping.Register(() => tagebuch.Schreiben("Server fährt herunter.")); } app.Run(); /// Damit die Tests den Server hochfahren können. public partial class Program;