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).WithName("GetAdminSiteSettings").WithOpenApi(); group.MapPut("/site-settings", UpdateSiteSettings).WithName("UpdateAdminSiteSettings").WithOpenApi(); group.MapGet("/operational-settings", GetOperationalSettings).WithName("GetAdminOperationalSettings").WithOpenApi(); group.MapPut("/operational-settings", UpdateOperationalSettings).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.ContactUrl, settings.SponsorsUrl, SeasonMappings.ReadSocialLinks(settings), SeasonMappings.ReadFaqItems(settings))); } private static async Task UpdateSiteSettings( HttpContext context, UpdateSiteSettingsRequest request, AwardsDbContext db, IAdminAuditService adminAuditService) { var session = AdminEndpointConventions.CurrentSession(context); if (!AdminRoles.CanManageContent(session.Role)) { return Results.Json(new { message = "Landingpage content requires a content admin, admin or owner role." }, statusCode: StatusCodes.Status403Forbidden); } var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); if (settings is null) { return Results.NotFound(); } settings.HostDisplayName = request.HostDisplayName.Trim(); settings.HostTagline = request.HostTagline.Trim(); settings.NewsletterUrl = request.NewsletterUrl.Trim(); 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 = request.ImprintUrl.Trim(); settings.ContactUrl = request.ContactUrl.Trim(); settings.SponsorsUrl = request.SponsorsUrl.Trim(); settings.SocialLinksJson = JsonSerializer.Serialize(request.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 = request.SocialLinks?.Length ?? 0, faqCount = request.Faq?.Length ?? 0, }, RequestMetadataReader.Read(context)); await db.SaveChangesAsync(context.RequestAborted); return Results.Ok(new { saved = true }); } 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); 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.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); 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"); 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 < 12) { return Results.BadRequest(new { message = "Das Demo-Passwort muss mindestens 12 Zeichen lang sein." }); } 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; 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), !string.IsNullOrWhiteSpace(passwordToPersist)); 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), }); } 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 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 OperationalSettingsSnapshot CreateOperationalSettingsSnapshot(SiteSettings settings) => new( settings.DemoLoginManagedByDatabase, settings.DemoLoginEnabled, settings.DemoLoginEmail, HasDatabaseDemoCredentials(settings), settings.DemoLoginTwitchUserId, settings.DemoLoginDisplayName, 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) { 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); 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 MaintenanceModeEnabled, string MaintenanceTitle, string MaintenanceMessage); }