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; 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(); 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(); app.UseStaticFiles(); } app.UseAuthentication(); app.UseAuthorization(); app.MapSystem(); app.MapAuth(); app.MapManagement(); app.MapLibraryContent(); app.MapSettings(); app.MapImages(); app.MapDelivery(); app.MapMatching(); // Alles, was keine Schnittstelle ist, bekommt die Seite — der Router läuft // im Browser. Ohne das endet ein Neuladen auf /admin in einem 404. if (Directory.Exists(oberfläche)) { app.MapFallback(() => 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;