using System.Text.Json; using Backend.Common; using Backend.Contracts; using Backend.Data; using Backend.Domain; using Backend.Security; using Backend.Services; using Microsoft.EntityFrameworkCore; namespace Backend.Endpoints; public static class AdminSiteSettingsEndpoints { private const string FallbackMaintenanceTitle = "Sternenpause"; private const string FallbackMaintenanceMessage = "Die Award-Galaxie wird gerade liebevoll poliert. Schau gleich wieder vorbei."; public static RouteGroupBuilder MapAdminSiteSettingsEndpoints(this RouteGroupBuilder group) { group.MapGet("/site-settings", GetSiteSettings) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Content)) .WithName("GetAdminSiteSettings") .WithOpenApi(); group.MapPut("/site-settings", UpdateSiteSettings) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Content)) .WithName("UpdateAdminSiteSettings") .WithOpenApi(); group.MapGet("/operational-settings", GetOperationalSettings) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings)) .WithName("GetAdminOperationalSettings") .WithOpenApi(); group.MapPut("/operational-settings", UpdateOperationalSettings) .AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings)) .WithName("UpdateAdminOperationalSettings") .WithOpenApi(); return group; } private static async Task GetSiteSettings(AwardsDbContext db) { var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); if (settings is null) { return Results.NotFound(); } return Results.Ok(new AdminSiteSettingsResponse( settings.HostDisplayName, settings.HostTagline, settings.NewsletterUrl, settings.PrivacyEmail, settings.PrivacyPolicyContent, settings.PrivacyPolicyUpdatedBy, settings.PrivacyPolicyUpdatedAt, settings.ImprintUrl, settings.ImprintContent, settings.ContactUrl, settings.ContactContent, settings.SponsorsUrl, settings.SponsorsContent, SeasonMappings.ReadSocialLinks(settings), SeasonMappings.ReadFaqItems(settings))); } private static async Task UpdateSiteSettings( HttpContext context, UpdateSiteSettingsRequest request, AwardsDbContext db, IAdminAuditService adminAuditService) { var session = AdminEndpointConventions.CurrentSession(context); var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); if (settings is null) { return Results.NotFound(); } var urlValidationError = NormalizeSiteSettingsUrls(request, out var normalizedUrls, out var socialLinks); if (urlValidationError is not null) { return urlValidationError; } settings.HostDisplayName = request.HostDisplayName.Trim(); settings.HostTagline = request.HostTagline.Trim(); settings.NewsletterUrl = normalizedUrls.NewsletterUrl; settings.PrivacyEmail = request.PrivacyEmail.Trim(); var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim(); var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal); settings.PrivacyPolicyContent = trimmedPrivacyContent; if (privacyChanged) { settings.PrivacyPolicyUpdatedBy = session.DisplayName.Trim(); settings.PrivacyPolicyUpdatedAt = DateTimeOffset.UtcNow; } settings.ImprintUrl = normalizedUrls.ImprintUrl; settings.ImprintContent = request.ImprintContent.Trim(); settings.ContactUrl = normalizedUrls.ContactUrl; settings.ContactContent = request.ContactContent.Trim(); settings.SponsorsUrl = normalizedUrls.SponsorsUrl; settings.SponsorsContent = request.SponsorsContent.Trim(); settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks); settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []); adminAuditService.AddEntry( session.TwitchUserId, "site-settings.update", "site-settings", settings.Id.ToString(), "Public Site Settings wurden aktualisiert.", new { settings.HostDisplayName, privacyChanged, socialLinkCount = socialLinks.Length, faqCount = request.Faq?.Length ?? 0, }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(new { saved = true }); } private static IResult? NormalizeSiteSettingsUrls( UpdateSiteSettingsRequest request, out PublicSiteUrlSettings normalizedUrls, out PublicSocialLinkDto[] socialLinks) { normalizedUrls = new PublicSiteUrlSettings(); socialLinks = []; if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage) || !TryNormalizePublicUrl(request.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage) || !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, out errorMessage) || !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage)) { return Results.BadRequest(new { message = errorMessage }); } normalizedUrls = new PublicSiteUrlSettings { NewsletterUrl = newsletterUrl, ImprintUrl = imprintUrl, ContactUrl = contactUrl, SponsorsUrl = sponsorsUrl, }; var normalizedSocialLinks = new List(); foreach (var social in request.SocialLinks ?? []) { if (!TryNormalizePublicUrl(social.Url, $"Social-Link {social.Label}", out var normalizedUrl, out errorMessage)) { return Results.BadRequest(new { message = errorMessage }); } normalizedSocialLinks.Add(social with { Url = normalizedUrl }); } socialLinks = normalizedSocialLinks.ToArray(); return null; } private static bool TryNormalizePublicUrl(string? value, string fieldName, out string normalizedUrl, out string errorMessage) { normalizedUrl = (value ?? string.Empty).Trim(); errorMessage = string.Empty; if (string.IsNullOrWhiteSpace(normalizedUrl)) { return true; } if (!Uri.TryCreate(normalizedUrl, UriKind.Absolute, out var uri) || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) { errorMessage = $"{fieldName} muss eine absolute http/https-URL sein."; return false; } normalizedUrl = uri.ToString(); return true; } private sealed class PublicSiteUrlSettings { public string NewsletterUrl { get; set; } = string.Empty; public string ImprintUrl { get; set; } = string.Empty; public string ContactUrl { get; set; } = string.Empty; public string SponsorsUrl { get; set; } = string.Empty; } private static async Task GetOperationalSettings(AwardsDbContext db, IConfiguration configuration) { var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1); if (settings is null) { return Results.NotFound(); } var usesDatabaseDemo = settings.DemoLoginManagedByDatabase || HasDatabaseDemoCredentials(settings); var twitchSettings = ReadEffectiveTwitchSettings(settings, configuration); return Results.Ok(new AdminOperationalSettingsResponse( usesDatabaseDemo, usesDatabaseDemo ? settings.DemoLoginEnabled : IsDemoLoginEnabled(configuration), usesDatabaseDemo ? settings.DemoLoginEmail : ReadDemoLoginIdentifier(configuration), usesDatabaseDemo ? HasDatabaseDemoCredentials(settings) : !string.IsNullOrWhiteSpace(ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD")), usesDatabaseDemo ? settings.DemoLoginTwitchUserId : ReadDemoSetting(configuration, "TwitchUserId", "VTSA_DEMO_ADMIN_TWITCH_ID"), usesDatabaseDemo ? settings.DemoLoginDisplayName : ReadDemoSetting(configuration, "DisplayName", "VTSA_DEMO_ADMIN_DISPLAY_NAME"), settings.TwitchAuthManagedByDatabase, twitchSettings.Configured, twitchSettings.ClientId, twitchSettings.ClientSecretSet, twitchSettings.RedirectUri, twitchSettings.Scope, settings.MaintenanceModeEnabled, string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle, string.IsNullOrWhiteSpace(settings.MaintenanceMessage) ? FallbackMaintenanceMessage : settings.MaintenanceMessage)); } private static async Task UpdateOperationalSettings( HttpContext context, UpdateOperationalSettingsRequest request, AwardsDbContext db, IConfiguration configuration, IAdminAuditService adminAuditService) { var session = AdminEndpointConventions.CurrentSession(context); if (!AdminRoles.CanManageOperationalSettings(session.Role)) { return Results.Json(new { message = "Demo-Zugang und Wartungsmodus können nur Owner ändern." }, statusCode: StatusCodes.Status403Forbidden); } var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); if (settings is null) { return Results.NotFound(); } var before = CreateOperationalSettingsSnapshot(settings, configuration); var loginIdentifier = request.DemoLoginEmail.Trim(); var twitchUserId = request.DemoLoginTwitchUserId.Trim(); var displayName = request.DemoLoginDisplayName.Trim(); var newPassword = request.DemoLoginPassword?.Trim() ?? string.Empty; var fallbackPassword = ReadDemoSetting(configuration, "Password", "VTSA_DEMO_ADMIN_PASSWORD"); var twitchClientId = request.TwitchClientId.Trim(); var twitchClientSecret = request.TwitchClientSecret?.Trim() ?? string.Empty; var twitchRedirectUri = request.TwitchRedirectUri.Trim(); var twitchScope = request.TwitchScope.Trim(); var existingTwitchSecretAvailable = !string.IsNullOrWhiteSpace(settings.TwitchClientSecret) || !string.IsNullOrWhiteSpace(ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET")); if (request.DemoLoginEnabled) { if (string.IsNullOrWhiteSpace(loginIdentifier) || string.IsNullOrWhiteSpace(twitchUserId) || string.IsNullOrWhiteSpace(displayName)) { return Results.BadRequest(new { message = "Demo-Login braucht Login, Twitch-ID und Anzeigenamen." }); } if (string.IsNullOrWhiteSpace(newPassword) && !HasDatabaseDemoCredentials(settings) && string.IsNullOrWhiteSpace(fallbackPassword)) { return Results.BadRequest(new { message = "Bitte setze beim ersten Aktivieren ein Demo-Passwort." }); } } if (!string.IsNullOrWhiteSpace(newPassword) && newPassword.Length < 10) { return Results.BadRequest(new { message = "Das Demo-Passwort muss mindestens 10 Zeichen lang sein." }); } if (HasAnyTwitchAuthSetting(twitchClientId, twitchClientSecret, twitchRedirectUri, twitchScope) && string.IsNullOrWhiteSpace(twitchClientId)) { return Results.BadRequest(new { message = "Twitch OAuth braucht eine Client-ID." }); } if (!string.IsNullOrWhiteSpace(twitchClientId) && string.IsNullOrWhiteSpace(twitchClientSecret) && !existingTwitchSecretAvailable) { return Results.BadRequest(new { message = "Twitch OAuth braucht beim ersten Speichern ein Client Secret." }); } settings.DemoLoginManagedByDatabase = true; settings.DemoLoginEnabled = request.DemoLoginEnabled; settings.DemoLoginEmail = loginIdentifier; settings.DemoLoginTwitchUserId = string.IsNullOrWhiteSpace(twitchUserId) ? "jayuhime_admin" : twitchUserId; settings.DemoLoginDisplayName = string.IsNullOrWhiteSpace(displayName) ? "Jayuhime Admin" : displayName; settings.TwitchClientId = twitchClientId; settings.TwitchRedirectUri = twitchRedirectUri; settings.TwitchScope = twitchScope; if (!string.IsNullOrWhiteSpace(twitchClientSecret)) { settings.TwitchClientSecret = twitchClientSecret; } else if (string.IsNullOrWhiteSpace(twitchClientId) && string.IsNullOrWhiteSpace(twitchRedirectUri) && string.IsNullOrWhiteSpace(twitchScope)) { settings.TwitchClientSecret = string.Empty; } settings.TwitchAuthManagedByDatabase = HasAnyTwitchAuthSetting( settings.TwitchClientId, settings.TwitchClientSecret, settings.TwitchRedirectUri, settings.TwitchScope); var passwordToPersist = !string.IsNullOrWhiteSpace(newPassword) ? newPassword : request.DemoLoginEnabled && !HasDatabaseDemoCredentials(settings) ? fallbackPassword : string.Empty; if (!string.IsNullOrWhiteSpace(passwordToPersist)) { var credentials = DemoCredentialHasher.HashPassword(passwordToPersist); settings.DemoLoginPasswordHash = credentials.Hash; settings.DemoLoginPasswordSalt = credentials.Salt; } settings.MaintenanceModeEnabled = request.MaintenanceModeEnabled; settings.MaintenanceTitle = NormalizeOperationalText(request.MaintenanceTitle, FallbackMaintenanceTitle, 120); settings.MaintenanceMessage = NormalizeOperationalText( request.MaintenanceMessage, FallbackMaintenanceMessage, 600); var changes = BuildOperationalSettingChanges( before, CreateOperationalSettingsSnapshot(settings, configuration), !string.IsNullOrWhiteSpace(passwordToPersist), !string.IsNullOrWhiteSpace(twitchClientSecret)); adminAuditService.AddEntry( session.TwitchUserId, "operational-settings.update", "site-settings", settings.Id.ToString(), "Demo-Zugang und Wartungsmodus wurden aktualisiert.", new { settings.DemoLoginEnabled, passwordChanged = !string.IsNullOrWhiteSpace(passwordToPersist), settings.MaintenanceModeEnabled, changes, }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(new { saved = true, demoLoginPasswordSet = HasDatabaseDemoCredentials(settings), twitchClientSecretSet = HasEffectiveTwitchClientSecret(settings, configuration), }); } private static string NormalizeOperationalText(string value, string fallback, int maxLength) { var trimmed = value.Trim(); if (string.IsNullOrWhiteSpace(trimmed)) { return fallback; } return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength]; } private static bool IsDemoLoginEnabled(IConfiguration configuration) { var rawValue = configuration["VTSA_DEMO_LOGIN_ENABLED"] ?? configuration["DemoAdmin:Enabled"]; return bool.TryParse(rawValue, out var enabled) && enabled; } private static string ReadDemoSetting(IConfiguration configuration, string key, string environmentKey) => configuration[environmentKey] ?? configuration[$"DemoAdmin:{key}"] ?? string.Empty; private static string ReadTwitchSetting(IConfiguration configuration, string key, string environmentKey) => configuration[environmentKey] ?? configuration[$"TwitchAuth:{key}"] ?? string.Empty; private static string ReadDemoLoginIdentifier(IConfiguration configuration) { var configuredLogin = ReadDemoSetting(configuration, "Login", "VTSA_DEMO_ADMIN_LOGIN"); return string.IsNullOrWhiteSpace(configuredLogin) ? ReadDemoSetting(configuration, "Email", "VTSA_DEMO_ADMIN_EMAIL") : configuredLogin; } private static bool HasDatabaseDemoCredentials(SiteSettings settings) => !string.IsNullOrWhiteSpace(settings.DemoLoginEmail) && !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordHash) && !string.IsNullOrWhiteSpace(settings.DemoLoginPasswordSalt); private static bool HasAnyTwitchAuthSetting(params string[] values) => values.Any(value => !string.IsNullOrWhiteSpace(value)); private static bool HasEffectiveTwitchClientSecret(SiteSettings settings, IConfiguration configuration) => !string.IsNullOrWhiteSpace(settings.TwitchClientSecret) || !string.IsNullOrWhiteSpace(ReadTwitchSetting(configuration, "ClientSecret", "VTSA_TWITCH_CLIENT_SECRET")); private static TwitchSettingsSnapshot ReadEffectiveTwitchSettings(SiteSettings settings, IConfiguration configuration) { var clientId = !string.IsNullOrWhiteSpace(settings.TwitchClientId) ? settings.TwitchClientId : ReadTwitchSetting(configuration, "ClientId", "VTSA_TWITCH_CLIENT_ID"); var redirectUri = !string.IsNullOrWhiteSpace(settings.TwitchRedirectUri) ? settings.TwitchRedirectUri : ReadTwitchSetting(configuration, "RedirectUri", "VTSA_TWITCH_REDIRECT_URI"); var scope = !string.IsNullOrWhiteSpace(settings.TwitchScope) ? settings.TwitchScope : ReadTwitchSetting(configuration, "Scope", "VTSA_TWITCH_SCOPE"); var secretSet = HasEffectiveTwitchClientSecret(settings, configuration); return new( clientId, secretSet, redirectUri, scope, !string.IsNullOrWhiteSpace(clientId) && secretSet); } private static OperationalSettingsSnapshot CreateOperationalSettingsSnapshot(SiteSettings settings, IConfiguration configuration) => new( settings.DemoLoginManagedByDatabase, settings.DemoLoginEnabled, settings.DemoLoginEmail, HasDatabaseDemoCredentials(settings), settings.DemoLoginTwitchUserId, settings.DemoLoginDisplayName, settings.TwitchAuthManagedByDatabase, settings.TwitchClientId, HasEffectiveTwitchClientSecret(settings, configuration), settings.TwitchRedirectUri, settings.TwitchScope, settings.MaintenanceModeEnabled, string.IsNullOrWhiteSpace(settings.MaintenanceTitle) ? FallbackMaintenanceTitle : settings.MaintenanceTitle, string.IsNullOrWhiteSpace(settings.MaintenanceMessage) ? FallbackMaintenanceMessage : settings.MaintenanceMessage); private static object[] BuildOperationalSettingChanges( OperationalSettingsSnapshot before, OperationalSettingsSnapshot after, bool passwordChanged, bool twitchClientSecretChanged) { var changes = new List(); AddOperationalChange(changes, "demoLoginManagedByDatabase", "Demo Quelle", before.DemoLoginManagedByDatabase, after.DemoLoginManagedByDatabase); AddOperationalChange(changes, "demoLoginEnabled", "Demo Login", before.DemoLoginEnabled, after.DemoLoginEnabled); AddOperationalChange(changes, "demoLoginEmail", "Demo Login", before.DemoLoginEmail, after.DemoLoginEmail); AddOperationalChange(changes, "demoLoginTwitchUserId", "Demo Twitch-ID", before.DemoLoginTwitchUserId, after.DemoLoginTwitchUserId); AddOperationalChange(changes, "demoLoginDisplayName", "Demo Anzeigename", before.DemoLoginDisplayName, after.DemoLoginDisplayName); AddOperationalChange(changes, "twitchAuthManagedByDatabase", "Twitch OAuth Quelle", before.TwitchAuthManagedByDatabase, after.TwitchAuthManagedByDatabase); AddOperationalChange(changes, "twitchClientId", "Twitch Client-ID", before.TwitchClientId, after.TwitchClientId); AddOperationalChange(changes, "twitchRedirectUri", "Twitch Redirect URI", before.TwitchRedirectUri, after.TwitchRedirectUri); AddOperationalChange(changes, "twitchScope", "Twitch Scope", before.TwitchScope, after.TwitchScope); if (before.TwitchClientSecretSet != after.TwitchClientSecretSet || twitchClientSecretChanged) { changes.Add(new { field = "twitchClientSecret", label = "Twitch Client Secret", @from = before.TwitchClientSecretSet ? "gesetzt" : "nicht gesetzt", to = twitchClientSecretChanged ? "neu gesetzt" : after.TwitchClientSecretSet ? "gesetzt" : "nicht gesetzt", sensitive = true, }); } if (passwordChanged) { changes.Add(new { field = "demoLoginPassword", label = "Demo Passwort", @from = before.DemoLoginPasswordSet ? "gesetzt" : "nicht gesetzt", to = "neu gesetzt", sensitive = true, }); } AddOperationalChange(changes, "maintenanceModeEnabled", "Wartungsmodus", before.MaintenanceModeEnabled, after.MaintenanceModeEnabled); AddOperationalChange(changes, "maintenanceTitle", "Wartungstitel", before.MaintenanceTitle, after.MaintenanceTitle); AddOperationalChange(changes, "maintenanceMessage", "Wartungstext", before.MaintenanceMessage, after.MaintenanceMessage); return changes.ToArray(); } private static void AddOperationalChange( ICollection changes, string field, string label, T before, T after) { if (EqualityComparer.Default.Equals(before, after)) { return; } changes.Add(new { field, label, @from = FormatOperationalAuditValue(before), to = FormatOperationalAuditValue(after), sensitive = false, }); } private static string FormatOperationalAuditValue(T value) { if (value is bool booleanValue) { return booleanValue ? "aktiv" : "aus"; } var text = Convert.ToString(value)?.Trim() ?? string.Empty; return string.IsNullOrWhiteSpace(text) ? "leer" : text; } private sealed record OperationalSettingsSnapshot( bool DemoLoginManagedByDatabase, bool DemoLoginEnabled, string DemoLoginEmail, bool DemoLoginPasswordSet, string DemoLoginTwitchUserId, string DemoLoginDisplayName, bool TwitchAuthManagedByDatabase, string TwitchClientId, bool TwitchClientSecretSet, string TwitchRedirectUri, string TwitchScope, bool MaintenanceModeEnabled, string MaintenanceTitle, string MaintenanceMessage); private sealed record TwitchSettingsSnapshot( string ClientId, bool ClientSecretSet, string RedirectUri, string Scope, bool Configured); }