Refactor app architecture and clean local artifacts

This commit is contained in:
AzuTear
2026-06-24 23:43:14 +02:00
parent 17134b3b82
commit fef1d36fe8
274 changed files with 37724 additions and 6065 deletions
@@ -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);
}
}
@@ -0,0 +1,84 @@
using Backend.Common;
using Backend.Data;
using Backend.Endpoints;
using Backend.Security;
using Microsoft.EntityFrameworkCore;
namespace Backend.Extensions;
public static class WebApplicationExtensions
{
public static void UseApplicationPipeline(this WebApplication app)
{
app.UseExceptionHandler();
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
if (app.Environment.IsDevelopment())
{
app.UseSwagger();
app.UseSwaggerUI();
}
app.UseCors(ApplicationDefaults.FrontendCorsPolicy);
app.UseMiddleware<SecurityHeadersMiddleware>();
app.UseRateLimiter();
if (!app.Environment.IsDevelopment())
{
app.UseHttpsRedirection();
}
}
public static async Task InitializeDatabaseAsync(this WebApplication app)
{
using var scope = app.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AwardsDbContext>();
var logger = scope.ServiceProvider.GetRequiredService<ILoggerFactory>()
.CreateLogger("DatabaseInitialization");
try
{
if (app.Environment.IsDevelopment())
{
await db.Database.MigrateAsync();
}
await SessionBootstrapper.EnsureAsync(db);
await OperationalTablesBootstrapper.EnsureAsync(db);
if (ShouldSeedPresentationData(app))
{
await SeedDataBootstrapper.EnsureAsync(db);
}
}
catch (Exception error)
{
logger.LogError(error, "Database initialization failed. Check the PostgreSQL connection, migrations, and seed data.");
throw;
}
}
public static void MapApplicationEndpoints(this WebApplication app)
{
app.MapSystemEndpoints();
app.MapAuthEndpoints();
app.MapPublicEndpoints();
app.MapAdminEndpoints();
}
private static bool ShouldSeedPresentationData(WebApplication app)
{
var mode = app.Configuration["VTSA_SEED_MODE"]
?? app.Configuration["SeedData:Mode"];
if (string.IsNullOrWhiteSpace(mode))
{
return app.Environment.IsDevelopment();
}
return mode.Trim().ToLowerInvariant() is "demo" or "presentation" or "sample";
}
}