Refactor app architecture and clean local artifacts

This commit is contained in:
AzuTear
2026-06-24 23:43:14 +02:00
parent 17134b3b82
commit fef1d36fe8
274 changed files with 37724 additions and 6065 deletions
+32
View File
@@ -0,0 +1,32 @@
using System.Text.Json;
using Backend.Common;
using Backend.Domain;
using Backend.Repositories;
namespace Backend.Services;
public sealed class AdminAuditService(IAdminAuditRepository adminAuditRepository) : IAdminAuditService
{
public void AddEntry(
string adminTwitchUserId,
string actionType,
string entityType,
string entityId,
string summary,
object? metadata = null,
RequestMetadata? requestMetadata = null)
{
adminAuditRepository.Add(new AdminAuditEntry
{
AdminTwitchUserId = adminTwitchUserId,
ActionType = actionType,
EntityType = entityType,
EntityId = entityId,
Summary = summary,
MetadataJson = JsonSerializer.Serialize(metadata ?? new { }),
CreatedFromIp = requestMetadata?.ClientIp ?? string.Empty,
UserAgent = requestMetadata?.UserAgent ?? string.Empty,
CreatedAt = DateTimeOffset.UtcNow,
});
}
}
+15
View File
@@ -0,0 +1,15 @@
using Backend.Common;
namespace Backend.Services;
public interface IAdminAuditService
{
void AddEntry(
string adminTwitchUserId,
string actionType,
string entityType,
string entityId,
string summary,
object? metadata = null,
RequestMetadata? requestMetadata = null);
}
+17
View File
@@ -0,0 +1,17 @@
using Backend.Common;
namespace Backend.Services;
public interface IRiskFlagService
{
Task AddIfMissingAsync(
int? seasonId,
string? twitchUserId,
string source,
string type,
string severity,
string summary,
RequestMetadata requestMetadata,
object? metadata = null,
CancellationToken cancellationToken = default);
}
+7
View File
@@ -0,0 +1,7 @@
namespace Backend.Services;
public interface IRiskRuleService
{
Task<RiskRuleSetting[]> GetRulesAsync(CancellationToken cancellationToken = default);
Task<RiskRuleSetting> GetRuleAsync(string key, CancellationToken cancellationToken = default);
}
+14
View File
@@ -0,0 +1,14 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Domain;
namespace Backend.Services;
public interface IUserSessionService
{
Task<UserSession?> ResolveSessionAsync(HttpContext context, CancellationToken cancellationToken = default);
Task<UserSession> CreateSessionAsync(string twitchUserId, string displayName, string role, RequestMetadata metadata, CancellationToken cancellationToken = default);
Task<UserSession> CreateDevSessionAsync(LoginRequest request, RequestMetadata metadata, CancellationToken cancellationToken = default);
Task<int> CountRecentSessionsFromIpAsync(string ipAddress, DateTimeOffset since, CancellationToken cancellationToken = default);
Task LogoutAsync(UserSession session, CancellationToken cancellationToken = default);
}
+59
View File
@@ -0,0 +1,59 @@
using System.Text.Json;
using Backend.Common;
using Backend.Domain;
using Backend.Repositories;
namespace Backend.Services;
public sealed class RiskFlagService(
IRiskFlagRepository riskFlagRepository,
IRiskRuleService riskRuleService) : IRiskFlagService
{
public async Task AddIfMissingAsync(
int? seasonId,
string? twitchUserId,
string source,
string type,
string severity,
string summary,
RequestMetadata requestMetadata,
object? metadata = null,
CancellationToken cancellationToken = default)
{
var rule = await riskRuleService.GetRuleAsync(type, cancellationToken);
if (!rule.Enabled)
{
return;
}
var threshold = DateTimeOffset.UtcNow.AddMinutes(-rule.WindowMinutes);
var exists = await riskFlagRepository.ExistsOpenRecentAsync(
seasonId,
twitchUserId,
source,
type,
requestMetadata.ClientIp,
threshold,
cancellationToken);
if (exists)
{
return;
}
riskFlagRepository.Add(new RiskFlag
{
SeasonId = seasonId,
TwitchUserId = twitchUserId,
Source = source,
Type = type,
Severity = severity,
Status = "open",
Summary = summary,
CreatedFromIp = requestMetadata.ClientIp,
UserAgent = requestMetadata.UserAgent,
MetadataJson = JsonSerializer.Serialize(metadata ?? new { }),
CreatedAt = DateTimeOffset.UtcNow,
});
}
}
+19
View File
@@ -0,0 +1,19 @@
using Backend.Data;
using Microsoft.EntityFrameworkCore;
namespace Backend.Services;
public sealed class RiskRuleService(AwardsDbContext db) : IRiskRuleService
{
public async Task<RiskRuleSetting[]> GetRulesAsync(CancellationToken cancellationToken = default)
{
var settings = await db.SiteSettings
.AsNoTracking()
.FirstOrDefaultAsync(item => item.Id == 1, cancellationToken);
return RiskRuleSettings.Read(settings);
}
public async Task<RiskRuleSetting> GetRuleAsync(string key, CancellationToken cancellationToken = default) =>
RiskRuleSettings.Find(await GetRulesAsync(cancellationToken), key);
}
+87
View File
@@ -0,0 +1,87 @@
using System.Text.Json;
using Backend.Domain;
namespace Backend.Services;
public sealed record RiskRuleSetting(
string Key,
string Label,
bool Enabled,
int Threshold,
int WindowMinutes,
string Severity,
string Description);
public static class RiskRuleSettings
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
public static RiskRuleSetting[] Defaults { get; } =
[
new("resubmitted_ballot", "Ballot erneut gespeichert", true, 1, 360, "low", "Erstellt einen Hinweis, wenn ein User sein Voting erneut speichert."),
new("rapid_vote_updates", "Voting-Burst", true, 3, 10, "high", "Erstellt einen Hinweis, wenn ein User sehr schnell mehrere Voting-Aenderungen ausloest."),
new("resubmitted_nomination", "Nominierung erneut eingereicht", true, 1, 360, "low", "Erstellt einen Hinweis, wenn ein User in derselben Kategorie erneut nominiert."),
new("rapid_nomination_burst", "Nominierungs-Burst", true, 10, 10, "high", "Erstellt einen Hinweis, wenn ein User sehr viele Nominierungen in kurzer Zeit sendet."),
new("duplicate_clip_submission", "Doppelter Clip", true, 1, 360, "medium", "Erstellt einen Hinweis, wenn ein User denselben Clip erneut einreicht."),
new("rapid_clip_burst", "Clip-Burst", true, 5, 10, "high", "Erstellt einen Hinweis, wenn ein User sehr viele Clips in kurzer Zeit sendet."),
new("rapid_login_ip", "Login-Burst pro IP", true, 3, 15, "medium", "Erstellt einen Hinweis, wenn mehrere neue Sessions von derselben IP entstehen."),
new("rapid_demo_login_ip", "Demo-Login-Burst pro IP", true, 3, 15, "medium", "Erstellt einen Hinweis, wenn mehrere Demo-Admin-Sessions von derselben IP entstehen."),
];
public static RiskRuleSetting[] Read(SiteSettings? settings)
{
var storedRules = Parse(settings?.RiskRulesJson);
return Defaults
.Select(defaultRule =>
{
var storedRule = storedRules.FirstOrDefault(item => string.Equals(item.Key, defaultRule.Key, StringComparison.OrdinalIgnoreCase));
return storedRule is null ? defaultRule : Normalize(storedRule, defaultRule);
})
.ToArray();
}
public static string Serialize(IEnumerable<RiskRuleSetting> rules) =>
JsonSerializer.Serialize(rules.Select(rule => Normalize(rule, Defaults.FirstOrDefault(item => item.Key == rule.Key) ?? rule)), JsonOptions);
public static RiskRuleSetting Find(IEnumerable<RiskRuleSetting> rules, string key) =>
rules.FirstOrDefault(item => string.Equals(item.Key, key, StringComparison.OrdinalIgnoreCase))
?? Defaults.First(item => item.Key == key);
private static RiskRuleSetting[] Parse(string? json)
{
if (string.IsNullOrWhiteSpace(json))
{
return [];
}
try
{
return JsonSerializer.Deserialize<RiskRuleSetting[]>(json, JsonOptions) ?? [];
}
catch (JsonException)
{
return [];
}
}
private static RiskRuleSetting Normalize(RiskRuleSetting rule, RiskRuleSetting fallback)
{
var severity = rule.Severity.Trim().ToLowerInvariant() switch
{
"high" => "high",
"medium" => "medium",
"low" => "low",
_ => fallback.Severity,
};
return rule with
{
Key = fallback.Key,
Label = string.IsNullOrWhiteSpace(rule.Label) ? fallback.Label : rule.Label.Trim(),
Threshold = Math.Clamp(rule.Threshold, 1, 500),
WindowMinutes = Math.Clamp(rule.WindowMinutes, 1, 1440),
Severity = severity,
Description = string.IsNullOrWhiteSpace(rule.Description) ? fallback.Description : rule.Description.Trim(),
};
}
}
+91
View File
@@ -0,0 +1,91 @@
using Backend.Common;
using Backend.Contracts;
using Backend.Domain;
using Backend.Repositories;
using Backend.Security;
using System.Security.Cryptography;
namespace Backend.Services;
public sealed class UserSessionService(IUserSessionRepository userSessionRepository) : IUserSessionService
{
public async Task<UserSession?> ResolveSessionAsync(HttpContext context, CancellationToken cancellationToken = default)
{
var token = ReadBearerToken(context);
if (string.IsNullOrWhiteSpace(token))
{
return null;
}
var session = await userSessionRepository.GetActiveByTokenAsync(token, cancellationToken);
if (session is null)
{
return null;
}
session.LastSeenAt = DateTimeOffset.UtcNow;
await userSessionRepository.SaveChangesAsync(cancellationToken);
context.SetCurrentSession(session);
return session;
}
public Task<UserSession> CreateDevSessionAsync(LoginRequest request, RequestMetadata metadata, CancellationToken cancellationToken = default)
{
return CreateSessionAsync(
request.TwitchUserId,
request.DisplayName,
request.Role,
metadata,
cancellationToken);
}
public Task<UserSession> CreateSessionAsync(
string twitchUserId,
string displayName,
string role,
RequestMetadata metadata,
CancellationToken cancellationToken = default)
{
var normalizedTwitchUserId = twitchUserId.Trim();
var normalizedDisplayName = displayName.Trim();
var session = new UserSession
{
Id = Guid.NewGuid(),
SessionToken = Convert.ToHexString(RandomNumberGenerator.GetBytes(32)).ToLowerInvariant(),
TwitchUserId = normalizedTwitchUserId,
DisplayName = normalizedDisplayName,
Role = AdminRoles.Normalize(role),
CreatedFromIp = metadata.ClientIp,
UserAgent = metadata.UserAgent,
CreatedAt = DateTimeOffset.UtcNow,
LastSeenAt = DateTimeOffset.UtcNow,
IsActive = true,
};
userSessionRepository.Add(session);
return PersistAndReturnAsync(session, cancellationToken);
}
public Task<int> CountRecentSessionsFromIpAsync(string ipAddress, DateTimeOffset since, CancellationToken cancellationToken = default) =>
userSessionRepository.CountRecentSessionsFromIpAsync(ipAddress, since, cancellationToken);
public async Task LogoutAsync(UserSession session, CancellationToken cancellationToken = default)
{
session.IsActive = false;
await userSessionRepository.SaveChangesAsync(cancellationToken);
}
private static string? ReadBearerToken(HttpContext context)
{
var header = context.Request.Headers.Authorization.ToString();
return header.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)
? header["Bearer ".Length..].Trim()
: null;
}
private async Task<UserSession> PersistAndReturnAsync(UserSession session, CancellationToken cancellationToken)
{
await userSessionRepository.SaveChangesAsync(cancellationToken);
return session;
}
}