Files
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

383 lines
14 KiB
C#

using Backend.Contracts;
using Backend.Common;
using Backend.Data;
using Backend.Security;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
namespace Backend.Endpoints;
public static partial class AdminModerationEndpoints
{
private static async Task<IResult> GetRiskFlags(
int? limit,
int? offset,
string? status,
string? severity,
string? query,
bool? reviewedOnly,
AwardsDbContext db)
{
var normalizedLimit = Math.Clamp(limit ?? 25, 1, 100);
var normalizedOffset = Math.Max(offset ?? 0, 0);
var normalizedStatus = NormalizeRiskFlagStatus(status, allowAll: true);
if (normalizedStatus is null)
{
return Results.BadRequest(new { message = "Risk flag status must be open, resolved, dismissed or all." });
}
var riskQuery = db.RiskFlags.AsNoTracking();
if (reviewedOnly == true)
{
riskQuery = riskQuery.Where(item => item.Status != "open");
}
if (!string.IsNullOrWhiteSpace(severity) && !string.Equals(severity, "all", StringComparison.OrdinalIgnoreCase))
{
var normalizedSeverity = severity.Trim().ToLowerInvariant();
if (normalizedSeverity is not ("low" or "medium" or "high"))
{
return Results.BadRequest(new { message = "Risk flag severity must be low, medium, high or all." });
}
riskQuery = riskQuery.Where(item => item.Severity == normalizedSeverity);
}
if (normalizedStatus != "all")
{
riskQuery = riskQuery.Where(item => item.Status == normalizedStatus);
}
if (!string.IsNullOrWhiteSpace(query))
{
var pattern = $"%{query.Trim()}%";
riskQuery = riskQuery.Where(item =>
EF.Functions.ILike(item.Source, pattern) ||
EF.Functions.ILike(item.Type, pattern) ||
EF.Functions.ILike(item.Severity, pattern) ||
EF.Functions.ILike(item.Status, pattern) ||
EF.Functions.ILike(item.Summary, pattern) ||
(item.TwitchUserId != null && EF.Functions.ILike(item.TwitchUserId, pattern)) ||
EF.Functions.ILike(item.CreatedFromIp, pattern) ||
(item.ReviewNote != null && EF.Functions.ILike(item.ReviewNote, pattern)) ||
EF.Functions.ILike(item.MetadataJson, pattern));
}
var totalCount = await riskQuery.CountAsync();
var severityCounts = await riskQuery
.GroupBy(item => item.Severity)
.Select(group => new AdminRiskCountDto(group.Key, group.Count()))
.ToArrayAsync();
var statusCounts = await riskQuery
.GroupBy(item => item.Status)
.Select(group => new AdminRiskCountDto(group.Key, group.Count()))
.ToArrayAsync();
var flags = await riskQuery
.OrderByDescending(item => item.CreatedAt)
.Skip(normalizedOffset)
.Take(normalizedLimit)
.ToArrayAsync();
var items = flags.Select(AdminRiskFlagMappings.ToDto).ToArray();
return Results.Ok(new AdminRiskFlagsResponse(
items,
totalCount,
items.Length,
normalizedOffset,
normalizedLimit,
normalizedOffset + items.Length < totalCount,
severityCounts,
statusCounts));
}
private static async Task<IResult> ResolveRiskFlag(
HttpContext context,
int riskFlagId,
ResolveRiskFlagRequest request,
AwardsDbContext db,
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
var riskFlag = await db.RiskFlags.FirstOrDefaultAsync(item => item.Id == riskFlagId);
if (riskFlag is null)
{
return Results.NotFound();
}
var normalizedStatus = NormalizeRiskFlagStatus(request.Status, allowAll: false);
if (normalizedStatus is null)
{
return Results.BadRequest(new { message = "Risk flag status must be open, resolved or dismissed." });
}
var previousStatus = riskFlag.Status;
var previousReviewNote = riskFlag.ReviewNote;
var normalizedReviewNote = NormalizeReviewNote(request.ReviewNote);
riskFlag.Status = normalizedStatus;
if (normalizedStatus == "open")
{
riskFlag.ReviewedAt = null;
riskFlag.ReviewedByTwitchId = null;
riskFlag.ReviewNote = null;
}
else
{
riskFlag.ReviewedAt = DateTimeOffset.UtcNow;
riskFlag.ReviewedByTwitchId = session.TwitchUserId;
riskFlag.ReviewNote = normalizedReviewNote;
}
adminAuditService.AddEntry(
session.TwitchUserId,
"risk.resolve",
"risk-flag",
riskFlag.Id.ToString(),
$"Risk Flag {riskFlag.Id} wurde als {riskFlag.Status} markiert.",
new
{
riskFlag.Type,
riskFlag.Source,
changes = BuildRiskResolutionChanges(previousStatus, riskFlag.Status, previousReviewNote, riskFlag.ReviewNote),
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, riskFlagId = riskFlag.Id, status = riskFlag.Status });
}
private static async Task<IResult> BulkResolveRiskFlags(
HttpContext context,
BulkResolveRiskFlagsRequest? request,
AwardsDbContext db,
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 normalizedIds = request.RiskFlagIds
.Distinct()
.Take(100)
.ToArray();
if (normalizedIds.Length == 0)
{
return Results.BadRequest(new { message = "Bitte waehle mindestens einen Risikohinweis aus." });
}
var normalizedStatus = NormalizeRiskFlagStatus(request.Status, allowAll: false);
if (normalizedStatus is null || normalizedStatus == "open")
{
return Results.BadRequest(new { message = "Bulk-Verarbeitung unterstuetzt erledigt oder verworfen." });
}
var normalizedReviewNote = NormalizeReviewNote(request.ReviewNote);
if (string.IsNullOrWhiteSpace(normalizedReviewNote))
{
return Results.BadRequest(new { message = "Bulk-Verarbeitung braucht eine Review-Notiz." });
}
var riskFlags = await db.RiskFlags
.Where(item => normalizedIds.Contains(item.Id))
.ToArrayAsync(context.RequestAborted);
if (riskFlags.Length != normalizedIds.Length)
{
return Results.BadRequest(new { message = "Mindestens ein Risikohinweis wurde nicht gefunden." });
}
if (riskFlags.Any(item => item.Severity != "low"))
{
return Results.BadRequest(new { message = "Bulk-Verarbeitung ist nur fuer offene Low-Severity-Hinweise erlaubt." });
}
if (riskFlags.Any(item => item.Status != "open"))
{
return Results.BadRequest(new { message = "Bulk-Verarbeitung ist nur fuer offene Hinweise erlaubt." });
}
var reviewedAt = DateTimeOffset.UtcNow;
foreach (var riskFlag in riskFlags)
{
riskFlag.Status = normalizedStatus;
riskFlag.ReviewNote = normalizedReviewNote;
riskFlag.ReviewedAt = reviewedAt;
riskFlag.ReviewedByTwitchId = session.TwitchUserId;
}
adminAuditService.AddEntry(
session.TwitchUserId,
"risk.bulk-resolve",
"risk-flag",
string.Join(",", normalizedIds),
$"{normalizedIds.Length} Low-Risk Flags wurden als {normalizedStatus} markiert.",
new
{
status = normalizedStatus,
count = normalizedIds.Length,
riskFlagIds = normalizedIds,
changes = new[]
{
new
{
field = "status",
label = "Status",
from = "open",
to = normalizedStatus,
sensitive = false,
},
new
{
field = "reviewNote",
label = "Review-Notiz",
from = "keine Notiz",
to = "Notiz vorhanden",
sensitive = true,
},
},
},
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new { saved = true, count = normalizedIds.Length, status = normalizedStatus });
}
private static async Task<IResult> GetRiskRules(AwardsDbContext db)
{
var settings = await db.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1);
if (settings is null)
{
return Results.NotFound();
}
return Results.Ok(new AdminRiskRulesResponse(RiskRuleSettings.Read(settings).Select(ToRiskRuleDto).ToArray()));
}
private static async Task<IResult> UpdateRiskRules(
HttpContext context,
UpdateRiskRulesRequest 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 = RiskRuleSettings.Read(settings);
var mergedRules = RiskRuleSettings.Defaults
.Select(defaultRule =>
{
var requestRule = request.Rules.FirstOrDefault(item => item.Key == defaultRule.Key);
return requestRule is null
? defaultRule
: new RiskRuleSetting(
defaultRule.Key,
defaultRule.Label,
requestRule.Enabled,
requestRule.Threshold,
requestRule.WindowMinutes,
requestRule.Severity,
defaultRule.Description);
})
.ToArray();
settings.RiskRulesJson = RiskRuleSettings.Serialize(mergedRules);
var after = RiskRuleSettings.Read(settings);
var changes = after
.Select(rule =>
{
var previous = before.First(item => item.Key == rule.Key);
return new
{
field = rule.Key,
label = rule.Label,
from = $"{previous.Enabled}/{previous.Threshold}/{previous.WindowMinutes}/{previous.Severity}",
to = $"{rule.Enabled}/{rule.Threshold}/{rule.WindowMinutes}/{rule.Severity}",
sensitive = false,
};
})
.Where(change => change.from != change.to)
.ToArray();
adminAuditService.AddEntry(
session.TwitchUserId,
"risk-rules.update",
"site-settings",
settings.Id.ToString(),
"Risk-Regeln wurden aktualisiert.",
new { changes },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(new AdminRiskRulesResponse(after.Select(ToRiskRuleDto).ToArray()));
}
private static AdminRiskRuleDto ToRiskRuleDto(RiskRuleSetting rule) =>
new(rule.Key, rule.Label, rule.Enabled, rule.Threshold, rule.WindowMinutes, rule.Severity, rule.Description);
private static string? NormalizeReviewNote(string? reviewNote)
{
if (string.IsNullOrWhiteSpace(reviewNote))
{
return null;
}
var trimmedReviewNote = reviewNote.Trim();
return trimmedReviewNote.Length <= 500 ? trimmedReviewNote : trimmedReviewNote[..500];
}
private static object[] BuildRiskResolutionChanges(
string previousStatus,
string currentStatus,
string? previousReviewNote,
string? currentReviewNote)
{
var changes = new List<object>
{
new
{
field = "status",
label = "Status",
from = previousStatus,
to = currentStatus,
sensitive = false,
},
};
if (!string.Equals(previousReviewNote, currentReviewNote, StringComparison.Ordinal))
{
changes.Add(new
{
field = "reviewNote",
label = "Review-Notiz",
from = string.IsNullOrWhiteSpace(previousReviewNote) ? "keine Notiz" : "Notiz vorhanden",
to = string.IsNullOrWhiteSpace(currentReviewNote) ? "keine Notiz" : "Notiz vorhanden",
sensitive = true,
});
}
return changes.ToArray();
}
private static string? NormalizeRiskFlagStatus(string? status, bool allowAll)
{
var normalizedStatus = string.IsNullOrWhiteSpace(status) ? "open" : status.Trim().ToLowerInvariant();
return normalizedStatus switch
{
"open" or "resolved" or "dismissed" => normalizedStatus,
"all" when allowAll => normalizedStatus,
_ => null,
};
}
}