Files
vtuber-awards/Backend/Endpoints/PublicVoteEndpoints.cs
T
AzuTear 33ba00a682
CI - Build & Verify / Build, Typecheck & Hygiene (push) Successful in 58s
CI - Build & Verify / Deploy to award.noveria.net (push) Successful in 1m24s
Harden request validation and admin UX cues
2026-06-30 08:45:28 +02:00

179 lines
7.1 KiB
C#

using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace Backend.Endpoints;
public static partial class PublicEndpoints
{
private static async Task<IResult> CreateVote(
HttpContext context,
CreateVoteRequest? request,
AwardsDbContext db,
IUserSessionService userSessionService,
IRiskFlagService riskFlagService,
IRiskRuleService riskRuleService)
{
var validationError = ValidateCreateVoteRequest(request);
if (validationError is not null)
{
return validationError;
}
var validatedRequest = request!;
var distinctCategoryCount = validatedRequest.Entries
.Select(item => item.CategoryId)
.Distinct()
.Count();
if (distinctCategoryCount != validatedRequest.Entries.Length)
{
return Results.BadRequest(new { message = "Only one vote entry per category is allowed." });
}
var season = await db.Seasons
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == validatedRequest.SeasonId);
var voteSeasonResolution = EnsurePublicWriteSeason(season, "voting");
if (voteSeasonResolution.Result is not null)
{
return voteSeasonResolution.Result;
}
var submitterIdResult = await ResolveSubmitterIdAsync(context, validatedRequest.TwitchUserId, userSessionService);
if (submitterIdResult.Result is not null)
{
return submitterIdResult.Result;
}
var submitterId = submitterIdResult.SubmitterId!;
var requestMetadata = RequestMetadataReader.Read(context);
var candidateIds = validatedRequest.Entries.Select(item => item.CandidateId).Distinct().ToArray();
var validCandidates = await db.Candidates
.AsNoTracking()
.Where(item => item.SeasonId == validatedRequest.SeasonId && candidateIds.Contains(item.Id))
.Select(item => new { item.Id, item.CategoryId })
.ToArrayAsync();
if (validCandidates.Length != candidateIds.Length)
{
return Results.BadRequest(new { message = "One or more selected candidates do not belong to this season." });
}
var candidateCategoryMap = validCandidates.ToDictionary(item => item.Id, item => 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." });
}
var ballot = await db.VoteBallots
.Include(item => item.Entries)
.FirstOrDefaultAsync(item => item.SeasonId == validatedRequest.SeasonId && item.SubmittedByTwitchId == submitterId);
var isResubmission = ballot is not null;
ballot = await ApplyVoteEntriesAsync(db, ballot, validatedRequest.SeasonId, submitterId, validatedRequest.Entries, context.RequestAborted);
var resubmittedBallotRule = await riskRuleService.GetRuleAsync("resubmitted_ballot", context.RequestAborted);
var rapidVoteUpdatesRule = await riskRuleService.GetRuleAsync("rapid_vote_updates", context.RequestAborted);
var recentVoteSubmissions = await db.VoteBallots.CountAsync(item =>
item.SubmittedByTwitchId == submitterId
&& item.SubmittedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidVoteUpdatesRule.WindowMinutes));
try
{
await db.SaveChangesAsync(context.RequestAborted);
}
catch (DbUpdateException error) when (!isResubmission && IsUniqueVoteBallotViolation(error))
{
db.ChangeTracker.Clear();
ballot = await db.VoteBallots
.Include(item => item.Entries)
.FirstAsync(item => item.SeasonId == validatedRequest.SeasonId && item.SubmittedByTwitchId == submitterId, context.RequestAborted);
isResubmission = true;
ballot = await ApplyVoteEntriesAsync(db, ballot, validatedRequest.SeasonId, submitterId, validatedRequest.Entries, context.RequestAborted);
await db.SaveChangesAsync(context.RequestAborted);
}
var ballotLink = new
{
label = "Voting-Analytics öffnen",
entityType = "vote",
entityId = ballot.Id.ToString(),
to = $"/admin/analytics?query={Uri.EscapeDataString(submitterId)}",
};
if (isResubmission && resubmittedBallotRule.Enabled)
{
await riskFlagService.AddIfMissingAsync(
validatedRequest.SeasonId,
submitterId,
"vote",
"resubmitted_ballot",
resubmittedBallotRule.Severity,
"Ein User hat sein Ballot erneut gespeichert oder aktualisiert.",
requestMetadata,
new { ballotId = ballot.Id, entryCount = validatedRequest.Entries.Length, entityLinks = new[] { ballotLink } },
context.RequestAborted);
}
if (rapidVoteUpdatesRule.Enabled && recentVoteSubmissions >= rapidVoteUpdatesRule.Threshold)
{
await riskFlagService.AddIfMissingAsync(
validatedRequest.SeasonId,
submitterId,
"vote",
"rapid_vote_updates",
rapidVoteUpdatesRule.Severity,
"Mehrere Voting-Aenderungen wurden in kurzer Zeit erkannt.",
requestMetadata,
new { ballotId = ballot.Id, recentVoteSubmissions, threshold = rapidVoteUpdatesRule.Threshold, windowMinutes = rapidVoteUpdatesRule.WindowMinutes, entityLinks = new[] { ballotLink } },
context.RequestAborted);
}
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { ballotId = ballot.Id, entries = ballot.Entries.Count, updated = isResubmission });
}
private static async Task<VoteBallot> ApplyVoteEntriesAsync(
AwardsDbContext db,
VoteBallot? ballot,
int seasonId,
string submitterId,
VoteEntryRequest[] entries,
CancellationToken cancellationToken)
{
if (ballot is null)
{
ballot = new VoteBallot
{
SeasonId = seasonId,
SubmittedByTwitchId = submitterId,
};
await db.VoteBallots.AddAsync(ballot, cancellationToken);
}
else
{
db.VoteEntries.RemoveRange(ballot.Entries);
ballot.Entries.Clear();
}
ballot.SubmittedAt = DateTimeOffset.UtcNow;
ballot.Status = "submitted";
ballot.Entries = entries.Select(entry => new VoteEntry
{
CategoryId = entry.CategoryId,
CandidateId = entry.CandidateId,
}).ToList();
return ballot;
}
private static bool IsUniqueVoteBallotViolation(DbUpdateException error) =>
error.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation };
}