Files
vtuber-awards/Backend/Endpoints/AdminSiteSettingsEndpoints.cs
T
2026-06-25 19:52:46 +02:00

477 lines
21 KiB
C#

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<IResult> 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<IResult> 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();
}
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.ImprintContent = request.ImprintContent.Trim();
settings.ContactUrl = request.ContactUrl.Trim();
settings.ContactContent = request.ContactContent.Trim();
settings.SponsorsUrl = request.SponsorsUrl.Trim();
settings.SponsorsContent = request.SponsorsContent.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<IResult> 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<IResult> 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<object>();
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<T>(
ICollection<object> changes,
string field,
string label,
T before,
T after)
{
if (EqualityComparer<T>.Default.Equals(before, after))
{
return;
}
changes.Add(new
{
field,
label,
@from = FormatOperationalAuditValue(before),
to = FormatOperationalAuditValue(after),
sensitive = false,
});
}
private static string FormatOperationalAuditValue<T>(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);
}