Compare commits
10 Commits
6e5bbec86e
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ae85c369ba | |||
| 1802fd6607 | |||
| e55a1b01f4 | |||
| a130cba41a | |||
| 3c7f3d2114 | |||
| 5b921063ec | |||
| 608c5368b3 | |||
| 06ae85f427 | |||
| 26379eef1a | |||
| 4d62d0bf50 |
14
AGENTS.md
14
AGENTS.md
@@ -1,6 +1,6 @@
|
||||
# Agent Guide — Pick'n'Play
|
||||
|
||||
Also see the other related technical documentation: API.md, IIS.md, SPEC.md, TESTS.md and README.md.
|
||||
Also see the other related technical documentation: API.md, IIS.md, SPEC.md, TESTS.md, REVIEW.md and README.md.
|
||||
Also see the user-facing documentation: per-language md files in wwwroot/data/i18n/faq
|
||||
|
||||
## Rules
|
||||
@@ -12,14 +12,6 @@ Also see the user-facing documentation: per-language md files in wwwroot/data/i1
|
||||
- After every iteration, run "scripts/ci-local.ps1" and ensure that nothing broke.
|
||||
- After every iteration, update all related documentation according to the change, and evaluate if a FAQ entry would help the users, serving as public documentation for this project.
|
||||
- After every iteration, do a git commit with a brief summary of the changes as a commit message.
|
||||
- Keep changes small and commit often. If one iteration encompasses many smaller tasks, create a git branch and do the commits there. Let me review the branch before merging it back to master.
|
||||
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated.), never revert them and never restore the old state. Assume that those changes happened with intent.
|
||||
- Keep changes small and commit often. If one iteration encompasses many smaller tasks with more than one commit, create a git branch and do the commits there. Let me review the branch before merging it back to master.
|
||||
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent.
|
||||
- After changing the database, run "dotnet ef database update". If this is blocked by a running dotnet process, feel free to kill the process and retry the operations once.
|
||||
|
||||
## Tech constraints:
|
||||
- .NET 10
|
||||
- ASP.NET Core Minimal API
|
||||
- Static HTML/CSS/JS (no Razor Pages, no Blazor, no HTMX)
|
||||
- SQLite via EF Core
|
||||
- Username+Password identity (account stored in database)
|
||||
- Runs on IIS (Windows Server)
|
||||
|
||||
10
API.md
10
API.md
@@ -2,6 +2,8 @@
|
||||
|
||||
All endpoints are JSON. Most routes require the HttpOnly `player` cookie issued after register/login. Admin access is granted only via an authenticated admin user session (`IsAdmin=true` on the account).
|
||||
Auth and admin-sensitive routes are rate-limited and return HTTP `429` on excessive requests.
|
||||
The machine-readable source of truth is the generated OpenAPI document at `openapi/GameList.json` (runtime endpoint: `GET /openapi/v1.json`).
|
||||
Frontend API calls are generated from that document into `wwwroot/js/api-client.generated.js` via `npm run generate:api-client`.
|
||||
|
||||
## Auth
|
||||
POST /api/auth/register — accepts optional `adminKey` to set `IsAdmin=true` only for bootstrap of the first admin account
|
||||
@@ -14,7 +16,8 @@ The first account created with a valid `adminKey` becomes both `IsAdmin=true` an
|
||||
Owner bootstrap is also enforced by a database uniqueness constraint (`IsOwner=true` can only exist once), so concurrent owner registration races fail safely with `400`.
|
||||
|
||||
## State (requires auth)
|
||||
GET /api/state — returns currentPhase (for caller), votesFinal, resultsOpen, updatedAt, counts (players/suggestions/votes)
|
||||
GET /api/state — returns caller identity (`id`, `username`, `displayName`, `isAdmin`, `isOwner`) plus currentPhase, votesFinal, resultsOpen, updatedAt, counts (players/suggestions/votes). Supports conditional reads with `ETag`/`If-None-Match`; unchanged state returns HTTP `304`.
|
||||
GET /api/events/state — server-sent events stream for state invalidation (`ready` and `state` events with monotonic version payload) for event-driven client refresh.
|
||||
GET /api/me — id, displayName, username, isAdmin, isOwner, currentPhase, votesFinal
|
||||
|
||||
## Player (requires auth)
|
||||
@@ -36,7 +39,7 @@ POST /api/votes/finalize — `{ final: bool }` toggles caller’s finalized stat
|
||||
Vote upsert includes conflict handling for concurrent writes against the unique `(PlayerId, SuggestionId)` index.
|
||||
|
||||
## Results (requires auth + Results phase + resultsOpen)
|
||||
GET /api/results — leaderboard with totals, counts, averages, caller’s vote, media/links, link metadata
|
||||
GET /api/results — leaderboard with totals, counts, averages, vote values, alphabetically sorted `voterNames`, caller’s vote, media/links, link metadata
|
||||
|
||||
## Admin (requires authenticated admin user)
|
||||
POST /api/admin/results — `{ resultsOpen: bool }` locks/unlocks results and aligns player phases
|
||||
@@ -52,6 +55,9 @@ POST /api/admin/factory-reset — `{ password }`; wipe players, suggestions, vot
|
||||
Owner restrictions: owner role/admin status cannot be changed, and owner account cannot be deleted.
|
||||
|
||||
## Security Defaults
|
||||
- Mutating authenticated API requests (`POST`/`PUT`/`DELETE`/`PATCH`) enforce same-origin CSRF checks via `Origin`/`Referer`; cross-origin or missing-origin authenticated writes are rejected with `400`.
|
||||
- Security headers are set on all responses (`CSP`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`).
|
||||
- CSP is tightened to disallow inline styles and insecure image origins (`img-src` excludes `http:`).
|
||||
- In production, HTTPS redirection and HSTS are enabled.
|
||||
- Screenshot URL validation rejects private/reserved address ranges and pins outbound connections to validated public IPs.
|
||||
- Password hashing is versioned with Argon2id as current; legacy hashes are transparently upgraded on successful login/admin password confirmation.
|
||||
|
||||
@@ -28,13 +28,13 @@ public record AdminResetStateResponse(Phase Phase, bool ResultsOpen, DateTimeOff
|
||||
|
||||
public record VoteStatusResponse(IReadOnlyList<VoteStatusDto> Voters, bool Ready, IReadOnlyList<string> Waiting);
|
||||
|
||||
public record ResultItemDto(int Id, string Name, string? Author, int? MinPlayers, int? MaxPlayers, int Total, int Count, double Average, IReadOnlyList<int> Votes, int? MyVote, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, string? Description, string? Genre, int? ParentSuggestionId, IReadOnlyList<int> LinkedIds, IReadOnlyList<string> LinkedTitles);
|
||||
public record ResultItemDto(int Id, string Name, string? Author, int? MinPlayers, int? MaxPlayers, int Total, int Count, double Average, IReadOnlyList<int> Votes, IReadOnlyList<string> VoterNames, int? MyVote, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, string? Description, string? Genre, int? ParentSuggestionId, IReadOnlyList<int> LinkedIds, IReadOnlyList<string> LinkedTitles);
|
||||
|
||||
public record AuthSessionResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin);
|
||||
|
||||
public record AuthOptionsResponse(bool OwnerExists);
|
||||
|
||||
public record StateSummaryResponse(Phase CurrentPhase, bool VotesFinal, bool HasJoker, bool ResultsOpen, DateTimeOffset UpdatedAt, int Players, int Suggestions, int Votes);
|
||||
public record StateSummaryResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin, bool IsOwner, Phase CurrentPhase, bool VotesFinal, bool HasJoker, bool ResultsOpen, DateTimeOffset UpdatedAt, int Players, int Suggestions, int Votes);
|
||||
|
||||
public record MeResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin, bool IsOwner, Phase CurrentPhase, bool VotesFinal, bool HasJoker);
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
builder.HasIndex(p => p.NormalizedUsername).IsUnique();
|
||||
builder.Property(p => p.PasswordHash).IsRequired();
|
||||
builder.Property(p => p.PasswordSalt).IsRequired();
|
||||
builder.Property(p => p.PasswordHashVersion).HasDefaultValue(1);
|
||||
builder.Property(p => p.IsAdmin).HasDefaultValue(false);
|
||||
builder.Property(p => p.IsOwner).HasDefaultValue(false);
|
||||
builder.HasIndex(p => p.IsOwner).HasFilter($"{nameof(Player.IsOwner)} = 1").IsUnique();
|
||||
|
||||
260
Data/Migrations/20260218194640_AddPasswordHashVersion.Designer.cs
generated
Normal file
260
Data/Migrations/20260218194640_AddPasswordHashVersion.Designer.cs
generated
Normal file
@@ -0,0 +1,260 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using GameList.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GameList.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260218194640_AddPasswordHashVersion")]
|
||||
partial class AddPasswordHashVersion
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.AppState", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("ResultsOpen")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AppState");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
ResultsOpen = false,
|
||||
UpdatedAt = new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Player", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrentPhase")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("HasJoker")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("IsOwner")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedUsername")
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte[]>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<int>("PasswordHashVersion")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<byte[]>("PasswordSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("VotesFinal")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsOwner")
|
||||
.IsUnique()
|
||||
.HasFilter("IsOwner = 1");
|
||||
|
||||
b.HasIndex("NormalizedUsername")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Players");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GameUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("MaxPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("MinPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParentSuggestionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PlayerId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ScreenshotUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("YoutubeUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentSuggestionId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("Suggestions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Vote", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("PlayerId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SuggestionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SuggestionId");
|
||||
|
||||
b.HasIndex("PlayerId", "SuggestionId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Votes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||
{
|
||||
b.HasOne("GameList.Domain.Suggestion", "ParentSuggestion")
|
||||
.WithMany("LinkedSuggestions")
|
||||
.HasForeignKey("ParentSuggestionId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("GameList.Domain.Player", "Player")
|
||||
.WithMany("Suggestions")
|
||||
.HasForeignKey("PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ParentSuggestion");
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Vote", b =>
|
||||
{
|
||||
b.HasOne("GameList.Domain.Player", "Player")
|
||||
.WithMany("Votes")
|
||||
.HasForeignKey("PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("GameList.Domain.Suggestion", "Suggestion")
|
||||
.WithMany("Votes")
|
||||
.HasForeignKey("SuggestionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Player");
|
||||
|
||||
b.Navigation("Suggestion");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Player", b =>
|
||||
{
|
||||
b.Navigation("Suggestions");
|
||||
|
||||
b.Navigation("Votes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||
{
|
||||
b.Navigation("LinkedSuggestions");
|
||||
|
||||
b.Navigation("Votes");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
29
Data/Migrations/20260218194640_AddPasswordHashVersion.cs
Normal file
29
Data/Migrations/20260218194640_AddPasswordHashVersion.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GameList.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPasswordHashVersion : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "PasswordHashVersion",
|
||||
table: "Players",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PasswordHashVersion",
|
||||
table: "Players");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,11 @@ namespace GameList.Data.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<int>("PasswordHashVersion")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<byte[]>("PasswordSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
@@ -17,6 +17,7 @@ public class Player
|
||||
|
||||
public byte[] PasswordHash { get; set; } = [];
|
||||
public byte[] PasswordSalt { get; set; } = [];
|
||||
public int PasswordHashVersion { get; set; } = 1;
|
||||
|
||||
public DateTimeOffset? LastLoginAt { get; set; }
|
||||
public bool IsAdmin { get; set; }
|
||||
|
||||
@@ -9,36 +9,36 @@ public static class AdminEndpoints
|
||||
{
|
||||
public static void MapAdminEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var admin = app.MapGroup("/api/admin").RequireAuthorization().RequireRateLimiting("admin-sensitive").AddEndpointFilter<AdminOnlyFilter>();
|
||||
var admin = app.MapGroup("/api/admin").WithTags("Admin").RequireAuthorization().RequireRateLimiting("admin-sensitive").AddEndpointFilter<AdminOnlyFilter>();
|
||||
|
||||
admin.MapPost("/results", async ([FromBody] ResultsOpenRequest request, AdminWorkflowService service) =>
|
||||
{
|
||||
var result = await service.SetResultsOpenAsync(request.ResultsOpen);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
});
|
||||
}).WithName("SetResultsOpen");
|
||||
|
||||
admin.MapGet("/vote-status", async (AdminWorkflowService service) =>
|
||||
{
|
||||
var result = await service.GetVoteStatusAsync();
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
});
|
||||
}).WithName("GetVoteStatus");
|
||||
|
||||
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) =>
|
||||
{
|
||||
var result = await service.GrantJokerAsync(request.PlayerId);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
});
|
||||
}).WithName("GrantJoker");
|
||||
|
||||
admin.MapPost("/player-phase", async ([FromBody] SetPlayerPhaseRequest request, AdminWorkflowService service) =>
|
||||
{
|
||||
var result = await service.SetPlayerPhaseAsync(request.PlayerId, request.Phase);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
});
|
||||
}).WithName("SetPlayerPhase");
|
||||
admin.MapPost("/player-admin", async ([FromBody] SetPlayerAdminRequest request, AdminWorkflowService service) =>
|
||||
{
|
||||
var result = await service.SetPlayerAdminAsync(request.PlayerId, request.IsAdmin);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
});
|
||||
}).WithName("SetPlayerAdmin");
|
||||
|
||||
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, [FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||
{
|
||||
@@ -48,7 +48,7 @@ public static class AdminEndpoints
|
||||
|
||||
var result = await service.DeletePlayerAsync(playerId, player.Id, request.Password, ctx);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
});
|
||||
}).WithName("DeletePlayer");
|
||||
|
||||
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||
{
|
||||
@@ -58,7 +58,7 @@ public static class AdminEndpoints
|
||||
|
||||
var result = await service.LinkSuggestionsAsync(player.Id, request.SourceSuggestionId, request.TargetSuggestionId);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
});
|
||||
}).WithName("LinkSuggestions");
|
||||
|
||||
admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||
{
|
||||
@@ -68,7 +68,7 @@ public static class AdminEndpoints
|
||||
|
||||
var result = await service.UnlinkSuggestionsAsync(player.Id, request.SuggestionId);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
});
|
||||
}).WithName("UnlinkSuggestions");
|
||||
|
||||
admin.MapPost("/reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||
{
|
||||
@@ -78,7 +78,7 @@ public static class AdminEndpoints
|
||||
|
||||
var result = await service.ResetAsync(player.Id, request.Password, ctx);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
});
|
||||
}).WithName("Reset");
|
||||
|
||||
admin.MapPost("/factory-reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||
{
|
||||
@@ -88,7 +88,7 @@ public static class AdminEndpoints
|
||||
|
||||
var result = await service.FactoryResetAsync(player.Id, request.Password, ctx);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
});
|
||||
}).WithName("FactoryReset");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -262,18 +262,27 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
return ServiceError.BadRequest("Admin password is required.");
|
||||
|
||||
var admin = await db.Players.AsNoTracking().FirstOrDefaultAsync(p => p.Id == adminPlayerId && p.IsAdmin);
|
||||
var admin = await db.Players.FirstOrDefaultAsync(p => p.Id == adminPlayerId && p.IsAdmin);
|
||||
if (admin is null)
|
||||
return ServiceError.Unauthorized();
|
||||
|
||||
var monitor = ctx.RequestServices.GetRequiredService<AuthAttemptMonitor>();
|
||||
var verified = PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt);
|
||||
var verified = PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt, admin.PasswordHashVersion, out var needsRehash);
|
||||
if (!verified)
|
||||
{
|
||||
monitor.RecordFailure(ctx, "admin-password", admin.NormalizedUsername, "invalid-password");
|
||||
return ServiceError.BadRequest("Invalid admin password.");
|
||||
}
|
||||
|
||||
if (needsRehash)
|
||||
{
|
||||
var (upgradedHash, upgradedSalt) = PasswordHasher.HashPassword(password);
|
||||
admin.PasswordHash = upgradedHash;
|
||||
admin.PasswordSalt = upgradedSalt;
|
||||
admin.PasswordHashVersion = PasswordHasher.CurrentVersion;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
monitor.RecordSuccess(ctx, "admin-password", admin.NormalizedUsername);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -11,13 +11,13 @@ public static class AuthEndpoints
|
||||
{
|
||||
public static void MapAuthEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/auth").RequireRateLimiting("auth-sensitive");
|
||||
var group = app.MapGroup("/api/auth").WithTags("Auth").RequireRateLimiting("auth-sensitive");
|
||||
|
||||
group.MapGet("/options", async (AppDbContext db) =>
|
||||
{
|
||||
var ownerExists = await db.Players.AsNoTracking().AnyAsync(p => p.IsOwner);
|
||||
return Results.Ok(new AuthOptionsResponse(ownerExists));
|
||||
});
|
||||
}).WithName("GetAuthOptions");
|
||||
|
||||
group.MapPost("/register", async ([FromBody] RegisterRequest request, HttpContext ctx, AppDbContext db, IConfiguration config, AuthAttemptMonitor authAttemptMonitor) =>
|
||||
{
|
||||
@@ -60,6 +60,7 @@ public static class AuthEndpoints
|
||||
NormalizedUsername = validated.NormalizedUsername,
|
||||
PasswordHash = hash,
|
||||
PasswordSalt = salt,
|
||||
PasswordHashVersion = PasswordHasher.CurrentVersion,
|
||||
DisplayName = validated.DisplayName,
|
||||
IsAdmin = isAdmin,
|
||||
IsOwner = isOwner,
|
||||
@@ -93,7 +94,7 @@ public static class AuthEndpoints
|
||||
player.DisplayName,
|
||||
player.IsAdmin
|
||||
));
|
||||
});
|
||||
}).WithName("Register");
|
||||
|
||||
group.MapPost("/login", async ([FromBody] LoginRequest request, HttpContext ctx, AppDbContext db, AuthAttemptMonitor authAttemptMonitor) =>
|
||||
{
|
||||
@@ -104,12 +105,21 @@ public static class AuthEndpoints
|
||||
}
|
||||
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.NormalizedUsername == normalizedUsername);
|
||||
if (player == null || !PasswordHasher.Verify(request.Password ?? string.Empty, player.PasswordHash, player.PasswordSalt))
|
||||
if (player == null
|
||||
|| !PasswordHasher.Verify(request.Password ?? string.Empty, player.PasswordHash, player.PasswordSalt, player.PasswordHashVersion, out var needsRehash))
|
||||
{
|
||||
authAttemptMonitor.RecordFailure(ctx, "auth-login", normalizedUsername, "invalid-credentials");
|
||||
return EndpointHelpers.UnauthorizedError("Invalid username or password.");
|
||||
}
|
||||
|
||||
if (needsRehash)
|
||||
{
|
||||
var (upgradedHash, upgradedSalt) = PasswordHasher.HashPassword(request.Password ?? string.Empty);
|
||||
player.PasswordHash = upgradedHash;
|
||||
player.PasswordSalt = upgradedSalt;
|
||||
player.PasswordHashVersion = PasswordHasher.CurrentVersion;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(player.DisplayName))
|
||||
{
|
||||
player.DisplayName = EndpointHelpers.TrimTo(player.Username, AuthValidator.MaxDisplayNameLength);
|
||||
@@ -127,13 +137,13 @@ public static class AuthEndpoints
|
||||
player.DisplayName,
|
||||
player.IsAdmin
|
||||
));
|
||||
});
|
||||
}).WithName("Login");
|
||||
|
||||
group.MapPost("/logout", async (HttpContext ctx) =>
|
||||
{
|
||||
await PlayerIdentityExtensions.SignOutPlayerAsync(ctx);
|
||||
return Results.NoContent();
|
||||
});
|
||||
}).WithName("Logout");
|
||||
}
|
||||
|
||||
private static string NormalizeActor(string? username) => string.IsNullOrWhiteSpace(username) ? "(missing)" : username.Trim();
|
||||
|
||||
@@ -9,6 +9,7 @@ public static class ResultsEndpoints
|
||||
public static void MapResultsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/results")
|
||||
.WithTags("Results")
|
||||
.RequireAuthorization()
|
||||
.AddEndpointFilter(new PhaseRequirementFilter(Phase.Results));
|
||||
|
||||
@@ -20,7 +21,7 @@ public static class ResultsEndpoints
|
||||
|
||||
var result = await service.GetResultsAsync(player.Id);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
});
|
||||
}).WithName("GetResults");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,9 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
|
||||
s.Votes.Count,
|
||||
Average = s.Votes.Count == 0 ? 0 : s.Votes.Average(v => v.Score),
|
||||
Votes = s.Votes.Select(v => v.Score).ToList(),
|
||||
VoterNames = s.Votes
|
||||
.Select(v => v.Player!.DisplayName ?? v.Player!.Username)
|
||||
.ToList(),
|
||||
MyVote = s.Votes
|
||||
.Where(v => v.PlayerId == playerId)
|
||||
.Select(v => (int?)v.Score)
|
||||
@@ -59,6 +62,11 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
|
||||
.Where(nameLookup.ContainsKey)
|
||||
.Select(id => nameLookup[id])
|
||||
.ToList();
|
||||
var voterNames = r.VoterNames
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return new ResultItemDto(
|
||||
r.Id,
|
||||
@@ -70,6 +78,7 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
|
||||
r.Count,
|
||||
r.Average,
|
||||
r.Votes,
|
||||
voterNames,
|
||||
r.MyVote,
|
||||
r.ScreenshotUrl,
|
||||
r.YoutubeUrl,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using GameList.Data;
|
||||
using GameList.Infrastructure;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
@@ -6,17 +7,73 @@ public static class StateEndpoints
|
||||
{
|
||||
public static void MapStateEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api").RequireAuthorization();
|
||||
var group = app.MapGroup("/api").WithTags("State").RequireAuthorization();
|
||||
|
||||
group.MapGet("/state", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
|
||||
group.MapGet("/state", async (HttpContext ctx, AppDbContext db, StateWorkflowService service, StateChangeNotifier notifier) =>
|
||||
{
|
||||
ctx.Response.Headers.CacheControl = "private, no-cache";
|
||||
if (notifier.MatchesCurrentEtag(ctx.Request.Headers.IfNoneMatch))
|
||||
{
|
||||
ctx.Response.Headers.ETag = notifier.CurrentEtag;
|
||||
return Results.StatusCode(StatusCodes.Status304NotModified);
|
||||
}
|
||||
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
var result = await service.GetStateAsync(player);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
});
|
||||
return result.ToHttpResult(payload =>
|
||||
{
|
||||
ctx.Response.Headers.ETag = notifier.CurrentEtag;
|
||||
return Results.Ok(payload);
|
||||
});
|
||||
}).WithName("GetState");
|
||||
|
||||
group.MapGet("/events/state", async (HttpContext ctx, AppDbContext db, StateChangeNotifier notifier) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
ctx.Response.ContentType = "text/event-stream";
|
||||
ctx.Response.Headers.CacheControl = "no-cache";
|
||||
ctx.Response.Headers["X-Accel-Buffering"] = "no";
|
||||
|
||||
var observedVersion = notifier.CurrentVersion;
|
||||
await WriteStateEventAsync(ctx, "ready", observedVersion, ctx.RequestAborted);
|
||||
|
||||
while (!ctx.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var changeTask = notifier.WaitForChangeAsync(observedVersion, ctx.RequestAborted);
|
||||
var heartbeatTask = Task.Delay(TimeSpan.FromSeconds(20), ctx.RequestAborted);
|
||||
var completed = await Task.WhenAny(changeTask, heartbeatTask);
|
||||
|
||||
if (completed == changeTask)
|
||||
{
|
||||
observedVersion = await changeTask;
|
||||
await WriteStateEventAsync(ctx, "state", observedVersion, ctx.RequestAborted);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ctx.Response.WriteAsync(": ping\n\n", ctx.RequestAborted);
|
||||
await ctx.Response.Body.FlushAsync(ctx.RequestAborted);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Empty;
|
||||
}).WithName("GetStateEvents");
|
||||
|
||||
group.MapGet("/me", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
|
||||
{
|
||||
@@ -26,7 +83,7 @@ public static class StateEndpoints
|
||||
|
||||
var result = await service.GetMeAsync(player);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
});
|
||||
}).WithName("GetMe");
|
||||
|
||||
group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
|
||||
{
|
||||
@@ -36,7 +93,7 @@ public static class StateEndpoints
|
||||
|
||||
var result = await service.NextPhaseAsync(player);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
});
|
||||
}).WithName("NextPhase");
|
||||
|
||||
group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
|
||||
{
|
||||
@@ -46,7 +103,14 @@ public static class StateEndpoints
|
||||
|
||||
var result = await service.PrevPhaseAsync(player);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
});
|
||||
}).WithName("PrevPhase");
|
||||
|
||||
}
|
||||
|
||||
private static async Task WriteStateEventAsync(HttpContext ctx, string eventName, long version, CancellationToken cancellationToken)
|
||||
{
|
||||
await ctx.Response.WriteAsync($"event: {eventName}\n", cancellationToken);
|
||||
await ctx.Response.WriteAsync($"data: {version}\n\n", cancellationToken);
|
||||
await ctx.Response.Body.FlushAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,9 +9,34 @@ internal sealed class StateWorkflowService(AppDbContext db)
|
||||
{
|
||||
public async Task<ServiceResult<StateSummaryResponse>> GetStateAsync(Player player)
|
||||
{
|
||||
var state = await db.AppState.AsNoTracking().SingleAsync();
|
||||
var state = await db.AppState
|
||||
.AsNoTracking()
|
||||
.Select(s => new
|
||||
{
|
||||
s.ResultsOpen,
|
||||
s.UpdatedAt,
|
||||
Players = db.Players.Count(),
|
||||
Suggestions = db.Suggestions.Count(),
|
||||
Votes = db.Votes.Count()
|
||||
})
|
||||
.SingleAsync();
|
||||
|
||||
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
|
||||
var summary = new StateSummaryResponse(phase, player.VotesFinal, player.HasJoker, state.ResultsOpen, state.UpdatedAt, await db.Players.CountAsync(), await db.Suggestions.CountAsync(), await db.Votes.CountAsync());
|
||||
var summary = new StateSummaryResponse(
|
||||
player.Id,
|
||||
player.Username,
|
||||
player.DisplayName,
|
||||
player.IsAdmin,
|
||||
player.IsOwner,
|
||||
phase,
|
||||
player.VotesFinal,
|
||||
player.HasJoker,
|
||||
state.ResultsOpen,
|
||||
state.UpdatedAt,
|
||||
state.Players,
|
||||
state.Suggestions,
|
||||
state.Votes
|
||||
);
|
||||
return ServiceResult<StateSummaryResponse>.Success(summary);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ public static class SuggestEndpoints
|
||||
{
|
||||
public static void MapSuggestEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/suggestions").RequireAuthorization();
|
||||
var group = app.MapGroup("/api/suggestions").WithTags("Suggestions").RequireAuthorization();
|
||||
|
||||
group.MapGet("/mine", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
|
||||
{
|
||||
@@ -19,7 +19,7 @@ public static class SuggestEndpoints
|
||||
|
||||
var result = await service.GetMineAsync(player.Id);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
});
|
||||
}).WithName("GetMySuggestions");
|
||||
|
||||
group.MapPost("/", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
|
||||
{
|
||||
@@ -42,7 +42,7 @@ public static class SuggestEndpoints
|
||||
);
|
||||
|
||||
return result.ToHttpResult(payload => Results.Created($"/api/suggestions/{payload.Id}", payload));
|
||||
}).AddEndpointFilter(new PhaseOrJokerFilter());
|
||||
}).AddEndpointFilter(new PhaseOrJokerFilter()).WithName("CreateSuggestion");
|
||||
|
||||
group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
|
||||
{
|
||||
@@ -52,7 +52,7 @@ public static class SuggestEndpoints
|
||||
|
||||
var result = await service.DeleteAsync(player.Id, id);
|
||||
return result.ToHttpResult(Results.NoContent);
|
||||
});
|
||||
}).WithName("DeleteSuggestion");
|
||||
|
||||
group.MapPut("/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
|
||||
{
|
||||
@@ -76,7 +76,7 @@ public static class SuggestEndpoints
|
||||
);
|
||||
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
});
|
||||
}).WithName("UpdateSuggestion");
|
||||
|
||||
group.MapGet("/all", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
|
||||
{
|
||||
@@ -86,7 +86,7 @@ public static class SuggestEndpoints
|
||||
|
||||
var result = await service.GetAllAsync(player.Id);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
});
|
||||
}).WithName("GetAllSuggestions");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ public static class VoteEndpoints
|
||||
{
|
||||
public static void MapVoteEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/votes").RequireAuthorization().AddEndpointFilter(new PhaseRequirementFilter(Phase.Vote));
|
||||
var group = app.MapGroup("/api/votes").WithTags("Votes").RequireAuthorization().AddEndpointFilter(new PhaseRequirementFilter(Phase.Vote));
|
||||
|
||||
group.MapGet("/mine", async (HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
|
||||
{
|
||||
@@ -19,7 +19,7 @@ public static class VoteEndpoints
|
||||
|
||||
var result = await service.GetMineAsync(player.Id);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
});
|
||||
}).WithName("GetMyVotes");
|
||||
|
||||
group.MapPost("/", async (VoteRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
|
||||
{
|
||||
@@ -29,7 +29,7 @@ public static class VoteEndpoints
|
||||
|
||||
var result = await service.UpsertAsync(player.Id, request.SuggestionId, request.Score);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
});
|
||||
}).WithName("UpsertVote");
|
||||
|
||||
group.MapPost("/finalize", async (VoteFinalizeRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
|
||||
{
|
||||
@@ -39,7 +39,7 @@ public static class VoteEndpoints
|
||||
|
||||
var result = await service.SetFinalizeAsync(player.Id, request.Final);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
});
|
||||
}).WithName("SetVotesFinalized");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ public class AuthTests
|
||||
Assert.True(player.DisplayName!.Length <= 16);
|
||||
Assert.NotEqual(Array.Empty<byte>(), player.PasswordHash);
|
||||
Assert.NotEqual(Array.Empty<byte>(), player.PasswordSalt);
|
||||
Assert.Equal(PasswordHasher.CurrentVersion, player.PasswordHashVersion);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -107,6 +108,37 @@ public class AuthTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_upgrades_legacy_password_hash_version()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("rehashme");
|
||||
|
||||
byte[] originalHash = [];
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var player = await db.Players.SingleAsync();
|
||||
var (legacyHash, legacySalt) = PasswordHasher.HashPassword("Pass123!", PasswordHasher.LegacyVersion);
|
||||
|
||||
originalHash = legacyHash.ToArray();
|
||||
player.PasswordHash = legacyHash;
|
||||
player.PasswordSalt = legacySalt;
|
||||
player.PasswordHashVersion = PasswordHasher.LegacyVersion;
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
|
||||
var login = await client.LoginAsync("rehashme", "Pass123!");
|
||||
login.EnsureSuccessStatusCode();
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var player = await db.Players.AsNoTracking().SingleAsync();
|
||||
Assert.Equal(PasswordHasher.CurrentVersion, player.PasswordHashVersion);
|
||||
Assert.False(player.PasswordHash.SequenceEqual(originalHash));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_with_admin_key_sets_admin_flag()
|
||||
{
|
||||
|
||||
@@ -21,7 +21,14 @@ public class HelperTests
|
||||
public void PasswordHasher_roundtrip_and_empty_guard()
|
||||
{
|
||||
var (hash, salt) = PasswordHasher.HashPassword("secret");
|
||||
Assert.True(PasswordHasher.Verify("secret", hash, salt));
|
||||
Assert.True(PasswordHasher.Verify("secret", hash, salt, PasswordHasher.CurrentVersion, out var currentNeedsRehash));
|
||||
Assert.False(currentNeedsRehash);
|
||||
|
||||
var (legacyHash, legacySalt) = PasswordHasher.HashPassword("secret", PasswordHasher.LegacyVersion);
|
||||
Assert.True(PasswordHasher.Verify("secret", legacyHash, legacySalt, PasswordHasher.LegacyVersion, out var legacyNeedsRehash));
|
||||
Assert.True(legacyNeedsRehash);
|
||||
Assert.False(PasswordHasher.Verify("secret", hash, salt, 999, out _));
|
||||
|
||||
Assert.False(PasswordHasher.Verify("other", hash, salt));
|
||||
Assert.Throws<ArgumentException>(() => PasswordHasher.HashPassword(""));
|
||||
}
|
||||
@@ -33,6 +40,24 @@ public class HelperTests
|
||||
Assert.False(hasRewriteMethod);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task OpenApi_document_exposes_stable_operation_ids()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/openapi/v1.json");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var paths = json.GetProperty("paths");
|
||||
|
||||
Assert.Equal("Login", paths.GetProperty("/api/auth/login").GetProperty("post").GetProperty("operationId").GetString());
|
||||
Assert.Equal("GetState", paths.GetProperty("/api/state").GetProperty("get").GetProperty("operationId").GetString());
|
||||
Assert.Equal("CreateSuggestion", paths.GetProperty("/api/suggestions").GetProperty("post").GetProperty("operationId").GetString());
|
||||
Assert.Equal("DeletePlayer", paths.GetProperty("/api/admin/players/{playerId}").GetProperty("delete").GetProperty("operationId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task IsReachableImageAsync_rejects_redirect_and_accepts_image()
|
||||
{
|
||||
@@ -264,7 +289,11 @@ public class HelperTests
|
||||
Assert.Equal("nosniff", response.Headers.GetValues("X-Content-Type-Options").Single());
|
||||
Assert.Equal("DENY", response.Headers.GetValues("X-Frame-Options").Single());
|
||||
Assert.Equal("no-referrer", response.Headers.GetValues("Referrer-Policy").Single());
|
||||
Assert.Contains("default-src 'self'", response.Headers.GetValues("Content-Security-Policy").Single());
|
||||
|
||||
var csp = response.Headers.GetValues("Content-Security-Policy").Single();
|
||||
Assert.Contains("default-src 'self'", csp);
|
||||
Assert.DoesNotContain("'unsafe-inline'", csp, StringComparison.Ordinal);
|
||||
Assert.DoesNotContain("http:", csp, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GameList.Tests.Support;
|
||||
|
||||
namespace GameList.Tests;
|
||||
@@ -36,4 +38,49 @@ public class MiddlewareTests
|
||||
var resp = await client.GetAsync("/api/state");
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mutating_authenticated_request_without_origin_is_rejected()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
var register = await client.RegisterAsync("csrfm");
|
||||
register.EnsureSuccessStatusCode();
|
||||
await client.CreateSuggestionAsync("Seed");
|
||||
await client.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
|
||||
client.DefaultRequestHeaders.Remove("Origin");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/votes/finalize", new
|
||||
{
|
||||
Final = true
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("CSRF validation failed.", body.GetProperty("error").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mutating_authenticated_request_with_cross_origin_is_rejected()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
var register = await client.RegisterAsync("csrfx");
|
||||
register.EnsureSuccessStatusCode();
|
||||
await client.CreateSuggestionAsync("Seed");
|
||||
await client.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
|
||||
client.DefaultRequestHeaders.Remove("Origin");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("Origin", "https://evil.example");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/votes/finalize", new
|
||||
{
|
||||
Final = true
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("CSRF validation failed.", body.GetProperty("error").GetString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,51 @@ public class ResultsTests
|
||||
Assert.Equal("High", results[0].GetProperty("name").GetString());
|
||||
Assert.Equal(9, (int)results[0].GetProperty("average").GetDouble());
|
||||
Assert.Equal(1, results[0].GetProperty("count").GetInt32());
|
||||
Assert.Equal("player-name", results[0].GetProperty("voterNames")[0].GetString());
|
||||
Assert.Equal(0, results[1].GetProperty("average").GetDouble());
|
||||
Assert.Equal(0, results[1].GetProperty("voterNames").GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Results_payload_contains_alphabetically_sorted_voter_names()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
|
||||
var author = factory.CreateClientWithCookies();
|
||||
await author.RegisterAsync("author");
|
||||
var targetSuggestionId = await author.CreateSuggestionAsync("Target");
|
||||
|
||||
var zeta = factory.CreateClientWithCookies();
|
||||
await zeta.RegisterAsync("zeta");
|
||||
await zeta.AdvanceToVoteAsync("zeta-seed");
|
||||
await zeta.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = targetSuggestionId,
|
||||
Score = 7
|
||||
});
|
||||
|
||||
var alpha = factory.CreateClientWithCookies();
|
||||
await alpha.RegisterAsync("alpha");
|
||||
await alpha.AdvanceToVoteAsync("alpha-seed");
|
||||
await alpha.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = targetSuggestionId,
|
||||
Score = 8
|
||||
});
|
||||
|
||||
await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
|
||||
|
||||
var results = await alpha.GetFromJsonAsync<List<JsonElement>>("/api/results");
|
||||
Assert.NotNull(results);
|
||||
var target = results.Single(r => r.GetProperty("name").GetString() == "Target");
|
||||
|
||||
var voterNames = target
|
||||
.GetProperty("voterNames")
|
||||
.EnumerateArray()
|
||||
.Select(n => n.GetString())
|
||||
.ToList();
|
||||
Assert.Equal(new[] { "alpha-name", "zeta-name" }, voterNames);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ public class StateTests
|
||||
|
||||
var state = await client.GetFromJsonAsync<JsonElement>("/api/state");
|
||||
|
||||
Assert.True(Guid.TryParse(state.GetProperty("id").GetString(), out _));
|
||||
Assert.Equal("payload", state.GetProperty("username").GetString());
|
||||
Assert.Equal("payload-name", state.GetProperty("displayName").GetString());
|
||||
Assert.False(state.GetProperty("isAdmin").GetBoolean());
|
||||
Assert.False(state.GetProperty("isOwner").GetBoolean());
|
||||
Assert.Equal(nameof(Phase.Suggest), state.GetProperty("currentPhase").GetString());
|
||||
Assert.False(state.GetProperty("votesFinal").GetBoolean());
|
||||
Assert.True(state.GetProperty("hasJoker").GetBoolean());
|
||||
@@ -335,6 +340,118 @@ public class StateTests
|
||||
|
||||
Assert.Equal(Phase.Results, phase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task State_endpoint_supports_conditional_get_with_etag()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("etag");
|
||||
|
||||
var first = await client.GetAsync("/api/state");
|
||||
first.EnsureSuccessStatusCode();
|
||||
var firstEtag = first.Headers.ETag?.ToString();
|
||||
Assert.False(string.IsNullOrWhiteSpace(firstEtag));
|
||||
|
||||
var conditional = new HttpRequestMessage(HttpMethod.Get, "/api/state");
|
||||
conditional.Headers.TryAddWithoutValidation("If-None-Match", firstEtag);
|
||||
var notModified = await client.SendAsync(conditional);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode);
|
||||
Assert.Equal(firstEtag, notModified.Headers.ETag?.ToString());
|
||||
|
||||
await client.CreateSuggestionAsync("etag-changed");
|
||||
|
||||
var stale = new HttpRequestMessage(HttpMethod.Get, "/api/state");
|
||||
stale.Headers.TryAddWithoutValidation("If-None-Match", firstEtag);
|
||||
var changed = await client.SendAsync(stale);
|
||||
|
||||
changed.EnsureSuccessStatusCode();
|
||||
Assert.NotEqual(firstEtag, changed.Headers.ETag?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task State_events_endpoint_emits_state_change_after_mutation()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var watcher = factory.CreateClientWithCookies();
|
||||
await watcher.RegisterAsync("watcher");
|
||||
|
||||
using var streamResponse = await watcher.GetAsync("/api/events/state", HttpCompletionOption.ResponseHeadersRead);
|
||||
streamResponse.EnsureSuccessStatusCode();
|
||||
Assert.Equal("text/event-stream", streamResponse.Content.Headers.ContentType?.MediaType);
|
||||
|
||||
await using var stream = await streamResponse.Content.ReadAsStreamAsync();
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
var readyVersion = await ReadSseEventVersionAsync(reader, "ready", TimeSpan.FromSeconds(2));
|
||||
|
||||
var mutator = factory.CreateClientWithCookies();
|
||||
var register = await mutator.RegisterAsync("mutator");
|
||||
register.EnsureSuccessStatusCode();
|
||||
|
||||
var changedVersion = await ReadSseEventVersionAsync(reader, "state", TimeSpan.FromSeconds(3));
|
||||
Assert.True(changedVersion > readyVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_does_not_invalidate_state_etag()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("quietetag");
|
||||
|
||||
var first = await client.GetAsync("/api/state");
|
||||
first.EnsureSuccessStatusCode();
|
||||
var firstEtag = first.Headers.ETag?.ToString();
|
||||
Assert.False(string.IsNullOrWhiteSpace(firstEtag));
|
||||
|
||||
var loginClient = factory.CreateClientWithCookies();
|
||||
var login = await loginClient.LoginAsync("quietetag", "Pass123!");
|
||||
login.EnsureSuccessStatusCode();
|
||||
|
||||
var conditional = new HttpRequestMessage(HttpMethod.Get, "/api/state");
|
||||
conditional.Headers.TryAddWithoutValidation("If-None-Match", firstEtag);
|
||||
var notModified = await client.SendAsync(conditional);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode);
|
||||
Assert.Equal(firstEtag, notModified.Headers.ETag?.ToString());
|
||||
}
|
||||
|
||||
private static async Task<long> ReadSseEventVersionAsync(StreamReader reader, string expectedEventName, TimeSpan timeout)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeout);
|
||||
var eventName = string.Empty;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var line = await reader.ReadLineAsync(cts.Token);
|
||||
if (line is null)
|
||||
throw new Xunit.Sdk.XunitException("SSE stream closed unexpectedly.");
|
||||
|
||||
if (line.Length == 0)
|
||||
{
|
||||
eventName = string.Empty;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("event: ", StringComparison.Ordinal))
|
||||
{
|
||||
eventName = line["event: ".Length..].Trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.StartsWith("data: ", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
if (!string.Equals(eventName, expectedEventName, StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
var payload = line["data: ".Length..].Trim();
|
||||
if (long.TryParse(payload, out var version))
|
||||
return version;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -76,10 +76,18 @@ internal class TestWebApplicationFactory : WebApplicationFactory<Program>
|
||||
|
||||
public HttpClient CreateClientWithCookies()
|
||||
{
|
||||
return CreateClient(new WebApplicationFactoryClientOptions
|
||||
var client = CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
HandleCookies = true,
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
if (client.BaseAddress is { } baseAddress)
|
||||
{
|
||||
var origin = $"{baseAddress.Scheme}://{baseAddress.Authority}";
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("Origin", origin);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
13
GameList.Tests/coverlet.runsettings
Normal file
13
GameList.Tests/coverlet.runsettings
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RunSettings>
|
||||
<DataCollectionRunSettings>
|
||||
<DataCollectors>
|
||||
<DataCollector friendlyName="XPlat Code Coverage">
|
||||
<Configuration>
|
||||
<Format>cobertura</Format>
|
||||
<ExcludeByFile>**/obj/**/Microsoft.AspNetCore.OpenApi.SourceGenerators/**/*.cs</ExcludeByFile>
|
||||
</Configuration>
|
||||
</DataCollector>
|
||||
</DataCollectors>
|
||||
</DataCollectionRunSettings>
|
||||
</RunSettings>
|
||||
@@ -4,14 +4,22 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<OpenApiGenerateDocuments>true</OpenApiGenerateDocuments>
|
||||
<OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)\openapi</OpenApiDocumentsDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
105
Infrastructure/CsrfProtectionMiddleware.cs
Normal file
105
Infrastructure/CsrfProtectionMiddleware.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace GameList.Infrastructure;
|
||||
|
||||
public sealed class CsrfProtectionMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (!ShouldValidate(context))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsSameOriginRequest(context))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
await WriteCsrfFailureAsync(context);
|
||||
}
|
||||
|
||||
private static bool ShouldValidate(HttpContext context)
|
||||
{
|
||||
if (!context.Request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
if (!HttpMethods.IsPost(context.Request.Method)
|
||||
&& !HttpMethods.IsPut(context.Request.Method)
|
||||
&& !HttpMethods.IsDelete(context.Request.Method)
|
||||
&& !HttpMethods.IsPatch(context.Request.Method))
|
||||
return false;
|
||||
|
||||
return context.User.Identity?.IsAuthenticated == true;
|
||||
}
|
||||
|
||||
private static bool IsSameOriginRequest(HttpContext context)
|
||||
{
|
||||
var originValues = context.Request.Headers.Origin;
|
||||
if (!StringValues.IsNullOrEmpty(originValues))
|
||||
{
|
||||
foreach (var origin in originValues)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(origin))
|
||||
return false;
|
||||
|
||||
if (!IsSameOrigin(origin, context))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
var referer = context.Request.Headers.Referer.ToString();
|
||||
if (string.IsNullOrWhiteSpace(referer))
|
||||
return false;
|
||||
|
||||
return IsSameOrigin(referer, context);
|
||||
}
|
||||
|
||||
private static bool IsSameOrigin(string raw, HttpContext context)
|
||||
{
|
||||
if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
|
||||
var requestScheme = context.Request.Scheme;
|
||||
if (!string.Equals(uri.Scheme, requestScheme, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
var requestHost = context.Request.Host.Host;
|
||||
if (!string.Equals(uri.Host, requestHost, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
var uriPort = uri.IsDefaultPort ? GetDefaultPort(uri.Scheme) : uri.Port;
|
||||
var requestPort = context.Request.Host.Port ?? GetDefaultPort(requestScheme);
|
||||
|
||||
return uriPort == requestPort;
|
||||
}
|
||||
|
||||
private static int GetDefaultPort(string scheme)
|
||||
{
|
||||
return string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase) ? 443 : 80;
|
||||
}
|
||||
|
||||
private static Task WriteCsrfFailureAsync(HttpContext context)
|
||||
{
|
||||
if (context.Response.HasStarted)
|
||||
return Task.CompletedTask;
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
context.Response.ContentType = "application/problem+json";
|
||||
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
Title = "Bad Request",
|
||||
Detail = "CSRF validation failed.",
|
||||
Extensions = { ["error"] = "CSRF validation failed." }
|
||||
};
|
||||
|
||||
return context.Response.WriteAsJsonAsync(problem);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
|
||||
namespace GameList.Infrastructure;
|
||||
@@ -10,12 +11,22 @@ public class EnsurePlayerExistsMiddleware(RequestDelegate next)
|
||||
if (context.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var id = context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (string.IsNullOrWhiteSpace(id) || !Guid.TryParse(id, out var playerId) || await db.Players.FindAsync(playerId) is null)
|
||||
if (string.IsNullOrWhiteSpace(id) || !Guid.TryParse(id, out var playerId))
|
||||
{
|
||||
await context.SignOutAsync();
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return;
|
||||
}
|
||||
|
||||
var player = await db.Players.FindAsync(playerId);
|
||||
if (player is null)
|
||||
{
|
||||
await context.SignOutAsync();
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return;
|
||||
}
|
||||
|
||||
context.Items[nameof(Player)] = player;
|
||||
}
|
||||
|
||||
await next(context);
|
||||
|
||||
@@ -1,35 +1,105 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Konscious.Security.Cryptography;
|
||||
|
||||
namespace GameList.Infrastructure;
|
||||
|
||||
public static class PasswordHasher
|
||||
{
|
||||
public const int LegacyVersion = 1;
|
||||
public const int Pbkdf2Version = 2;
|
||||
public const int CurrentVersion = 3;
|
||||
|
||||
private const int SaltSize = 16;
|
||||
private const int KeySize = 32;
|
||||
private const int Iterations = 210_000;
|
||||
private const int IterationsV1 = 210_000;
|
||||
private const int IterationsV2 = 350_000;
|
||||
private const int Argon2Iterations = 2;
|
||||
private const int Argon2MemoryKiB = 19_456;
|
||||
private const int Argon2DegreeOfParallelism = 1;
|
||||
|
||||
public static (byte[] Hash, byte[] Salt) HashPassword(string password)
|
||||
=> HashPassword(password, CurrentVersion);
|
||||
|
||||
public static (byte[] Hash, byte[] Salt) HashPassword(string password, int version)
|
||||
{
|
||||
if (string.IsNullOrEmpty(password))
|
||||
throw new ArgumentException("Password required", nameof(password));
|
||||
|
||||
var normalizedVersion = NormalizeHashVersion(version);
|
||||
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||
var hash = PBKDF2(password, salt);
|
||||
var hash = Derive(password, salt, normalizedVersion);
|
||||
return (hash, salt);
|
||||
}
|
||||
|
||||
public static bool Verify(string password, byte[] hash, byte[] salt)
|
||||
=> Verify(password, hash, salt, CurrentVersion, out _);
|
||||
|
||||
public static bool Verify(string password, byte[] hash, byte[] salt, int version, out bool needsRehash)
|
||||
{
|
||||
needsRehash = false;
|
||||
if (hash.Length == 0 || salt.Length == 0)
|
||||
return false;
|
||||
|
||||
var computed = PBKDF2(password, salt);
|
||||
return CryptographicOperations.FixedTimeEquals(computed, hash);
|
||||
var normalizedVersion = NormalizeVerifyVersion(version);
|
||||
if (normalizedVersion == 0)
|
||||
return false;
|
||||
|
||||
var computed = Derive(password, salt, normalizedVersion);
|
||||
var verified = CryptographicOperations.FixedTimeEquals(computed, hash);
|
||||
|
||||
needsRehash = verified && normalizedVersion < CurrentVersion;
|
||||
return verified;
|
||||
}
|
||||
|
||||
private static byte[] PBKDF2(string password, byte[] salt)
|
||||
private static int NormalizeHashVersion(int version)
|
||||
{
|
||||
return Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, Iterations, HashAlgorithmName.SHA256, KeySize);
|
||||
return version switch
|
||||
{
|
||||
<= LegacyVersion => LegacyVersion,
|
||||
Pbkdf2Version => Pbkdf2Version,
|
||||
CurrentVersion => CurrentVersion,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(version), "Unsupported password hash version.")
|
||||
};
|
||||
}
|
||||
|
||||
private static int NormalizeVerifyVersion(int version)
|
||||
{
|
||||
return version switch
|
||||
{
|
||||
<= LegacyVersion => LegacyVersion,
|
||||
Pbkdf2Version => Pbkdf2Version,
|
||||
CurrentVersion => CurrentVersion,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] Derive(string password, byte[] salt, int version)
|
||||
{
|
||||
return version switch
|
||||
{
|
||||
LegacyVersion => PBKDF2(password, salt, IterationsV1),
|
||||
Pbkdf2Version => PBKDF2(password, salt, IterationsV2),
|
||||
CurrentVersion => Argon2id(password, salt),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(version), "Unsupported password hash version.")
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] PBKDF2(string password, byte[] salt, int iterations)
|
||||
{
|
||||
return Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, iterations, HashAlgorithmName.SHA256, KeySize);
|
||||
}
|
||||
|
||||
private static byte[] Argon2id(string password, byte[] salt)
|
||||
{
|
||||
using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password))
|
||||
{
|
||||
Salt = salt,
|
||||
Iterations = Argon2Iterations,
|
||||
MemorySize = Argon2MemoryKiB,
|
||||
DegreeOfParallelism = Argon2DegreeOfParallelism
|
||||
};
|
||||
|
||||
return argon2.GetBytes(KeySize);
|
||||
}
|
||||
}
|
||||
|
||||
31
Infrastructure/StateChangeNotificationMiddleware.cs
Normal file
31
Infrastructure/StateChangeNotificationMiddleware.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
namespace GameList.Infrastructure;
|
||||
|
||||
public sealed class StateChangeNotificationMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context, StateChangeNotifier notifier)
|
||||
{
|
||||
await next(context);
|
||||
|
||||
if (ShouldNotify(context))
|
||||
notifier.NotifyChange();
|
||||
}
|
||||
|
||||
private static bool ShouldNotify(HttpContext context)
|
||||
{
|
||||
if (context.Response.StatusCode >= StatusCodes.Status400BadRequest)
|
||||
return false;
|
||||
|
||||
if (!HttpMethods.IsPost(context.Request.Method)
|
||||
&& !HttpMethods.IsPut(context.Request.Method)
|
||||
&& !HttpMethods.IsDelete(context.Request.Method))
|
||||
return false;
|
||||
|
||||
var path = context.Request.Path;
|
||||
|
||||
return path.StartsWithSegments("/api/suggestions", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.StartsWithSegments("/api/votes", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.StartsWithSegments("/api/admin", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.StartsWithSegments("/api/me/phase", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.StartsWithSegments("/api/auth/register", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
69
Infrastructure/StateChangeNotifier.cs
Normal file
69
Infrastructure/StateChangeNotifier.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace GameList.Infrastructure;
|
||||
|
||||
public sealed class StateChangeNotifier
|
||||
{
|
||||
private readonly string _instanceId = Guid.NewGuid().ToString("N");
|
||||
private long _version = 1;
|
||||
private TaskCompletionSource<long> _nextChange = CreateWaiter();
|
||||
|
||||
public long CurrentVersion => Interlocked.Read(ref _version);
|
||||
|
||||
public string CurrentEtag => $"\"{_instanceId}:{CurrentVersion}\"";
|
||||
|
||||
public long NotifyChange()
|
||||
{
|
||||
var newVersion = Interlocked.Increment(ref _version);
|
||||
|
||||
while (true)
|
||||
{
|
||||
var waiter = Volatile.Read(ref _nextChange);
|
||||
var replacement = CreateWaiter();
|
||||
if (Interlocked.CompareExchange(ref _nextChange, replacement, waiter) != waiter)
|
||||
continue;
|
||||
|
||||
waiter.TrySetResult(newVersion);
|
||||
return newVersion;
|
||||
}
|
||||
}
|
||||
|
||||
public bool MatchesCurrentEtag(StringValues ifNoneMatchValues)
|
||||
{
|
||||
if (StringValues.IsNullOrEmpty(ifNoneMatchValues))
|
||||
return false;
|
||||
|
||||
var current = CurrentEtag;
|
||||
foreach (var raw in ifNoneMatchValues)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
continue;
|
||||
|
||||
var parts = raw.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (part == "*" || string.Equals(part, current, StringComparison.Ordinal))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<long> WaitForChangeAsync(long observedVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var current = CurrentVersion;
|
||||
if (current > observedVersion)
|
||||
return current;
|
||||
|
||||
var waiter = Volatile.Read(ref _nextChange);
|
||||
var signaled = await waiter.Task.WaitAsync(cancellationToken);
|
||||
if (signaled > observedVersion)
|
||||
return signaled;
|
||||
}
|
||||
}
|
||||
|
||||
private static TaskCompletionSource<long> CreateWaiter() => new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
@@ -46,6 +46,8 @@ builder.Services.AddScoped<AdminWorkflowService>();
|
||||
builder.Services.AddScoped<ResultsWorkflowService>();
|
||||
builder.Services.AddScoped<StateWorkflowService>();
|
||||
builder.Services.AddSingleton<AuthAttemptMonitor>();
|
||||
builder.Services.AddSingleton<StateChangeNotifier>();
|
||||
builder.Services.AddOpenApi("v1");
|
||||
|
||||
builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); });
|
||||
|
||||
@@ -135,7 +137,7 @@ app.Use(async (ctx, next) =>
|
||||
headers["Referrer-Policy"] = "no-referrer";
|
||||
headers["Permissions-Policy"] = "camera=(), geolocation=(), microphone=()";
|
||||
headers["Content-Security-Policy"] =
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: https: http:; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
|
||||
"default-src 'self'; script-src 'self'; style-src 'self' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
@@ -151,12 +153,15 @@ if (!string.IsNullOrWhiteSpace(basePath))
|
||||
app.UseGlobalExceptionLogging();
|
||||
app.UseAuthentication();
|
||||
app.UseMiddleware<EnsurePlayerExistsMiddleware>();
|
||||
app.UseMiddleware<CsrfProtectionMiddleware>();
|
||||
app.UseAuthorization();
|
||||
app.UseMiddleware<StateChangeNotificationMiddleware>();
|
||||
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.MapHealthChecks();
|
||||
app.MapOpenApi("/openapi/{documentName}.json");
|
||||
app.MapAuthEndpoints();
|
||||
app.MapStateEndpoints();
|
||||
app.MapSuggestEndpoints();
|
||||
|
||||
14
README.md
14
README.md
@@ -18,6 +18,7 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
|
||||
## Frontend Tooling
|
||||
|
||||
- Install tooling: `npm install`
|
||||
- Generate API client from OpenAPI: `npm run generate:api-client` (expects `openapi/GameList.json` generated by `dotnet build`)
|
||||
- Lint JS: `npm run lint`
|
||||
- Check formatting: `npm run format:check`
|
||||
- Apply formatting: `npm run format`
|
||||
@@ -29,9 +30,18 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
|
||||
- Owner model: first valid admin-key registration becomes `owner`; admins can grant/revoke admin role for non-owner accounts.
|
||||
- Core invariants are DB-enforced: single owner account and non-joker suggestion cap.
|
||||
- Gameplay phases: `Suggest`, `Vote`, `Results`.
|
||||
- Realtime sync: `/api/events/state` (SSE) plus `ETag`-based conditional `/api/state` reads to reduce polling load.
|
||||
- Storage: SQLite database under `App_Data/gamelist.db`.
|
||||
- Migrations are deployment-time operations (`dotnet ef database update`); app startup does not auto-migrate.
|
||||
- Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement.
|
||||
- CSRF baseline: authenticated mutating API requests require same-origin `Origin`/`Referer` headers.
|
||||
- Password hashes are versioned and upgraded on successful login/admin-password verification; current rollout uses Argon2id for new hashes while transparently upgrading legacy PBKDF2 hashes.
|
||||
|
||||
## Password Hash Migration Plan
|
||||
|
||||
1. Existing hashes remain valid under versioned verification (`LegacyVersion=1`).
|
||||
2. Successful authentication transparently rehashes credentials to `CurrentVersion=3` (Argon2id) and persists the upgraded hash metadata.
|
||||
3. Legacy versions can be retired after full rollout once no remaining accounts depend on them.
|
||||
|
||||
## Module Ownership
|
||||
|
||||
@@ -50,6 +60,7 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
|
||||
## Operations
|
||||
|
||||
- API surface and endpoint contract: `API.md`
|
||||
- Generated OpenAPI document: `openapi/GameList.json` (runtime: `/openapi/v1.json`)
|
||||
- Product/feature expectations: `SPEC.md`
|
||||
- IIS deployment notes: `IIS.md`
|
||||
- Test strategy details: `TESTS.md`
|
||||
@@ -59,7 +70,8 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
|
||||
GitHub Actions workflow: `.github/workflows/ci.yml`
|
||||
|
||||
- Restores dependencies
|
||||
- Runs frontend lint and format checks
|
||||
- Builds with warnings treated as errors
|
||||
- Generates frontend API client from OpenAPI contract
|
||||
- Runs frontend lint and format checks
|
||||
- Runs `GameList.Tests` with coverage collection
|
||||
- Enforces minimum coverage thresholds (line 90%, branch 70%)
|
||||
|
||||
196
REVIEW.md
196
REVIEW.md
@@ -1,163 +1,165 @@
|
||||
# Review - Pick'n'Play
|
||||
# Review - Pick'n'Play (State-of-the-Art Assessment)
|
||||
|
||||
Date: 2026-02-08
|
||||
Date: 2026-02-18
|
||||
|
||||
## Scope and baseline
|
||||
## Scope
|
||||
|
||||
- Reviewed backend, frontend, data model, tests, CI, deployment scripts, and documentation.
|
||||
- Ran local CI checks via `scripts/ci-local.ps1 -SkipNpmInstall` (pass: lint, format, build, tests).
|
||||
- Ran coverage collection (`dotnet test --collect:"XPlat Code Coverage"`).
|
||||
- Latest collected coverage: line-rate `92.75%`, branch-rate `74.88%` (`GameList.Tests/TestResults/*/coverage.cobertura.xml:2`).
|
||||
- Evaluated backend (`Program.cs`, `Endpoints/*`, `Infrastructure/*`, `Data/*`), frontend (`wwwroot/*`), and CI/deployment scripts.
|
||||
- Focused on risks in maintainability, extensibility, scalability, and security.
|
||||
|
||||
## Executive summary
|
||||
|
||||
The app is in a solid functional state for a small group and has good safety basics (auth, rate limits, tests, security headers).
|
||||
However, several design and reliability issues will slow down extension work and make this repo hard to use as a reusable foundation for new apps unless addressed first.
|
||||
The app is solid for a small private group, but it still has several architectural bottlenecks compared to current best practice for long-term product growth. The largest risks are read amplification from client polling, hard-coded workflow/permission modeling, and security hardening gaps (CSRF posture and crypto modernization).
|
||||
|
||||
## Findings (highest priority first)
|
||||
## Findings
|
||||
|
||||
### P0 - Core invariants are not concurrency-safe
|
||||
### 1) High - Scalability - Single-node SQLite bottleneck
|
||||
|
||||
Evidence:
|
||||
- Owner bootstrap is check-then-insert (`Endpoints/AuthEndpoints.cs:45`, `Endpoints/AuthEndpoints.cs:70`, `Endpoints/AuthEndpoints.cs:71`) without a DB-level uniqueness guard for owner role (`Data/AppDbContext.cs:25`).
|
||||
- Suggestion cap (<=5) is count-then-insert (`Endpoints/SuggestionWorkflowService.cs:63`, `Endpoints/SuggestionWorkflowService.cs:64`, `Endpoints/SuggestionWorkflowService.cs:82`, `Endpoints/SuggestionWorkflowService.cs:92`).
|
||||
- Vote upsert is read-then-insert/update (`Endpoints/VoteWorkflowService.cs:70`, `Endpoints/VoteWorkflowService.cs:79`, `Endpoints/VoteWorkflowService.cs:92`) and can race into unique-key conflicts.
|
||||
- SQLite is the primary DB (`Program.cs:42`).
|
||||
|
||||
Impact:
|
||||
- Under concurrent requests, business rules can be violated (multiple owners, over-limit suggestions) or requests can fail with server errors.
|
||||
Risk:
|
||||
- SQLite is excellent for small single-node deployments, but write concurrency and horizontal scale are limited for larger or bursty usage.
|
||||
|
||||
Recommendation:
|
||||
- Enforce invariants in the DB and handle race paths explicitly (transaction isolation, constrained indexes, retry/409 handling on expected conflicts).
|
||||
Alternative:
|
||||
- Keep SQLite for local/dev and migrate production to PostgreSQL/SQL Server with provider-specific migrations and connection pooling.
|
||||
|
||||
### P0 - Business layer is tightly coupled to HTTP transport
|
||||
### 2) High - Scalability - Polling causes read amplification
|
||||
|
||||
Evidence:
|
||||
- Workflow services return `Task<IResult>` directly across the board (`Endpoints/SuggestionWorkflowService.cs:10`, `Endpoints/VoteWorkflowService.cs:10`, `Endpoints/ResultsWorkflowService.cs:10`, `Endpoints/StateWorkflowService.cs:10`, `Endpoints/AdminWorkflowService.cs:11`).
|
||||
- Frontend polling runs continuously with 3s-20s cadence (`wwwroot/app.js:30`, `wwwroot/app.js:58`).
|
||||
- Each refresh can hit multiple endpoints (`wwwroot/js/data.js:21`, `wwwroot/js/data.js:109`).
|
||||
- `/api/state` also executes multiple aggregate counts each time (`Endpoints/StateWorkflowService.cs:14`).
|
||||
|
||||
Impact:
|
||||
- Domain logic cannot be reused cleanly by other apps/services without carrying ASP.NET HTTP result types everywhere.
|
||||
- Harder to unit test business logic in isolation.
|
||||
Risk:
|
||||
- As concurrent users increase, backend read load grows quickly and mostly serves unchanged data.
|
||||
|
||||
Recommendation:
|
||||
- Split into application services (domain result objects/errors) + thin endpoint adapters (HTTP mapping only).
|
||||
Alternative:
|
||||
- Move to event-driven updates (SSE/WebSocket) plus conditional GET (`ETag`/`If-None-Match`) and/or a consolidated bootstrap endpoint.
|
||||
|
||||
### P1 - Startup/runtime side effects are risky for production scale-out
|
||||
### 3) High - Security - CSRF protection is implicit, not explicit
|
||||
|
||||
Evidence:
|
||||
- Automatic schema migration on app startup (`Program.cs:161`).
|
||||
- Runtime mutation of static frontend file for base path (`Program.cs:149`, `Program.cs:277`), with silent catch (`Program.cs:313`).
|
||||
- Cookie authentication is used for API auth (`Program.cs:100`, `Program.cs:104`).
|
||||
- Many state-changing endpoints rely on cookie auth (`Endpoints/SuggestEndpoints.cs:24`, `Endpoints/VoteEndpoints.cs:24`, `Endpoints/AdminEndpoints.cs:14`).
|
||||
- No antiforgery middleware/tokens are configured in startup.
|
||||
|
||||
Impact:
|
||||
- Multi-instance startup races and operational fragility.
|
||||
- Read-only deployments or immutable artifacts can fail subtly.
|
||||
Risk:
|
||||
- `SameSite=Strict` helps (`Program.cs:104`) but is not a full long-term CSRF strategy across browser/proxy edge cases.
|
||||
|
||||
Recommendation:
|
||||
- Move migrations to explicit deployment step; remove runtime file rewrite and make base path purely configuration-driven at build/deploy time.
|
||||
Alternative:
|
||||
- Add explicit anti-forgery tokens for mutating requests (or move to bearer tokens for API calls) and verify origin headers server-side.
|
||||
|
||||
### P1 - Null-safety gaps in auth input validation can produce 500s
|
||||
### 4) High - Extensibility - Workflow is hard-coded across backend and frontend
|
||||
|
||||
Evidence:
|
||||
- Direct `.Trim()` on potentially null request values (`Endpoints/AuthValidator.cs:15`, `Endpoints/AuthValidator.cs:71`, `Endpoints/AuthEndpoints.cs:26`, `Endpoints/AuthEndpoints.cs:90`).
|
||||
- Phase behavior is encoded via enum and many explicit checks/switches (`Domain/Phase.cs:3`, `Endpoints/StateWorkflowService.cs:70`, `Endpoints/EndpointHelpers.cs:97`).
|
||||
- Frontend also hard-codes phase logic in many files (`wwwroot/app.js:99`, `wwwroot/js/data.js:47`, `wwwroot/js/votes-ui.js:167`).
|
||||
|
||||
Impact:
|
||||
- Malformed JSON payloads can bypass intended 400 responses and trigger 500 errors.
|
||||
Risk:
|
||||
- Adding a new phase or changing transitions requires touching many scattered branches, increasing regression risk.
|
||||
|
||||
Recommendation:
|
||||
- Treat inbound fields as nullable at boundaries, normalize safely, and fail closed with typed validation errors.
|
||||
Alternative:
|
||||
- Introduce a shared workflow/state-machine model (transition table) and consume it in both backend and frontend.
|
||||
|
||||
### P1 - Deployment automation is environment-specific and hard-coded
|
||||
### 5) High - Extensibility - Role model is fixed to booleans
|
||||
|
||||
Evidence:
|
||||
- Script contains fixed host/user/path/tooling details (`scripts/deploy-ftp.ps1:2`, `scripts/deploy-ftp.ps1:3`, `scripts/deploy-ftp.ps1:11`, `scripts/deploy-ftp.ps1:15`).
|
||||
- Uses direct FTP credential in command string (`scripts/deploy-ftp.ps1:102`).
|
||||
- Role/state flags are booleans on player (`Domain/Player.cs:22`, `Domain/Player.cs:23`).
|
||||
- Admin checks are tightly coupled to that model (`Infrastructure/AdminOnlyFilter.cs:13`, `Endpoints/AdminWorkflowService.cs:93`).
|
||||
|
||||
Impact:
|
||||
- Not portable, hard to onboard new maintainers/environments, and higher operational/security risk.
|
||||
Risk:
|
||||
- Future needs (moderator, read-only admin, per-feature permissions) require schema and logic rewrites instead of additive changes.
|
||||
|
||||
Recommendation:
|
||||
- Externalize all environment values into secure config; provide a generic deploy profile template per environment.
|
||||
Alternative:
|
||||
- Move to role/permission tables (or claims-based capability model) and policy-based authorization.
|
||||
|
||||
### P1 - Coverage policy and reality are misaligned
|
||||
### 6) Medium - Maintainability - Frontend is string-template heavy with global mutable state
|
||||
|
||||
Evidence:
|
||||
- Documentation claims target of 100% line/branch (`TESTS.md:96`).
|
||||
- Actual measured coverage is lower (`GameList.Tests/TestResults/*/coverage.cobertura.xml:2`).
|
||||
- CI/local scripts run tests but do not enforce coverage thresholds (`.github/workflows/ci.yml:43`, `scripts/ci-local.ps1:56`, `scripts/ci-local.ps1:59`).
|
||||
- Single global state object (`wwwroot/js/state.js:1`).
|
||||
- Heavy `innerHTML` rendering across modules (`wwwroot/js/suggestions-ui.js:115`, `wwwroot/js/votes-ui.js:35`, `wwwroot/js/results-ui.js:72`).
|
||||
|
||||
Impact:
|
||||
- False confidence in quality gates and unclear definition of done.
|
||||
Risk:
|
||||
- Harder refactoring, weaker static guarantees, and easy XSS regressions when new contributors add templates.
|
||||
|
||||
Recommendation:
|
||||
- Decide real threshold policy, enforce it in CI, and keep docs aligned with measured truth.
|
||||
Alternative:
|
||||
- Incrementally move to TypeScript + componentized rendering (or at minimum typed JSDoc + stricter lint rules + centralized safe render helpers).
|
||||
|
||||
### P2 - Frontend refresh strategy is chatty and DB-heavy
|
||||
### 7) Medium - Scalability/Security - In-memory dictionaries are unbounded
|
||||
|
||||
Evidence:
|
||||
- Global polling every 4s (`wwwroot/app.js:27`, `wwwroot/app.js:49`).
|
||||
- Each cycle can trigger multiple API reads (`wwwroot/js/data.js:6`, `wwwroot/js/data.js:93`, `wwwroot/js/data.js:101`).
|
||||
- Auth attempt monitor stores failures in unbounded `ConcurrentDictionary` (`Infrastructure/AuthAttemptMonitor.cs:14`).
|
||||
- Image reachability cache is a static dictionary without size limits (`Endpoints/SuggestionValidator.cs:7`, `Endpoints/SuggestionValidator.cs:42`).
|
||||
|
||||
Impact:
|
||||
- Scales poorly with player count/sessions; unnecessary backend load.
|
||||
Risk:
|
||||
- High-cardinality traffic can grow memory and become a denial-of-service vector.
|
||||
|
||||
Recommendation:
|
||||
- Move to event-driven or adaptive refresh (backoff, ETag/delta, push where needed).
|
||||
Alternative:
|
||||
- Replace with bounded `MemoryCache` (size limits + eviction) or distributed cache (Redis) with TTL and cardinality controls.
|
||||
|
||||
### P2 - Frontend DOM drift and invalid markup
|
||||
### 8) Medium - Scalability - Linking/results workflows load full sets into memory
|
||||
|
||||
Evidence:
|
||||
- Missing closing `</div>` for status bar around section boundary (`wwwroot/index.html:83`, `wwwroot/index.html:102`).
|
||||
- JS references elements not present in HTML: `all-suggestions`, `nav-vote-next` (`wwwroot/js/suggestions-ui.js:52`, `wwwroot/js/votes-ui.js:264`).
|
||||
- Link/unlink loads all suggestions then computes roots in memory (`Endpoints/AdminWorkflowService.cs:136`, `Endpoints/AdminWorkflowService.cs:188`).
|
||||
- Results projection includes full vote lists per suggestion (`Endpoints/ResultsWorkflowService.cs:23`, `Endpoints/ResultsWorkflowService.cs:34`, `Endpoints/ResultsWorkflowService.cs:50`).
|
||||
|
||||
Impact:
|
||||
- Browser autocorrection masks real bugs, increases regression risk, and confuses future contributors.
|
||||
Risk:
|
||||
- Memory and query cost rise non-linearly with larger datasets.
|
||||
|
||||
Recommendation:
|
||||
- Reconcile HTML/JS contracts and remove dead UI paths.
|
||||
Alternative:
|
||||
- Introduce persisted link-group IDs and push aggregation to SQL; add pagination/windowing for large result sets.
|
||||
|
||||
### P2 - Login flow can fail silently for non-auth errors
|
||||
### 9) Medium - Security - Crypto is good but not state-of-the-art
|
||||
|
||||
Evidence:
|
||||
- In login catch block, non-401/non-auth-handler errors are swallowed without user feedback (`wwwroot/js/app-auth-handlers.js:113`, `wwwroot/js/app-auth-handlers.js:116`).
|
||||
- Password hashing uses PBKDF2-SHA256 (`Infrastructure/PasswordHasher.cs:33`) with fixed iteration count (`Infrastructure/PasswordHasher.cs:10`).
|
||||
|
||||
Impact:
|
||||
- Poor UX and harder support/debugging.
|
||||
Risk:
|
||||
- PBKDF2 remains acceptable, but modern guidance favors memory-hard KDFs (Argon2id/scrypt) against GPU/ASIC attacks.
|
||||
|
||||
Recommendation:
|
||||
- Add explicit fallback toast/logging for unexpected errors in login flow.
|
||||
Alternative:
|
||||
- Add versioned password hashes and migrate to Argon2id on login/re-hash.
|
||||
|
||||
### P2 - Formatting checks do not cover the whole frontend codebase
|
||||
### 10) Medium - Security - CSP remains permissive for inline style and mixed image origins
|
||||
|
||||
Evidence:
|
||||
- ESLint scans all JS (`package.json:6`), but Prettier scripts only include a subset of files (`package.json:7`, `package.json:8`).
|
||||
- CSP allows `style-src 'unsafe-inline'` and `img-src ... https: http:` (`Program.cs:138`).
|
||||
|
||||
Impact:
|
||||
- Style drift and inconsistent diffs across untouched files.
|
||||
Risk:
|
||||
- Wider policy surface than needed, especially for long-term hardening.
|
||||
|
||||
Recommendation:
|
||||
- Align formatting scope with lint scope.
|
||||
Alternative:
|
||||
- Remove inline style dependence (e.g., CSS classes or nonce/hash), and restrict image sources to `https` and/or a media proxy allowlist.
|
||||
|
||||
### P2 - Suggestion validation does synchronous external network checks on write paths
|
||||
### 11) Medium - Maintainability/Extensibility - API contract sync is manual
|
||||
|
||||
Evidence:
|
||||
- Suggestion validation calls image reachability check (`Endpoints/SuggestionValidator.cs:13`).
|
||||
- Reachability check performs outbound call with timeout per request (`Endpoints/EndpointHelpers.cs:175`, `Endpoints/EndpointHelpers.cs:194`, `Endpoints/EndpointHelpers.cs:197`).
|
||||
- Frontend endpoints are hard-coded in JS (`wwwroot/js/api.js:37`, `wwwroot/js/api.js:70`).
|
||||
- Contract is also maintained manually in markdown (`API.md`).
|
||||
|
||||
Impact:
|
||||
- Slower writes and external dependency coupling even for basic edit operations.
|
||||
Risk:
|
||||
- Contract drift between backend DTOs and frontend consumers over time.
|
||||
|
||||
Recommendation:
|
||||
- Make this validation less blocking (cache results, only revalidate when URL changes, or async verification workflow).
|
||||
Alternative:
|
||||
- Generate OpenAPI from endpoints and produce typed client code for frontend consumption.
|
||||
|
||||
## Positive foundations worth keeping
|
||||
### 12) Medium - Scalability/Resilience - External image validation is synchronous on write path
|
||||
|
||||
- Security baseline is present: cookie hardening + rate limiting + security headers (`Program.cs:100`, `Program.cs:104`, `Program.cs:121`, `Program.cs:137`).
|
||||
- Global exception handling and health endpoint exist (`Infrastructure/PlayerIdentityExtensions.cs:40`, `Infrastructure/PlayerIdentityExtensions.cs:68`).
|
||||
- Broad integration test suite exists and is currently green (104 tests).
|
||||
Evidence:
|
||||
- Suggestion validation calls network reachability checks (`Endpoints/SuggestionValidator.cs:40`).
|
||||
- Validation does outbound DNS/connect/HEAD/GET in request path (`Endpoints/EndpointHelpers.cs:196`, `Endpoints/EndpointHelpers.cs:240`, `Endpoints/EndpointHelpers.cs:270`).
|
||||
|
||||
## Suggested initial remediation order
|
||||
Risk:
|
||||
- User write latency is coupled to third-party host responsiveness.
|
||||
|
||||
1. Concurrency/invariant hardening for owner bootstrap, suggestion limits, and vote upsert.
|
||||
2. Service boundary refactor (`IResult` decoupling) to make logic reusable for future apps.
|
||||
3. Startup/deployment hardening (migration strategy + remove runtime file rewrite + parameterized deploy script).
|
||||
4. Coverage policy enforcement and documentation correction.
|
||||
5. Frontend cleanup pass (invalid markup, dead selectors, polling strategy, error handling).
|
||||
Alternative:
|
||||
- Accept URL quickly, then validate asynchronously (background job + status flag), optionally with trusted media proxying.
|
||||
|
||||
## Recommended remediation order
|
||||
|
||||
1. Reduce read amplification: event-driven updates + state endpoint optimization.
|
||||
2. Harden security baseline: explicit CSRF, CSP tightening, and password-hash migration plan.
|
||||
3. Decouple growth hotspots: workflow state machine + role/permission model.
|
||||
4. Improve operational scale: replace unbounded in-memory structures and large in-memory link/result operations.
|
||||
5. Introduce contract tooling: OpenAPI + generated frontend client to reduce drift.
|
||||
|
||||
2
SPEC.md
2
SPEC.md
@@ -35,8 +35,10 @@ Help a small Discord group (4–8 players) pick a co-op game via phased flow:
|
||||
- Visible only after admin enables results; players auto-advance when opened
|
||||
- Admin controls results availability with a single toggle button whose label reflects enabled/disabled state
|
||||
- Leaderboard sorted by average score; shows totals, counts, player’s own vote, and links/media
|
||||
- Average score and score emojis expose the same tooltip showing the game's voters in alphabetical order
|
||||
- When results are closed again, only accounts with at least one suggestion return to Vote; accounts without suggestions return to Suggest
|
||||
|
||||
## Non-functional
|
||||
- Desktop + mobile friendly
|
||||
- Runs on IIS; SQLite via EF Core
|
||||
- Browser security baseline: strict CSP (no inline styles, no insecure image origins) and same-origin protection for authenticated mutating API requests
|
||||
|
||||
319
TECH.md
Normal file
319
TECH.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# TECH - Kickoff Blueprint from Pick'n'Play
|
||||
|
||||
This document extracts the implementation patterns, conventions, and guardrails from the current codebase so a new webapp can start with proven structure and avoid known pitfalls.
|
||||
|
||||
Scope covered:
|
||||
- Backend (`Program.cs`, `Endpoints/*`, `Infrastructure/*`, `Data/*`, `Domain/*`, `Contracts/*`)
|
||||
- Frontend (`wwwroot/*`)
|
||||
- Tooling and CI (`scripts/*`, `.github/workflows/ci.yml`, npm tooling)
|
||||
- Tests (`GameList.Tests/*`)
|
||||
- Review carryover (`REVIEW.md` unresolved topics)
|
||||
|
||||
## 1) Stack and baseline choices
|
||||
|
||||
- ASP.NET Core Minimal API on .NET 10.
|
||||
- EF Core + SQLite in current project (single-node deployment).
|
||||
- Cookie authentication (`HttpOnly`, `SameSite=Strict`, secure in production).
|
||||
- Static frontend (HTML/CSS/JS modules), no frontend framework.
|
||||
- OpenAPI generated from backend and consumed by generated JS client.
|
||||
- xUnit integration-heavy test suite with in-memory SQLite and coverage gates.
|
||||
|
||||
## 2) Architecture patterns to keep
|
||||
|
||||
### 2.1 API shape and layering
|
||||
|
||||
- Route mapping in thin endpoint modules (`MapXEndpoints` per feature area).
|
||||
- Domain logic in workflow services (`*WorkflowService`) instead of endpoint lambdas.
|
||||
- Service responses normalized via `ServiceResult<T>` + `ServiceError`, then mapped to HTTP at the edge.
|
||||
- Consistent `ProblemDetails` payloads with `error` extension for machine-usable errors.
|
||||
- Endpoint-level concerns handled by endpoint filters (`AdminOnlyFilter`, `PhaseRequirementFilter`, `PhaseOrJokerFilter`).
|
||||
|
||||
Keep this split:
|
||||
- Endpoint adapters: auth, deserialization, HTTP mapping only.
|
||||
- Workflow services: validation, query/update rules, transactions.
|
||||
- Helpers: shared utility and security-sensitive routines.
|
||||
|
||||
### 2.2 Middleware pipeline discipline
|
||||
|
||||
- Security and behavior depend on middleware ordering; keep explicit ordering.
|
||||
- Important current order:
|
||||
1. Forwarded headers
|
||||
2. Rate limiter
|
||||
3. HSTS + HTTPS redirect (prod)
|
||||
4. Security headers writer
|
||||
5. Base path
|
||||
6. Global exception handling
|
||||
7. Authentication
|
||||
8. Ensure player still exists
|
||||
9. CSRF origin/referer checks
|
||||
10. Authorization
|
||||
11. State change notifier middleware
|
||||
12. Static files
|
||||
13. Endpoint mapping
|
||||
|
||||
### 2.3 State synchronization
|
||||
|
||||
- Event-driven invalidation with SSE (`/api/events/state`) plus heartbeats.
|
||||
- Conditional reads for state (`ETag` + `If-None-Match`) to return `304`.
|
||||
- In-process notifier (`StateChangeNotifier`) with monotonic version and etag stamp.
|
||||
- Mutation middleware (`StateChangeNotificationMiddleware`) emits invalidation only for successful mutating API calls.
|
||||
|
||||
This pattern is a strong baseline for low to medium scale and should be the default in the new app.
|
||||
|
||||
### 2.4 Security baseline
|
||||
|
||||
- Cookie auth with short/medium session sliding expiration plus absolute lifetime cap.
|
||||
- Explicit same-origin CSRF checks for authenticated mutating API calls.
|
||||
- Rate limiting on auth-sensitive and admin-sensitive surfaces with custom `429` payload.
|
||||
- Security headers on all responses (`CSP`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`).
|
||||
- Forwarded headers restricted to configured trusted proxies/networks only.
|
||||
- Owner/admin protection rules enforced in business logic and DB constraints.
|
||||
- Destructive admin operations require password re-confirmation.
|
||||
- Password hashing is versioned and supports transparent upgrade on successful auth.
|
||||
- Current hash defaults to Argon2id, with legacy compatibility retained.
|
||||
|
||||
### 2.5 Data and invariants
|
||||
|
||||
- Strong DB model with explicit constraints:
|
||||
- Unique normalized username
|
||||
- Unique owner partial index
|
||||
- Unique vote key `(PlayerId, SuggestionId)`
|
||||
- Seeded singleton app state row
|
||||
- DB-level guardrails (trigger) complement app-level checks:
|
||||
- Suggestion limit enforced in DB (`suggestion_limit_exceeded`) to survive concurrent writes.
|
||||
- EF patterns:
|
||||
- `AsNoTracking()` for read-only queries
|
||||
- `ExecuteUpdateAsync` / `ExecuteDeleteAsync` for efficient bulk operations
|
||||
- Explicit transactions for multi-step destructive/admin operations
|
||||
- Conflict handling around unique constraints
|
||||
|
||||
### 2.6 Workflow and permission model
|
||||
|
||||
- Phases (`Suggest`, `Vote`, `Results`) drive endpoint access and UX behavior.
|
||||
- Effective phase can be derived from persisted phase + global `resultsOpen`.
|
||||
- Reconciliation helper functions centralize phase alignment rules.
|
||||
- Admin abilities are intentionally constrained per operation (e.g., only specific transitions allowed).
|
||||
|
||||
Keep centralization of workflow rules. Avoid spreading phase/permission checks inline.
|
||||
|
||||
### 2.7 Frontend architecture
|
||||
|
||||
- ES module split by concern:
|
||||
- API wrapper
|
||||
- Data loaders
|
||||
- UI composition
|
||||
- Feature-specific renderers/handlers
|
||||
- Shared utils and runtime dependency injection
|
||||
- Single runtime state object with deliberate clear/reset logic.
|
||||
- Refresh scheduler:
|
||||
- Serialized refreshes (no overlap)
|
||||
- Adaptive polling backoff
|
||||
- SSE-triggered immediate refresh for state mutations
|
||||
- Visibility-aware refresh suppression
|
||||
- API client is generated from OpenAPI operation ids, not handwritten endpoints.
|
||||
- Internationalization:
|
||||
- translation file validation at startup
|
||||
- language-specific FAQ markdown loading with fallback to default language
|
||||
|
||||
### 2.8 Frontend safety and rendering hygiene
|
||||
|
||||
- Safe rendering helpers:
|
||||
- `escapeHtml` for template interpolation
|
||||
- `safeUrl` for links/media
|
||||
- Sensitive modal content set via `textContent`, not interpolated HTML.
|
||||
- Trusted output patterns covered by regression tests.
|
||||
|
||||
Maintain this as a non-negotiable standard for any user-supplied content path.
|
||||
|
||||
### 2.9 Testing strategy patterns
|
||||
|
||||
- Full-stack integration tests via `WebApplicationFactory`.
|
||||
- Real migrations applied to in-memory SQLite during test host startup.
|
||||
- HTTP side effects mocked deterministically (`StubHttpMessageHandler` and `IHttpClientFactory` replacement).
|
||||
- Coverage-focused tests for:
|
||||
- auth/security rules
|
||||
- middleware behavior
|
||||
- filter behavior
|
||||
- link/vote/result edge cases
|
||||
- OpenAPI operation id stability
|
||||
- CI-local parity script (`scripts/ci-local.ps1`) mirrors pipeline flow.
|
||||
|
||||
### 2.10 Tooling and contract discipline
|
||||
|
||||
- OpenAPI generated during build (`openapi/GameList.json`).
|
||||
- JS client generated from OpenAPI with required operation-id checks.
|
||||
- Separate lint + format + tests + coverage threshold checks.
|
||||
- Build configured with warnings as errors in CI/local script.
|
||||
|
||||
## 3) Concrete feature set currently implemented
|
||||
|
||||
Use this as a reusable "starter scope menu" for the new app:
|
||||
|
||||
- Auth:
|
||||
- register/login/logout
|
||||
- owner bootstrap via admin key
|
||||
- immutable display name post-registration
|
||||
- auth options endpoint for registration UX
|
||||
- Identity/session:
|
||||
- cookie claim identity with admin claim
|
||||
- stale/deleted-account cookie invalidation
|
||||
- absolute session lifetime enforcement
|
||||
- State:
|
||||
- `/api/state`, `/api/me`
|
||||
- phase next/prev
|
||||
- SSE state invalidation
|
||||
- etag conditional state reads
|
||||
- Suggestions:
|
||||
- create/update/delete/mine/all
|
||||
- phase gating + admin override behavior
|
||||
- suggestion cap + joker path
|
||||
- link metadata exposure
|
||||
- screenshot URL and reachability validation
|
||||
- Votes:
|
||||
- vote upsert
|
||||
- finalize/unfinalize
|
||||
- linked suggestion vote fan-out
|
||||
- conflict handling on concurrent insert/update
|
||||
- Results:
|
||||
- gated by phase + admin-open flag
|
||||
- ordered leaderboard with aggregates and voter metadata
|
||||
- per-user own vote context
|
||||
- Admin:
|
||||
- results open/close toggle with phase realignment
|
||||
- vote status panel
|
||||
- joker grant
|
||||
- player phase correction
|
||||
- admin role grant/revoke (owner protected)
|
||||
- player delete with cascades and password confirmation
|
||||
- link/unlink suggestions with vote reset and unfinalize behavior
|
||||
- reset/factory reset with password confirmation
|
||||
|
||||
## 4) REVIEW.md unresolved topics -> new-project design defaults
|
||||
|
||||
This section translates outstanding review risks into early decisions for the new app.
|
||||
|
||||
### 4.1 Data store scalability
|
||||
|
||||
Review concern:
|
||||
- SQLite bottlenecks under higher write concurrency and multi-node scaling.
|
||||
|
||||
New-project default:
|
||||
- Start with PostgreSQL (or SQL Server) for production profile.
|
||||
- Keep provider abstraction and provider-specific migration strategy from day one.
|
||||
- Keep SQLite only as local dev/test convenience if needed.
|
||||
|
||||
### 4.2 Workflow extensibility
|
||||
|
||||
Review concern:
|
||||
- Workflow transitions are hard-coded in many places.
|
||||
|
||||
New-project default:
|
||||
- Define transitions in a single state-machine table/model.
|
||||
- Drive backend authorization and frontend navigation from same transition metadata.
|
||||
- Add tests that validate the transition table itself.
|
||||
|
||||
### 4.3 Authorization model growth
|
||||
|
||||
Review concern:
|
||||
- Role booleans (`IsAdmin`, `IsOwner`) limit future permission expansion.
|
||||
|
||||
New-project default:
|
||||
- Use role/permission tables or claims-based capabilities.
|
||||
- Keep owner as a protected capability, not a special-case boolean spread across code.
|
||||
- Use policy-based authorization with explicit capability names.
|
||||
|
||||
### 4.4 Frontend maintainability
|
||||
|
||||
Review concern:
|
||||
- String-template-heavy UI + global mutable state can become fragile.
|
||||
|
||||
New-project default:
|
||||
- Move to TypeScript (or strict JSDoc typing) early.
|
||||
- Keep module boundaries by feature.
|
||||
- Keep explicit escaping/safe-url guards and DOM `textContent` standards.
|
||||
- If not using framework, introduce a small typed view-model layer.
|
||||
|
||||
### 4.5 In-memory cache bounds
|
||||
|
||||
Review concern:
|
||||
- Unbounded dictionaries may grow under high-cardinality traffic.
|
||||
|
||||
New-project default:
|
||||
- Replace unbounded maps with bounded `MemoryCache` (size + TTL + eviction).
|
||||
- For distributed deployments, use Redis with cardinality and TTL controls.
|
||||
|
||||
### 4.6 Linking/results query scaling
|
||||
|
||||
Review concern:
|
||||
- Link and result workflows currently pull full sets into memory.
|
||||
|
||||
New-project default:
|
||||
- Persist link-group ids and compute aggregates in SQL.
|
||||
- Add pagination/windowing for large result sets.
|
||||
- Benchmark query plans on realistic volumes.
|
||||
|
||||
### 4.7 External URL validation latency
|
||||
|
||||
Review concern:
|
||||
- Reachability validation happens synchronously on write path.
|
||||
|
||||
New-project default:
|
||||
- Accept user URL quickly, validate asynchronously (job queue/background worker), store validation status.
|
||||
- Optionally proxy or prefetch media through controlled media service.
|
||||
|
||||
## 5) Topics from REVIEW that are already fixed and should be carried forward
|
||||
|
||||
- Polling amplification has been reduced through SSE + etag conditional reads.
|
||||
- CSRF protection is explicit (same-origin validation for authenticated mutating requests).
|
||||
- Password hashing is versioned and modernized (Argon2id current, transparent upgrades).
|
||||
- CSP is tightened compared to prior permissive baseline (no inline style allowance, no insecure image origins).
|
||||
- API drift risk reduced with OpenAPI generation + generated frontend client.
|
||||
|
||||
These are not optional add-ons; they should be baseline in the new app.
|
||||
|
||||
## 6) New-project starter checklist
|
||||
|
||||
- Bootstrap:
|
||||
- choose production DB provider first
|
||||
- define transition/state-machine model before endpoint coding
|
||||
- define permission/capability model before admin features
|
||||
- Security:
|
||||
- cookie or token strategy finalized with CSRF model
|
||||
- rate limiting partitions and thresholds defined
|
||||
- strict CSP and security headers in first commit
|
||||
- versioned password hashing with migration strategy
|
||||
- trusted proxy/host settings explicit
|
||||
- Contract:
|
||||
- OpenAPI generation enabled in build
|
||||
- generated client wired into frontend
|
||||
- operation-id stability tested
|
||||
- Data integrity:
|
||||
- enforce critical invariants both app-side and DB-side
|
||||
- transaction boundaries for multi-entity admin actions
|
||||
- Frontend:
|
||||
- module boundaries and state refresh model defined
|
||||
- escaping/url-safe helpers mandatory
|
||||
- i18n structure and fallback behavior in place
|
||||
- Testing:
|
||||
- integration test host with real migrations
|
||||
- deterministic stubs for network dependencies
|
||||
- coverage gate enforced in local + CI scripts
|
||||
|
||||
## 7) Keep/avoid quick reference
|
||||
|
||||
Keep:
|
||||
- Thin endpoints + workflow services.
|
||||
- Shared service result abstraction.
|
||||
- Explicit middleware order.
|
||||
- SSE + ETag state sync.
|
||||
- Generated API client from OpenAPI.
|
||||
- DB-enforced invariants.
|
||||
- Regression tests for security-sensitive UI rendering.
|
||||
|
||||
Avoid:
|
||||
- Hard-coded workflow transitions scattered in backend/frontend.
|
||||
- Boolean-only role model for long-term products.
|
||||
- Unbounded in-memory caches.
|
||||
- Synchronous external network checks on hot write paths.
|
||||
- Manual API contract duplication between docs/frontend/backend.
|
||||
|
||||
8
TESTS.md
8
TESTS.md
@@ -38,12 +38,15 @@ stateDiagram-v2
|
||||
- Database uniqueness guard enforces single owner row (`IsOwner=true`) even if writes bypass endpoint-level checks.
|
||||
- `/api/auth/options` reports owner presence for registration UI behavior.
|
||||
- Login success updates LastLoginAt and sets DisplayName if null; rejects wrong password/username; enforces length limits.
|
||||
- Successful login upgrades legacy password-hash versions to current Argon2id parameters.
|
||||
- Logout clears cookie.
|
||||
- EnsurePlayerExistsMiddleware: signed cookie for deleted player returns 401 and clears auth.
|
||||
- Cookie contains admin claim; non-admin cookie cannot access admin routes (401/403 via filter).
|
||||
|
||||
### 2) State & Phase Alignment (/api/state, /api/me)
|
||||
- /api/state returns player-specific phase, votesFinal, hasJoker, counts; unauthorized returns 401.
|
||||
- /api/state supports `ETag`/`If-None-Match` and returns 304 when unchanged.
|
||||
- /api/events/state (SSE) emits invalidation events after successful state mutations.
|
||||
- GetPhase auto-upgrades legacy Reveal -> Vote and realigns when resultsOpen toggles (to Results and back to Vote clearing votesFinal).
|
||||
- /me/phase/next: moves Suggest->Vote, Vote->Results only when resultsOpen true; clears votesFinal; rejects when results locked.
|
||||
- /me/phase/prev: admin only; moves back one step, clears votesFinal, rejects for player.
|
||||
@@ -67,7 +70,7 @@ stateDiagram-v2
|
||||
- Finalize: POST /finalize toggles VotesFinal flag; allowed only in Vote.
|
||||
|
||||
### 5) Results
|
||||
- GET /api/results: requires auth, resultsOpen=true, phase=Results; returns ordered leaderboard with totals/count/avg, caller’s vote, link metadata, and handles empty vote lists (Average=0).
|
||||
- GET /api/results: requires auth, resultsOpen=true, phase=Results; returns ordered leaderboard with totals/count/avg, vote values, alphabetically sorted voter names, caller’s vote, link metadata, and handles empty vote lists (Average=0).
|
||||
- Phase mismatch and locked results return 400; unauthorized 401.
|
||||
|
||||
### 6) Admin Operations
|
||||
@@ -89,13 +92,16 @@ stateDiagram-v2
|
||||
- IsReachableImageAsync: with mocked Http responses covers head success, get fallback, redirect rejection, size guard, and private/reserved host range detection (IPv4/IPv6).
|
||||
- BuildLinkRoots/LinkedIdsFor/FindRootId: cover disjoint groups, chains, cycles guard (visited set), non-existent ids.
|
||||
- Program startup avoids runtime frontend file rewrites; BasePath remains purely configuration/deploy managed.
|
||||
- OpenAPI endpoint exposes generated contract with stable operationIds used by frontend client generation (`/openapi/v1.json`).
|
||||
- Global exception handler returns 500 with JSON body and logs error.
|
||||
- /health returns {status:"ok"}.
|
||||
- Security middleware tests validate response headers and rate-limiting behavior on auth/admin routes.
|
||||
- CSRF middleware tests validate that authenticated mutating requests reject missing/cross-origin `Origin`/`Referer` values.
|
||||
- Frontend regression guard tests assert modal/admin JS no longer interpolate untrusted values in vulnerable patterns.
|
||||
|
||||
## Coverage Policy
|
||||
- CI and local script enforce Cobertura thresholds from test coverage collection.
|
||||
- Coverage collection excludes OpenAPI source-generator files under `obj/**/Microsoft.AspNetCore.OpenApi.SourceGenerators/**` to avoid penalizing generated framework code.
|
||||
- Minimum line coverage: 90%.
|
||||
- Minimum branch coverage: 70%.
|
||||
|
||||
|
||||
867
openapi/GameList.json
Normal file
867
openapi/GameList.json
Normal file
@@ -0,0 +1,867 @@
|
||||
{
|
||||
"openapi": "3.1.1",
|
||||
"info": {
|
||||
"title": "GameList | v1",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"paths": {
|
||||
"/health": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"GameList"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/options": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"operationId": "GetAuthOptions",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/register": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"operationId": "Register",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RegisterRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/login": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"operationId": "Login",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LoginRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/logout": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"operationId": "Logout",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/state": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"State"
|
||||
],
|
||||
"operationId": "GetState",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/events/state": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"State"
|
||||
],
|
||||
"operationId": "GetStateEvents",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/me": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"State"
|
||||
],
|
||||
"operationId": "GetMe",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/me/phase/next": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"State"
|
||||
],
|
||||
"operationId": "NextPhase",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/me/phase/prev": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"State"
|
||||
],
|
||||
"operationId": "PrevPhase",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/suggestions/mine": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Suggestions"
|
||||
],
|
||||
"operationId": "GetMySuggestions",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/suggestions": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Suggestions"
|
||||
],
|
||||
"operationId": "CreateSuggestion",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SuggestionRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/suggestions/{id}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Suggestions"
|
||||
],
|
||||
"operationId": "DeleteSuggestion",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"Suggestions"
|
||||
],
|
||||
"operationId": "UpdateSuggestion",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SuggestionRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/suggestions/all": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Suggestions"
|
||||
],
|
||||
"operationId": "GetAllSuggestions",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/votes/mine": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Votes"
|
||||
],
|
||||
"operationId": "GetMyVotes",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/votes": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Votes"
|
||||
],
|
||||
"operationId": "UpsertVote",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/VoteRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/votes/finalize": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Votes"
|
||||
],
|
||||
"operationId": "SetVotesFinalized",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/VoteFinalizeRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/results": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Results"
|
||||
],
|
||||
"operationId": "GetResults",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/results": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "SetResultsOpen",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ResultsOpenRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/vote-status": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "GetVoteStatus",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/joker": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "GrantJoker",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GrantJokerRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/player-phase": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "SetPlayerPhase",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SetPlayerPhaseRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/player-admin": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "SetPlayerAdmin",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SetPlayerAdminRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/players/{playerId}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "DeletePlayer",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "playerId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AdminPasswordRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/link-suggestions": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "LinkSuggestions",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LinkSuggestionsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/unlink-suggestions": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "UnlinkSuggestions",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnlinkSuggestionsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/reset": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "Reset",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AdminPasswordRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/factory-reset": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "FactoryReset",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AdminPasswordRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"AdminPasswordRequest": {
|
||||
"required": [
|
||||
"password"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"GrantJokerRequest": {
|
||||
"required": [
|
||||
"playerId"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"playerId": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
},
|
||||
"LinkSuggestionsRequest": {
|
||||
"required": [
|
||||
"sourceSuggestionId",
|
||||
"targetSuggestionId"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sourceSuggestionId": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"targetSuggestionId": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoginRequest": {
|
||||
"required": [
|
||||
"username",
|
||||
"password"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"password": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Phase": {
|
||||
"enum": [
|
||||
"Suggest",
|
||||
"Vote",
|
||||
"Results"
|
||||
]
|
||||
},
|
||||
"RegisterRequest": {
|
||||
"required": [
|
||||
"username",
|
||||
"password",
|
||||
"displayName",
|
||||
"adminKey"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"password": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"displayName": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"adminKey": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ResultsOpenRequest": {
|
||||
"required": [
|
||||
"resultsOpen"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resultsOpen": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SetPlayerAdminRequest": {
|
||||
"required": [
|
||||
"playerId",
|
||||
"isAdmin"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"playerId": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"isAdmin": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SetPlayerPhaseRequest": {
|
||||
"required": [
|
||||
"playerId",
|
||||
"phase"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"playerId": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"phase": {
|
||||
"$ref": "#/components/schemas/Phase"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SuggestionRequest": {
|
||||
"required": [
|
||||
"name",
|
||||
"genre",
|
||||
"description",
|
||||
"screenshotUrl",
|
||||
"youtubeUrl",
|
||||
"gameUrl",
|
||||
"minPlayers",
|
||||
"maxPlayers"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"genre": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"screenshotUrl": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"youtubeUrl": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"gameUrl": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"minPlayers": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"null",
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"maxPlayers": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"null",
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UnlinkSuggestionsRequest": {
|
||||
"required": [
|
||||
"suggestionId"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suggestionId": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"VoteFinalizeRequest": {
|
||||
"required": [
|
||||
"final"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"final": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"VoteRequest": {
|
||||
"required": [
|
||||
"suggestionId",
|
||||
"score"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suggestionId": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"score": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "GameList"
|
||||
},
|
||||
{
|
||||
"name": "Auth"
|
||||
},
|
||||
{
|
||||
"name": "State"
|
||||
},
|
||||
{
|
||||
"name": "Suggestions"
|
||||
},
|
||||
{
|
||||
"name": "Votes"
|
||||
},
|
||||
{
|
||||
"name": "Results"
|
||||
},
|
||||
{
|
||||
"name": "Admin"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"generate:api-client": "node ./scripts/generate-api-client.mjs",
|
||||
"lint": "eslint \"wwwroot/**/*.js\"",
|
||||
"format": "prettier --write \"eslint.config.js\" \"wwwroot/**/*.js\"",
|
||||
"format:check": "prettier --check \"eslint.config.js\" \"wwwroot/**/*.js\""
|
||||
|
||||
@@ -31,14 +31,6 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Lint frontend" -Action {
|
||||
npm run lint
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Check frontend formatting" -Action {
|
||||
npm run format:check
|
||||
}
|
||||
|
||||
if (-not $SkipDotnetRestore) {
|
||||
Invoke-Step -Name "Restore .NET solution" -Action {
|
||||
dotnet restore GameList.sln
|
||||
@@ -51,12 +43,24 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Generate frontend API client from OpenAPI" -Action {
|
||||
npm run generate:api-client
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Lint frontend" -Action {
|
||||
npm run lint
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Check frontend formatting" -Action {
|
||||
npm run format:check
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Run tests" -Action {
|
||||
if ($SkipBuild) {
|
||||
dotnet test GameList.Tests/GameList.Tests.csproj --verbosity normal --collect:"XPlat Code Coverage"
|
||||
dotnet test GameList.Tests/GameList.Tests.csproj --verbosity normal --collect:"XPlat Code Coverage" --settings GameList.Tests/coverlet.runsettings
|
||||
}
|
||||
else {
|
||||
dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal --collect:"XPlat Code Coverage"
|
||||
dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal --collect:"XPlat Code Coverage" --settings GameList.Tests/coverlet.runsettings
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -112,9 +112,14 @@ function Set-FrontendAppBaseMeta {
|
||||
[Parameter(Mandatory = $true)][string]$BasePath
|
||||
)
|
||||
|
||||
$indexPath = Join-Path $PublishDir "index.html"
|
||||
if (-not (Test-Path $indexPath)) {
|
||||
throw "Publish output is missing index.html at '$indexPath'."
|
||||
$candidatePaths = @(
|
||||
(Join-Path $PublishDir "wwwroot\index.html"),
|
||||
(Join-Path $PublishDir "index.html")
|
||||
)
|
||||
|
||||
$indexPath = $candidatePaths | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
if ([string]::IsNullOrWhiteSpace($indexPath)) {
|
||||
throw "Publish output is missing index.html. Checked: $($candidatePaths -join ", ")."
|
||||
}
|
||||
|
||||
$pattern = '<meta\s+name=["'']app-base["'']\s+content=["''][^"'']*["'']\s*/?>'
|
||||
|
||||
209
scripts/generate-api-client.mjs
Normal file
209
scripts/generate-api-client.mjs
Normal file
@@ -0,0 +1,209 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import prettier from "prettier";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const openApiPath = path.join(repoRoot, "openapi", "GameList.json");
|
||||
const outputPath = path.join(repoRoot, "wwwroot", "js", "api-client.generated.js");
|
||||
|
||||
const requiredOperationIds = [
|
||||
"GetAuthOptions",
|
||||
"Register",
|
||||
"Login",
|
||||
"Logout",
|
||||
"GetState",
|
||||
"GetStateEvents",
|
||||
"GetMe",
|
||||
"NextPhase",
|
||||
"PrevPhase",
|
||||
"GetMySuggestions",
|
||||
"CreateSuggestion",
|
||||
"DeleteSuggestion",
|
||||
"UpdateSuggestion",
|
||||
"GetAllSuggestions",
|
||||
"GetMyVotes",
|
||||
"UpsertVote",
|
||||
"SetVotesFinalized",
|
||||
"GetResults",
|
||||
"SetResultsOpen",
|
||||
"GetVoteStatus",
|
||||
"GrantJoker",
|
||||
"SetPlayerPhase",
|
||||
"SetPlayerAdmin",
|
||||
"DeletePlayer",
|
||||
"LinkSuggestions",
|
||||
"UnlinkSuggestions",
|
||||
"Reset",
|
||||
"FactoryReset",
|
||||
];
|
||||
|
||||
if (!fs.existsSync(openApiPath)) {
|
||||
throw new Error(`OpenAPI document not found at ${openApiPath}. Build the .NET solution first.`);
|
||||
}
|
||||
|
||||
const document = JSON.parse(fs.readFileSync(openApiPath, "utf8"));
|
||||
const operations = collectOperations(document);
|
||||
validateRequiredOperations(operations);
|
||||
|
||||
const generated = renderClient(operations);
|
||||
const prettierConfig =
|
||||
(await prettier.resolveConfig(outputPath, { editorconfig: true })) ?? {};
|
||||
const formatted = await prettier.format(generated, {
|
||||
...prettierConfig,
|
||||
filepath: outputPath,
|
||||
});
|
||||
fs.writeFileSync(outputPath, formatted, "utf8");
|
||||
console.log(`Generated ${path.relative(repoRoot, outputPath)} from ${path.relative(repoRoot, openApiPath)}`);
|
||||
|
||||
function collectOperations(openApiDocument) {
|
||||
const methods = ["get", "post", "put", "delete", "patch"];
|
||||
const entries = [];
|
||||
|
||||
for (const [routePath, pathItem] of Object.entries(openApiDocument.paths ?? {})) {
|
||||
for (const method of methods) {
|
||||
const operation = pathItem?.[method];
|
||||
if (!operation?.operationId) continue;
|
||||
if (!routePath.startsWith("/api/")) continue;
|
||||
|
||||
const pathParameters = (operation.parameters ?? [])
|
||||
.filter((p) => p.in === "path")
|
||||
.map((p) => p.name);
|
||||
|
||||
entries.push({
|
||||
operationId: operation.operationId,
|
||||
method: method.toUpperCase(),
|
||||
path: routePath,
|
||||
hasBody: Boolean(operation.requestBody),
|
||||
pathParameters,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort((a, b) => a.operationId.localeCompare(b.operationId));
|
||||
return entries;
|
||||
}
|
||||
|
||||
function validateRequiredOperations(operationsList) {
|
||||
const found = new Set(operationsList.map((operation) => operation.operationId));
|
||||
const missing = requiredOperationIds.filter((operationId) => !found.has(operationId));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`OpenAPI document is missing expected operations: ${missing.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderClient(operationsList) {
|
||||
const operationObjectLiteral = operationsList
|
||||
.map((operation) => {
|
||||
const pathParams = `[${operation.pathParameters.map((name) => `"${name}"`).join(", ")}]`;
|
||||
return [
|
||||
` ${operation.operationId}: {`,
|
||||
` method: "${operation.method}",`,
|
||||
` path: "${operation.path}",`,
|
||||
` hasBody: ${operation.hasBody ? "true" : "false"},`,
|
||||
` pathParameters: ${pathParams},`,
|
||||
" },",
|
||||
].join("\n");
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const clientFunctions = requiredOperationIds
|
||||
.map((operationId) => {
|
||||
const methodName = toCamelCase(operationId);
|
||||
return ` ${methodName}: (options = {}) => requestOperation("${operationId}", options),`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `// AUTO-GENERATED FILE. DO NOT EDIT.
|
||||
// Source: scripts/generate-api-client.mjs and openapi/GameList.json
|
||||
|
||||
const defaultHeaders = { "Content-Type": "application/json" };
|
||||
|
||||
const rawBase = document.querySelector('meta[name="app-base"]')?.content || "";
|
||||
const basePath = normalizeBase(rawBase);
|
||||
const withBase = (routePath) => \`\${basePath}\${routePath}\`;
|
||||
|
||||
function normalizeBase(value) {
|
||||
if (!value) return "";
|
||||
if (!value.startsWith("/")) return \`/\${value}\`;
|
||||
return value.endsWith("/") ? value.slice(0, -1) : value;
|
||||
}
|
||||
|
||||
function toApiError(res, fallbackMessage = \`\${res.status}\`) {
|
||||
const err = new Error(fallbackMessage);
|
||||
err.status = res.status;
|
||||
return err;
|
||||
}
|
||||
|
||||
function buildPath(template, pathParameters = {}) {
|
||||
return template.replace(/{([^}]+)}/g, (_, key) => {
|
||||
const value = pathParameters[key];
|
||||
if (value === undefined || value === null) {
|
||||
throw new Error(\`Missing path parameter "\${key}" for route \${template}\`);
|
||||
}
|
||||
|
||||
return encodeURIComponent(String(value));
|
||||
});
|
||||
}
|
||||
|
||||
async function parseApiError(res) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
const message = data.error || data.detail || data.title || JSON.stringify(data);
|
||||
return toApiError(res, message);
|
||||
} catch {
|
||||
return toApiError(res);
|
||||
}
|
||||
}
|
||||
|
||||
export const operations = Object.freeze({
|
||||
${operationObjectLiteral}
|
||||
});
|
||||
|
||||
export function resolveOperationPath(operationId, pathParameters = {}) {
|
||||
const operation = operations[operationId];
|
||||
if (!operation) {
|
||||
throw new Error(\`Unknown operationId "\${operationId}"\`);
|
||||
}
|
||||
|
||||
return withBase(buildPath(operation.path, pathParameters));
|
||||
}
|
||||
|
||||
export async function requestOperation(
|
||||
operationId,
|
||||
{ pathParameters = {}, body, headers = {}, raw = false, acceptStatuses = [] } = {}
|
||||
) {
|
||||
const operation = operations[operationId];
|
||||
if (!operation) {
|
||||
throw new Error(\`Unknown operationId "\${operationId}"\`);
|
||||
}
|
||||
|
||||
const response = await fetch(resolveOperationPath(operationId, pathParameters), {
|
||||
method: operation.method,
|
||||
credentials: "same-origin",
|
||||
headers: { ...defaultHeaders, ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
|
||||
const acceptedStatusSet = new Set(acceptStatuses);
|
||||
if (!response.ok && !acceptedStatusSet.has(response.status)) {
|
||||
throw await parseApiError(response);
|
||||
}
|
||||
|
||||
if (raw) return response;
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const apiClient = Object.freeze({
|
||||
${clientFunctions}
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
function toCamelCase(value) {
|
||||
if (!value) return value;
|
||||
return `${value.charAt(0).toLowerCase()}${value.slice(1)}`;
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
updatePhaseNav,
|
||||
configureUiRuntime,
|
||||
} from "./js/ui.js";
|
||||
import { api } from "./js/api.js";
|
||||
import { loadSuggestData, loadVoteData, refreshPhaseData } from "./js/data.js";
|
||||
import { setupAuthHandlers } from "./js/app-auth-handlers.js";
|
||||
import { setupAdminHandlers } from "./js/app-admin-handlers.js";
|
||||
@@ -29,11 +30,16 @@ import { setupVoteNavigationHandlers } from "./js/app-vote-nav-handlers.js";
|
||||
|
||||
const REFRESH_MIN_MS = 3000;
|
||||
const REFRESH_MAX_MS = 20000;
|
||||
const EVENTS_RECONNECT_MIN_MS = 1000;
|
||||
const EVENTS_RECONNECT_MAX_MS = 15000;
|
||||
let refreshInFlight = null;
|
||||
let refreshTimerId = null;
|
||||
let refreshSchedulerStarted = false;
|
||||
let unchangedRefreshCycles = 0;
|
||||
let nextRefreshDelayMs = REFRESH_MIN_MS;
|
||||
let stateEventSource = null;
|
||||
let eventsReconnectTimerId = null;
|
||||
let eventsReconnectDelayMs = EVENTS_RECONNECT_MIN_MS;
|
||||
|
||||
async function runSerializedRefresh() {
|
||||
if (refreshInFlight) return refreshInFlight;
|
||||
@@ -47,13 +53,81 @@ async function refreshWithUiErrorHandling() {
|
||||
try {
|
||||
const changed = await runSerializedRefresh();
|
||||
updateRefreshCadence(changed === true);
|
||||
if (state.isAuthenticated) {
|
||||
ensureStateEventStream();
|
||||
} else {
|
||||
closeStateEventStream();
|
||||
}
|
||||
} catch (err) {
|
||||
// Back off after transient failures to avoid hammering server/dependencies.
|
||||
nextRefreshDelayMs = Math.min(nextRefreshDelayMs * 2, REFRESH_MAX_MS);
|
||||
if (!handleAuthError(err, clearUserState)) toast(err.message, true);
|
||||
if (handleAuthError(err, clearUserState)) {
|
||||
closeStateEventStream();
|
||||
return;
|
||||
}
|
||||
|
||||
toast(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function closeStateEventStream() {
|
||||
if (eventsReconnectTimerId !== null) {
|
||||
window.clearTimeout(eventsReconnectTimerId);
|
||||
eventsReconnectTimerId = null;
|
||||
}
|
||||
|
||||
if (stateEventSource) {
|
||||
stateEventSource.close();
|
||||
stateEventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStateEventReconnect() {
|
||||
if (eventsReconnectTimerId !== null || !state.isAuthenticated) return;
|
||||
|
||||
eventsReconnectTimerId = window.setTimeout(() => {
|
||||
eventsReconnectTimerId = null;
|
||||
ensureStateEventStream();
|
||||
}, eventsReconnectDelayMs);
|
||||
|
||||
eventsReconnectDelayMs = Math.min(
|
||||
Math.round(eventsReconnectDelayMs * 1.8),
|
||||
EVENTS_RECONNECT_MAX_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function ensureStateEventStream() {
|
||||
if (!state.isAuthenticated || typeof window.EventSource === "undefined") {
|
||||
closeStateEventStream();
|
||||
return;
|
||||
}
|
||||
|
||||
if (stateEventSource) return;
|
||||
|
||||
stateEventSource = new EventSource(api.stateEventsUrl(), {
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
stateEventSource.onopen = () => {
|
||||
eventsReconnectDelayMs = EVENTS_RECONNECT_MIN_MS;
|
||||
};
|
||||
|
||||
stateEventSource.onerror = () => {
|
||||
if (!stateEventSource) return;
|
||||
stateEventSource.close();
|
||||
stateEventSource = null;
|
||||
scheduleStateEventReconnect();
|
||||
};
|
||||
|
||||
stateEventSource.addEventListener("state", () => {
|
||||
unchangedRefreshCycles = 0;
|
||||
nextRefreshDelayMs = baseRefreshDelayForPhase();
|
||||
if (!document.hidden && !state.adminStatusSelectActive) {
|
||||
refreshWithUiErrorHandling();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleNextRefresh() {
|
||||
refreshTimerId = window.setTimeout(async () => {
|
||||
if (!document.hidden && !state.adminStatusSelectActive) {
|
||||
@@ -119,6 +193,9 @@ function setupHandlers() {
|
||||
setupAdminHandlers({ runSerializedRefresh });
|
||||
setupVoteNavigationHandlers({ runSerializedRefresh });
|
||||
setupLanguageSwitchers();
|
||||
document.getElementById("logout")?.addEventListener("click", () => {
|
||||
closeStateEventStream();
|
||||
});
|
||||
|
||||
onLanguageChange(() => {
|
||||
updateLanguageButtons();
|
||||
|
||||
@@ -47,6 +47,16 @@
|
||||
display: block;
|
||||
padding: 0;
|
||||
}
|
||||
.card-visual.has-image {
|
||||
background: #f6b24f;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-visual-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.card-visual.hovering {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
@@ -269,3 +279,10 @@ input[type="range"].full-slider:disabled::-moz-range-thumb {
|
||||
background: #f1f1f1;
|
||||
border-color: #c1c1c1;
|
||||
}
|
||||
|
||||
.fx-canvas {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,10 @@ Registriere dich mit:
|
||||
|
||||
Dein Anzeigename ist erforderlich ‒ er erscheint neben all deinen Vorschlägen und Bewertungen.
|
||||
|
||||
### Wie werden Passwörter geschützt?
|
||||
|
||||
Passwörter werden niemals im Klartext gespeichert. Pick'n'Play speichert gesalzene, versionierte Passwort-Hashes. Neue und aktualisierte Hashes verwenden Argon2id, während ältere Hash-Versionen nach erfolgreicher Anmeldung oder Admin-Passwort-Bestätigung transparent aktualisiert werden.
|
||||
|
||||
### Brauche ich Admin-Rechte?
|
||||
|
||||
Wenn du einen **Admin-Schlüssel** erhalten hast, gib ihn bei der Registrierung ein. Ist der Schlüssel ungültig, wird die Anfrage abgelehnt. Die Admin-Schlüssel-Registrierung ist nur verfügbar, bis das erste Admin-Konto erstellt wurde. Admin-Rechte können später nicht über die öffentliche Registrierung hinzugefügt werden.
|
||||
@@ -27,6 +31,10 @@ Jeder Spieler durchläuft die Phasen unabhängig voneinander:
|
||||
Klicke auf **„Weiter"**, um fortzufahren. Admins können sich bei Bedarf auch wieder zurücksetzen.
|
||||
In der **Vorschlagsphase** bleibt **„Weiter"** deaktiviert, bis dein Konto mindestens einen eigenen Spielvorschlag hat.
|
||||
|
||||
### Muss ich die Seite manuell aktualisieren?
|
||||
|
||||
Normalerweise nicht. Pick'n'Play erhält Live-Updates vom Server und nutzt nur dann periodische Prüfungen, wenn der Live-Kanal vorübergehend nicht verfügbar ist.
|
||||
|
||||
## Spiele vorschlagen
|
||||
|
||||
### Wie viele Spiele kann ich vorschlagen?
|
||||
@@ -146,6 +154,10 @@ Die Ergebnisse bleiben verborgen, bis ein Admin sie freigibt. Danach werden alle
|
||||
|
||||
Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf an einen Admin.
|
||||
|
||||
### Wie sehe ich, wer für ein Spiel abgestimmt hat?
|
||||
|
||||
Fahre mit der Maus über den Durchschnittswert oder ein Bewertungs-Emoji in der Ergebniszeile, um die alphabetisch sortierte Liste der Abstimmenden zu sehen.
|
||||
|
||||
## Admin-Tools (Für Hosts)
|
||||
|
||||
### Was können Admin-Konten tun?
|
||||
@@ -197,6 +209,11 @@ Registriere dich erneut mit dem korrekten Schlüssel vom Host ‒ oder lasse das
|
||||
Auth- und Admin-sensitive Routen sind gegen Brute-Force-Angriffe rate-limitiert.
|
||||
Warte kurz und versuche es dann erneut.
|
||||
|
||||
### „CSRF-Validierung fehlgeschlagen."
|
||||
|
||||
Authentifizierte Schreibaktionen erfordern jetzt eine Same-Origin-Browseranfrage.
|
||||
Lade die Seite neu und versuche es erneut. Bei eigener API-Nutzung müssen `Origin`/`Referer` zum App-Host passen.
|
||||
|
||||
## Daten & Datenschutz
|
||||
|
||||
- Vorschläge, Stimmen und Phasenstatus werden in einer gemeinsamen Datenbank gespeichert.
|
||||
|
||||
@@ -11,6 +11,10 @@ Register with:
|
||||
|
||||
Your display name is required ‒ it appears next to all of your suggestions and scores.
|
||||
|
||||
### How are passwords protected?
|
||||
|
||||
Passwords are never stored in plain text. Pick'n'Play stores salted, versioned password hashes. New and upgraded hashes use Argon2id, while older hash versions are transparently upgraded after successful sign-in or admin-password confirmation.
|
||||
|
||||
### Do I need admin privileges?
|
||||
|
||||
If you've been given an **admin key**, enter it during registration. If the key is invalid, the request is rejected.
|
||||
@@ -28,6 +32,10 @@ Each player progresses independently through the phases:
|
||||
Click **"Next"** to move forward. Admins can move themselves backward if needed.
|
||||
In the **Suggest** phase, **Next** stays disabled until your account has at least one own game suggestion.
|
||||
|
||||
### Do I need to refresh the page manually?
|
||||
|
||||
Usually no. Pick'n'Play receives live server updates and falls back to periodic checks if the live channel is temporarily unavailable.
|
||||
|
||||
## Suggesting Games
|
||||
|
||||
### How many games can I suggest?
|
||||
@@ -150,6 +158,10 @@ If needed, an admin can close the Results: players with at least one own suggest
|
||||
|
||||
No. Suggestions and votes are read-only. Contact an admin for assistance.
|
||||
|
||||
### How can I see who voted for a game?
|
||||
|
||||
Hover the average score or any score emoji in that result row to see the voter list (sorted alphabetically).
|
||||
|
||||
## Admin Tools (For Hosts)
|
||||
|
||||
### What can admin accounts do?
|
||||
@@ -201,6 +213,11 @@ Register again using the correct key from the host ‒ or leave it blank to crea
|
||||
Auth and admin-sensitive routes are rate-limited to reduce brute-force attempts.
|
||||
Wait briefly, then retry.
|
||||
|
||||
### "CSRF validation failed."
|
||||
|
||||
Authenticated write actions now require a same-origin browser request.
|
||||
Reload the page and retry. If you're calling the API from custom tooling, send matching `Origin`/`Referer` values for your app host.
|
||||
|
||||
## Data & Privacy
|
||||
|
||||
- Suggestions, votes, and phase states are stored in a shared database.
|
||||
|
||||
@@ -91,6 +91,8 @@
|
||||
"results.average": "Ø",
|
||||
"results.votesList": "All votes",
|
||||
"results.myVote": "Your vote",
|
||||
"results.votersTooltip": "Voted by: {users}",
|
||||
"results.votersTooltipEmpty": "No votes yet",
|
||||
"results.links": "Links",
|
||||
"results.link.site": "Site ↗",
|
||||
"results.link.youtube": "YouTube ↗",
|
||||
@@ -264,6 +266,8 @@
|
||||
"results.average": "Ø",
|
||||
"results.votesList": "Alle Stimmen",
|
||||
"results.myVote": "Deine Stimme",
|
||||
"results.votersTooltip": "Abgestimmt von: {users}",
|
||||
"results.votersTooltipEmpty": "Noch keine Stimmen",
|
||||
"results.links": "Links",
|
||||
"results.link.site": "Webseite ↗",
|
||||
"results.link.youtube": "YouTube ↗",
|
||||
|
||||
303
wwwroot/js/api-client.generated.js
Normal file
303
wwwroot/js/api-client.generated.js
Normal file
@@ -0,0 +1,303 @@
|
||||
// AUTO-GENERATED FILE. DO NOT EDIT.
|
||||
// Source: scripts/generate-api-client.mjs and openapi/GameList.json
|
||||
|
||||
const defaultHeaders = { "Content-Type": "application/json" };
|
||||
|
||||
const rawBase = document.querySelector('meta[name="app-base"]')?.content || "";
|
||||
const basePath = normalizeBase(rawBase);
|
||||
const withBase = (routePath) => `${basePath}${routePath}`;
|
||||
|
||||
function normalizeBase(value) {
|
||||
if (!value) return "";
|
||||
if (!value.startsWith("/")) return `/${value}`;
|
||||
return value.endsWith("/") ? value.slice(0, -1) : value;
|
||||
}
|
||||
|
||||
function toApiError(res, fallbackMessage = `${res.status}`) {
|
||||
const err = new Error(fallbackMessage);
|
||||
err.status = res.status;
|
||||
return err;
|
||||
}
|
||||
|
||||
function buildPath(template, pathParameters = {}) {
|
||||
return template.replace(/{([^}]+)}/g, (_, key) => {
|
||||
const value = pathParameters[key];
|
||||
if (value === undefined || value === null) {
|
||||
throw new Error(
|
||||
`Missing path parameter "${key}" for route ${template}`,
|
||||
);
|
||||
}
|
||||
|
||||
return encodeURIComponent(String(value));
|
||||
});
|
||||
}
|
||||
|
||||
async function parseApiError(res) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
const message =
|
||||
data.error || data.detail || data.title || JSON.stringify(data);
|
||||
return toApiError(res, message);
|
||||
} catch {
|
||||
return toApiError(res);
|
||||
}
|
||||
}
|
||||
|
||||
export const operations = Object.freeze({
|
||||
CreateSuggestion: {
|
||||
method: "POST",
|
||||
path: "/api/suggestions",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
DeletePlayer: {
|
||||
method: "DELETE",
|
||||
path: "/api/admin/players/{playerId}",
|
||||
hasBody: true,
|
||||
pathParameters: ["playerId"],
|
||||
},
|
||||
DeleteSuggestion: {
|
||||
method: "DELETE",
|
||||
path: "/api/suggestions/{id}",
|
||||
hasBody: false,
|
||||
pathParameters: ["id"],
|
||||
},
|
||||
FactoryReset: {
|
||||
method: "POST",
|
||||
path: "/api/admin/factory-reset",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
GetAllSuggestions: {
|
||||
method: "GET",
|
||||
path: "/api/suggestions/all",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
GetAuthOptions: {
|
||||
method: "GET",
|
||||
path: "/api/auth/options",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
GetMe: {
|
||||
method: "GET",
|
||||
path: "/api/me",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
GetMySuggestions: {
|
||||
method: "GET",
|
||||
path: "/api/suggestions/mine",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
GetMyVotes: {
|
||||
method: "GET",
|
||||
path: "/api/votes/mine",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
GetResults: {
|
||||
method: "GET",
|
||||
path: "/api/results",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
GetState: {
|
||||
method: "GET",
|
||||
path: "/api/state",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
GetStateEvents: {
|
||||
method: "GET",
|
||||
path: "/api/events/state",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
GetVoteStatus: {
|
||||
method: "GET",
|
||||
path: "/api/admin/vote-status",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
GrantJoker: {
|
||||
method: "POST",
|
||||
path: "/api/admin/joker",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
LinkSuggestions: {
|
||||
method: "POST",
|
||||
path: "/api/admin/link-suggestions",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
Login: {
|
||||
method: "POST",
|
||||
path: "/api/auth/login",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
Logout: {
|
||||
method: "POST",
|
||||
path: "/api/auth/logout",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
NextPhase: {
|
||||
method: "POST",
|
||||
path: "/api/me/phase/next",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
PrevPhase: {
|
||||
method: "POST",
|
||||
path: "/api/me/phase/prev",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
Register: {
|
||||
method: "POST",
|
||||
path: "/api/auth/register",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
Reset: {
|
||||
method: "POST",
|
||||
path: "/api/admin/reset",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
SetPlayerAdmin: {
|
||||
method: "POST",
|
||||
path: "/api/admin/player-admin",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
SetPlayerPhase: {
|
||||
method: "POST",
|
||||
path: "/api/admin/player-phase",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
SetResultsOpen: {
|
||||
method: "POST",
|
||||
path: "/api/admin/results",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
SetVotesFinalized: {
|
||||
method: "POST",
|
||||
path: "/api/votes/finalize",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
UnlinkSuggestions: {
|
||||
method: "POST",
|
||||
path: "/api/admin/unlink-suggestions",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
UpdateSuggestion: {
|
||||
method: "PUT",
|
||||
path: "/api/suggestions/{id}",
|
||||
hasBody: true,
|
||||
pathParameters: ["id"],
|
||||
},
|
||||
UpsertVote: {
|
||||
method: "POST",
|
||||
path: "/api/votes",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
});
|
||||
|
||||
export function resolveOperationPath(operationId, pathParameters = {}) {
|
||||
const operation = operations[operationId];
|
||||
if (!operation) {
|
||||
throw new Error(`Unknown operationId "${operationId}"`);
|
||||
}
|
||||
|
||||
return withBase(buildPath(operation.path, pathParameters));
|
||||
}
|
||||
|
||||
export async function requestOperation(
|
||||
operationId,
|
||||
{
|
||||
pathParameters = {},
|
||||
body,
|
||||
headers = {},
|
||||
raw = false,
|
||||
acceptStatuses = [],
|
||||
} = {},
|
||||
) {
|
||||
const operation = operations[operationId];
|
||||
if (!operation) {
|
||||
throw new Error(`Unknown operationId "${operationId}"`);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
resolveOperationPath(operationId, pathParameters),
|
||||
{
|
||||
method: operation.method,
|
||||
credentials: "same-origin",
|
||||
headers: { ...defaultHeaders, ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
|
||||
const acceptedStatusSet = new Set(acceptStatuses);
|
||||
if (!response.ok && !acceptedStatusSet.has(response.status)) {
|
||||
throw await parseApiError(response);
|
||||
}
|
||||
|
||||
if (raw) return response;
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const apiClient = Object.freeze({
|
||||
getAuthOptions: (options = {}) =>
|
||||
requestOperation("GetAuthOptions", options),
|
||||
register: (options = {}) => requestOperation("Register", options),
|
||||
login: (options = {}) => requestOperation("Login", options),
|
||||
logout: (options = {}) => requestOperation("Logout", options),
|
||||
getState: (options = {}) => requestOperation("GetState", options),
|
||||
getStateEvents: (options = {}) =>
|
||||
requestOperation("GetStateEvents", options),
|
||||
getMe: (options = {}) => requestOperation("GetMe", options),
|
||||
nextPhase: (options = {}) => requestOperation("NextPhase", options),
|
||||
prevPhase: (options = {}) => requestOperation("PrevPhase", options),
|
||||
getMySuggestions: (options = {}) =>
|
||||
requestOperation("GetMySuggestions", options),
|
||||
createSuggestion: (options = {}) =>
|
||||
requestOperation("CreateSuggestion", options),
|
||||
deleteSuggestion: (options = {}) =>
|
||||
requestOperation("DeleteSuggestion", options),
|
||||
updateSuggestion: (options = {}) =>
|
||||
requestOperation("UpdateSuggestion", options),
|
||||
getAllSuggestions: (options = {}) =>
|
||||
requestOperation("GetAllSuggestions", options),
|
||||
getMyVotes: (options = {}) => requestOperation("GetMyVotes", options),
|
||||
upsertVote: (options = {}) => requestOperation("UpsertVote", options),
|
||||
setVotesFinalized: (options = {}) =>
|
||||
requestOperation("SetVotesFinalized", options),
|
||||
getResults: (options = {}) => requestOperation("GetResults", options),
|
||||
setResultsOpen: (options = {}) =>
|
||||
requestOperation("SetResultsOpen", options),
|
||||
getVoteStatus: (options = {}) => requestOperation("GetVoteStatus", options),
|
||||
grantJoker: (options = {}) => requestOperation("GrantJoker", options),
|
||||
setPlayerPhase: (options = {}) =>
|
||||
requestOperation("SetPlayerPhase", options),
|
||||
setPlayerAdmin: (options = {}) =>
|
||||
requestOperation("SetPlayerAdmin", options),
|
||||
deletePlayer: (options = {}) => requestOperation("DeletePlayer", options),
|
||||
linkSuggestions: (options = {}) =>
|
||||
requestOperation("LinkSuggestions", options),
|
||||
unlinkSuggestions: (options = {}) =>
|
||||
requestOperation("UnlinkSuggestions", options),
|
||||
reset: (options = {}) => requestOperation("Reset", options),
|
||||
factoryReset: (options = {}) => requestOperation("FactoryReset", options),
|
||||
});
|
||||
@@ -1,111 +1,91 @@
|
||||
const defaultHeaders = { "Content-Type": "application/json" };
|
||||
import { apiClient, resolveOperationPath } from "./api-client.generated.js";
|
||||
|
||||
const rawBase = document.querySelector('meta[name="app-base"]')?.content || "";
|
||||
const basePath = normalizeBase(rawBase);
|
||||
const withBase = (path) => `${basePath}${path}`;
|
||||
async function requestState(ifNoneMatch) {
|
||||
const headers = {};
|
||||
if (ifNoneMatch) headers["If-None-Match"] = ifNoneMatch;
|
||||
|
||||
function normalizeBase(value) {
|
||||
if (!value) return "";
|
||||
if (!value.startsWith("/")) return `/${value}`;
|
||||
return value.endsWith("/") ? value.slice(0, -1) : value;
|
||||
}
|
||||
|
||||
async function request(path, { method = "GET", body } = {}) {
|
||||
const res = await fetch(withBase(path), {
|
||||
method,
|
||||
credentials: "same-origin",
|
||||
headers: defaultHeaders,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
const res = await apiClient.getState({
|
||||
headers,
|
||||
raw: true,
|
||||
acceptStatuses: [304],
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let msg = `${res.status}`;
|
||||
try {
|
||||
const data = await res.json();
|
||||
msg =
|
||||
data.error || data.detail || data.title || JSON.stringify(data);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
const err = new Error(msg);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
if (res.status === 304) {
|
||||
return {
|
||||
notModified: true,
|
||||
etag: res.headers.get("ETag"),
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
return res.status === 204 ? null : res.json();
|
||||
|
||||
return {
|
||||
notModified: false,
|
||||
etag: res.headers.get("ETag"),
|
||||
data: await res.json(),
|
||||
};
|
||||
}
|
||||
|
||||
export const api = {
|
||||
state: () => request("/api/state"),
|
||||
me: () => request("/api/me"),
|
||||
authOptions: () => request("/api/auth/options"),
|
||||
register: (payload) =>
|
||||
request("/api/auth/register", { method: "POST", body: payload }),
|
||||
login: (payload) =>
|
||||
request("/api/auth/login", { method: "POST", body: payload }),
|
||||
logout: () => request("/api/auth/logout", { method: "POST" }),
|
||||
state: (ifNoneMatch) => requestState(ifNoneMatch),
|
||||
stateEventsUrl: () => resolveOperationPath("GetStateEvents"),
|
||||
me: () => apiClient.getMe(),
|
||||
authOptions: () => apiClient.getAuthOptions(),
|
||||
register: (payload) => apiClient.register({ body: payload }),
|
||||
login: (payload) => apiClient.login({ body: payload }),
|
||||
logout: () => apiClient.logout(),
|
||||
|
||||
mySuggestions: () => request("/api/suggestions/mine"),
|
||||
mySuggestions: () => apiClient.getMySuggestions(),
|
||||
createSuggestion: (payload) =>
|
||||
request("/api/suggestions", { method: "POST", body: payload }),
|
||||
apiClient.createSuggestion({ body: payload }),
|
||||
deleteSuggestion: (id) =>
|
||||
request(`/api/suggestions/${id}`, { method: "DELETE" }),
|
||||
apiClient.deleteSuggestion({ pathParameters: { id } }),
|
||||
updateSuggestion: (id, payload) =>
|
||||
request(`/api/suggestions/${id}`, { method: "PUT", body: payload }),
|
||||
allSuggestions: () => request("/api/suggestions/all"),
|
||||
apiClient.updateSuggestion({ pathParameters: { id }, body: payload }),
|
||||
allSuggestions: () => apiClient.getAllSuggestions(),
|
||||
|
||||
myVotes: () => request("/api/votes/mine"),
|
||||
myVotes: () => apiClient.getMyVotes(),
|
||||
vote: (suggestionId, score) =>
|
||||
request("/api/votes", {
|
||||
method: "POST",
|
||||
apiClient.upsertVote({
|
||||
body: { suggestionId, score },
|
||||
}),
|
||||
finalizeVotes: (final) =>
|
||||
request("/api/votes/finalize", { method: "POST", body: { final } }),
|
||||
finalizeVotes: (final) => apiClient.setVotesFinalized({ body: { final } }),
|
||||
|
||||
results: () => request("/api/results"),
|
||||
nextPhase: () => request("/api/me/phase/next", { method: "POST" }),
|
||||
prevPhase: () => request("/api/me/phase/prev", { method: "POST" }),
|
||||
results: () => apiClient.getResults(),
|
||||
nextPhase: () => apiClient.nextPhase(),
|
||||
prevPhase: () => apiClient.prevPhase(),
|
||||
};
|
||||
|
||||
export const adminApi = {
|
||||
setResultsOpen: (resultsOpen) =>
|
||||
request("/api/admin/results", {
|
||||
method: "POST",
|
||||
apiClient.setResultsOpen({
|
||||
body: { resultsOpen },
|
||||
}),
|
||||
voteStatus: () => request("/api/admin/vote-status"),
|
||||
reset: (password) =>
|
||||
request("/api/admin/reset", { method: "POST", body: { password } }),
|
||||
voteStatus: () => apiClient.getVoteStatus(),
|
||||
reset: (password) => apiClient.reset({ body: { password } }),
|
||||
factoryReset: (password) =>
|
||||
request("/api/admin/factory-reset", {
|
||||
method: "POST",
|
||||
apiClient.factoryReset({
|
||||
body: { password },
|
||||
}),
|
||||
grantJoker: (playerId) =>
|
||||
request("/api/admin/joker", { method: "POST", body: { playerId } }),
|
||||
grantJoker: (playerId) => apiClient.grantJoker({ body: { playerId } }),
|
||||
setPlayerAdmin: (playerId, isAdmin) =>
|
||||
request("/api/admin/player-admin", {
|
||||
method: "POST",
|
||||
apiClient.setPlayerAdmin({
|
||||
body: { playerId, isAdmin },
|
||||
}),
|
||||
setPlayerPhase: (playerId, phase) =>
|
||||
request("/api/admin/player-phase", {
|
||||
method: "POST",
|
||||
apiClient.setPlayerPhase({
|
||||
body: { playerId, phase },
|
||||
}),
|
||||
deletePlayer: (playerId, password) =>
|
||||
request(`/api/admin/players/${playerId}`, {
|
||||
method: "DELETE",
|
||||
apiClient.deletePlayer({
|
||||
pathParameters: { playerId },
|
||||
body: { password },
|
||||
}),
|
||||
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
|
||||
request("/api/admin/link-suggestions", {
|
||||
method: "POST",
|
||||
apiClient.linkSuggestions({
|
||||
body: { sourceSuggestionId, targetSuggestionId },
|
||||
}),
|
||||
unlinkSuggestions: (suggestionId) =>
|
||||
request("/api/admin/unlink-suggestions", {
|
||||
method: "POST",
|
||||
apiClient.unlinkSuggestions({
|
||||
body: { suggestionId },
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -18,14 +18,27 @@ import {
|
||||
import { state, clearUserState } from "./state.js";
|
||||
|
||||
export async function loadState() {
|
||||
const [me, stateData] = await Promise.all([api.me(), api.state()]);
|
||||
const stateResponse = await api.state(state.stateEtag);
|
||||
if (stateResponse?.etag) state.stateEtag = stateResponse.etag;
|
||||
if (stateResponse?.notModified) return false;
|
||||
|
||||
const stateData = stateResponse.data;
|
||||
state.isAuthenticated = true;
|
||||
state.me = me;
|
||||
state.hasJoker = me.hasJoker ?? false;
|
||||
state.me = {
|
||||
id: stateData.id,
|
||||
username: stateData.username,
|
||||
displayName: stateData.displayName,
|
||||
isAdmin: stateData.isAdmin,
|
||||
isOwner: stateData.isOwner,
|
||||
currentPhase: stateData.currentPhase,
|
||||
votesFinal: stateData.votesFinal,
|
||||
hasJoker: stateData.hasJoker,
|
||||
};
|
||||
state.hasJoker = stateData.hasJoker ?? false;
|
||||
state.prevPhase = state.phase;
|
||||
state.phase = stateData.currentPhase;
|
||||
state.resultsOpen = stateData.resultsOpen;
|
||||
state.votesFinal = stateData.votesFinal ?? me?.votesFinal ?? false;
|
||||
state.votesFinal = stateData.votesFinal ?? false;
|
||||
state.counts = stateData;
|
||||
if (state.prevPhase !== state.phase && state.phase === "Vote") {
|
||||
state.votesRendered = false;
|
||||
@@ -34,6 +47,7 @@ export async function loadState() {
|
||||
renderWelcome();
|
||||
renderPhasePill();
|
||||
renderCounts();
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function loadSuggestData() {
|
||||
@@ -105,7 +119,19 @@ export async function refreshPhaseData() {
|
||||
try {
|
||||
const prevPhase = state.phase;
|
||||
const prevResultsOpen = state.resultsOpen;
|
||||
await loadState();
|
||||
const stateChanged = await loadState();
|
||||
const adminCard = document.getElementById("admin-card");
|
||||
const adminPanelVisible =
|
||||
!!adminCard && !adminCard.classList.contains("hidden");
|
||||
|
||||
if (!stateChanged) {
|
||||
if (state.me?.isAdmin && adminPanelVisible) {
|
||||
state.adminVoteStatus = await adminApi.voteStatus();
|
||||
}
|
||||
updatePhaseNav();
|
||||
return false;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
loadSuggestData(),
|
||||
loadSuggestionsData(),
|
||||
@@ -117,9 +143,6 @@ export async function refreshPhaseData() {
|
||||
state.votesRendered = false;
|
||||
await loadVoteData();
|
||||
}
|
||||
const adminCard = document.getElementById("admin-card");
|
||||
const adminPanelVisible =
|
||||
!!adminCard && !adminCard.classList.contains("hidden");
|
||||
if (state.me?.isAdmin && adminPanelVisible) {
|
||||
state.adminVoteStatus = await adminApi.voteStatus();
|
||||
}
|
||||
|
||||
47
wwwroot/js/effects.js
vendored
47
wwwroot/js/effects.js
vendored
@@ -3,48 +3,15 @@
|
||||
// Screenshot hover ---------------------------------------------------
|
||||
export function setupCardVisualHover(el, url) {
|
||||
if (!el || !url) return;
|
||||
const img = new Image();
|
||||
let naturalW = 0;
|
||||
let naturalH = 0;
|
||||
let loaded = false;
|
||||
img.src = url;
|
||||
img.onload = () => {
|
||||
naturalW = img.naturalWidth;
|
||||
naturalH = img.naturalHeight;
|
||||
loaded = true;
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
el.classList.remove("hovering");
|
||||
el.style.backgroundSize = "";
|
||||
el.style.backgroundPosition = "";
|
||||
el.style.backgroundRepeat = "";
|
||||
};
|
||||
|
||||
el.addEventListener("mouseenter", () => {
|
||||
el.classList.add("hovering");
|
||||
el.style.backgroundSize = "auto";
|
||||
el.style.backgroundRepeat = "no-repeat";
|
||||
el.style.backgroundPosition = "center";
|
||||
});
|
||||
|
||||
el.addEventListener("mousemove", (e) => {
|
||||
if (!loaded) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const overW = naturalW - rect.width;
|
||||
const overH = naturalH - rect.height;
|
||||
if (overW <= 0 && overH <= 0) {
|
||||
el.style.backgroundPosition = "center";
|
||||
return;
|
||||
}
|
||||
const xRatio = (e.clientX - rect.left) / rect.width;
|
||||
const yRatio = (e.clientY - rect.top) / rect.height;
|
||||
const xPercent = overW > 0 ? xRatio * 100 : 50;
|
||||
const yPercent = overH > 0 ? yRatio * 100 : 50;
|
||||
el.style.backgroundPosition = `${xPercent}% ${yPercent}%`;
|
||||
});
|
||||
|
||||
["mouseleave", "blur"].forEach((evt) => el.addEventListener(evt, reset));
|
||||
["mouseleave", "blur"].forEach((evt) =>
|
||||
el.addEventListener(evt, () => {
|
||||
el.classList.remove("hovering");
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Celebration FX -----------------------------------------------------
|
||||
@@ -57,10 +24,6 @@ function ensureFxCanvas() {
|
||||
if (fxCanvas) return;
|
||||
fxCanvas = document.createElement("canvas");
|
||||
fxCanvas.className = "fx-canvas";
|
||||
fxCanvas.style.position = "fixed";
|
||||
fxCanvas.style.inset = "0";
|
||||
fxCanvas.style.pointerEvents = "none";
|
||||
fxCanvas.style.zIndex = "120";
|
||||
fxCanvas.width = window.innerWidth;
|
||||
fxCanvas.height = window.innerHeight;
|
||||
fxCtx = fxCanvas.getContext("2d");
|
||||
|
||||
@@ -63,6 +63,12 @@ export function renderResults() {
|
||||
const safeShot = safeUrl(r.screenshotUrl);
|
||||
const safeGameUrl = safeUrl(r.gameUrl);
|
||||
const safeYoutubeUrl = safeUrl(r.youtubeUrl);
|
||||
const votersTooltip = buildVotersTooltip(r);
|
||||
const safeVotersTooltip = escapeHtml(votersTooltip);
|
||||
const averageScore =
|
||||
r.average?.toFixed && typeof r.average === "number"
|
||||
? r.average.toFixed(1)
|
||||
: r.average;
|
||||
row.innerHTML = `
|
||||
<td class="rank-cell"><span class="medal">${medal}</span></td>
|
||||
<td class="game-cell">
|
||||
@@ -76,9 +82,9 @@ export function renderResults() {
|
||||
</div>
|
||||
</td>
|
||||
<td class="author-cell">${safeAuthor || "—"}</td>
|
||||
<td>${r.average?.toFixed ? r.average.toFixed(1) : r.average}</td>
|
||||
<td>${formatVotes(r.votes)}</td>
|
||||
<td>${formatMyVote(r.myVote)}</td>
|
||||
<td><span title="${safeVotersTooltip}">${averageScore}</span></td>
|
||||
<td>${formatVotes(r.votes, votersTooltip)}</td>
|
||||
<td>${formatMyVote(r.myVote, votersTooltip)}</td>
|
||||
<td>
|
||||
${safeGameUrl ? `<a class="link compact" href="${safeGameUrl}" target="_blank" rel="noopener">${t("results.link.site")}</a><br>` : ""}
|
||||
${safeYoutubeUrl ? `<a class="link compact" href="${safeYoutubeUrl}" target="_blank" rel="noopener">${t("results.link.youtube")}</a>` : ""}
|
||||
@@ -110,13 +116,32 @@ function buildResultMeta(r) {
|
||||
return `<div class="muted small">${bits.join(" • ")}</div>`;
|
||||
}
|
||||
|
||||
function formatVotes(votes) {
|
||||
if (!Array.isArray(votes) || votes.length === 0) return "⚠️";
|
||||
function formatVotes(votes, tooltip) {
|
||||
const safeTooltip = escapeHtml(tooltip);
|
||||
if (!Array.isArray(votes) || votes.length === 0) {
|
||||
return `<span class="score-emoji" title="${safeTooltip}">⚠️</span>`;
|
||||
}
|
||||
const sorted = [...votes].sort((a, b) => a - b);
|
||||
return sorted.map((v) => scoreToEmoji(v)).join("");
|
||||
return sorted
|
||||
.map(
|
||||
(v) =>
|
||||
`<span class="score-emoji" title="${safeTooltip}">${scoreToEmoji(v)}</span>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function formatMyVote(score) {
|
||||
function formatMyVote(score, tooltip) {
|
||||
if (score == null || Number.isNaN(score)) return "—";
|
||||
return `${score} ${scoreToEmoji(score)}`;
|
||||
const safeTooltip = escapeHtml(tooltip);
|
||||
return `${score} <span class="score-emoji" title="${safeTooltip}">${scoreToEmoji(score)}</span>`;
|
||||
}
|
||||
|
||||
function buildVotersTooltip(result) {
|
||||
const voterNames = Array.isArray(result?.voterNames)
|
||||
? result.voterNames.filter(
|
||||
(name) => typeof name === "string" && name.trim().length > 0,
|
||||
)
|
||||
: [];
|
||||
if (voterNames.length === 0) return t("results.votersTooltipEmpty");
|
||||
return t("results.votersTooltip", { users: voterNames.join(", ") });
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export const state = {
|
||||
votesRendered: false,
|
||||
adminVoteStatus: null,
|
||||
adminStatusSelectActive: false,
|
||||
stateEtag: null,
|
||||
};
|
||||
|
||||
export function clearUserState() {
|
||||
@@ -34,7 +35,9 @@ export function clearUserState() {
|
||||
state.myVotes = [];
|
||||
state.results = [];
|
||||
state.votesRendered = false;
|
||||
state.adminVoteStatus = null;
|
||||
state.adminStatusSelectActive = false;
|
||||
state.stateEtag = null;
|
||||
const adminCard = document.getElementById("admin-card");
|
||||
if (adminCard) adminCard.classList.add("hidden");
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { setupCardVisualHover, triggerCelebration } from "./effects.js";
|
||||
import { renderAdminLinker } from "./admin-ui.js";
|
||||
import { getUiRuntime } from "./ui-runtime.js";
|
||||
import {
|
||||
cssEscapeUrl,
|
||||
escapeHtml,
|
||||
isLinked,
|
||||
linkedPeerTitles,
|
||||
@@ -95,7 +94,7 @@ export function buildCard(
|
||||
: "";
|
||||
const visual =
|
||||
hasImage && safeShot
|
||||
? `<button class="card-visual" data-img="${safeShot}" aria-label="${t("card.openScreenshot")}" style="background-image:url('${cssEscapeUrl(safeShot)}')"></button>`
|
||||
? `<button class="card-visual has-image" data-img="${escapeHtml(safeShot)}" aria-label="${t("card.openScreenshot")}"><img class="card-visual-image" src="${escapeHtml(safeShot)}" alt="" loading="lazy" decoding="async" /></button>`
|
||||
: `<div class="card-visual"></div>`;
|
||||
const hasPlayers = s.minPlayers || s.maxPlayers;
|
||||
const players = hasPlayers
|
||||
|
||||
Reference in New Issue
Block a user