Refactor app architecture and clean local artifacts
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user