Harden request validation and admin UX cues
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 58s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m24s

This commit is contained in:
AzuTear
2026-06-30 08:45:28 +02:00
parent 1191abc914
commit 33ba00a682
11 changed files with 248 additions and 70 deletions
+7 -2
View File
@@ -151,10 +151,15 @@ public static partial class AdminModerationEndpoints
private static async Task<IResult> BulkResolveRiskFlags( private static async Task<IResult> BulkResolveRiskFlags(
HttpContext context, HttpContext context,
BulkResolveRiskFlagsRequest request, BulkResolveRiskFlagsRequest? request,
AwardsDbContext db, AwardsDbContext db,
IAdminAuditService adminAuditService) IAdminAuditService adminAuditService)
{ {
if (request is null || request.RiskFlagIds is null)
{
return Results.BadRequest(new { message = "Bulk-Verarbeitung braucht mindestens einen Risikohinweis." });
}
var session = AdminEndpointConventions.CurrentSession(context); var session = AdminEndpointConventions.CurrentSession(context);
var normalizedIds = request.RiskFlagIds var normalizedIds = request.RiskFlagIds
.Distinct() .Distinct()
@@ -188,7 +193,7 @@ public static partial class AdminModerationEndpoints
if (riskFlags.Any(item => item.Severity != "low")) if (riskFlags.Any(item => item.Severity != "low"))
{ {
return Results.BadRequest(new { message = "Bulk-Verarbeitung ist nur fuer Low-Severity-Hinweise erlaubt." }); return Results.BadRequest(new { message = "Bulk-Verarbeitung ist nur fuer offene Low-Severity-Hinweise erlaubt." });
} }
if (riskFlags.Any(item => item.Status != "open")) if (riskFlags.Any(item => item.Status != "open"))
+87 -28
View File
@@ -155,10 +155,17 @@ public static class AdminSiteSettingsEndpoints
private static async Task<IResult> UpdateSiteSettings( private static async Task<IResult> UpdateSiteSettings(
HttpContext context, HttpContext context,
UpdateSiteSettingsRequest request, UpdateSiteSettingsRequest? request,
AwardsDbContext db, AwardsDbContext db,
IAdminAuditService adminAuditService) IAdminAuditService adminAuditService)
{ {
var requestValidationError = ValidateSiteSettingsRequest(request);
if (requestValidationError is not null)
{
return requestValidationError;
}
var validatedRequest = request!;
var session = AdminEndpointConventions.CurrentSession(context); var session = AdminEndpointConventions.CurrentSession(context);
var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1); var settings = await db.SiteSettings.FirstOrDefaultAsync(item => item.Id == 1);
@@ -167,20 +174,20 @@ public static class AdminSiteSettingsEndpoints
return Results.NotFound(); return Results.NotFound();
} }
var urlValidationError = NormalizeSiteSettingsUrls(request, out var normalizedUrls, out var socialLinks); var urlValidationError = NormalizeSiteSettingsUrls(validatedRequest, out var normalizedUrls, out var socialLinks);
if (urlValidationError is not null) if (urlValidationError is not null)
{ {
return urlValidationError; return urlValidationError;
} }
settings.HostDisplayName = request.HostDisplayName.Trim(); settings.HostDisplayName = validatedRequest.HostDisplayName.Trim();
settings.HostTagline = request.HostTagline.Trim(); settings.HostTagline = validatedRequest.HostTagline.Trim();
settings.HostArtistName = request.HostArtistName.Trim(); settings.HostArtistName = validatedRequest.HostArtistName.Trim();
settings.NewsletterUrl = normalizedUrls.NewsletterUrl; settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
settings.ShareXUrl = normalizedUrls.ShareXUrl; settings.ShareXUrl = normalizedUrls.ShareXUrl;
settings.ShareDiscordUrl = normalizedUrls.ShareDiscordUrl; settings.ShareDiscordUrl = normalizedUrls.ShareDiscordUrl;
settings.PrivacyEmail = request.PrivacyEmail.Trim(); settings.PrivacyEmail = validatedRequest.PrivacyEmail.Trim();
var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim(); var trimmedPrivacyContent = validatedRequest.PrivacyPolicyContent.Trim();
var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal); var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal);
settings.PrivacyPolicyContent = trimmedPrivacyContent; settings.PrivacyPolicyContent = trimmedPrivacyContent;
if (privacyChanged) if (privacyChanged)
@@ -190,32 +197,32 @@ public static class AdminSiteSettingsEndpoints
} }
settings.ImprintUrl = normalizedUrls.ImprintUrl; settings.ImprintUrl = normalizedUrls.ImprintUrl;
settings.ImprintContent = request.ImprintContent.Trim(); settings.ImprintContent = validatedRequest.ImprintContent.Trim();
settings.ContactUrl = normalizedUrls.ContactUrl; settings.ContactUrl = normalizedUrls.ContactUrl;
settings.ContactContent = request.ContactContent.Trim(); settings.ContactContent = validatedRequest.ContactContent.Trim();
settings.SponsorsUrl = normalizedUrls.SponsorsUrl; settings.SponsorsUrl = normalizedUrls.SponsorsUrl;
settings.SponsorsContent = request.SponsorsContent.Trim(); settings.SponsorsContent = validatedRequest.SponsorsContent.Trim();
settings.ShowactsUrl = normalizedUrls.ShowactsUrl; settings.ShowactsUrl = normalizedUrls.ShowactsUrl;
settings.ShowactsContent = request.ShowactsContent.Trim(); settings.ShowactsContent = validatedRequest.ShowactsContent.Trim();
settings.StreamBannerEyebrow = SeasonMappings.NormalizePlainTextContent(request.StreamBannerEyebrow); settings.StreamBannerEyebrow = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerEyebrow);
settings.StreamBannerTitle = SeasonMappings.NormalizePlainTextContent(request.StreamBannerTitle); settings.StreamBannerTitle = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerTitle);
settings.StreamBannerText = SeasonMappings.NormalizePlainTextContent(request.StreamBannerText); settings.StreamBannerText = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerText);
settings.StreamBannerLiveButtonLabel = SeasonMappings.NormalizePlainTextContent(request.StreamBannerLiveButtonLabel); settings.StreamBannerLiveButtonLabel = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerLiveButtonLabel);
settings.StreamBannerLiveButtonUrl = normalizedUrls.StreamBannerLiveButtonUrl; settings.StreamBannerLiveButtonUrl = normalizedUrls.StreamBannerLiveButtonUrl;
settings.StreamBannerLockedButtonLabel = SeasonMappings.NormalizePlainTextContent(request.StreamBannerLockedButtonLabel); settings.StreamBannerLockedButtonLabel = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerLockedButtonLabel);
settings.StreamBannerUseCompletedContent = request.StreamBannerUseCompletedContent; settings.StreamBannerUseCompletedContent = validatedRequest.StreamBannerUseCompletedContent;
settings.StreamBannerCompletedEyebrow = SeasonMappings.NormalizePlainTextContent(request.StreamBannerCompletedEyebrow); settings.StreamBannerCompletedEyebrow = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerCompletedEyebrow);
settings.StreamBannerCompletedTitle = SeasonMappings.NormalizePlainTextContent(request.StreamBannerCompletedTitle); settings.StreamBannerCompletedTitle = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerCompletedTitle);
settings.StreamBannerCompletedText = SeasonMappings.NormalizePlainTextContent(request.StreamBannerCompletedText); settings.StreamBannerCompletedText = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerCompletedText);
settings.StreamBannerCompletedButtonLabel = SeasonMappings.NormalizePlainTextContent(request.StreamBannerCompletedButtonLabel); settings.StreamBannerCompletedButtonLabel = SeasonMappings.NormalizePlainTextContent(validatedRequest.StreamBannerCompletedButtonLabel);
settings.StreamBannerCompletedButtonUrl = normalizedUrls.StreamBannerCompletedButtonUrl; settings.StreamBannerCompletedButtonUrl = normalizedUrls.StreamBannerCompletedButtonUrl;
settings.AwardsSectionTitle = SeasonMappings.NormalizePlainTextContent(request.AwardsSectionTitle); settings.AwardsSectionTitle = SeasonMappings.NormalizePlainTextContent(validatedRequest.AwardsSectionTitle);
settings.AwardsSectionDescription = SeasonMappings.NormalizePlainTextContent(request.AwardsSectionDescription); settings.AwardsSectionDescription = SeasonMappings.NormalizePlainTextContent(validatedRequest.AwardsSectionDescription);
settings.SubcategoriesSectionTitle = SeasonMappings.NormalizePlainTextContent(request.SubcategoriesSectionTitle); settings.SubcategoriesSectionTitle = SeasonMappings.NormalizePlainTextContent(validatedRequest.SubcategoriesSectionTitle);
settings.SubcategoriesSectionDescription = SeasonMappings.NormalizePlainTextContent(request.SubcategoriesSectionDescription); settings.SubcategoriesSectionDescription = SeasonMappings.NormalizePlainTextContent(validatedRequest.SubcategoriesSectionDescription);
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks); settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []); settings.FaqJson = JsonSerializer.Serialize(validatedRequest.Faq);
settings.ShowactFormSchemaJson = request.ShowactFormSchemaJson ?? "[]"; settings.ShowactFormSchemaJson = validatedRequest.ShowactFormSchemaJson ?? "[]";
adminAuditService.AddEntry( adminAuditService.AddEntry(
session.TwitchUserId, session.TwitchUserId,
@@ -228,7 +235,7 @@ public static class AdminSiteSettingsEndpoints
settings.HostDisplayName, settings.HostDisplayName,
privacyChanged, privacyChanged,
socialLinkCount = socialLinks.Length, socialLinkCount = socialLinks.Length,
faqCount = request.Faq?.Length ?? 0, faqCount = validatedRequest.Faq.Length,
}, },
RequestMetadataReader.Read(context)); RequestMetadataReader.Read(context));
@@ -340,6 +347,58 @@ public static class AdminSiteSettingsEndpoints
return null; return null;
} }
private static IResult? ValidateSiteSettingsRequest(UpdateSiteSettingsRequest? request)
{
if (request is null)
{
return Results.BadRequest(new { message = "Public Site Settings brauchen einen Request-Body." });
}
if (request.SocialLinks is null || request.Faq is null)
{
return Results.BadRequest(new { message = "Public Site Settings brauchen Social Links und FAQ im Request-Body." });
}
string?[] requiredTextFields =
[
request.HostDisplayName,
request.HostTagline,
request.HostArtistName,
request.NewsletterUrl,
request.ShareXUrl,
request.ShareDiscordUrl,
request.PrivacyEmail,
request.PrivacyPolicyContent,
request.ImprintUrl,
request.ImprintContent,
request.ContactUrl,
request.ContactContent,
request.SponsorsUrl,
request.SponsorsContent,
request.ShowactsUrl,
request.ShowactsContent,
request.StreamBannerEyebrow,
request.StreamBannerTitle,
request.StreamBannerText,
request.StreamBannerLiveButtonLabel,
request.StreamBannerLiveButtonUrl,
request.StreamBannerLockedButtonLabel,
request.StreamBannerCompletedEyebrow,
request.StreamBannerCompletedTitle,
request.StreamBannerCompletedText,
request.StreamBannerCompletedButtonLabel,
request.StreamBannerCompletedButtonUrl,
request.AwardsSectionTitle,
request.AwardsSectionDescription,
request.SubcategoriesSectionTitle,
request.SubcategoriesSectionDescription,
];
return requiredTextFields.Any(field => field is null)
? Results.BadRequest(new { message = "Public Site Settings brauchen alle Pflichtfelder im Request-Body." })
: null;
}
private static bool TryNormalizePublicUrl(string? value, string fieldName, out string normalizedUrl, out string errorMessage) private static bool TryNormalizePublicUrl(string? value, string fieldName, out string normalizedUrl, out string errorMessage)
{ {
normalizedUrl = (value ?? string.Empty).Trim(); normalizedUrl = (value ?? string.Empty).Trim();
+10 -5
View File
@@ -15,16 +15,21 @@ public static partial class AuthEndpoints
private static async Task<IResult> TeamLogin( private static async Task<IResult> TeamLogin(
HttpContext context, HttpContext context,
TeamLoginRequest request, TeamLoginRequest? request,
AwardsDbContext db, AwardsDbContext db,
IUserSessionService userSessionService) IUserSessionService userSessionService)
{ {
var login = NormalizeTeamLogin(request.Login); if (request is null)
{
return Results.BadRequest(new { message = "Login und Passwort sind erforderlich." });
}
var login = NormalizeTeamLogin(request.Login ?? string.Empty);
var password = request.Password ?? string.Empty; var password = request.Password ?? string.Empty;
if (string.IsNullOrWhiteSpace(login) || string.IsNullOrWhiteSpace(password)) if (string.IsNullOrWhiteSpace(login) || string.IsNullOrWhiteSpace(password))
{ {
return Results.Unauthorized(); return Results.BadRequest(new { message = "Login und Passwort sind erforderlich." });
} }
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == login, context.RequestAborted); var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == login, context.RequestAborted);
@@ -120,8 +125,8 @@ public static partial class AuthEndpoints
: await db.TeamMembers.FirstOrDefaultAsync(item => item.BoundTwitchUserId == twitchUserId, cancellationToken); : await db.TeamMembers.FirstOrDefaultAsync(item => item.BoundTwitchUserId == twitchUserId, cancellationToken);
} }
private static string NormalizeTeamLogin(string value) => private static string NormalizeTeamLogin(string? value) =>
value.Trim().TrimStart('@').ToLowerInvariant(); (value ?? string.Empty).Trim().TrimStart('@').ToLowerInvariant();
private static string NormalizeTwitchUserId(string? value) => private static string NormalizeTwitchUserId(string? value) =>
(value ?? string.Empty).Trim().TrimStart('@').ToLowerInvariant(); (value ?? string.Empty).Trim().TrimStart('@').ToLowerInvariant();
+17 -10
View File
@@ -11,13 +11,20 @@ public static partial class PublicEndpoints
{ {
private static async Task<IResult> CreateClip( private static async Task<IResult> CreateClip(
HttpContext context, HttpContext context,
CreateClipRequest request, CreateClipRequest? request,
AwardsDbContext db, AwardsDbContext db,
IUserSessionService userSessionService, IUserSessionService userSessionService,
IRiskFlagService riskFlagService, IRiskFlagService riskFlagService,
IRiskRuleService riskRuleService) IRiskRuleService riskRuleService)
{ {
if (!TryNormalizeExternalUrl(request.ClipUrl, out var clipUrl)) var validationError = ValidateCreateClipRequest(request);
if (validationError is not null)
{
return validationError;
}
var validatedRequest = request!;
if (!TryNormalizeExternalUrl(validatedRequest.ClipUrl, out var clipUrl))
{ {
return Results.BadRequest(new { message = "A valid http(s) clip link is required." }); return Results.BadRequest(new { message = "A valid http(s) clip link is required." });
} }
@@ -44,7 +51,7 @@ public static partial class PublicEndpoints
return Results.BadRequest(new { message }); return Results.BadRequest(new { message });
} }
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year); var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == validatedRequest.Year);
var clipSeasonResolution = EnsurePublicWriteSeason(season, "nomination"); var clipSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
if (clipSeasonResolution.Result is not null) if (clipSeasonResolution.Result is not null)
{ {
@@ -53,26 +60,26 @@ public static partial class PublicEndpoints
season = clipSeasonResolution.Season!; season = clipSeasonResolution.Season!;
var selectedCandidate = request.CandidateId is int candidateId var selectedCandidate = validatedRequest.CandidateId is int candidateId
? await db.Candidates ? await db.Candidates
.AsNoTracking() .AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == candidateId && item.SeasonId == season.Id) .FirstOrDefaultAsync(item => item.Id == candidateId && item.SeasonId == season.Id)
: null; : null;
if (request.CandidateId is not null && selectedCandidate is null) if (validatedRequest.CandidateId is not null && selectedCandidate is null)
{ {
return Results.BadRequest(new { message = "The selected candidate does not exist for this season." }); return Results.BadRequest(new { message = "The selected candidate does not exist for this season." });
} }
if (request.CategoryId is int requestedCategoryId if (validatedRequest.CategoryId is int requestedCategoryId
&& selectedCandidate is not null && selectedCandidate is not null
&& selectedCandidate.CategoryId != requestedCategoryId) && selectedCandidate.CategoryId != requestedCategoryId)
{ {
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." }); return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
} }
var resolvedCategoryId = request.CategoryId ?? selectedCandidate?.CategoryId; var resolvedCategoryId = validatedRequest.CategoryId ?? selectedCandidate?.CategoryId;
var normalizedTitle = request.Title?.Trim() ?? string.Empty; var normalizedTitle = validatedRequest.Title?.Trim() ?? string.Empty;
var submittedCreator = request.Creator?.Trim(); var submittedCreator = validatedRequest.Creator?.Trim();
var normalizedCreator = string.IsNullOrWhiteSpace(submittedCreator) var normalizedCreator = string.IsNullOrWhiteSpace(submittedCreator)
? selectedCandidate?.DisplayName ?? string.Empty ? selectedCandidate?.DisplayName ?? string.Empty
: submittedCreator; : submittedCreator;
@@ -96,7 +103,7 @@ public static partial class PublicEndpoints
} }
} }
var submitterIdResult = await ResolveSubmitterIdAsync(context, request.TwitchUserId, userSessionService); var submitterIdResult = await ResolveSubmitterIdAsync(context, validatedRequest.TwitchUserId, userSessionService);
if (submitterIdResult.Result is not null) if (submitterIdResult.Result is not null)
{ {
return submitterIdResult.Result; return submitterIdResult.Result;
@@ -1,4 +1,5 @@
using Backend.Common; using Backend.Common;
using Backend.Contracts;
using Backend.Domain; using Backend.Domain;
using Backend.Services; using Backend.Services;
using Microsoft.AspNetCore.Http.Extensions; using Microsoft.AspNetCore.Http.Extensions;
@@ -205,4 +206,74 @@ public static partial class PublicEndpoints
"show" => "show", "show" => "show",
_ => "current", _ => "current",
}; };
private static IResult? ValidateCreateNominationRequest(CreateNominationRequest? request)
{
if (request is null)
{
return Results.BadRequest(new { message = "A nomination request body is required." });
}
if (request.Year <= 0)
{
return Results.BadRequest(new { message = "A valid season year is required." });
}
if ((request.Nominations?.Length ?? 0) == 0 && (request.Nominees?.Length ?? 0) == 0)
{
return Results.BadRequest(new { message = "A nomination request must include at least one stream link." });
}
return null;
}
private static IResult? ValidateCreateVoteRequest(CreateVoteRequest? request)
{
if (request is null)
{
return Results.BadRequest(new { message = "A vote request body is required." });
}
if (request.SeasonId <= 0)
{
return Results.BadRequest(new { message = "A valid season id is required." });
}
if (request.Entries is null || request.Entries.Length == 0)
{
return Results.BadRequest(new { message = "At least one vote entry is required." });
}
if (request.Entries.Any(item => item.CategoryId <= 0 || item.CandidateId <= 0))
{
return Results.BadRequest(new { message = "Each vote entry requires a valid category and candidate." });
}
return null;
}
private static IResult? ValidateCreateClipRequest(CreateClipRequest? request)
{
if (request is null)
{
return Results.BadRequest(new { message = "A clip request body is required." });
}
if (request.Year <= 0)
{
return Results.BadRequest(new { message = "A valid season year is required." });
}
if (request.CategoryId is <= 0 || request.CandidateId is <= 0)
{
return Results.BadRequest(new { message = "Category and candidate ids must be positive when provided." });
}
if (string.IsNullOrWhiteSpace(request.ClipUrl))
{
return Results.BadRequest(new { message = "A valid http(s) clip link is required." });
}
return null;
}
} }
@@ -21,7 +21,7 @@ public static partial class PublicEndpoints
if (settings?.HostImageData is not { Length: > 0 } imageData if (settings?.HostImageData is not { Length: > 0 } imageData
|| string.IsNullOrWhiteSpace(settings.HostImageContentType)) || string.IsNullOrWhiteSpace(settings.HostImageContentType))
{ {
return Results.NotFound(); return Results.Redirect("/assets/amaterasu2sei_2.png", permanent: false);
} }
var entityTag = settings.HostImageUpdatedAt?.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture) var entityTag = settings.HostImageUpdatedAt?.ToUnixTimeSeconds().ToString(System.Globalization.CultureInfo.InvariantCulture)
+12 -5
View File
@@ -11,14 +11,21 @@ public static partial class PublicEndpoints
{ {
private static async Task<IResult> CreateNomination( private static async Task<IResult> CreateNomination(
HttpContext context, HttpContext context,
CreateNominationRequest request, CreateNominationRequest? request,
AwardsDbContext db, AwardsDbContext db,
IUserSessionService userSessionService, IUserSessionService userSessionService,
IRiskFlagService riskFlagService, IRiskFlagService riskFlagService,
IRiskRuleService riskRuleService, IRiskRuleService riskRuleService,
NominationEnrichmentService nominationEnrichmentService) NominationEnrichmentService nominationEnrichmentService)
{ {
var submittedNominations = NormalizeSubmittedNominations(request); var validationError = ValidateCreateNominationRequest(request);
if (validationError is not null)
{
return validationError;
}
var validatedRequest = request!;
var submittedNominations = NormalizeSubmittedNominations(validatedRequest);
if (submittedNominations.Length == 0) if (submittedNominations.Length == 0)
{ {
@@ -66,14 +73,14 @@ public static partial class PublicEndpoints
return Results.BadRequest(new { message = "Dieser Link kann nicht nominiert werden. Bitte reiche einen direkten Kanal- oder Profil-Link ein." }); return Results.BadRequest(new { message = "Dieser Link kann nicht nominiert werden. Bitte reiche einen direkten Kanal- oder Profil-Link ein." });
} }
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year); var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == validatedRequest.Year);
if (season is null) if (season is null)
{ {
return Results.BadRequest(new { message = "The selected season does not exist." }); return Results.BadRequest(new { message = "The selected season does not exist." });
} }
var categoryGroupName = await ResolveCategoryGroupNameAsync(db, season.Id, request, context.RequestAborted); var categoryGroupName = await ResolveCategoryGroupNameAsync(db, season.Id, validatedRequest, context.RequestAborted);
if (string.IsNullOrWhiteSpace(categoryGroupName)) if (string.IsNullOrWhiteSpace(categoryGroupName))
{ {
return Results.BadRequest(new { message = "The selected category group does not exist for this season." }); return Results.BadRequest(new { message = "The selected category group does not exist for this season." });
@@ -102,7 +109,7 @@ public static partial class PublicEndpoints
return nominationSeasonResolution.Result; return nominationSeasonResolution.Result;
} }
var submitterIdResult = await ResolveSubmitterIdAsync(context, request.TwitchUserId, userSessionService); var submitterIdResult = await ResolveSubmitterIdAsync(context, validatedRequest.TwitchUserId, userSessionService);
if (submitterIdResult.Result is not null) if (submitterIdResult.Result is not null)
{ {
return submitterIdResult.Result; return submitterIdResult.Result;
+19 -17
View File
@@ -12,37 +12,39 @@ public static partial class PublicEndpoints
{ {
private static async Task<IResult> CreateVote( private static async Task<IResult> CreateVote(
HttpContext context, HttpContext context,
CreateVoteRequest request, CreateVoteRequest? request,
AwardsDbContext db, AwardsDbContext db,
IUserSessionService userSessionService, IUserSessionService userSessionService,
IRiskFlagService riskFlagService, IRiskFlagService riskFlagService,
IRiskRuleService riskRuleService) IRiskRuleService riskRuleService)
{ {
if (request.Entries.Length == 0) var validationError = ValidateCreateVoteRequest(request);
if (validationError is not null)
{ {
return Results.BadRequest(new { message = "At least one vote entry is required." }); return validationError;
} }
var distinctCategoryCount = request.Entries var validatedRequest = request!;
var distinctCategoryCount = validatedRequest.Entries
.Select(item => item.CategoryId) .Select(item => item.CategoryId)
.Distinct() .Distinct()
.Count(); .Count();
if (distinctCategoryCount != request.Entries.Length) if (distinctCategoryCount != validatedRequest.Entries.Length)
{ {
return Results.BadRequest(new { message = "Only one vote entry per category is allowed." }); return Results.BadRequest(new { message = "Only one vote entry per category is allowed." });
} }
var season = await db.Seasons var season = await db.Seasons
.AsNoTracking() .AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == request.SeasonId); .FirstOrDefaultAsync(item => item.Id == validatedRequest.SeasonId);
var voteSeasonResolution = EnsurePublicWriteSeason(season, "voting"); var voteSeasonResolution = EnsurePublicWriteSeason(season, "voting");
if (voteSeasonResolution.Result is not null) if (voteSeasonResolution.Result is not null)
{ {
return voteSeasonResolution.Result; return voteSeasonResolution.Result;
} }
var submitterIdResult = await ResolveSubmitterIdAsync(context, request.TwitchUserId, userSessionService); var submitterIdResult = await ResolveSubmitterIdAsync(context, validatedRequest.TwitchUserId, userSessionService);
if (submitterIdResult.Result is not null) if (submitterIdResult.Result is not null)
{ {
return submitterIdResult.Result; return submitterIdResult.Result;
@@ -50,10 +52,10 @@ public static partial class PublicEndpoints
var submitterId = submitterIdResult.SubmitterId!; var submitterId = submitterIdResult.SubmitterId!;
var requestMetadata = RequestMetadataReader.Read(context); var requestMetadata = RequestMetadataReader.Read(context);
var candidateIds = request.Entries.Select(item => item.CandidateId).Distinct().ToArray(); var candidateIds = validatedRequest.Entries.Select(item => item.CandidateId).Distinct().ToArray();
var validCandidates = await db.Candidates var validCandidates = await db.Candidates
.AsNoTracking() .AsNoTracking()
.Where(item => item.SeasonId == request.SeasonId && candidateIds.Contains(item.Id)) .Where(item => item.SeasonId == validatedRequest.SeasonId && candidateIds.Contains(item.Id))
.Select(item => new { item.Id, item.CategoryId }) .Select(item => new { item.Id, item.CategoryId })
.ToArrayAsync(); .ToArrayAsync();
@@ -63,17 +65,17 @@ public static partial class PublicEndpoints
} }
var candidateCategoryMap = validCandidates.ToDictionary(item => item.Id, item => item.CategoryId); var candidateCategoryMap = validCandidates.ToDictionary(item => item.Id, item => item.CategoryId);
if (request.Entries.Any(item => candidateCategoryMap[item.CandidateId] != item.CategoryId)) if (validatedRequest.Entries.Any(item => candidateCategoryMap[item.CandidateId] != item.CategoryId))
{ {
return Results.BadRequest(new { message = "A selected candidate does not match the submitted category." }); return Results.BadRequest(new { message = "A selected candidate does not match the submitted category." });
} }
var ballot = await db.VoteBallots var ballot = await db.VoteBallots
.Include(item => item.Entries) .Include(item => item.Entries)
.FirstOrDefaultAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId); .FirstOrDefaultAsync(item => item.SeasonId == validatedRequest.SeasonId && item.SubmittedByTwitchId == submitterId);
var isResubmission = ballot is not null; var isResubmission = ballot is not null;
ballot = await ApplyVoteEntriesAsync(db, ballot, request.SeasonId, submitterId, request.Entries, context.RequestAborted); ballot = await ApplyVoteEntriesAsync(db, ballot, validatedRequest.SeasonId, submitterId, validatedRequest.Entries, context.RequestAborted);
var resubmittedBallotRule = await riskRuleService.GetRuleAsync("resubmitted_ballot", context.RequestAborted); var resubmittedBallotRule = await riskRuleService.GetRuleAsync("resubmitted_ballot", context.RequestAborted);
var rapidVoteUpdatesRule = await riskRuleService.GetRuleAsync("rapid_vote_updates", context.RequestAborted); var rapidVoteUpdatesRule = await riskRuleService.GetRuleAsync("rapid_vote_updates", context.RequestAborted);
@@ -90,9 +92,9 @@ public static partial class PublicEndpoints
db.ChangeTracker.Clear(); db.ChangeTracker.Clear();
ballot = await db.VoteBallots ballot = await db.VoteBallots
.Include(item => item.Entries) .Include(item => item.Entries)
.FirstAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId, context.RequestAborted); .FirstAsync(item => item.SeasonId == validatedRequest.SeasonId && item.SubmittedByTwitchId == submitterId, context.RequestAborted);
isResubmission = true; isResubmission = true;
ballot = await ApplyVoteEntriesAsync(db, ballot, request.SeasonId, submitterId, request.Entries, context.RequestAborted); ballot = await ApplyVoteEntriesAsync(db, ballot, validatedRequest.SeasonId, submitterId, validatedRequest.Entries, context.RequestAborted);
await db.SaveChangesAsync(context.RequestAborted); await db.SaveChangesAsync(context.RequestAborted);
} }
@@ -107,21 +109,21 @@ public static partial class PublicEndpoints
if (isResubmission && resubmittedBallotRule.Enabled) if (isResubmission && resubmittedBallotRule.Enabled)
{ {
await riskFlagService.AddIfMissingAsync( await riskFlagService.AddIfMissingAsync(
request.SeasonId, validatedRequest.SeasonId,
submitterId, submitterId,
"vote", "vote",
"resubmitted_ballot", "resubmitted_ballot",
resubmittedBallotRule.Severity, resubmittedBallotRule.Severity,
"Ein User hat sein Ballot erneut gespeichert oder aktualisiert.", "Ein User hat sein Ballot erneut gespeichert oder aktualisiert.",
requestMetadata, requestMetadata,
new { ballotId = ballot.Id, entryCount = request.Entries.Length, entityLinks = new[] { ballotLink } }, new { ballotId = ballot.Id, entryCount = validatedRequest.Entries.Length, entityLinks = new[] { ballotLink } },
context.RequestAborted); context.RequestAborted);
} }
if (rapidVoteUpdatesRule.Enabled && recentVoteSubmissions >= rapidVoteUpdatesRule.Threshold) if (rapidVoteUpdatesRule.Enabled && recentVoteSubmissions >= rapidVoteUpdatesRule.Threshold)
{ {
await riskFlagService.AddIfMissingAsync( await riskFlagService.AddIfMissingAsync(
request.SeasonId, validatedRequest.SeasonId,
submitterId, submitterId,
"vote", "vote",
"rapid_vote_updates", "rapid_vote_updates",
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { KeyRound, Save, Wrench } from '@lucide/vue' import { KeyRound, LockKeyhole, Save, Wrench } from '@lucide/vue'
import { computed, onBeforeUnmount, onMounted } from 'vue' import { computed, onBeforeUnmount, onMounted } from 'vue'
import { onBeforeRouteLeave } from 'vue-router' import { onBeforeRouteLeave } from 'vue-router'
@@ -65,6 +65,14 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
</Button> </Button>
</div> </div>
<p
v-if="!canManageOperationalSettings"
class="flex items-start gap-3 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm font-semibold leading-6 text-slate-700"
>
<LockKeyhole class="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />
Demo-Schutz und Wartungsmodus sind sichtbar, aber nur Owner oder Creator dürfen diese Einstellungen speichern.
</p>
<section class="grid gap-6 xl:grid-cols-2"> <section class="grid gap-6 xl:grid-cols-2">
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center gap-2 px-1 text-sm font-semibold text-violet-700"> <div class="flex items-center gap-2 px-1 text-sm font-semibold text-violet-700">
@@ -208,6 +208,9 @@ function statToneClass(tone: string) {
<!-- Flag list --> <!-- Flag list -->
<div class="flex-1 divide-y divide-violet-50 overflow-y-auto xl:max-h-[640px]"> <div class="flex-1 divide-y divide-violet-50 overflow-y-auto xl:max-h-[640px]">
<div class="border-b border-amber-100 bg-amber-50/70 px-5 py-3 text-xs font-semibold leading-5 text-amber-800">
Bulk-Aktionen greifen nur für offene Low-Severity-Hinweise. Medium- und High-Fälle bleiben absichtlich Einzelentscheidungen.
</div>
<button <button
v-for="flag in filteredRiskFlags" v-for="flag in filteredRiskFlags"
:key="flag.id" :key="flag.id"
@@ -336,6 +339,9 @@ function statToneClass(tone: string) {
class="mb-3 h-10 w-full rounded-xl border border-amber-200 bg-white px-3 text-sm outline-none transition focus:border-amber-400 focus:ring-4 focus:ring-amber-100/60" class="mb-3 h-10 w-full rounded-xl border border-amber-200 bg-white px-3 text-sm outline-none transition focus:border-amber-400 focus:ring-4 focus:ring-amber-100/60"
placeholder="Bulk-Notiz eingeben (mind. 3 Zeichen)" placeholder="Bulk-Notiz eingeben (mind. 3 Zeichen)"
/> />
<p class="mb-3 text-xs font-semibold text-amber-800">
Die Auswahl bleibt auf offene Low-Hinweise beschränkt. Höhere Severity wird bewusst nicht in Bulk verarbeitet.
</p>
<div class="flex gap-2"> <div class="flex gap-2">
<Button <Button
size="sm" size="sm"
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { Save, Settings } from '@lucide/vue' import { LockKeyhole, Save, Settings } from '@lucide/vue'
import { computed, onBeforeUnmount, onMounted } from 'vue' import { computed, onBeforeUnmount, onMounted } from 'vue'
import { onBeforeRouteLeave } from 'vue-router' import { onBeforeRouteLeave } from 'vue-router'
@@ -87,6 +87,14 @@ onBeforeUnmount(() => window.removeEventListener('beforeunload', handleBeforeUnl
</Button> </Button>
</div> </div>
<p
v-if="!canManageOperationalSettings"
class="flex items-start gap-3 rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm font-semibold leading-6 text-slate-700"
>
<LockKeyhole class="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />
Die Seite bleibt für Admins sichtbar, aber Demo-, OAuth- und Session-Betriebseinstellungen dürfen nur Owner oder Creator speichern.
</p>
<AdminSettingsOverviewBoard <AdminSettingsOverviewBoard
:checks="checks" :checks="checks"
:gates="gates" :gates="gates"