Refine admin workflows and team access

This commit is contained in:
AzuTear
2026-06-26 18:09:29 +02:00
parent b7804e10ea
commit 8b69dfbafb
71 changed files with 5066 additions and 751 deletions
+2
View File
@@ -17,6 +17,8 @@ public sealed record AdminTeamMemberDto(
DateTimeOffset CreatedAt,
DateTimeOffset? UpdatedAt,
DateTimeOffset? LastLoginAt,
DateTimeOffset? LastOnlineAt,
bool IsOnline,
DateTimeOffset? TwitchBoundAt,
DateTimeOffset? PasswordResetAt);
-8
View File
@@ -14,18 +14,10 @@ public sealed record TeamLoginRequest(
string Login,
string Password);
public sealed record TeamTwitchLoginRequest(
string TwitchUserId,
string? DisplayName);
public sealed record ChangePasswordRequest(
string CurrentPassword,
string NewPassword);
public sealed record BindTeamTwitchRequest(
string TwitchUserId,
string? DisplayName);
public sealed record TwitchAuthorizeRequest(
string Purpose,
string? ReturnUrl,
+1
View File
@@ -103,6 +103,7 @@ public sealed class AwardsDbContext(DbContextOptions<AwardsDbContext> options) :
modelBuilder.Entity<VoteBallot>(entity =>
{
entity.HasIndex(item => new { item.SeasonId, item.SubmittedByTwitchId }).IsUnique();
entity.Property(item => item.SubmittedByTwitchId).HasMaxLength(120);
entity.Property(item => item.Status).HasMaxLength(30);
});
+38 -1
View File
@@ -17,7 +17,7 @@ public static class TeamAccountBootstrapper
defaultLogin: "jayuhime",
defaultDisplayName: "Jayuhime",
role: AdminRoles.Owner,
fallbackPassword: configuration["VTSA_DEMO_ADMIN_PASSWORD"] ?? configuration["DemoAdmin:Password"]);
fallbackPassword: null);
var creatorSeed = BuildSeed(
configuration,
@@ -33,6 +33,11 @@ public static class TeamAccountBootstrapper
await EnsureMemberAsync(db, seed);
}
await DeactivateDemoBackedSeedAccountAsync(
db,
ownerSeed,
configuration["VTSA_DEMO_ADMIN_PASSWORD"] ?? configuration["DemoAdmin:Password"]);
await db.SaveChangesAsync();
}
@@ -80,6 +85,18 @@ public static class TeamAccountBootstrapper
changed = true;
}
if (member.CreatedByTwitchId == SeedActor
&& (string.IsNullOrWhiteSpace(member.UpdatedByTwitchId)
|| string.Equals(member.UpdatedByTwitchId, SeedActor, StringComparison.Ordinal))
&& (!string.Equals(member.PasswordHash, seed.Credentials.Value.Hash, StringComparison.Ordinal)
|| !string.Equals(member.PasswordSalt, seed.Credentials.Value.Salt, StringComparison.Ordinal)))
{
member.PasswordHash = seed.Credentials.Value.Hash;
member.PasswordSalt = seed.Credentials.Value.Salt;
member.MustChangePassword = false;
changed = true;
}
if (changed)
{
member.UpdatedAt = DateTimeOffset.UtcNow;
@@ -87,6 +104,26 @@ public static class TeamAccountBootstrapper
}
}
private static async Task DeactivateDemoBackedSeedAccountAsync(AwardsDbContext db, TeamAccountSeed seed, string? demoPassword)
{
if (seed.Credentials is not null || string.IsNullOrWhiteSpace(demoPassword))
{
return;
}
var member = await db.TeamMembers.FirstOrDefaultAsync(item => item.Login == seed.Login);
if (member is null
|| member.CreatedByTwitchId != SeedActor
|| !DemoCredentialHasher.VerifyPassword(demoPassword, member.PasswordHash, member.PasswordSalt))
{
return;
}
member.IsActive = false;
member.UpdatedAt = DateTimeOffset.UtcNow;
member.UpdatedByTwitchId = SeedActor;
}
private static TeamAccountSeed BuildSeed(
IConfiguration configuration,
string sectionName,
@@ -75,9 +75,15 @@ public static class AdminSiteSettingsEndpoints
return Results.NotFound();
}
var urlValidationError = NormalizeSiteSettingsUrls(request, out var normalizedUrls, out var socialLinks);
if (urlValidationError is not null)
{
return urlValidationError;
}
settings.HostDisplayName = request.HostDisplayName.Trim();
settings.HostTagline = request.HostTagline.Trim();
settings.NewsletterUrl = request.NewsletterUrl.Trim();
settings.NewsletterUrl = normalizedUrls.NewsletterUrl;
settings.PrivacyEmail = request.PrivacyEmail.Trim();
var trimmedPrivacyContent = request.PrivacyPolicyContent.Trim();
var privacyChanged = !string.Equals(settings.PrivacyPolicyContent, trimmedPrivacyContent, StringComparison.Ordinal);
@@ -88,13 +94,13 @@ public static class AdminSiteSettingsEndpoints
settings.PrivacyPolicyUpdatedAt = DateTimeOffset.UtcNow;
}
settings.ImprintUrl = request.ImprintUrl.Trim();
settings.ImprintUrl = normalizedUrls.ImprintUrl;
settings.ImprintContent = request.ImprintContent.Trim();
settings.ContactUrl = request.ContactUrl.Trim();
settings.ContactUrl = normalizedUrls.ContactUrl;
settings.ContactContent = request.ContactContent.Trim();
settings.SponsorsUrl = request.SponsorsUrl.Trim();
settings.SponsorsUrl = normalizedUrls.SponsorsUrl;
settings.SponsorsContent = request.SponsorsContent.Trim();
settings.SocialLinksJson = JsonSerializer.Serialize(request.SocialLinks ?? []);
settings.SocialLinksJson = JsonSerializer.Serialize(socialLinks);
settings.FaqJson = JsonSerializer.Serialize(request.Faq ?? []);
adminAuditService.AddEntry(
@@ -107,7 +113,7 @@ public static class AdminSiteSettingsEndpoints
{
settings.HostDisplayName,
privacyChanged,
socialLinkCount = request.SocialLinks?.Length ?? 0,
socialLinkCount = socialLinks.Length,
faqCount = request.Faq?.Length ?? 0,
},
RequestMetadataReader.Read(context));
@@ -116,6 +122,73 @@ public static class AdminSiteSettingsEndpoints
return Results.Ok(new { saved = true });
}
private static IResult? NormalizeSiteSettingsUrls(
UpdateSiteSettingsRequest request,
out PublicSiteUrlSettings normalizedUrls,
out PublicSocialLinkDto[] socialLinks)
{
normalizedUrls = new PublicSiteUrlSettings();
socialLinks = [];
if (!TryNormalizePublicUrl(request.NewsletterUrl, "Newsletter-Link", out var newsletterUrl, out var errorMessage)
|| !TryNormalizePublicUrl(request.ImprintUrl, "Impressum-Link", out var imprintUrl, out errorMessage)
|| !TryNormalizePublicUrl(request.ContactUrl, "Kontakt-Link", out var contactUrl, out errorMessage)
|| !TryNormalizePublicUrl(request.SponsorsUrl, "Sponsoren-Link", out var sponsorsUrl, out errorMessage))
{
return Results.BadRequest(new { message = errorMessage });
}
normalizedUrls = new PublicSiteUrlSettings
{
NewsletterUrl = newsletterUrl,
ImprintUrl = imprintUrl,
ContactUrl = contactUrl,
SponsorsUrl = sponsorsUrl,
};
var normalizedSocialLinks = new List<PublicSocialLinkDto>();
foreach (var social in request.SocialLinks ?? [])
{
if (!TryNormalizePublicUrl(social.Url, $"Social-Link {social.Label}", out var normalizedUrl, out errorMessage))
{
return Results.BadRequest(new { message = errorMessage });
}
normalizedSocialLinks.Add(social with { Url = normalizedUrl });
}
socialLinks = normalizedSocialLinks.ToArray();
return null;
}
private static bool TryNormalizePublicUrl(string? value, string fieldName, out string normalizedUrl, out string errorMessage)
{
normalizedUrl = (value ?? string.Empty).Trim();
errorMessage = string.Empty;
if (string.IsNullOrWhiteSpace(normalizedUrl))
{
return true;
}
if (!Uri.TryCreate(normalizedUrl, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps))
{
errorMessage = $"{fieldName} muss eine absolute http/https-URL sein.";
return false;
}
normalizedUrl = uri.ToString();
return true;
}
private sealed class PublicSiteUrlSettings
{
public string NewsletterUrl { get; set; } = string.Empty;
public string ImprintUrl { get; set; } = string.Empty;
public string ContactUrl { get; set; } = string.Empty;
public string SponsorsUrl { get; set; } = string.Empty;
}
private static async Task<IResult> GetOperationalSettings(AwardsDbContext db, IConfiguration configuration)
{
var settings = await db.SiteSettings.AsNoTracking().FirstOrDefaultAsync(item => item.Id == 1);
+67 -10
View File
@@ -13,6 +13,7 @@ namespace Backend.Endpoints;
public static class AdminTeamEndpoints
{
private const int MinPasswordLength = 10;
private static readonly TimeSpan OnlineActivityWindow = TimeSpan.FromMinutes(5);
private const string PersonalOwnerLogin = "jayuhime";
private static readonly string[] PersonalCreatorLogins = ["sleepy_bao"];
@@ -79,11 +80,21 @@ public static class AdminTeamEndpoints
.OrderByDescending(item => item.Role == AdminRoles.Owner)
.ThenBy(item => item.Role)
.ThenBy(item => item.DisplayName)
.Select(item => ToMemberDto(item))
.ToArrayAsync();
var sessionKeys = members
.SelectMany(MemberSessionKeys)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
var sessions = sessionKeys.Length == 0
? []
: await db.UserSessions
.AsNoTracking()
.Where(item => sessionKeys.Contains(item.TwitchUserId))
.ToArrayAsync();
var now = DateTimeOffset.UtcNow;
return Results.Ok(new AdminTeamResponse(
members,
members.Select(member => ToMemberDto(member, sessions, now)),
await BuildRoleDtosAsync(db),
PermissionCatalog));
}
@@ -322,10 +333,15 @@ public static class AdminTeamEndpoints
IAdminAuditService adminAuditService)
{
var session = AdminEndpointConventions.CurrentSession(context);
if (!AdminRoles.IsPrivilegedFullControlRole(session.Role))
{
return Results.Json(new { message = "Rollenberechtigungen können nur von Owner oder Creator geändert werden." }, statusCode: StatusCodes.Status403Forbidden);
}
var allowedPermissions = AdminPermissionCatalog.AllPermissionKeys.ToHashSet(StringComparer.OrdinalIgnoreCase);
var allowedRoles = DefaultRoles.Select(item => item.Key).ToHashSet(StringComparer.OrdinalIgnoreCase);
var existingRows = await db.TeamRolePermissions.ToDictionaryAsync(item => item.Role, StringComparer.OrdinalIgnoreCase, context.RequestAborted);
var roleChanges = new List<object>();
foreach (var roleUpdate in request.Roles ?? [])
{
@@ -339,14 +355,18 @@ public static class AdminTeamEndpoints
.Select(item => item.Trim())
.Where(item => allowedPermissions.Contains(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(item => item)
.ToArray();
permissionKeys = AdminPermissionCatalog.NormalizePermissionKeys(role, permissionKeys);
if (AdminRoles.IsPrivilegedFullControlRole(role) && permissionKeys.Length != AdminPermissionCatalog.AllPermissionKeys.Length)
{
return Results.BadRequest(new { message = "Owner und Creator müssen alle Berechtigungen behalten." });
}
var beforePermissions = existingRows.TryGetValue(role, out var existingRow)
? AdminPermissionCatalog.NormalizePermissionKeys(role, ReadPermissionKeys(existingRow.PermissionsJson, AdminPermissionCatalog.DefaultPermissionKeys(role)))
: AdminPermissionCatalog.NormalizePermissionKeys(role, AdminPermissionCatalog.DefaultPermissionKeys(role));
if (!existingRows.TryGetValue(role, out var row))
{
row = new TeamRolePermission { Role = role };
@@ -356,6 +376,18 @@ public static class AdminTeamEndpoints
row.PermissionsJson = JsonSerializer.Serialize(permissionKeys);
row.UpdatedAt = DateTimeOffset.UtcNow;
row.UpdatedByTwitchId = session.TwitchUserId;
if (!beforePermissions.SequenceEqual(permissionKeys, StringComparer.OrdinalIgnoreCase))
{
roleChanges.Add(new
{
role,
before = beforePermissions,
after = permissionKeys,
added = permissionKeys.Except(beforePermissions, StringComparer.OrdinalIgnoreCase).ToArray(),
removed = beforePermissions.Except(permissionKeys, StringComparer.OrdinalIgnoreCase).ToArray(),
});
}
}
adminAuditService.AddEntry(
@@ -364,7 +396,7 @@ public static class AdminTeamEndpoints
"team-role",
"matrix",
"Team-Rollenberechtigungen wurden gespeichert.",
new { roleCount = request.Roles?.Length ?? 0 },
new { roleCount = request.Roles?.Length ?? 0, changes = roleChanges },
RequestMetadataReader.Read(context));
await db.SaveChangesAsync(context.RequestAborted);
@@ -384,18 +416,30 @@ public static class AdminTeamEndpoints
? ReadPermissionKeys(json, role.PermissionKeys)
: role.PermissionKeys;
if (AdminRoles.IsPrivilegedFullControlRole(role.Key))
{
permissions = AdminPermissionCatalog.AllPermissionKeys;
}
permissions = AdminPermissionCatalog.NormalizePermissionKeys(role.Key, permissions);
return role with { PermissionKeys = permissions };
})
.ToArray();
}
private static AdminTeamMemberDto ToMemberDto(TeamMember member) =>
new(
private static AdminTeamMemberDto ToMemberDto(
TeamMember member,
IReadOnlyCollection<UserSession>? sessions = null,
DateTimeOffset? now = null)
{
var memberSessions = sessions?
.Where(session => MemberSessionKeys(member).Contains(session.TwitchUserId, StringComparer.OrdinalIgnoreCase))
.ToArray() ?? [];
var lastSeenAt = memberSessions
.Select(session => (DateTimeOffset?)session.LastSeenAt)
.Max() ?? member.LastLoginAt;
var isOnline = member.IsActive
&& memberSessions.Any(session =>
session.IsActive
&& session.LastSeenAt >= (now ?? DateTimeOffset.UtcNow).Subtract(OnlineActivityWindow));
return new(
member.Id,
member.Login,
member.DisplayName,
@@ -407,8 +451,21 @@ public static class AdminTeamEndpoints
member.CreatedAt,
member.UpdatedAt,
member.LastLoginAt,
lastSeenAt,
isOnline,
member.TwitchBoundAt,
member.PasswordResetAt);
}
private static IEnumerable<string> MemberSessionKeys(TeamMember member)
{
yield return $"team:{member.Login}";
if (!string.IsNullOrWhiteSpace(member.BoundTwitchUserId))
{
yield return member.BoundTwitchUserId;
}
}
private static IEnumerable<string> ReadPermissionKeys(string json, IEnumerable<string> fallback)
{
-10
View File
@@ -23,21 +23,11 @@ public static partial class AuthEndpoints
.WithName("TeamLogin")
.WithOpenApi();
group.MapPost("/team-twitch-login", TeamTwitchLogin)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("TeamTwitchLogin")
.WithOpenApi();
group.MapPost("/password/change", ChangePassword)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("ChangePassword")
.WithOpenApi();
group.MapPost("/team/twitch-binding", BindTeamTwitch)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("BindTeamTwitch")
.WithOpenApi();
group.MapPost("/twitch/authorize", StartTwitchAuthorization)
.RequireRateLimiting(ApplicationDefaults.AuthRateLimitPolicy)
.WithName("StartTwitchAuthorization")
@@ -47,38 +47,6 @@ public static partial class AuthEndpoints
return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted));
}
private static async Task<IResult> TeamTwitchLogin(
HttpContext context,
TeamTwitchLoginRequest request,
AwardsDbContext db,
IUserSessionService userSessionService)
{
var twitchUserId = NormalizeTwitchUserId(request.TwitchUserId);
if (string.IsNullOrWhiteSpace(twitchUserId))
{
return Results.Unauthorized();
}
var member = await db.TeamMembers.FirstOrDefaultAsync(
item => item.BoundTwitchUserId == twitchUserId,
context.RequestAborted);
if (member is null || !member.IsActive)
{
return Results.Unauthorized();
}
member.LastLoginAt = DateTimeOffset.UtcNow;
var session = await userSessionService.CreateSessionAsync(
twitchUserId,
member.DisplayName,
member.Role,
RequestMetadataReader.Read(context),
context.RequestAborted);
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(await ToAuthSessionDtoAsync(db, session, member.MustChangePassword, context.RequestAborted));
}
private static async Task<IResult> ChangePassword(
HttpContext context,
ChangePasswordRequest request,
@@ -127,64 +95,6 @@ public static partial class AuthEndpoints
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
}
private static async Task<IResult> BindTeamTwitch(
HttpContext context,
BindTeamTwitchRequest request,
AwardsDbContext db,
IUserSessionService userSessionService)
{
var session = await userSessionService.ResolveSessionAsync(context, context.RequestAborted);
if (session is null)
{
return Results.Unauthorized();
}
var member = await FindTeamMemberForSessionAsync(db, session, context.RequestAborted);
if (member is null || !member.IsActive)
{
return Results.BadRequest(new { message = "Dieser Account ist kein aktiver Team-Login." });
}
if (member.MustChangePassword)
{
return Results.BadRequest(new { message = "Bitte ändere zuerst dein temporäres Passwort." });
}
var twitchUserId = NormalizeTwitchUserId(request.TwitchUserId);
var twitchDisplayName = NormalizeTwitchDisplayName(request.DisplayName);
if (string.IsNullOrWhiteSpace(twitchUserId))
{
return Results.BadRequest(new { message = "Bitte gib deine private Twitch-ID an." });
}
var alreadyBound = await db.TeamMembers.AnyAsync(
item => item.Id != member.Id && item.BoundTwitchUserId == twitchUserId,
context.RequestAborted);
if (alreadyBound)
{
return Results.Conflict(new { message = "Diese Twitch-ID ist bereits mit einem Team-Account verbunden." });
}
var previousTwitchUserId = member.BoundTwitchUserId;
if (!string.IsNullOrWhiteSpace(previousTwitchUserId)
&& !string.Equals(previousTwitchUserId, twitchUserId, StringComparison.OrdinalIgnoreCase))
{
foreach (var oldSession in db.UserSessions.Where(item => item.TwitchUserId == previousTwitchUserId))
{
oldSession.IsActive = false;
}
}
member.BoundTwitchUserId = twitchUserId;
member.BoundTwitchDisplayName = string.IsNullOrWhiteSpace(twitchDisplayName) ? twitchUserId : twitchDisplayName;
member.TwitchBoundAt = DateTimeOffset.UtcNow;
member.UpdatedAt = DateTimeOffset.UtcNow;
member.UpdatedByTwitchId = session.TwitchUserId;
await db.SaveChangesAsync(context.RequestAborted);
return Results.Ok(await ToAuthSessionDtoAsync(db, session, false, context.RequestAborted));
}
private static string BuildTeamSessionId(string login) =>
$"{TeamSessionPrefix}{login}";
@@ -215,7 +125,4 @@ public static partial class AuthEndpoints
private static string NormalizeTwitchUserId(string? value) =>
(value ?? string.Empty).Trim().TrimStart('@').ToLowerInvariant();
private static string NormalizeTwitchDisplayName(string? value) =>
(value ?? string.Empty).Trim();
}
+55 -24
View File
@@ -4,6 +4,7 @@ using Backend.Data;
using Backend.Domain;
using Backend.Services;
using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace Backend.Endpoints;
@@ -72,29 +73,7 @@ public static partial class PublicEndpoints
.FirstOrDefaultAsync(item => item.SeasonId == request.SeasonId && item.SubmittedByTwitchId == submitterId);
var isResubmission = ballot is not null;
if (ballot is null)
{
ballot = new VoteBallot
{
SeasonId = request.SeasonId,
SubmittedByTwitchId = submitterId,
};
await db.VoteBallots.AddAsync(ballot);
}
else
{
db.VoteEntries.RemoveRange(ballot.Entries);
ballot.Entries.Clear();
}
ballot.SubmittedAt = DateTimeOffset.UtcNow;
ballot.Status = "submitted";
ballot.Entries = request.Entries.Select(entry => new VoteEntry
{
CategoryId = entry.CategoryId,
CandidateId = entry.CandidateId,
}).ToList();
ballot = await ApplyVoteEntriesAsync(db, ballot, request.SeasonId, submitterId, request.Entries, context.RequestAborted);
var resubmittedBallotRule = await riskRuleService.GetRuleAsync("resubmitted_ballot", context.RequestAborted);
var rapidVoteUpdatesRule = await riskRuleService.GetRuleAsync("rapid_vote_updates", context.RequestAborted);
@@ -102,7 +81,21 @@ public static partial class PublicEndpoints
item.SubmittedByTwitchId == submitterId
&& item.SubmittedAt >= DateTimeOffset.UtcNow.AddMinutes(-rapidVoteUpdatesRule.WindowMinutes));
await db.SaveChangesAsync(context.RequestAborted);
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 == request.SeasonId && item.SubmittedByTwitchId == submitterId, context.RequestAborted);
isResubmission = true;
ballot = await ApplyVoteEntriesAsync(db, ballot, request.SeasonId, submitterId, request.Entries, context.RequestAborted);
await db.SaveChangesAsync(context.RequestAborted);
}
var ballotLink = new
{
label = "Voting-Analytics öffnen",
@@ -142,4 +135,42 @@ public static partial class PublicEndpoints
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 };
}
@@ -9,10 +9,25 @@ public static partial class PublicEndpoints
{
private static async Task<IResult> GetWinnerArchive(int year, AwardsDbContext db)
{
var season = await db.Seasons
.AsNoTracking()
.Where(item => item.Year == year)
.Select(item => new { item.Id, item.Year, item.IsCurrent, item.CurrentPhase })
.FirstOrDefaultAsync();
if (season is null)
{
return Results.NotFound();
}
if (season.IsCurrent && !CanExposeCurrentSeasonWinners(season.CurrentPhase))
{
return Results.Ok(new WinnerArchiveResponse(year, []));
}
var winnerRows = await db.Results
.AsNoTracking()
.Include(result => result.Candidate)
.Where(result => result.Season.Year == year)
.Where(result => result.SeasonId == season.Id)
.OrderBy(result => result.CategoryName)
.Select(result => new
{
@@ -34,4 +49,10 @@ public static partial class PublicEndpoints
return Results.Ok(new WinnerArchiveResponse(year, items));
}
private static bool CanExposeCurrentSeasonWinners(string currentPhase)
{
var phaseKey = SeasonMappings.NormalizePhaseKey(currentPhase);
return phaseKey is "completed";
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,135 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Backend.Migrations
{
/// <inheritdoc />
public partial class AddVoteBallotSubmitterUniqueIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_VoteBallots_SeasonId",
table: "VoteBallots");
migrationBuilder.AddColumn<bool>(
name: "TwitchAuthManagedByDatabase",
table: "SiteSettings",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<string>(
name: "TwitchClientId",
table: "SiteSettings",
type: "character varying(120)",
maxLength: 120,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "TwitchClientSecret",
table: "SiteSettings",
type: "character varying(180)",
maxLength: 180,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "TwitchRedirectUri",
table: "SiteSettings",
type: "character varying(400)",
maxLength: 400,
nullable: false,
defaultValue: "");
migrationBuilder.AddColumn<string>(
name: "TwitchScope",
table: "SiteSettings",
type: "character varying(300)",
maxLength: 300,
nullable: false,
defaultValue: "");
migrationBuilder.UpdateData(
table: "SiteSettings",
keyColumn: "Id",
keyValue: 1,
columns: new[] { "TwitchAuthManagedByDatabase", "TwitchClientId", "TwitchClientSecret", "TwitchRedirectUri", "TwitchScope" },
values: new object[] { false, "", "", "", "" });
migrationBuilder.Sql("""
DELETE FROM "VoteEntries"
WHERE "BallotId" IN (
SELECT "Id"
FROM (
SELECT
"Id",
ROW_NUMBER() OVER (
PARTITION BY "SeasonId", "SubmittedByTwitchId"
ORDER BY "SubmittedAt" DESC, "Id" DESC
) AS duplicate_rank
FROM "VoteBallots"
) ranked_ballots
WHERE duplicate_rank > 1
);
DELETE FROM "VoteBallots"
WHERE "Id" IN (
SELECT "Id"
FROM (
SELECT
"Id",
ROW_NUMBER() OVER (
PARTITION BY "SeasonId", "SubmittedByTwitchId"
ORDER BY "SubmittedAt" DESC, "Id" DESC
) AS duplicate_rank
FROM "VoteBallots"
) ranked_ballots
WHERE duplicate_rank > 1
);
""");
migrationBuilder.CreateIndex(
name: "IX_VoteBallots_SeasonId_SubmittedByTwitchId",
table: "VoteBallots",
columns: new[] { "SeasonId", "SubmittedByTwitchId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_VoteBallots_SeasonId_SubmittedByTwitchId",
table: "VoteBallots");
migrationBuilder.DropColumn(
name: "TwitchAuthManagedByDatabase",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "TwitchClientId",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "TwitchClientSecret",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "TwitchRedirectUri",
table: "SiteSettings");
migrationBuilder.DropColumn(
name: "TwitchScope",
table: "SiteSettings");
migrationBuilder.CreateIndex(
name: "IX_VoteBallots_SeasonId",
table: "VoteBallots",
column: "SeasonId");
}
}
}
@@ -966,6 +966,29 @@ namespace Backend.Migrations
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<bool>("TwitchAuthManagedByDatabase")
.HasColumnType("boolean");
b.Property<string>("TwitchClientId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("TwitchClientSecret")
.IsRequired()
.HasMaxLength(180)
.HasColumnType("character varying(180)");
b.Property<string>("TwitchRedirectUri")
.IsRequired()
.HasMaxLength(400)
.HasColumnType("character varying(400)");
b.Property<string>("TwitchScope")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.HasKey("Id");
b.ToTable("SiteSettings");
@@ -999,7 +1022,12 @@ namespace Backend.Migrations
RiskRulesJson = "[{\"key\":\"resubmitted_ballot\",\"label\":\"Ballot erneut gespeichert\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User sein Voting erneut speichert.\"},{\"key\":\"rapid_vote_updates\",\"label\":\"Voting-Burst\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr schnell mehrere Voting-Aenderungen ausloest.\"},{\"key\":\"resubmitted_nomination\",\"label\":\"Nominierung erneut eingereicht\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"low\",\"description\":\"Erstellt einen Hinweis, wenn ein User in derselben Kategorie erneut nominiert.\"},{\"key\":\"rapid_nomination_burst\",\"label\":\"Nominierungs-Burst\",\"enabled\":true,\"threshold\":10,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Nominierungen in kurzer Zeit sendet.\"},{\"key\":\"duplicate_clip_submission\",\"label\":\"Doppelter Clip\",\"enabled\":true,\"threshold\":1,\"windowMinutes\":360,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn ein User denselben Clip erneut einreicht.\"},{\"key\":\"rapid_clip_burst\",\"label\":\"Clip-Burst\",\"enabled\":true,\"threshold\":5,\"windowMinutes\":10,\"severity\":\"high\",\"description\":\"Erstellt einen Hinweis, wenn ein User sehr viele Clips in kurzer Zeit sendet.\"},{\"key\":\"rapid_login_ip\",\"label\":\"Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere neue Sessions von derselben IP entstehen.\"},{\"key\":\"rapid_demo_login_ip\",\"label\":\"Demo-Login-Burst pro IP\",\"enabled\":true,\"threshold\":3,\"windowMinutes\":15,\"severity\":\"medium\",\"description\":\"Erstellt einen Hinweis, wenn mehrere Demo-Admin-Sessions von derselben IP entstehen.\"}]",
SocialLinksJson = "[{\"label\":\"Twitch\",\"platform\":\"twitch\",\"url\":\"https://twitch.tv/jayuhime\",\"icon\":\"twitch\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"YouTube\",\"platform\":\"youtube\",\"url\":\"https://youtube.com/c/Jayuhime\",\"icon\":\"youtube\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"X\",\"platform\":\"x\",\"url\":\"https://x.com/jayuhime\",\"icon\":\"x\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Instagram\",\"platform\":\"instagram\",\"url\":\"https://instagram.com/jayuhime\",\"icon\":\"instagram\",\"showOnHost\":true,\"showOnCommunity\":true},{\"label\":\"Discord\",\"platform\":\"discord\",\"url\":\"https://discord.gg/jayuhime\",\"icon\":\"discord\",\"showOnHost\":true,\"showOnCommunity\":true}]",
SponsorsContent = "Sponsoren & Partner\nHier koennen Sponsor:innen, Medienpartner, Community-Partner und Supporter des VTuber Star Awards vorgestellt werden.\n\nPartner werden im Rahmen der Show und auf den oeffentlichen Kontaktflaechen genannt, sobald sie final bestaetigt sind.",
SponsorsUrl = "https://vtuber-star-awards.de/partner"
SponsorsUrl = "https://vtuber-star-awards.de/partner",
TwitchAuthManagedByDatabase = false,
TwitchClientId = "",
TwitchClientSecret = "",
TwitchRedirectUri = "",
TwitchScope = ""
});
});
@@ -1011,14 +1039,6 @@ namespace Backend.Migrations
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedByTwitchId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("BoundTwitchDisplayName")
.HasMaxLength(120)
.HasColumnType("character varying(120)");
@@ -1027,6 +1047,14 @@ namespace Backend.Migrations
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("CreatedByTwitchId")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(120)
@@ -1076,10 +1104,10 @@ namespace Backend.Migrations
b.HasKey("Id");
b.HasIndex("Login")
b.HasIndex("BoundTwitchUserId")
.IsUnique();
b.HasIndex("BoundTwitchUserId")
b.HasIndex("Login")
.IsUnique();
b.ToTable("TeamMembers");
@@ -1197,7 +1225,8 @@ namespace Backend.Migrations
b.HasKey("Id");
b.HasIndex("SeasonId");
b.HasIndex("SeasonId", "SubmittedByTwitchId")
.IsUnique();
b.ToTable("VoteBallots");
+7
View File
@@ -105,6 +105,13 @@ VTSA_DEMO_ADMIN_DISPLAY_NAME=Jayuhime Admin
The frontend route is `/login`. `VTSA_DEMO_ADMIN_LOGIN` may be a username or an email-style identifier; the backend also accepts the configured email, Twitch ID, and display name for admin flexibility. Disable the demo login for release with `VTSA_DEMO_LOGIN_ENABLED=false`.
Team owner bootstrap credentials are separate from demo login credentials:
```text
VTSA_TEAM_OWNER_PASSWORD=<set-secure-owner-password>
VTSA_TEAM_CREATOR_PASSWORD=<set-secure-creator-password>
```
Frontend app-wide demo gate:
```text
+26 -6
View File
@@ -58,7 +58,7 @@ public static class AdminPermissionCatalog
{
AdminRoles.Owner => AllPermissionKeys,
AdminRoles.Creator => AllPermissionKeys,
AdminRoles.Admin => AllPermissionKeys.Where(key => key is not Settings).ToArray(),
AdminRoles.Admin => AllPermissionKeys,
AdminRoles.Member => [Dashboard, Nominations, Categories, Candidates, Clips, Content],
AdminRoles.Reviewer => [Dashboard, Nominations, Clips, Risk, Audit],
AdminRoles.OrganizationTeam => [Dashboard, Content, Analytics, Winners, Settings],
@@ -67,6 +67,30 @@ public static class AdminPermissionCatalog
};
}
public static string[] NormalizePermissionKeys(string? role, IEnumerable<string> permissionKeys)
{
var normalizedRole = AdminRoles.Normalize(role);
var permissions = permissionKeys
.Where(IsKnownPermission)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (normalizedRole is AdminRoles.Owner or AdminRoles.Creator)
{
return AllPermissionKeys;
}
if (DefaultPermissionKeys(normalizedRole).Contains(Settings, StringComparer.OrdinalIgnoreCase)
&& !permissions.Contains(Settings, StringComparer.OrdinalIgnoreCase))
{
permissions.Add(Settings);
}
return permissions
.OrderBy(item => item)
.ToArray();
}
public static async Task<string[]> GetPermissionKeysAsync(
AwardsDbContext db,
string? role,
@@ -92,11 +116,7 @@ public static class AdminPermissionCatalog
try
{
return (JsonSerializer.Deserialize<string[]>(overrideJson) ?? fallback)
.Where(IsKnownPermission)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(item => item)
.ToArray();
return NormalizePermissionKeys(normalizedRole, JsonSerializer.Deserialize<string[]>(overrideJson) ?? fallback);
}
catch (JsonException)
{
+16 -1
View File
@@ -9,6 +9,9 @@ namespace Backend.Services;
public sealed class UserSessionService(IUserSessionRepository userSessionRepository) : IUserSessionService
{
private static readonly TimeSpan IdleSessionLifetime = TimeSpan.FromHours(12);
private static readonly TimeSpan AbsoluteSessionLifetime = TimeSpan.FromDays(30);
public async Task<UserSession?> ResolveSessionAsync(HttpContext context, CancellationToken cancellationToken = default)
{
var token = ReadBearerToken(context);
@@ -23,7 +26,15 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit
return null;
}
session.LastSeenAt = DateTimeOffset.UtcNow;
var now = DateTimeOffset.UtcNow;
if (IsExpired(session, now))
{
session.IsActive = false;
await userSessionRepository.SaveChangesAsync(cancellationToken);
return null;
}
session.LastSeenAt = now;
await userSessionRepository.SaveChangesAsync(cancellationToken);
context.SetCurrentSession(session);
return session;
@@ -83,6 +94,10 @@ public sealed class UserSessionService(IUserSessionRepository userSessionReposit
: null;
}
private static bool IsExpired(UserSession session, DateTimeOffset now) =>
session.CreatedAt <= now.Subtract(AbsoluteSessionLifetime)
|| session.LastSeenAt <= now.Subtract(IdleSessionLifetime);
private async Task<UserSession> PersistAndReturnAsync(UserSession session, CancellationToken cancellationToken)
{
await userSessionRepository.SaveChangesAsync(cancellationToken);