18b61bed52
- Widen AdminCandidateEditorModal to size lg for better readability - Rename "Clip-Compilation" section to "Clip / Compilation", update copy to reflect single clips too, drop upload hint and Clip-Plattform field, rename label to "Link" - Fix NativeSelect dropdown clipping inside overflow-y-auto modals by teleporting the menu to body with fixed positioning, flip-up logic, and dynamic maxHeight capped to viewport - Add ClipAdminMenuVisible setting (backend domain, contracts, endpoint, migration) with matching frontend types, defaults, form wiring, and toggle in the Clip-Workflow modal — hides the Clips nav item from the admin sidebar when disabled Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
667 lines
30 KiB
C#
667 lines
30 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.";
|
|
private const string FallbackClipSubmissionDisabledMessage = "Clip-Einreichungen sind aktuell geschlossen.";
|
|
|
|
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();
|
|
group.MapGet("/optional-feature-settings", GetOptionalFeatureSettings)
|
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequirePermission(context, next, AdminPermissionCatalog.Settings))
|
|
.WithName("GetAdminOptionalFeatureSettings")
|
|
.WithOpenApi();
|
|
group.MapPut("/optional-feature-settings", UpdateOptionalFeatureSettings)
|
|
.AddEndpointFilter((context, next) => AdminEndpointConventions.RequireWritePermission(context, next, AdminPermissionCatalog.Settings))
|
|
.WithName("UpdateAdminOptionalFeatureSettings")
|
|
.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,
|
|
settings.ShowactsUrl,
|
|
settings.ShowactsContent,
|
|
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();
|
|
}
|
|
|
|
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.ShowactsUrl = normalizedUrls.ShowactsUrl;
|
|
settings.ShowactsContent = request.ShowactsContent.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)
|
|
|| !TryNormalizePublicUrl(request.ShowactsUrl, "Showact-Link", out var showactsUrl, out errorMessage))
|
|
{
|
|
return Results.BadRequest(new { message = errorMessage });
|
|
}
|
|
|
|
normalizedUrls = new PublicSiteUrlSettings
|
|
{
|
|
NewsletterUrl = newsletterUrl,
|
|
ImprintUrl = imprintUrl,
|
|
ContactUrl = contactUrl,
|
|
SponsorsUrl = sponsorsUrl,
|
|
ShowactsUrl = showactsUrl,
|
|
};
|
|
|
|
var normalizedSocialLinks = new List<PublicSocialLinkDto>();
|
|
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;
|
|
public string ShowactsUrl { get; set; } = string.Empty;
|
|
}
|
|
|
|
private static async Task<IResult> GetOptionalFeatureSettings(AwardsDbContext db)
|
|
{
|
|
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
|
|
if (settings is null)
|
|
{
|
|
return Results.NotFound();
|
|
}
|
|
|
|
return Results.Ok(ToOptionalFeatureSettingsResponse(settings));
|
|
}
|
|
|
|
private static async Task<IResult> UpdateOptionalFeatureSettings(
|
|
HttpContext context,
|
|
UpdateOptionalFeatureSettingsRequest 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 before = ToOptionalFeatureSettingsResponse(settings);
|
|
var disabledMessage = NormalizeOptionalFeatureText(
|
|
request.ClipSubmissionDisabledMessage,
|
|
FallbackClipSubmissionDisabledMessage,
|
|
240);
|
|
var showactDisabledMessage = NormalizeOptionalFeatureText(
|
|
request.ShowactApplicationDisabledMessage,
|
|
"Showact-Bewerbungen sind aktuell geschlossen.",
|
|
240);
|
|
|
|
settings.ClipSubmissionsEnabled = request.ClipSubmissionsEnabled;
|
|
settings.ClipReviewEnabled = request.ClipReviewEnabled;
|
|
settings.ClipAdminMenuVisible = request.ClipAdminMenuVisible;
|
|
settings.ClipSubmissionDisabledMessage = disabledMessage;
|
|
settings.ShowactApplicationsEnabled = request.ShowactApplicationsEnabled;
|
|
settings.ShowactApplicationDisabledMessage = showactDisabledMessage;
|
|
settings.SponsorsVisible = request.SponsorsVisible;
|
|
|
|
var after = ToOptionalFeatureSettingsResponse(settings);
|
|
adminAuditService.AddEntry(
|
|
session.TwitchUserId,
|
|
"optional-features.update",
|
|
"site-settings",
|
|
settings.Id.ToString(),
|
|
"Optionale Workflow-Features wurden aktualisiert.",
|
|
new
|
|
{
|
|
before,
|
|
after,
|
|
changes = BuildOptionalFeatureChanges(before, after),
|
|
},
|
|
RequestMetadataReader.Read(context));
|
|
|
|
await db.SaveChangesAsync(context.RequestAborted);
|
|
return Results.Ok(after);
|
|
}
|
|
|
|
private static AdminOptionalFeatureSettingsResponse ToOptionalFeatureSettingsResponse(SiteSettings settings) =>
|
|
new(
|
|
settings.ClipSubmissionsEnabled,
|
|
settings.ClipReviewEnabled,
|
|
settings.ClipAdminMenuVisible,
|
|
string.IsNullOrWhiteSpace(settings.ClipSubmissionDisabledMessage)
|
|
? FallbackClipSubmissionDisabledMessage
|
|
: settings.ClipSubmissionDisabledMessage,
|
|
settings.ShowactApplicationsEnabled,
|
|
string.IsNullOrWhiteSpace(settings.ShowactApplicationDisabledMessage)
|
|
? "Showact-Bewerbungen sind aktuell geschlossen."
|
|
: settings.ShowactApplicationDisabledMessage,
|
|
settings.SponsorsVisible);
|
|
|
|
private static string NormalizeOptionalFeatureText(string? value, string fallback, int maxLength)
|
|
{
|
|
var trimmed = (value ?? string.Empty).Trim();
|
|
if (string.IsNullOrWhiteSpace(trimmed))
|
|
{
|
|
return fallback;
|
|
}
|
|
|
|
return trimmed.Length <= maxLength ? trimmed : trimmed[..maxLength];
|
|
}
|
|
|
|
private static object[] BuildOptionalFeatureChanges(
|
|
AdminOptionalFeatureSettingsResponse before,
|
|
AdminOptionalFeatureSettingsResponse after)
|
|
{
|
|
var changes = new List<object>();
|
|
AddOperationalChange(changes, "clipSubmissionsEnabled", "Clip-Einreichung", before.ClipSubmissionsEnabled, after.ClipSubmissionsEnabled);
|
|
AddOperationalChange(changes, "clipReviewEnabled", "Clip-Review", before.ClipReviewEnabled, after.ClipReviewEnabled);
|
|
AddOperationalChange(changes, "clipAdminMenuVisible", "Clips-Menüpunkt", before.ClipAdminMenuVisible, after.ClipAdminMenuVisible);
|
|
AddOperationalChange(changes, "clipSubmissionDisabledMessage", "Deaktiviert-Hinweis", before.ClipSubmissionDisabledMessage, after.ClipSubmissionDisabledMessage);
|
|
AddOperationalChange(changes, "showactApplicationsEnabled", "Showact-Bewerbungen", before.ShowactApplicationsEnabled, after.ShowactApplicationsEnabled);
|
|
AddOperationalChange(changes, "showactApplicationDisabledMessage", "Showact-Hinweis", before.ShowactApplicationDisabledMessage, after.ShowactApplicationDisabledMessage);
|
|
AddOperationalChange(changes, "sponsorsVisible", "Sponsoren sichtbar", before.SponsorsVisible, after.SponsorsVisible);
|
|
return changes.ToArray();
|
|
}
|
|
|
|
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);
|
|
}
|