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(o => o.UseSqlite($"Data Source={pfade.DatabaseFile}")); 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(); // 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(); // 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.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()); 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(); // 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.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().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(); /// Damit die Tests den Server hochfahren können. public partial class Program;