Files
vtuber-awards/Backend/Extensions/ServiceCollectionExtensions.cs
AzuTear b53c7fb736 Add viewer-range categories, nomination tracking, dynamic showact form, session timeout, share URLs, and workflow-per-season
Features:
- Category viewer ranges + subcategory templates (admin group modal, tree workspace)
- Nomination enrichment via TwitchTracker API (NominationEnrichmentService,
  TwitchTrackerViewerStatsProvider) with admin tracking rules editor
- Nomination group tracker: CategoryGroupName as primary identifier,
  CategoryId stays as nullable legacy field; StreamerIdentity table
- Dynamic showact application form builder (AdminShowactFormBuilder,
  ShowactApplicationSchedule)
- Session idle timeout setting (AdminSessionTimeoutCard)
- Share URLs for X and Discord (SiteSettings, public extras)
- Workflow rules now stored per season (falls back to global SiteSettings)
- New admin routes: settings/access, settings/workflows, tracking-rules
- New admin review workspace with subcategory tabs
- AdminCategoriesView rebuilt with group/subcategory modals

Migrations (all additive):
- AddShareUrls, AddShowactDynamicForm, AddCategoryViewerRanges,
  AddSessionIdleTimeoutSettings, AddSeasonSubcategoryTemplates,
  AddNominationGroupTrackerIdentity, AddShowactApplicationSchedule,
  AddSeasonWorkflowRulesJson

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 23:32:21 +02:00

153 lines
6.2 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));
services.Configure<TwitchAuthOptions>(configuration.GetSection(TwitchAuthOptions.SectionName));
services.AddMemoryCache();
services.AddHttpClient();
services.AddHttpClient("TwitchTracker", client =>
{
client.Timeout = TimeSpan.FromSeconds(4);
client.DefaultRequestHeaders.UserAgent.ParseAdd("VTuberStarAwards/1.0");
});
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<IViewerStatsProvider, TwitchTrackerViewerStatsProvider>();
services.AddScoped<NominationTrackingReviewService>();
services.AddScoped<NominationEnrichmentService>();
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);
}
}