383 lines
14 KiB
C#
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,
|
|
};
|
|
}
|
|
}
|