Files
vtuber-awards/Backend/Extensions/ServiceCollectionExtensions.cs
T

142 lines
5.6 KiB
C#

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<FrontendOptions>(configuration.GetSection(FrontendOptions.SectionName));
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<AwardsDbContext>(options => options.UseNpgsql(connectionString));
services.AddScoped<IUserSessionRepository, UserSessionRepository>();
services.AddScoped<IRiskFlagRepository, RiskFlagRepository>();
services.AddScoped<IAdminAuditRepository, AdminAuditRepository>();
services.AddScoped<IUserSessionService, UserSessionService>();
services.AddScoped<IRiskRuleService, RiskRuleService>();
services.AddScoped<IRiskFlagService, RiskFlagService>();
services.AddScoped<IAdminAuditService, AdminAuditService>();
services.AddScoped<AdminSessionFilter>();
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<FrontendOptions>();
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);
}
}