Harden request validation and admin UX cues
This commit is contained in:
@@ -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"))
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user