Files
vtuber-awards/Backend/Endpoints/PublicClipEndpoints.cs
T

159 lines
6.3 KiB
C#

using Backend.Common;
using Backend.Contracts;
using Backend.Data;
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class PublicEndpoints
{
private static async Task<IResult> CreateClip(
HttpContext context,
CreateClipRequest request,
AwardsDbContext db,
IUserSessionService userSessionService,
IRiskFlagService riskFlagService,
IRiskRuleService riskRuleService)
{
if (!TryNormalizeExternalUrl(request.ClipUrl, out var clipUrl))
{
return Results.BadRequest(new { message = "A valid http(s) clip link is required." });
}
var platform = ResolveClipPlatform(clipUrl);
if (platform is null)
{
return Results.BadRequest(new { message = "Only Twitch or YouTube clip links are supported." });
}
var season = await db.Seasons.FirstOrDefaultAsync(item => item.Year == request.Year);
var clipSeasonResolution = EnsurePublicWriteSeason(season, "nomination");
if (clipSeasonResolution.Result is not null)
{
return clipSeasonResolution.Result;
}
season = clipSeasonResolution.Season!;
var selectedCandidate = request.CandidateId is int candidateId
? await db.Candidates
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == candidateId && item.SeasonId == season.Id)
: null;
if (request.CandidateId is not null && selectedCandidate is null)
{
return Results.BadRequest(new { message = "The selected candidate does not exist for this season." });
}
if (request.CategoryId is int requestedCategoryId
&& selectedCandidate is not null
&& selectedCandidate.CategoryId != requestedCategoryId)
{
return Results.BadRequest(new { message = "The selected candidate does not belong to the selected category." });
}
var resolvedCategoryId = request.CategoryId ?? selectedCandidate?.CategoryId;
var normalizedTitle = request.Title?.Trim() ?? string.Empty;
var submittedCreator = request.Creator?.Trim();
var normalizedCreator = string.IsNullOrWhiteSpace(submittedCreator)
? selectedCandidate?.DisplayName ?? string.Empty
: submittedCreator;
if (normalizedTitle.Length > 160)
{
return Results.BadRequest(new { message = "Clip titles must stay below 160 characters." });
}
if (normalizedCreator.Length > 160)
{
return Results.BadRequest(new { message = "Creator names must stay below 160 characters." });
}
if (resolvedCategoryId is int categoryId)
{
var categoryExists = selectedCandidate?.CategoryId == categoryId
|| await db.Categories.AnyAsync(item => item.Id == categoryId && item.SeasonId == season.Id);
if (!categoryExists)
{
return Results.BadRequest(new { message = "The selected category does not exist for this season." });
}
}
var submitterIdResult = await ResolveSubmitterIdAsync(context, request.TwitchUserId, userSessionService);
if (submitterIdResult.Result is not null)
{
return submitterIdResult.Result;
}
var submitterId = submitterIdResult.SubmitterId!;
var requestMetadata = RequestMetadataReader.Read(context);
var duplicateClipRule = await riskRuleService.GetRuleAsync("duplicate_clip_submission", context.RequestAborted);
var rapidClipBurstRule = await riskRuleService.GetRuleAsync("rapid_clip_burst", context.RequestAborted);
var recentClipSubmissions = await db.ClipSubmissions.CountAsync(item =>
item.SubmittedByTwitchId == submitterId
&& item.CreatedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidClipBurstRule.WindowMinutes));
var alreadySubmittedClip = await db.ClipSubmissions.AnyAsync(item =>
item.SeasonId == season.Id
&& item.SubmittedByTwitchId == submitterId
&& item.ClipUrl == clipUrl);
var clip = new ClipSubmission
{
SeasonId = season.Id,
CategoryId = resolvedCategoryId,
CandidateId = selectedCandidate?.Id,
SubmittedByTwitchId = submitterId,
ClipUrl = clipUrl,
Title = normalizedTitle,
Creator = normalizedCreator,
Platform = platform,
Status = "pending",
CreatedFromIp = requestMetadata.ClientIp,
CreatedAt = DateTimeOffset.UtcNow,
};
db.ClipSubmissions.Add(clip);
await db.SaveChangesAsync(context.RequestAborted);
var clipLink = new
{
label = "Clip öffnen",
entityType = "clip",
entityId = clip.Id.ToString(),
to = $"/admin/clips?query={Uri.EscapeDataString(clip.Id.ToString())}",
};
if (alreadySubmittedClip && duplicateClipRule.Enabled)
{
await riskFlagService.AddIfMissingAsync(
season.Id,
submitterId,
"clip",
"duplicate_clip_submission",
duplicateClipRule.Severity,
"Ein User hat denselben Clip erneut eingereicht.",
requestMetadata,
new { clipId = clip.Id, clipUrl, CategoryId = resolvedCategoryId, CandidateId = selectedCandidate?.Id, entityLinks = new[] { clipLink } },
context.RequestAborted);
}
if (rapidClipBurstRule.Enabled && recentClipSubmissions >= rapidClipBurstRule.Threshold)
{
await riskFlagService.AddIfMissingAsync(
season.Id,
submitterId,
"clip",
"rapid_clip_burst",
rapidClipBurstRule.Severity,
"Ungewoehnlich viele Clip-Einreichungen in kurzer Zeit erkannt.",
requestMetadata,
new { clipId = clip.Id, recentClipSubmissions, threshold = rapidClipBurstRule.Threshold, windowMinutes = rapidClipBurstRule.WindowMinutes, entityLinks = new[] { clipLink } },
context.RequestAborted);
}
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, clipId = clip.Id });
}
}