using System.Text; using System.Threading.RateLimiting; using LehrerApp.Api; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.RateLimiting; using Microsoft.IdentityModel.Tokens; var builder = WebApplication.CreateBuilder(args); builder.WebHost.UseKestrel(o => { var port = builder.Configuration.GetValue("Api:Port", 5000); o.ListenAnyIP(port); // Anhänge sind clientseitig auf 10 MB begrenzt (IAttachmentStorage.MaxSizeBytes) - etwas // Puffer für Verschlüsselungs-Overhead/JSON-Ereignisse, aber trotzdem eine harte Obergrenze. o.Limits.MaxRequestBodySize = 15 * 1024 * 1024; }); var data = builder.Configuration["Api:DataPath"] ?? "./data"; if (args.Length > 0 && args[0] == "create-user") return await Cli.RunCreateUserAsync(data, args); if (args.Length > 0 && args[0] == "set-password") return await Cli.RunSetPasswordAsync(data, args); var secret = builder.Configuration["JWT_SECRET"] ?? throw new InvalidOperationException("JWT_SECRET nicht konfiguriert."); var outboundUserAgent = builder.Configuration["Geocoding:UserAgent"] ?? "LehrerApp-Server/1.0 (+https://science-teaching.de)"; builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(o => o.TokenValidationParameters = new() { ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secret)), ValidateIssuer = false, ValidateAudience = false, ClockSkew = TimeSpan.FromMinutes(5), }); builder.Services.AddAuthorization(); // Läuft hinter einem Reverse Proxy (Dokploy/Traefik) - ohne diese Weiterleitung sähe Kestrel für // JEDE Anfrage dieselbe interne Proxy-IP als Connection.RemoteIpAddress, wodurch das unten // definierte Pro-IP-Rate-Limit faktisch zu einem einzigen globalen Limit für die gesamte // Bereitstellung würde (Bug, siehe TODO.md 10.2.2 Nachtrag). KnownNetworks/KnownProxies bewusst // geleert: der Container ist nur über den Reverse Proxy erreichbar, nie direkt aus dem Internet, // daher ist der unmittelbare Absender von X-Forwarded-For hier immer vertrauenswürdig. builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; options.KnownIPNetworks.Clear(); options.KnownProxies.Clear(); }); // Rate Limiting (10.2.2): striktes Limit gezielt gegen Brute-Force auf /api/auth/login, plus ein // grobes globales Limit pro IP als einfacher Schutz vor Überlastung der übrigen Endpunkte. builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddFixedWindowLimiter("login", o => { o.PermitLimit = 5; o.Window = TimeSpan.FromMinutes(1); o.QueueLimit = 0; }); options.GlobalLimiter = PartitionedRateLimiter.Create(context => RateLimitPartition.GetFixedWindowLimiter( partitionKey: context.Connection.RemoteIpAddress?.ToString() ?? "unknown", factory: _ => new FixedWindowRateLimiterOptions { PermitLimit = 120, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, })); }); builder.Services.AddSingleton(_ => new UserStore(data)); builder.Services.AddSingleton(_ => new AttachmentStore(data)); builder.Services.AddSingleton(_ => new EventStore(data)); builder.Services.AddSingleton(_ => new SnapshotStore(data)); builder.Services.AddSingleton(_ => new ReadableSnapshotStore(data)); builder.Services.AddSingleton(sp => new PlainEventStore(sp.GetRequiredService())); builder.Services.AddSingleton(_ => new SchoolWeatherStore(data)); builder.Services.AddHttpClient(client => { client.BaseAddress = new Uri("https://nominatim.openstreetmap.org/"); client.DefaultRequestHeaders.UserAgent.ParseAdd(outboundUserAgent); client.Timeout = TimeSpan.FromSeconds(15); }); builder.Services.AddHttpClient("dwd", client => { client.DefaultRequestHeaders.UserAgent.ParseAdd(outboundUserAgent); client.Timeout = TimeSpan.FromSeconds(30); }); builder.Services.AddSingleton(sp => new DwdWeatherService( sp.GetRequiredService().CreateClient("dwd"))); builder.Services.Configure(options => { builder.Configuration.GetSection("WebUntis").Bind(options); options.School = builder.Configuration["WEBUNTIS_SCHOOL"] ?? options.School; options.Host = builder.Configuration["WEBUNTIS_HOST"] ?? options.Host; options.Username = builder.Configuration["WEBUNTIS_USER"] ?? options.Username; options.Password = builder.Configuration["WEBUNTIS_PASSWORD"] ?? options.Password; options.Client = builder.Configuration["WEBUNTIS_CLIENT"] ?? options.Client; if (int.TryParse(builder.Configuration["WEBUNTIS_SESSION_IDLE_MINUTES"], out var idleMinutes)) options.SessionIdleTimeoutMinutes = idleMinutes; }); builder.Services.AddHttpClient("webuntis", client => { // Die einzelnen WebUntis-Schritte besitzen eigene Timeouts; insbesondere ein asynchron // erzeugter Schülerreport darf länger als der HttpClient-Standardtimeout pollen. client.Timeout = Timeout.InfiniteTimeSpan; }); // Der Client hält eine WebUntis-Session über mehrere API-Aufrufe hinweg. Deshalb muss seine // Lebensdauer der Serveranwendung entsprechen und darf nicht pro HTTP-Anfrage neu beginnen. builder.Services.AddSingleton(sp => new WebUntisClient( sp.GetRequiredService().CreateClient("webuntis"), sp.GetRequiredService>())); builder.Services.AddSingleton(); var app = builder.Build(); app.UseForwardedHeaders(); app.UseRateLimiter(); // Muss vor Auth/Endpoints laufen: inkompatible Clients dürfen keinen Sync-Store erreichen. app.UseMiddleware(); app.UseAuthentication(); app.UseAuthorization(); app.MapAuthEndpoints(secret); app.MapSyncEndpoints(); app.MapAttachmentEndpoints(); app.MapSnapshotEndpoints(); app.MapReadableSnapshotEndpoints(); app.MapPlainSyncEndpoints(); app.MapSchoolWeatherEndpoints(); app.MapWebUntisEndpoints(); app.Run(); return 0;