using System.Threading.RateLimiting; using Backend.Common; using Backend.Configuration; using Backend.Data; using Backend.Repositories; using Backend.Security; using Backend.Services; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using System.Globalization; namespace Backend.Extensions; public static class ServiceCollectionExtensions { public static IServiceCollection AddApplicationServices( this IServiceCollection services, IConfiguration configuration, IWebHostEnvironment environment) { services.AddProblemDetails(); services.AddEndpointsApiExplorer(); services.AddSwaggerGen(); services.Configure(configuration.GetSection(FrontendOptions.SectionName)); services.Configure(configuration.GetSection(TwitchAuthOptions.SectionName)); services.AddMemoryCache(); services.AddHttpClient(); var allowedOrigins = ResolveAllowedOrigins(configuration, environment); var connectionString = configuration["VTSA_POSTGRES"] ?? configuration.GetConnectionString("Postgres"); if (string.IsNullOrWhiteSpace(connectionString)) { throw new InvalidOperationException( "No PostgreSQL connection string configured. Set VTSA_POSTGRES or ConnectionStrings:Postgres."); } services.AddCors(options => { options.AddPolicy(ApplicationDefaults.FrontendCorsPolicy, policy => { policy.WithOrigins(allowedOrigins) .WithHeaders("Authorization", "Content-Type") .WithMethods(HttpMethods.Get, HttpMethods.Post, HttpMethods.Put, HttpMethods.Delete); }); }); services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.OnRejected = async (context, cancellationToken) => { context.HttpContext.Response.ContentType = "application/json"; await context.HttpContext.Response.WriteAsJsonAsync( new { message = "Zu viele Anfragen. Bitte kurz warten und erneut versuchen." }, cancellationToken); }; options.AddPolicy(ApplicationDefaults.AuthRateLimitPolicy, context => RateLimitPartition.GetFixedWindowLimiter( partitionKey: BuildRateLimitPartitionKey(context, "auth"), factory: _ => new FixedWindowRateLimiterOptions { PermitLimit = 5, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, AutoReplenishment = true, })); options.AddPolicy(ApplicationDefaults.PublicWriteRateLimitPolicy, context => RateLimitPartition.GetFixedWindowLimiter( partitionKey: BuildRateLimitPartitionKey(context, "public-write"), factory: _ => new FixedWindowRateLimiterOptions { PermitLimit = 20, Window = TimeSpan.FromMinutes(1), QueueLimit = 0, AutoReplenishment = true, })); }); services.AddDbContext(options => options.UseNpgsql(connectionString)); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); return services; } private static string BuildRateLimitPartitionKey(HttpContext context, string policyName) { var ipAddress = context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip"; var route = context.Request.Path.Value ?? "/"; return string.Create( CultureInfo.InvariantCulture, $"{policyName}:{ipAddress}:{route}"); } private static string[] ResolveAllowedOrigins(IConfiguration configuration, IWebHostEnvironment environment) { var frontendOptions = configuration.GetSection(FrontendOptions.SectionName).Get(); var configuredOrigins = frontendOptions?.AllowedOrigins .Select(NormalizeCorsOrigin) .Distinct(StringComparer.OrdinalIgnoreCase) .ToArray() ?? []; if (configuredOrigins.Length > 0) { return configuredOrigins; } if (environment.IsDevelopment()) { return ApplicationDefaults.FrontendOrigins; } throw new InvalidOperationException( "Frontend:AllowedOrigins must be configured in non-development environments."); } private static string NormalizeCorsOrigin(string origin) { var trimmedOrigin = origin.Trim(); if (string.IsNullOrWhiteSpace(trimmedOrigin) || trimmedOrigin.Contains('*', StringComparison.Ordinal)) { throw new InvalidOperationException("CORS origins must be explicit http(s) origins. Wildcards are not allowed."); } if (!Uri.TryCreate(trimmedOrigin, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https") || string.IsNullOrWhiteSpace(uri.Host)) { throw new InvalidOperationException($"Invalid CORS origin configured: {trimmedOrigin}"); } return uri.GetLeftPart(UriPartial.Authority); } }