32 Commits

Author SHA1 Message Date
ae85c369ba Add TECH kickoff blueprint from current architecture
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-02-24 20:35:53 +01:00
1802fd6607 Add OpenAPI contract and generated frontend client 2026-02-18 21:25:07 +01:00
e55a1b01f4 Migrate current password hashing to Argon2id 2026-02-18 21:06:22 +01:00
a130cba41a Harden CSRF/CSP and add hash version upgrades 2026-02-18 20:51:18 +01:00
3c7f3d2114 Add event-driven state sync with ETag optimization 2026-02-18 19:58:57 +01:00
5b921063ec Updated Agents 2026-02-18 19:51:36 +01:00
608c5368b3 Update architecture risk review 2026-02-18 19:24:36 +01:00
06ae85f427 Make tooltip voter order backend-driven 2026-02-17 19:26:24 +01:00
26379eef1a Add voter tooltips across results emojis and average 2026-02-17 19:06:05 +01:00
4d62d0bf50 Fix deploy app-base rewrite path for published wwwroot 2026-02-09 18:48:56 +01:00
6e5bbec86e Automate app-base injection during FTP deploy 2026-02-09 18:46:52 +01:00
78dccff90f Cleanup 2026-02-08 22:46:12 +01:00
a6408979ee Merge branch 'chore/review-remediation-2026-02-08' 2026-02-08 22:43:16 +01:00
018fc47d9c Ignore test result coverage artifacts 2026-02-08 22:42:47 +01:00
bc0245c1d4 Fix deploy password prompt and WinRM app pool args 2026-02-08 22:40:29 +01:00
6eea5dcf32 Fix deploy profile data file compatibility 2026-02-08 22:38:08 +01:00
de9123b260 Add local deploy wrappers and ignore private FTP profile 2026-02-08 22:33:09 +01:00
d375b942ff Reduce frontend polling load and clean stale UI hooks 2026-02-08 21:57:47 +01:00
726ba79fdf Enforce explicit test coverage thresholds in CI 2026-02-08 21:52:37 +01:00
368b4877bc Parameterize FTP deployment with environment profiles 2026-02-08 21:50:58 +01:00
d2ab8a676f Harden auth validation against null request fields 2026-02-08 21:48:07 +01:00
acffbc199d Remove startup migration and runtime frontend rewrites 2026-02-08 21:46:26 +01:00
2d2201d0a2 Decouple workflow services from HTTP result types 2026-02-08 21:43:07 +01:00
fe6a9d5da4 Harden owner and suggestion invariants for concurrent writes 2026-02-08 21:37:46 +01:00
569cea161f Add critical architecture and quality review findings 2026-02-08 21:13:32 +01:00
d62ccdcf53 Removed tasks 2026-02-08 21:01:36 +01:00
1bb34c51bf Removed symbol requirement for password, fix formatting. 2026-02-08 20:44:44 +01:00
1c59d68a50 Add owner role and admin management controls 2026-02-08 19:01:58 +01:00
97f1b30b75 Formatting and loca 2026-02-08 18:51:01 +01:00
42e60d2a5a Harden app security controls from audit 2026-02-08 18:40:13 +01:00
a6364b0802 Add categorized webapp security audit tasks 2026-02-08 18:21:34 +01:00
e922afacdf Merge branch 'codex/tasks-md-2026-02-08' 2026-02-08 16:11:23 +01:00
83 changed files with 5902 additions and 945 deletions

View File

@@ -40,4 +40,7 @@ jobs:
run: dotnet build GameList.sln --no-restore -warnaserror run: dotnet build GameList.sln --no-restore -warnaserror
- name: Test - name: Test
run: dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal run: dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal --collect:"XPlat Code Coverage"
- name: Enforce coverage thresholds
run: pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70

5
.gitignore vendored
View File

@@ -11,12 +11,17 @@ node_modules/
# User secrets / configs # User secrets / configs
appsettings.Development.json appsettings.Development.json
scripts/deploy-ftp.profile.psd1
*.user *.user
*.suo *.suo
# Logs # Logs
*.log *.log
# Test results / coverage artifacts
TestResults/
coverage.cobertura.xml
# SQLite data # SQLite data
App_Data/ App_Data/
*.db *.db

View File

@@ -1,6 +1,6 @@
# Agent Guide — Pick'n'Play # 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 Also see the user-facing documentation: per-language md files in wwwroot/data/i18n/faq
## Rules ## 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, 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, 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. - 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. - 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. - 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. - 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)

28
API.md
View File

@@ -1,16 +1,24 @@
# API Contract (auth-enabled) # API Contract (auth-enabled)
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). 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 ## Auth
POST /api/auth/register — accepts optional `adminKey` to set `IsAdmin=true` POST /api/auth/register — accepts optional `adminKey` to set `IsAdmin=true` only for bootstrap of the first admin account
GET /api/auth/options — `{ ownerExists }` for registration UX (hide admin-key input after owner bootstrap)
POST /api/auth/login POST /api/auth/login
POST /api/auth/logout POST /api/auth/logout
Display names are set during registration and are immutable afterward. Display names are set during registration and are immutable afterward.
Passwords must be 8-128 chars and contain uppercase, lowercase and number.
The first account created with a valid `adminKey` becomes both `IsAdmin=true` and `IsOwner=true`.
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) ## 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/me — id, displayName, username, isAdmin, currentPhase, votesFinal 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) ## Player (requires auth)
POST /api/me/phase/next — advance caller to next phase (Suggest→Vote requires at least one own suggestion; Vote→Results is gated by resultsOpen) POST /api/me/phase/next — advance caller to next phase (Suggest→Vote requires at least one own suggestion; Vote→Results is gated by resultsOpen)
@@ -22,22 +30,34 @@ POST /api/suggestions — create (name required ≤100; max 5 per player; valida
PUT /api/suggestions/{id} — update (non-admin: own suggestion; title locked after Suggest) PUT /api/suggestions/{id} — update (non-admin: own suggestion; title locked after Suggest)
DELETE /api/suggestions/{id} — delete (non-admin only in Suggest; admin any time) DELETE /api/suggestions/{id} — delete (non-admin only in Suggest; admin any time)
GET /api/suggestions/all — all suggestions (from Vote onward), includes author, link metadata GET /api/suggestions/all — all suggestions (from Vote onward), includes author, link metadata
Suggestion limit is enforced in both app logic and DB trigger; concurrent writes that exceed limit return `400`.
## Votes (requires auth + Vote phase) ## Votes (requires auth + Vote phase)
GET /api/votes/mine GET /api/votes/mine
POST /api/votes — upsert vote; if suggestion is in a linked group, applies the same score to all linked siblings POST /api/votes — upsert vote; if suggestion is in a linked group, applies the same score to all linked siblings
POST /api/votes/finalize — `{ final: bool }` toggles callers finalized status (blocks further vote edits when true) POST /api/votes/finalize — `{ final: bool }` toggles callers finalized status (blocks further vote edits when true)
Vote upsert includes conflict handling for concurrent writes against the unique `(PlayerId, SuggestionId)` index.
## Results (requires auth + Results phase + resultsOpen) ## Results (requires auth + Results phase + resultsOpen)
GET /api/results — leaderboard with totals, counts, averages, callers vote, media/links, link metadata GET /api/results — leaderboard with totals, counts, averages, vote values, alphabetically sorted `voterNames`, callers vote, media/links, link metadata
## Admin (requires authenticated admin user) ## Admin (requires authenticated admin user)
POST /api/admin/results — `{ resultsOpen: bool }` locks/unlocks results and aligns player phases POST /api/admin/results — `{ resultsOpen: bool }` locks/unlocks results and aligns player phases
GET /api/admin/vote-status — readiness overview (who finalized) GET /api/admin/vote-status — readiness overview (who finalized)
POST /api/admin/joker — `{ playerId }` grants a vote-phase joker to the target player POST /api/admin/joker — `{ playerId }` grants a vote-phase joker to the target player
POST /api/admin/player-phase — `{ playerId, phase }`; currently supports Vote→Suggest transitions only POST /api/admin/player-phase — `{ playerId, phase }`; currently supports Vote→Suggest transitions only
POST /api/admin/player-admin — `{ playerId, isAdmin }`; grant/revoke admin role for non-owner accounts
DELETE /api/admin/players/{playerId} — `{ password }`; deletes player account plus their suggestions/votes DELETE /api/admin/players/{playerId} — `{ password }`; deletes player account plus their suggestions/votes
POST /api/admin/link-suggestions — `{ sourceSuggestionId, targetSuggestionId }`; merges vote groups during Vote, clears votes in the linked group, unfinalizes **all** players POST /api/admin/link-suggestions — `{ sourceSuggestionId, targetSuggestionId }`; merges vote groups during Vote, clears votes in the linked group, unfinalizes **all** players
POST /api/admin/unlink-suggestions — `{ suggestionId }`; breaks links, clears votes for that group, unfinalizes **all** players POST /api/admin/unlink-suggestions — `{ suggestionId }`; breaks links, clears votes for that group, unfinalizes **all** players
POST /api/admin/reset — `{ password }`; clear suggestions/votes, keep players, reset phases/vote-final flags POST /api/admin/reset — `{ password }`; clear suggestions/votes, keep players, reset phases/vote-final flags
POST /api/admin/factory-reset — `{ password }`; wipe players, suggestions, votes, state POST /api/admin/factory-reset — `{ password }`; wipe players, suggestions, votes, state
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.

View File

@@ -1,5 +1,5 @@
namespace GameList.Contracts; namespace GameList.Contracts;
public record RegisterRequest(string Username, string Password, string? DisplayName, string? AdminKey); public record RegisterRequest(string? Username, string? Password, string? DisplayName, string? AdminKey);
public record LoginRequest(string Username, string Password); public record LoginRequest(string? Username, string? Password);

View File

@@ -6,13 +6,17 @@ public record SuggestionRequest(string Name, string? Genre, string? Description,
public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers, int? ParentSuggestionId = null, IReadOnlyList<int>? LinkedIds = null, IReadOnlyList<string>? LinkedTitles = null); public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers, int? ParentSuggestionId = null, IReadOnlyList<int>? LinkedIds = null, IReadOnlyList<string>? LinkedTitles = null);
public record SuggestionAllDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers, string? Author, int? ParentSuggestionId, bool IsOwner, IReadOnlyList<int> LinkedIds, IReadOnlyList<string> LinkedTitles);
public record VoteRequest(int SuggestionId, int Score); public record VoteRequest(int SuggestionId, int Score);
public record VoteRecordDto(int SuggestionId, int Score);
public record ResultsOpenRequest(bool ResultsOpen); public record ResultsOpenRequest(bool ResultsOpen);
public record VoteFinalizeRequest(bool Final); public record VoteFinalizeRequest(bool Final);
public record VoteStatusDto(Guid PlayerId, string Name, string Username, Phase Phase, bool Finalized, bool HasJoker, int SuggestionCount, IReadOnlyList<string> SuggestionTitles); public record VoteStatusDto(Guid PlayerId, string Name, string Username, Phase Phase, bool Finalized, bool HasJoker, bool IsAdmin, bool IsOwner, int SuggestionCount, IReadOnlyList<string> SuggestionTitles);
public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestionId); public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestionId);
@@ -22,4 +26,6 @@ public record GrantJokerRequest(Guid PlayerId);
public record SetPlayerPhaseRequest(Guid PlayerId, Phase Phase); public record SetPlayerPhaseRequest(Guid PlayerId, Phase Phase);
public record SetPlayerAdminRequest(Guid PlayerId, bool IsAdmin);
public record AdminPasswordRequest(string Password); public record AdminPasswordRequest(string Password);

View File

@@ -4,17 +4,7 @@ namespace GameList.Contracts;
public record SuggestionCreatedResponse(int Id); public record SuggestionCreatedResponse(int Id);
public record SuggestionUpdatedResponse( public record SuggestionUpdatedResponse(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers);
int Id,
string Name,
string? Genre,
string? Description,
string? ScreenshotUrl,
string? YoutubeUrl,
string? GameUrl,
int? MinPlayers,
int? MaxPlayers
);
public record VoteUpsertResponse(IReadOnlyList<int> SuggestionIds, int Score); public record VoteUpsertResponse(IReadOnlyList<int> SuggestionIds, int Score);
@@ -26,6 +16,8 @@ public record AdminGrantJokerResponse(Guid Id, bool HasJoker);
public record AdminSetPlayerPhaseResponse(Guid PlayerId, Phase CurrentPhase, bool VotesFinal); public record AdminSetPlayerPhaseResponse(Guid PlayerId, Phase CurrentPhase, bool VotesFinal);
public record AdminSetPlayerAdminResponse(Guid PlayerId, bool IsAdmin);
public record AdminDeletePlayerResponse(Guid DeletedPlayerId); public record AdminDeletePlayerResponse(Guid DeletedPlayerId);
public record AdminLinkSuggestionsResponse(int RootId, IReadOnlyList<int> LinkedSuggestionIds, int UnfinalizedPlayers); public record AdminLinkSuggestionsResponse(int RootId, IReadOnlyList<int> LinkedSuggestionIds, int UnfinalizedPlayers);
@@ -36,48 +28,14 @@ public record AdminResetStateResponse(Phase Phase, bool ResultsOpen, DateTimeOff
public record VoteStatusResponse(IReadOnlyList<VoteStatusDto> Voters, bool Ready, IReadOnlyList<string> Waiting); public record VoteStatusResponse(IReadOnlyList<VoteStatusDto> Voters, bool Ready, IReadOnlyList<string> Waiting);
public record ResultItemDto( 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);
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 AuthSessionResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin); public record AuthSessionResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin);
public record StateSummaryResponse( public record AuthOptionsResponse(bool OwnerExists);
Phase CurrentPhase,
bool VotesFinal,
bool HasJoker,
bool ResultsOpen,
DateTimeOffset UpdatedAt,
int Players,
int Suggestions,
int Votes
);
public record MeResponse( 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);
Guid Id,
string Username, public record MeResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin, bool IsOwner, Phase CurrentPhase, bool VotesFinal, bool HasJoker);
string? DisplayName,
bool IsAdmin,
Phase CurrentPhase,
bool VotesFinal,
bool HasJoker
);
public record PhaseTransitionResponse(Phase CurrentPhase, bool ResultsOpen); public record PhaseTransitionResponse(Phase CurrentPhase, bool ResultsOpen);

View File

@@ -21,7 +21,10 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
builder.HasIndex(p => p.NormalizedUsername).IsUnique(); builder.HasIndex(p => p.NormalizedUsername).IsUnique();
builder.Property(p => p.PasswordHash).IsRequired(); builder.Property(p => p.PasswordHash).IsRequired();
builder.Property(p => p.PasswordSalt).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.IsAdmin).HasDefaultValue(false);
builder.Property(p => p.IsOwner).HasDefaultValue(false);
builder.HasIndex(p => p.IsOwner).HasFilter($"{nameof(Player.IsOwner)} = 1").IsUnique();
builder.Property(p => p.HasJoker).HasDefaultValue(false); builder.Property(p => p.HasJoker).HasDefaultValue(false);
builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest); builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest);
builder.Property(p => p.VotesFinal).HasDefaultValue(false); builder.Property(p => p.VotesFinal).HasDefaultValue(false);

View File

@@ -0,0 +1,251 @@
// <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("20260208175912_AddOwnerRole")]
partial class AddOwnerRole
{
/// <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<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("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
}
}
}

View File

@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GameList.Data.Migrations
{
/// <inheritdoc />
public partial class AddOwnerRole : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsOwner",
table: "Players",
type: "INTEGER",
nullable: false,
defaultValue: false);
migrationBuilder.Sql(
"""
UPDATE Players
SET IsOwner = 1
WHERE Id = (
SELECT Id
FROM Players
WHERE IsAdmin = 1
ORDER BY CreatedAt, Id
LIMIT 1
);
""");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsOwner",
table: "Players");
}
}
}

View File

@@ -0,0 +1,255 @@
// <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("20260208203323_HardenOwnerAndSuggestionInvariants")]
partial class HardenOwnerAndSuggestionInvariants
{
/// <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<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
}
}
}

View File

@@ -0,0 +1,47 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace GameList.Data.Migrations
{
/// <inheritdoc />
public partial class HardenOwnerAndSuggestionInvariants : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_Players_IsOwner",
table: "Players",
column: "IsOwner",
unique: true,
filter: "IsOwner = 1");
migrationBuilder.Sql(
"""
CREATE TRIGGER IF NOT EXISTS TR_Suggestions_MaxFivePerPlayer
BEFORE INSERT ON Suggestions
WHEN
(SELECT COUNT(1) FROM Suggestions WHERE PlayerId = NEW.PlayerId) >= 5
AND (
COALESCE((SELECT HasJoker FROM Players WHERE Id = NEW.PlayerId), 0) = 0
OR COALESCE((SELECT CurrentPhase FROM Players WHERE Id = NEW.PlayerId), 0) != 2
)
BEGIN
SELECT RAISE(ABORT, 'suggestion_limit_exceeded');
END;
"""
);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("DROP TRIGGER IF EXISTS TR_Suggestions_MaxFivePerPlayer;");
migrationBuilder.DropIndex(
name: "IX_Players_IsOwner",
table: "Players");
}
}
}

View 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
}
}
}

View 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");
}
}
}

View File

@@ -70,6 +70,11 @@ namespace GameList.Data.Migrations
.HasColumnType("INTEGER") .HasColumnType("INTEGER")
.HasDefaultValue(false); .HasDefaultValue(false);
b.Property<bool>("IsOwner")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("LastLoginAt") b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -82,6 +87,11 @@ namespace GameList.Data.Migrations
.IsRequired() .IsRequired()
.HasColumnType("BLOB"); .HasColumnType("BLOB");
b.Property<int>("PasswordHashVersion")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(1);
b.Property<byte[]>("PasswordSalt") b.Property<byte[]>("PasswordSalt")
.IsRequired() .IsRequired()
.HasColumnType("BLOB"); .HasColumnType("BLOB");
@@ -98,6 +108,10 @@ namespace GameList.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("IsOwner")
.IsUnique()
.HasFilter("IsOwner = 1");
b.HasIndex("NormalizedUsername") b.HasIndex("NormalizedUsername")
.IsUnique(); .IsUnique();

View File

@@ -17,9 +17,11 @@ public class Player
public byte[] PasswordHash { get; set; } = []; public byte[] PasswordHash { get; set; } = [];
public byte[] PasswordSalt { get; set; } = []; public byte[] PasswordSalt { get; set; } = [];
public int PasswordHashVersion { get; set; } = 1;
public DateTimeOffset? LastLoginAt { get; set; } public DateTimeOffset? LastLoginAt { get; set; }
public bool IsAdmin { get; set; } public bool IsAdmin { get; set; }
public bool IsOwner { get; set; }
public Phase CurrentPhase { get; set; } = Phase.Suggest; public Phase CurrentPhase { get; set; } = Phase.Suggest;
public bool VotesFinal { get; set; } public bool VotesFinal { get; set; }
public bool HasJoker { get; set; } public bool HasJoker { get; set; }

View File

@@ -9,15 +9,36 @@ public static class AdminEndpoints
{ {
public static void MapAdminEndpoints(this IEndpointRouteBuilder app) public static void MapAdminEndpoints(this IEndpointRouteBuilder app)
{ {
var admin = app.MapGroup("/api/admin").RequireAuthorization().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) => await service.SetResultsOpenAsync(request.ResultsOpen)); 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) => await service.GetVoteStatusAsync()); 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) => await service.GrantJokerAsync(request.PlayerId)); 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) => await service.SetPlayerPhaseAsync(request.PlayerId, request.Phase)); 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) => admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, [FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
{ {
@@ -25,8 +46,9 @@ public static class AdminEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.DeletePlayerAsync(playerId, player.Id, request.Password); 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) => admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
{ {
@@ -34,8 +56,9 @@ public static class AdminEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.LinkSuggestionsAsync(player.Id, request.SourceSuggestionId, request.TargetSuggestionId); 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) => admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
{ {
@@ -43,8 +66,9 @@ public static class AdminEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.UnlinkSuggestionsAsync(player.Id, request.SuggestionId); 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) => admin.MapPost("/reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
{ {
@@ -52,8 +76,9 @@ public static class AdminEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.ResetAsync(player.Id, request.Password); 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) => admin.MapPost("/factory-reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
{ {
@@ -61,8 +86,9 @@ public static class AdminEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.FactoryResetAsync(player.Id, request.Password); var result = await service.FactoryResetAsync(player.Id, request.Password, ctx);
}); return result.ToHttpResult(Results.Ok);
}).WithName("FactoryReset");
} }
} }

View File

@@ -8,7 +8,7 @@ namespace GameList.Endpoints;
internal sealed class AdminWorkflowService(AppDbContext db) internal sealed class AdminWorkflowService(AppDbContext db)
{ {
public async Task<IResult> SetResultsOpenAsync(bool resultsOpen) public async Task<ServiceResult<AdminResultsStateResponse>> SetResultsOpenAsync(bool resultsOpen)
{ {
var state = await db.AppState.SingleAsync(); var state = await db.AppState.SingleAsync();
state.ResultsOpen = resultsOpen; state.ResultsOpen = resultsOpen;
@@ -22,80 +22,88 @@ internal sealed class AdminWorkflowService(AppDbContext db)
} }
else else
{ {
await db.Players await db.Players.Where(p => p.Suggestions.Any()).ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false));
.Where(p => p.Suggestions.Any()) await db.Players.Where(p => !p.Suggestions.Any()).ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false));
.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false));
await db.Players
.Where(p => !p.Suggestions.Any())
.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false));
} }
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await tx.CommitAsync(); await tx.CommitAsync();
var currentState = await db.AppState.AsNoTracking().SingleAsync(); var currentState = await db.AppState.AsNoTracking().SingleAsync();
return Results.Ok(new AdminResultsStateResponse(currentState.ResultsOpen, currentState.UpdatedAt)); return ServiceResult<AdminResultsStateResponse>.Success(new AdminResultsStateResponse(currentState.ResultsOpen, currentState.UpdatedAt));
} }
public async Task<IResult> GetVoteStatusAsync() public async Task<ServiceResult<VoteStatusResponse>> GetVoteStatusAsync()
{ {
var voters = await db.Players var voters = await db.Players.AsNoTracking().Include(p => p.Suggestions).OrderBy(p => p.DisplayName ?? p.Username).Select(p => new VoteStatusDto(p.Id, p.DisplayName ?? p.Username, p.Username, p.CurrentPhase, p.VotesFinal, p.HasJoker, p.IsAdmin, p.IsOwner, p.Suggestions.Count, p.Suggestions.Select(s => s.Name).ToList())).ToListAsync();
.AsNoTracking()
.Include(p => p.Suggestions)
.OrderBy(p => p.DisplayName ?? p.Username)
.Select(p => new VoteStatusDto(p.Id, p.DisplayName ?? p.Username, p.Username, p.CurrentPhase, p.VotesFinal, p.HasJoker, p.Suggestions.Count, p.Suggestions.Select(s => s.Name).ToList()))
.ToListAsync();
var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList(); var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList();
var ready = waiting.Count == 0; var ready = waiting.Count == 0;
return Results.Ok(new VoteStatusResponse(voters, ready, waiting)); return ServiceResult<VoteStatusResponse>.Success(new VoteStatusResponse(voters, ready, waiting));
} }
public async Task<IResult> GrantJokerAsync(Guid playerId) public async Task<ServiceResult<AdminGrantJokerResponse>> GrantJokerAsync(Guid playerId)
{ {
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId); var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null) if (player is null)
return EndpointHelpers.NotFoundError("Player not found."); return ServiceResult<AdminGrantJokerResponse>.Failure(ServiceError.NotFound("Player not found."));
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
if (phase != Phase.Vote) if (phase != Phase.Vote)
return EndpointHelpers.BadRequestError("Player must be in the Vote phase to receive a joker."); return ServiceResult<AdminGrantJokerResponse>.Failure(ServiceError.BadRequest("Player must be in the Vote phase to receive a joker."));
player.HasJoker = true; player.HasJoker = true;
player.VotesFinal = false; player.VotesFinal = false;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(new AdminGrantJokerResponse(player.Id, player.HasJoker)); return ServiceResult<AdminGrantJokerResponse>.Success(new AdminGrantJokerResponse(player.Id, player.HasJoker));
} }
public async Task<IResult> SetPlayerPhaseAsync(Guid playerId, Phase phase) public async Task<ServiceResult<AdminSetPlayerPhaseResponse>> SetPlayerPhaseAsync(Guid playerId, Phase phase)
{ {
if (phase != Phase.Suggest) if (phase != Phase.Suggest)
return EndpointHelpers.BadRequestError("Only transition to Suggest is supported."); return ServiceResult<AdminSetPlayerPhaseResponse>.Failure(ServiceError.BadRequest("Only transition to Suggest is supported."));
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId); var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null) if (player is null)
return EndpointHelpers.NotFoundError("Player not found."); return ServiceResult<AdminSetPlayerPhaseResponse>.Failure(ServiceError.NotFound("Player not found."));
var currentPhase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); var currentPhase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
if (currentPhase != Phase.Vote) if (currentPhase != Phase.Vote)
return EndpointHelpers.BadRequestError("Player must currently be in the Vote phase."); return ServiceResult<AdminSetPlayerPhaseResponse>.Failure(ServiceError.BadRequest("Player must currently be in the Vote phase."));
player.CurrentPhase = Phase.Suggest; player.CurrentPhase = Phase.Suggest;
player.VotesFinal = false; player.VotesFinal = false;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal)); return ServiceResult<AdminSetPlayerPhaseResponse>.Success(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal));
} }
public async Task<IResult> DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password) public async Task<ServiceResult<AdminSetPlayerAdminResponse>> SetPlayerAdminAsync(Guid playerId, bool isAdmin)
{ {
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password); var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null)
return ServiceResult<AdminSetPlayerAdminResponse>.Failure(ServiceError.NotFound("Player not found."));
if (player.IsOwner)
return ServiceResult<AdminSetPlayerAdminResponse>.Failure(ServiceError.BadRequest("Owner permissions cannot be changed."));
player.IsAdmin = isAdmin;
await db.SaveChangesAsync();
return ServiceResult<AdminSetPlayerAdminResponse>.Success(new AdminSetPlayerAdminResponse(player.Id, player.IsAdmin));
}
public async Task<ServiceResult<AdminDeletePlayerResponse>> DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password, HttpContext ctx)
{
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
if (passwordError is not null) if (passwordError is not null)
return passwordError; return ServiceResult<AdminDeletePlayerResponse>.Failure(passwordError);
var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId); var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId);
if (player is null) if (player is null)
return EndpointHelpers.NotFoundError("Player not found."); return ServiceResult<AdminDeletePlayerResponse>.Failure(ServiceError.NotFound("Player not found."));
if (player.IsOwner)
return ServiceResult<AdminDeletePlayerResponse>.Failure(ServiceError.BadRequest("Owner account cannot be deleted."));
await using var tx = await db.Database.BeginTransactionAsync(); await using var tx = await db.Database.BeginTransactionAsync();
@@ -104,9 +112,7 @@ internal sealed class AdminWorkflowService(AppDbContext db)
var suggestionIds = player.Suggestions.Select(s => s.Id).ToList(); var suggestionIds = player.Suggestions.Select(s => s.Id).ToList();
if (suggestionIds.Count > 0) if (suggestionIds.Count > 0)
{ {
await db.Suggestions await db.Suggestions.Where(s => s.ParentSuggestionId != null && suggestionIds.Contains(s.ParentSuggestionId.Value)).ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
.Where(s => s.ParentSuggestionId != null && suggestionIds.Contains(s.ParentSuggestionId.Value))
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
await db.Votes.Where(v => suggestionIds.Contains(v.SuggestionId)).ExecuteDeleteAsync(); await db.Votes.Where(v => suggestionIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
} }
@@ -115,30 +121,30 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await tx.CommitAsync(); await tx.CommitAsync();
return Results.Ok(new AdminDeletePlayerResponse(playerId)); return ServiceResult<AdminDeletePlayerResponse>.Success(new AdminDeletePlayerResponse(playerId));
} }
public async Task<IResult> LinkSuggestionsAsync(Guid adminPlayerId, int sourceSuggestionId, int targetSuggestionId) public async Task<ServiceResult<AdminLinkSuggestionsResponse>> LinkSuggestionsAsync(Guid adminPlayerId, int sourceSuggestionId, int targetSuggestionId)
{ {
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId);
if (phase != Phase.Vote) if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
if (sourceSuggestionId == targetSuggestionId) if (sourceSuggestionId == targetSuggestionId)
return EndpointHelpers.BadRequestError("Pick two different games to link."); return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.BadRequest("Pick two different games to link."));
var suggestions = await db.Suggestions.ToListAsync(); var suggestions = await db.Suggestions.ToListAsync();
var source = suggestions.FirstOrDefault(s => s.Id == sourceSuggestionId); var source = suggestions.FirstOrDefault(s => s.Id == sourceSuggestionId);
var target = suggestions.FirstOrDefault(s => s.Id == targetSuggestionId); var target = suggestions.FirstOrDefault(s => s.Id == targetSuggestionId);
if (source is null || target is null) if (source is null || target is null)
return EndpointHelpers.NotFoundError("Suggestion not found."); return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.NotFound("Suggestion not found."));
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId))); var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
if (!rootIndex.TryGetValue(source.Id, out var sourceRoot) || !rootIndex.TryGetValue(target.Id, out var targetRoot)) if (!rootIndex.TryGetValue(source.Id, out var sourceRoot) || !rootIndex.TryGetValue(target.Id, out var targetRoot))
return EndpointHelpers.NotFoundError("Suggestion not found."); return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.NotFound("Suggestion not found."));
if (sourceRoot == targetRoot) if (sourceRoot == targetRoot)
return EndpointHelpers.BadRequestError("These games are already linked."); return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.BadRequest("These games are already linked."));
var affectedRootIds = new HashSet<int> var affectedRootIds = new HashSet<int>
{ {
@@ -170,23 +176,23 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await tx.CommitAsync(); await tx.CommitAsync();
return Results.Ok(new AdminLinkSuggestionsResponse(targetRoot, affectedIds, await db.Players.CountAsync())); return ServiceResult<AdminLinkSuggestionsResponse>.Success(new AdminLinkSuggestionsResponse(targetRoot, affectedIds, await db.Players.CountAsync()));
} }
public async Task<IResult> UnlinkSuggestionsAsync(Guid adminPlayerId, int suggestionId) public async Task<ServiceResult<AdminUnlinkSuggestionsResponse>> UnlinkSuggestionsAsync(Guid adminPlayerId, int suggestionId)
{ {
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId);
if (phase != Phase.Vote) if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); return ServiceResult<AdminUnlinkSuggestionsResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
var suggestions = await db.Suggestions.ToListAsync(); var suggestions = await db.Suggestions.ToListAsync();
var target = suggestions.FirstOrDefault(s => s.Id == suggestionId); var target = suggestions.FirstOrDefault(s => s.Id == suggestionId);
if (target is null) if (target is null)
return Results.Ok(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0)); return ServiceResult<AdminUnlinkSuggestionsResponse>.Success(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0));
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId))); var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
if (!rootIndex.TryGetValue(target.Id, out var rootId)) if (!rootIndex.TryGetValue(target.Id, out var rootId))
return Results.Ok(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0)); return ServiceResult<AdminUnlinkSuggestionsResponse>.Success(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0));
var groupIds = rootIndex.Where(kv => kv.Value == rootId).Select(kv => kv.Key).ToList(); var groupIds = rootIndex.Where(kv => kv.Value == rootId).Select(kv => kv.Key).ToList();
@@ -205,14 +211,14 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await tx.CommitAsync(); await tx.CommitAsync();
return Results.Ok(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync())); return ServiceResult<AdminUnlinkSuggestionsResponse>.Success(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync()));
} }
public async Task<IResult> ResetAsync(Guid adminPlayerId, string password) public async Task<ServiceResult<AdminResetStateResponse>> ResetAsync(Guid adminPlayerId, string password, HttpContext ctx)
{ {
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password); var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
if (passwordError is not null) if (passwordError is not null)
return passwordError; return ServiceResult<AdminResetStateResponse>.Failure(passwordError);
await using var tx = await db.Database.BeginTransactionAsync(); await using var tx = await db.Database.BeginTransactionAsync();
@@ -226,14 +232,14 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await tx.CommitAsync(); await tx.CommitAsync();
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt)); return ServiceResult<AdminResetStateResponse>.Success(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt));
} }
public async Task<IResult> FactoryResetAsync(Guid adminPlayerId, string password) public async Task<ServiceResult<AdminResetStateResponse>> FactoryResetAsync(Guid adminPlayerId, string password, HttpContext ctx)
{ {
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password); var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
if (passwordError is not null) if (passwordError is not null)
return passwordError; return ServiceResult<AdminResetStateResponse>.Failure(passwordError);
await using var tx = await db.Database.BeginTransactionAsync(); await using var tx = await db.Database.BeginTransactionAsync();
@@ -248,20 +254,36 @@ internal sealed class AdminWorkflowService(AppDbContext db)
await tx.CommitAsync(); await tx.CommitAsync();
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt)); return ServiceResult<AdminResetStateResponse>.Success(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt));
} }
private async Task<IResult?> ValidateAdminPasswordAsync(Guid adminPlayerId, string password) private async Task<ServiceError?> ValidateAdminPasswordAsync(Guid adminPlayerId, string password, HttpContext ctx)
{ {
if (string.IsNullOrWhiteSpace(password)) if (string.IsNullOrWhiteSpace(password))
return EndpointHelpers.BadRequestError("Admin password is required."); 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) if (admin is null)
return EndpointHelpers.UnauthorizedError(); return ServiceError.Unauthorized();
return PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt) var monitor = ctx.RequestServices.GetRequiredService<AuthAttemptMonitor>();
? null var verified = PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt, admin.PasswordHashVersion, out var needsRehash);
: EndpointHelpers.BadRequestError("Invalid admin password."); 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;
} }
} }

View File

@@ -11,27 +11,47 @@ public static class AuthEndpoints
{ {
public static void MapAuthEndpoints(this IEndpointRouteBuilder app) public static void MapAuthEndpoints(this IEndpointRouteBuilder app)
{ {
var group = app.MapGroup("/api/auth"); var group = app.MapGroup("/api/auth").WithTags("Auth").RequireRateLimiting("auth-sensitive");
group.MapPost("/register", async ([FromBody] RegisterRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) => 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) =>
{ {
if (!AuthValidator.TryValidateRegistration(request, out var validated, out var registrationError)) if (!AuthValidator.TryValidateRegistration(request, out var validated, out var registrationError))
{
authAttemptMonitor.RecordFailure(ctx, "auth-register", NormalizeActor(request.Username), "validation-failed");
return EndpointHelpers.BadRequestError(registrationError); return EndpointHelpers.BadRequestError(registrationError);
}
var exists = await db.Players.AnyAsync(p => p.NormalizedUsername == validated.NormalizedUsername); var exists = await db.Players.AnyAsync(p => p.NormalizedUsername == validated.NormalizedUsername);
if (exists) if (exists)
return EndpointHelpers.ConflictError("Username already taken."); return EndpointHelpers.ConflictError("Username already taken.");
var (hash, salt) = PasswordHasher.HashPassword(request.Password); var (hash, salt) = PasswordHasher.HashPassword(validated.Password);
var expectedAdminKey = config["ADMIN_PASSWORD"]; var expectedAdminKey = config["ADMIN_PASSWORD"];
var wantsAdmin = !string.IsNullOrWhiteSpace(validated.AdminKey); var wantsAdmin = !string.IsNullOrWhiteSpace(validated.AdminKey);
if (wantsAdmin) if (wantsAdmin)
{ {
if (string.IsNullOrWhiteSpace(expectedAdminKey) || validated.AdminKey != expectedAdminKey) if (string.IsNullOrWhiteSpace(expectedAdminKey) || validated.AdminKey != expectedAdminKey)
{
authAttemptMonitor.RecordFailure(ctx, "auth-register-admin", validated.NormalizedUsername, "invalid-admin-key");
return EndpointHelpers.BadRequestError("Invalid admin key."); return EndpointHelpers.BadRequestError("Invalid admin key.");
}
var ownerExists = await db.Players.AsNoTracking().AnyAsync(p => p.IsOwner);
if (ownerExists)
{
authAttemptMonitor.RecordFailure(ctx, "auth-register-admin", validated.NormalizedUsername, "bootstrap-admin-disabled");
return EndpointHelpers.BadRequestError("Admin registration via admin key is disabled once an owner account exists.");
}
} }
var isAdmin = wantsAdmin; var isAdmin = wantsAdmin;
var isOwner = wantsAdmin;
var player = new Player var player = new Player
{ {
@@ -40,14 +60,31 @@ public static class AuthEndpoints
NormalizedUsername = validated.NormalizedUsername, NormalizedUsername = validated.NormalizedUsername,
PasswordHash = hash, PasswordHash = hash,
PasswordSalt = salt, PasswordSalt = salt,
PasswordHashVersion = PasswordHasher.CurrentVersion,
DisplayName = validated.DisplayName, DisplayName = validated.DisplayName,
IsAdmin = isAdmin, IsAdmin = isAdmin,
IsOwner = isOwner,
CreatedAt = DateTimeOffset.UtcNow, CreatedAt = DateTimeOffset.UtcNow,
LastLoginAt = DateTimeOffset.UtcNow LastLoginAt = DateTimeOffset.UtcNow
}; };
db.Players.Add(player); db.Players.Add(player);
await db.SaveChangesAsync(); try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException ex) when (isOwner && EndpointHelpers.IsSqliteConstraintViolation(ex, EndpointHelpers.SingleOwnerIndexName))
{
authAttemptMonitor.RecordFailure(ctx, "auth-register-admin", validated.NormalizedUsername, "bootstrap-admin-race");
return EndpointHelpers.BadRequestError("Admin registration via admin key is disabled once an owner account exists.");
}
catch (DbUpdateException ex) when (EndpointHelpers.IsSqliteConstraintViolation(ex, "IX_Players_NormalizedUsername"))
{
return EndpointHelpers.ConflictError("Username already taken.");
}
if (isAdmin)
authAttemptMonitor.RecordSuccess(ctx, "auth-register-admin", validated.NormalizedUsername);
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player); await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
@@ -57,16 +94,31 @@ public static class AuthEndpoints
player.DisplayName, player.DisplayName,
player.IsAdmin player.IsAdmin
)); ));
}); }).WithName("Register");
group.MapPost("/login", async ([FromBody] LoginRequest request, HttpContext ctx, AppDbContext db) => group.MapPost("/login", async ([FromBody] LoginRequest request, HttpContext ctx, AppDbContext db, AuthAttemptMonitor authAttemptMonitor) =>
{ {
if (!AuthValidator.TryValidateLogin(request, out _, out var normalizedUsername, out var loginError)) if (!AuthValidator.TryValidateLogin(request, out _, out var normalizedUsername, out var loginError))
{
authAttemptMonitor.RecordFailure(ctx, "auth-login", NormalizeActor(request.Username), "validation-failed");
return EndpointHelpers.BadRequestError(loginError); return EndpointHelpers.BadRequestError(loginError);
}
var player = await db.Players.FirstOrDefaultAsync(p => p.NormalizedUsername == normalizedUsername); var player = await db.Players.FirstOrDefaultAsync(p => p.NormalizedUsername == normalizedUsername);
if (player == null || !PasswordHasher.Verify(request.Password, 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."); 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)) if (string.IsNullOrWhiteSpace(player.DisplayName))
{ {
@@ -76,6 +128,7 @@ public static class AuthEndpoints
player.LastLoginAt = DateTimeOffset.UtcNow; player.LastLoginAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
authAttemptMonitor.RecordSuccess(ctx, "auth-login", normalizedUsername);
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player); await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
return Results.Ok(new AuthSessionResponse( return Results.Ok(new AuthSessionResponse(
@@ -84,12 +137,14 @@ public static class AuthEndpoints
player.DisplayName, player.DisplayName,
player.IsAdmin player.IsAdmin
)); ));
}); }).WithName("Login");
group.MapPost("/logout", async (HttpContext ctx) => group.MapPost("/logout", async (HttpContext ctx) =>
{ {
await PlayerIdentityExtensions.SignOutPlayerAsync(ctx); await PlayerIdentityExtensions.SignOutPlayerAsync(ctx);
return Results.NoContent(); return Results.NoContent();
}); }).WithName("Logout");
} }
private static string NormalizeActor(string? username) => string.IsNullOrWhiteSpace(username) ? "(missing)" : username.Trim();
} }

View File

@@ -7,10 +7,12 @@ internal static class AuthValidator
public const int MaxUsernameLength = 24; public const int MaxUsernameLength = 24;
public const int MaxDisplayNameLength = 16; public const int MaxDisplayNameLength = 16;
public const int MaxAdminKeyLength = 128; public const int MaxAdminKeyLength = 128;
public const int MinPasswordLength = 8;
public const int MaxPasswordLength = 128;
public static bool TryValidateRegistration(RegisterRequest request, out ValidatedRegistration validated, out string error) public static bool TryValidateRegistration(RegisterRequest request, out ValidatedRegistration validated, out string error)
{ {
var username = (request.Username).Trim(); var username = (request.Username ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(username) || username.Length > MaxUsernameLength) if (string.IsNullOrWhiteSpace(username) || username.Length > MaxUsernameLength)
{ {
validated = default; validated = default;
@@ -25,6 +27,24 @@ internal static class AuthValidator
return false; return false;
} }
var password = request.Password.Trim();
if (password.Length < MinPasswordLength || password.Length > MaxPasswordLength)
{
validated = default;
error = $"Password must be between {MinPasswordLength} and {MaxPasswordLength} characters.";
return false;
}
var hasUpper = password.Any(char.IsUpper);
var hasLower = password.Any(char.IsLower);
var hasDigit = password.Any(char.IsDigit);
if (!hasUpper || !hasLower || !hasDigit)
{
validated = default;
error = "Password must include at least one uppercase and one lowercase characters and and digit.";
return false;
}
if ((request.DisplayName ?? string.Empty).Trim().Length > MaxDisplayNameLength) if ((request.DisplayName ?? string.Empty).Trim().Length > MaxDisplayNameLength)
{ {
validated = default; validated = default;
@@ -41,14 +61,14 @@ internal static class AuthValidator
} }
var adminKey = EndpointHelpers.TrimTo(request.AdminKey, MaxAdminKeyLength); var adminKey = EndpointHelpers.TrimTo(request.AdminKey, MaxAdminKeyLength);
validated = new ValidatedRegistration(username, username.ToLowerInvariant(), displayName, adminKey); validated = new ValidatedRegistration(username, username.ToLowerInvariant(), password, displayName, adminKey);
error = string.Empty; error = string.Empty;
return true; return true;
} }
public static bool TryValidateLogin(LoginRequest request, out string username, out string normalizedUsername, out string error) public static bool TryValidateLogin(LoginRequest request, out string username, out string normalizedUsername, out string error)
{ {
username = (request.Username).Trim(); username = (request.Username ?? string.Empty).Trim();
normalizedUsername = string.Empty; normalizedUsername = string.Empty;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password)) if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password))
@@ -63,15 +83,16 @@ internal static class AuthValidator
return false; return false;
} }
if (request.Password.Length > MaxPasswordLength)
{
error = $"Password must be <= {MaxPasswordLength} characters.";
return false;
}
normalizedUsername = username.ToLowerInvariant(); normalizedUsername = username.ToLowerInvariant();
error = string.Empty; error = string.Empty;
return true; return true;
} }
public readonly record struct ValidatedRegistration( public readonly record struct ValidatedRegistration(string Username, string NormalizedUsername, string Password, string DisplayName, string? AdminKey);
string Username,
string NormalizedUsername,
string DisplayName,
string? AdminKey
);
} }

View File

@@ -1,12 +1,18 @@
using GameList.Data; using GameList.Data;
using GameList.Domain; using GameList.Domain;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Net;
using System.Net.Sockets;
using System.Security.Claims; using System.Security.Claims;
namespace GameList.Endpoints; namespace GameList.Endpoints;
internal static class EndpointHelpers internal static class EndpointHelpers
{ {
public const string SingleOwnerIndexName = "IX_Players_IsOwner";
public const string SuggestionLimitTriggerError = "suggestion_limit_exceeded";
public static async Task<Player?> GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db) public static async Task<Player?> GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db)
{ {
if (ctx.User.Identity?.IsAuthenticated != true) if (ctx.User.Identity?.IsAuthenticated != true)
@@ -106,6 +112,36 @@ internal static class EndpointHelpers
public static IResult UnauthorizedError(string detail = "Unauthorized") => Problem(StatusCodes.Status401Unauthorized, "Unauthorized", detail); public static IResult UnauthorizedError(string detail = "Unauthorized") => Problem(StatusCodes.Status401Unauthorized, "Unauthorized", detail);
public static IResult ToHttpResult<T>(this ServiceResult<T> result, Func<T, IResult> onSuccess)
{
if (result.IsSuccess)
return onSuccess(result.Value!);
return ToHttpError(result.Error!);
}
public static IResult ToHttpResult(this ServiceResult<Unit> result, Func<IResult> onSuccess)
{
if (result.IsSuccess)
return onSuccess();
return ToHttpError(result.Error!);
}
public static bool IsSqliteConstraintViolation(DbUpdateException ex)
{
return ex.InnerException is SqliteException sqliteEx
&& sqliteEx.SqliteErrorCode == 19;
}
public static bool IsSqliteConstraintViolation(DbUpdateException ex, string containsMessage)
{
if (!IsSqliteConstraintViolation(ex))
return false;
return ex.InnerException?.Message.Contains(containsMessage, StringComparison.OrdinalIgnoreCase) == true;
}
private static IResult Problem(int statusCode, string title, string detail) private static IResult Problem(int statusCode, string title, string detail)
{ {
return Results.Problem( return Results.Problem(
@@ -140,6 +176,48 @@ internal static class EndpointHelpers
|| path.EndsWith(".avif", StringComparison.Ordinal); || path.EndsWith(".avif", StringComparison.Ordinal);
} }
private static IResult ToHttpError(ServiceError error)
{
return error.Code switch
{
ServiceErrorCode.BadRequest => BadRequestError(error.Detail),
ServiceErrorCode.Unauthorized => UnauthorizedError(error.Detail),
ServiceErrorCode.NotFound => NotFoundError(error.Detail),
ServiceErrorCode.Conflict => ConflictError(error.Detail),
_ => Problem(StatusCodes.Status500InternalServerError, "Internal Server Error", "Unhandled service error.")
};
}
public static HttpMessageHandler CreateImageValidationHandler()
{
return new SocketsHttpHandler
{
AllowAutoRedirect = false,
ConnectCallback = async (context, cancellationToken) =>
{
var addresses = await ResolveSafePublicAddressesAsync(context.DnsEndPoint.Host, cancellationToken);
if (addresses.Count == 0)
throw new HttpRequestException("No safe public IPs found for host.");
foreach (var ip in addresses)
{
var socket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
try
{
await socket.ConnectAsync(new IPEndPoint(ip, context.DnsEndPoint.Port), cancellationToken);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
}
}
throw new HttpRequestException("Unable to connect to validated public IP for host.");
}
};
}
public static async Task<bool> IsReachableImageAsync(string? url, IHttpClientFactory httpFactory, HttpMessageHandler? handler = null, CancellationToken ct = default) public static async Task<bool> IsReachableImageAsync(string? url, IHttpClientFactory httpFactory, HttpMessageHandler? handler = null, CancellationToken ct = default)
{ {
if (string.IsNullOrWhiteSpace(url)) if (string.IsNullOrWhiteSpace(url))
@@ -148,13 +226,21 @@ internal static class EndpointHelpers
return false; return false;
if (uri.Scheme is not ("http" or "https")) if (uri.Scheme is not ("http" or "https"))
return false; return false;
if (!await IsSafePublicHostAsync(uri, ct)) if (handler is null)
{
if (!await IsSafePublicHostAsync(uri, ct))
return false;
}
else if (IPAddress.TryParse(uri.Host, out var literal) && IsBlockedAddress(literal))
{
return false; return false;
}
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
cts.CancelAfter(TimeSpan.FromSeconds(3)); cts.CancelAfter(TimeSpan.FromSeconds(3));
var client = handler is null ? httpFactory.CreateClient("imageValidation") : new HttpClient(handler, disposeHandler: false); using var fallbackClient = handler is null ? null : new HttpClient(handler, disposeHandler: false);
var client = fallbackClient ?? httpFactory.CreateClient("imageValidation");
try try
{ {
@@ -234,24 +320,8 @@ internal static class EndpointHelpers
{ {
try try
{ {
var host = uri.Host; var addresses = await ResolveSafePublicAddressesAsync(uri.Host, ct);
if (Uri.CheckHostName(host) == UriHostNameType.Dns || Uri.CheckHostName(host) == UriHostNameType.IPv4 || Uri.CheckHostName(host) == UriHostNameType.IPv6) return addresses.Count > 0;
{
var addresses = await System.Net.Dns.GetHostAddressesAsync(host, ct);
foreach (var ip in addresses)
{
if (System.Net.IPAddress.IsLoopback(ip))
return false;
if (IsPrivate(ip))
return false;
}
}
else
{
return false;
}
return true;
} }
catch catch
{ {
@@ -259,26 +329,90 @@ internal static class EndpointHelpers
} }
} }
private static bool IsPrivate(System.Net.IPAddress ip) private static async Task<IReadOnlyList<IPAddress>> ResolveSafePublicAddressesAsync(string host, CancellationToken ct)
{ {
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork) if (!IsSupportedHostType(host))
return [];
IPAddress[] resolved;
if (IPAddress.TryParse(host, out var literal))
{ {
var bytes = ip.GetAddressBytes(); resolved = [literal];
return bytes[0] switch }
{ else
10 => true, {
172 when bytes[1] >= 16 && bytes[1] <= 31 => true, resolved = await Dns.GetHostAddressesAsync(host, ct);
192 when bytes[1] == 168 => true,
127 => true,
_ => false
};
} }
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6) var safe = new List<IPAddress>(resolved.Length);
foreach (var ip in resolved)
{ {
return ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal || ip.IsIPv6Multicast || System.Net.IPAddress.IsLoopback(ip); if (!IsBlockedAddress(ip))
safe.Add(ip);
} }
return safe.Distinct().ToArray();
}
private static bool IsSupportedHostType(string host)
{
var type = Uri.CheckHostName(host);
return type is UriHostNameType.Dns or UriHostNameType.IPv4 or UriHostNameType.IPv6;
}
private static bool IsBlockedAddress(IPAddress ip)
{
if (IPAddress.IsLoopback(ip))
return true;
if (ip.IsIPv4MappedToIPv6)
return IsBlockedAddress(ip.MapToIPv4());
if (ip.AddressFamily == AddressFamily.InterNetwork)
return IsBlockedIpv4(ip);
if (ip.AddressFamily == AddressFamily.InterNetworkV6)
return IsBlockedIpv6(ip);
return true;
}
private static bool IsBlockedIpv4(IPAddress ip)
{
var b = ip.GetAddressBytes();
return b[0] switch
{
0 => true, // "This network"
10 => true, // private
100 when b[1] >= 64 && b[1] <= 127 => true, // CGNAT
127 => true, // loopback
169 when b[1] == 254 => true, // link local
172 when b[1] >= 16 && b[1] <= 31 => true, // private
192 when b[1] == 0 && b[2] == 0 => true, // IETF protocol assignments
192 when b[1] == 0 && b[2] == 2 => true, // documentation
192 when b[1] == 88 && b[2] == 99 => true, // 6to4 relay anycast
192 when b[1] == 168 => true, // private
198 when b[1] is 18 or 19 => true, // benchmarking
198 when b[1] == 51 && b[2] == 100 => true, // documentation
203 when b[1] == 0 && b[2] == 113 => true, // documentation
>= 224 => true, // multicast/reserved/broadcast
_ => false
};
}
private static bool IsBlockedIpv6(IPAddress ip)
{
if (ip.Equals(IPAddress.IPv6None))
return true;
if (ip.IsIPv6Multicast || ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal)
return true;
var bytes = ip.GetAddressBytes();
if ((bytes[0] & 0xFE) == 0xFC) // fc00::/7 unique local
return true;
if (bytes[0] == 0x20 && bytes[1] == 0x01 && bytes[2] == 0x0D && bytes[3] == 0xB8) // 2001:db8::/32 docs
return true;
return false; return false;
} }

View File

@@ -9,6 +9,7 @@ public static class ResultsEndpoints
public static void MapResultsEndpoints(this IEndpointRouteBuilder app) public static void MapResultsEndpoints(this IEndpointRouteBuilder app)
{ {
var group = app.MapGroup("/api/results") var group = app.MapGroup("/api/results")
.WithTags("Results")
.RequireAuthorization() .RequireAuthorization()
.AddEndpointFilter(new PhaseRequirementFilter(Phase.Results)); .AddEndpointFilter(new PhaseRequirementFilter(Phase.Results));
@@ -18,8 +19,9 @@ public static class ResultsEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.GetResultsAsync(player.Id); var result = await service.GetResultsAsync(player.Id);
}); return result.ToHttpResult(Results.Ok);
}).WithName("GetResults");
} }
} }

View File

@@ -7,15 +7,15 @@ namespace GameList.Endpoints;
internal sealed class ResultsWorkflowService(AppDbContext db) internal sealed class ResultsWorkflowService(AppDbContext db)
{ {
public async Task<IResult> GetResultsAsync(Guid playerId) public async Task<ServiceResult<IReadOnlyList<ResultItemDto>>> GetResultsAsync(Guid playerId)
{ {
var appState = await db.AppState.AsNoTracking().SingleAsync(); var appState = await db.AppState.AsNoTracking().SingleAsync();
if (!appState.ResultsOpen) if (!appState.ResultsOpen)
return EndpointHelpers.BadRequestError("Results are locked until the admin enables them."); return ServiceResult<IReadOnlyList<ResultItemDto>>.Failure(ServiceError.BadRequest("Results are locked until the admin enables them."));
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Results) if (phase != Phase.Results)
return EndpointHelpers.PhaseMismatch(Phase.Results, phase); return ServiceResult<IReadOnlyList<ResultItemDto>>.Failure(ServiceError.PhaseMismatch(Phase.Results, phase));
var results = await db var results = await db
.Suggestions.AsNoTracking() .Suggestions.AsNoTracking()
@@ -32,6 +32,9 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
s.Votes.Count, s.Votes.Count,
Average = s.Votes.Count == 0 ? 0 : s.Votes.Average(v => v.Score), Average = s.Votes.Count == 0 ? 0 : s.Votes.Average(v => v.Score),
Votes = s.Votes.Select(v => v.Score).ToList(), Votes = s.Votes.Select(v => v.Score).ToList(),
VoterNames = s.Votes
.Select(v => v.Player!.DisplayName ?? v.Player!.Username)
.ToList(),
MyVote = s.Votes MyVote = s.Votes
.Where(v => v.PlayerId == playerId) .Where(v => v.PlayerId == playerId)
.Select(v => (int?)v.Score) .Select(v => (int?)v.Score)
@@ -49,7 +52,7 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
var rootIndex = EndpointHelpers.BuildLinkRoots(results.Select(r => (r.Id, r.ParentSuggestionId))); var rootIndex = EndpointHelpers.BuildLinkRoots(results.Select(r => (r.Id, r.ParentSuggestionId)));
var nameLookup = results.ToDictionary(r => r.Id, r => r.Name); var nameLookup = results.ToDictionary(r => r.Id, r => r.Name);
var shaped = results.Select(r => IReadOnlyList<ResultItemDto> shaped = results.Select(r =>
{ {
var linkedIds = EndpointHelpers.LinkedIdsFor(r.Id, rootIndex) var linkedIds = EndpointHelpers.LinkedIdsFor(r.Id, rootIndex)
.Where(id => id != r.Id) .Where(id => id != r.Id)
@@ -59,6 +62,11 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
.Where(nameLookup.ContainsKey) .Where(nameLookup.ContainsKey)
.Select(id => nameLookup[id]) .Select(id => nameLookup[id])
.ToList(); .ToList();
var voterNames = r.VoterNames
.Where(name => !string.IsNullOrWhiteSpace(name))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
.ToList();
return new ResultItemDto( return new ResultItemDto(
r.Id, r.Id,
@@ -70,6 +78,7 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
r.Count, r.Count,
r.Average, r.Average,
r.Votes, r.Votes,
voterNames,
r.MyVote, r.MyVote,
r.ScreenshotUrl, r.ScreenshotUrl,
r.YoutubeUrl, r.YoutubeUrl,
@@ -80,8 +89,8 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
linkedIds, linkedIds,
linkedTitles linkedTitles
); );
}); }).ToList();
return Results.Ok(shaped); return ServiceResult<IReadOnlyList<ResultItemDto>>.Success(shaped);
} }
} }

View File

@@ -0,0 +1,36 @@
using GameList.Domain;
namespace GameList.Endpoints;
internal enum ServiceErrorCode
{
BadRequest,
Unauthorized,
NotFound,
Conflict
}
internal sealed record ServiceError(ServiceErrorCode Code, string Detail)
{
public static ServiceError BadRequest(string detail) => new(ServiceErrorCode.BadRequest, detail);
public static ServiceError Unauthorized(string detail = "Unauthorized") => new(ServiceErrorCode.Unauthorized, detail);
public static ServiceError NotFound(string detail) => new(ServiceErrorCode.NotFound, detail);
public static ServiceError Conflict(string detail) => new(ServiceErrorCode.Conflict, detail);
public static ServiceError PhaseMismatch(Phase required, Phase current) =>
BadRequest($"This endpoint is available in the {required} phase. Your current phase is {current}.");
}
internal readonly record struct Unit;
internal readonly record struct ServiceResult<T>(T? Value, ServiceError? Error)
{
public bool IsSuccess => Error is null;
public static ServiceResult<T> Success(T value) => new(value, null);
public static ServiceResult<T> Failure(ServiceError error) => new(default, error);
}

View File

@@ -1,4 +1,5 @@
using GameList.Data; using GameList.Data;
using GameList.Infrastructure;
namespace GameList.Endpoints; namespace GameList.Endpoints;
@@ -6,16 +7,73 @@ public static class StateEndpoints
{ {
public static void MapStateEndpoints(this IEndpointRouteBuilder app) 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(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); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.GetStateAsync(player); 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) => group.MapGet("/me", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
{ {
@@ -23,8 +81,9 @@ public static class StateEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.GetMeAsync(player); 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) => group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
{ {
@@ -32,8 +91,9 @@ public static class StateEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.NextPhaseAsync(player); 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) => group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
{ {
@@ -41,8 +101,16 @@ public static class StateEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.PrevPhaseAsync(player); 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);
}
} }

View File

@@ -7,39 +7,47 @@ namespace GameList.Endpoints;
internal sealed class StateWorkflowService(AppDbContext db) internal sealed class StateWorkflowService(AppDbContext db)
{ {
public async Task<IResult> GetStateAsync(Player player) 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 phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
var summary = new StateSummaryResponse( var summary = new StateSummaryResponse(
player.Id,
player.Username,
player.DisplayName,
player.IsAdmin,
player.IsOwner,
phase, phase,
player.VotesFinal, player.VotesFinal,
player.HasJoker, player.HasJoker,
state.ResultsOpen, state.ResultsOpen,
state.UpdatedAt, state.UpdatedAt,
await db.Players.CountAsync(), state.Players,
await db.Suggestions.CountAsync(), state.Suggestions,
await db.Votes.CountAsync() state.Votes
); );
return Results.Ok(summary); return ServiceResult<StateSummaryResponse>.Success(summary);
} }
public async Task<IResult> GetMeAsync(Player player) public async Task<ServiceResult<MeResponse>> GetMeAsync(Player player)
{ {
var state = await db.AppState.AsNoTracking().SingleAsync(); var state = await db.AppState.AsNoTracking().SingleAsync();
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen); var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
return Results.Ok(new MeResponse( return ServiceResult<MeResponse>.Success(new MeResponse(player.Id, player.Username, player.DisplayName, player.IsAdmin, player.IsOwner, phase, player.VotesFinal, player.HasJoker));
player.Id,
player.Username,
player.DisplayName,
player.IsAdmin,
phase,
player.VotesFinal,
player.HasJoker
));
} }
public async Task<IResult> NextPhaseAsync(Player player) public async Task<ServiceResult<PhaseTransitionResponse>> NextPhaseAsync(Player player)
{ {
var appState = await db.AppState.SingleAsync(); var appState = await db.AppState.SingleAsync();
var shouldSave = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen); var shouldSave = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
@@ -52,16 +60,16 @@ internal sealed class StateWorkflowService(AppDbContext db)
{ {
var hasSuggestions = await db.Suggestions.AnyAsync(s => s.PlayerId == player.Id); var hasSuggestions = await db.Suggestions.AnyAsync(s => s.PlayerId == player.Id);
if (!hasSuggestions) if (!hasSuggestions)
return EndpointHelpers.BadRequestError("Add at least one suggestion before entering the Vote phase."); return ServiceResult<PhaseTransitionResponse>.Failure(ServiceError.BadRequest("Add at least one suggestion before entering the Vote phase."));
} }
if (next == Phase.Results && !appState.ResultsOpen) if (next == Phase.Results && !appState.ResultsOpen)
return EndpointHelpers.BadRequestError("Results are locked until the admin enables them."); return ServiceResult<PhaseTransitionResponse>.Failure(ServiceError.BadRequest("Results are locked until the admin enables them."));
player.CurrentPhase = next; player.CurrentPhase = next;
player.VotesFinal = false; // moving forward clears any prior finalize player.VotesFinal = false; // moving forward clears any prior finalize
shouldSave = true; shouldSave = true;
return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen)); return ServiceResult<PhaseTransitionResponse>.Success(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
} }
finally finally
{ {
@@ -70,10 +78,10 @@ internal sealed class StateWorkflowService(AppDbContext db)
} }
} }
public async Task<IResult> PrevPhaseAsync(Player player) public async Task<ServiceResult<PhaseTransitionResponse>> PrevPhaseAsync(Player player)
{ {
if (!player.IsAdmin) if (!player.IsAdmin)
return EndpointHelpers.BadRequestError("Only admins can move backward."); return ServiceResult<PhaseTransitionResponse>.Failure(ServiceError.BadRequest("Only admins can move backward."));
var appState = await db.AppState.SingleAsync(); var appState = await db.AppState.SingleAsync();
_ = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen); _ = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
@@ -81,7 +89,7 @@ internal sealed class StateWorkflowService(AppDbContext db)
player.CurrentPhase = PrevPhase(player.CurrentPhase); player.CurrentPhase = PrevPhase(player.CurrentPhase);
player.VotesFinal = false; player.VotesFinal = false;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen)); return ServiceResult<PhaseTransitionResponse>.Success(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
} }
private static Phase NextPhase(Phase current) => current switch private static Phase NextPhase(Phase current) => current switch

View File

@@ -9,7 +9,7 @@ public static class SuggestEndpoints
{ {
public static void MapSuggestEndpoints(this IEndpointRouteBuilder app) 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) => group.MapGet("/mine", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
{ {
@@ -17,8 +17,9 @@ public static class SuggestEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.GetMineAsync(player.Id); 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) => group.MapPost("/", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
{ {
@@ -26,7 +27,7 @@ public static class SuggestEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.CreateAsync( var result = await service.CreateAsync(
player.Id, player.Id,
new SuggestionInput( new SuggestionInput(
request.Name, request.Name,
@@ -39,7 +40,9 @@ public static class SuggestEndpoints
request.MaxPlayers request.MaxPlayers
) )
); );
}).AddEndpointFilter(new PhaseOrJokerFilter());
return result.ToHttpResult(payload => Results.Created($"/api/suggestions/{payload.Id}", payload));
}).AddEndpointFilter(new PhaseOrJokerFilter()).WithName("CreateSuggestion");
group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
{ {
@@ -47,8 +50,9 @@ public static class SuggestEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.DeleteAsync(player.Id, id); 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) => group.MapPut("/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
{ {
@@ -56,7 +60,7 @@ public static class SuggestEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.UpdateAsync( var result = await service.UpdateAsync(
player.Id, player.Id,
id, id,
new SuggestionInput( new SuggestionInput(
@@ -70,7 +74,9 @@ public static class SuggestEndpoints
request.MaxPlayers request.MaxPlayers
) )
); );
});
return result.ToHttpResult(Results.Ok);
}).WithName("UpdateSuggestion");
group.MapGet("/all", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => group.MapGet("/all", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
{ {
@@ -78,8 +84,9 @@ public static class SuggestEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.GetAllAsync(player.Id); var result = await service.GetAllAsync(player.Id);
}); return result.ToHttpResult(Results.Ok);
}).WithName("GetAllSuggestions");
} }
} }

View File

@@ -1,8 +1,14 @@
using System.Collections.Concurrent;
namespace GameList.Endpoints; namespace GameList.Endpoints;
internal static class SuggestionValidator internal static class SuggestionValidator
{ {
public static async Task<string?> ValidateAsync(SuggestionInput input, IHttpClientFactory httpFactory) private static readonly ConcurrentDictionary<string, (bool Reachable, DateTimeOffset ExpiresAt)> ImageReachabilityCache = new(StringComparer.OrdinalIgnoreCase);
private static readonly TimeSpan ReachableCacheTtl = TimeSpan.FromMinutes(15);
private static readonly TimeSpan UnreachableCacheTtl = TimeSpan.FromMinutes(2);
public static async Task<string?> ValidateAsync(SuggestionInput input, IHttpClientFactory httpFactory, bool shouldValidateImageReachability = true)
{ {
if (string.IsNullOrWhiteSpace(input.Name) || input.Name.Length > 100) if (string.IsNullOrWhiteSpace(input.Name) || input.Name.Length > 100)
return "Name is required and must be <= 100 characters."; return "Name is required and must be <= 100 characters.";
@@ -10,7 +16,7 @@ internal static class SuggestionValidator
if (!EndpointHelpers.IsValidImageUrl(input.ScreenshotUrl)) if (!EndpointHelpers.IsValidImageUrl(input.ScreenshotUrl))
return "Screenshot URL must be http(s) and end with an image file extension."; return "Screenshot URL must be http(s) and end with an image file extension.";
if (!await EndpointHelpers.IsReachableImageAsync(input.ScreenshotUrl, httpFactory)) if (shouldValidateImageReachability && !await IsReachableImageCachedAsync(input.ScreenshotUrl, httpFactory))
return "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB)."; return "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB).";
if (!EndpointHelpers.IsValidHttpUrl(input.GameUrl)) if (!EndpointHelpers.IsValidHttpUrl(input.GameUrl))
@@ -22,6 +28,21 @@ internal static class SuggestionValidator
return ValidatePlayers(input.MinPlayers, input.MaxPlayers); return ValidatePlayers(input.MinPlayers, input.MaxPlayers);
} }
private static async Task<bool> IsReachableImageCachedAsync(string? url, IHttpClientFactory httpFactory)
{
if (string.IsNullOrWhiteSpace(url))
return true;
var normalized = url.Trim();
if (ImageReachabilityCache.TryGetValue(normalized, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
return cached.Reachable;
var reachable = await EndpointHelpers.IsReachableImageAsync(normalized, httpFactory);
var ttl = reachable ? ReachableCacheTtl : UnreachableCacheTtl;
ImageReachabilityCache[normalized] = (reachable, DateTimeOffset.UtcNow.Add(ttl));
return reachable;
}
private static string? ValidatePlayers(int? minPlayers, int? maxPlayers) private static string? ValidatePlayers(int? minPlayers, int? maxPlayers)
{ {
if (minPlayers is null && maxPlayers is null) if (minPlayers is null && maxPlayers is null)

View File

@@ -7,7 +7,7 @@ namespace GameList.Endpoints;
internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFactory httpFactory) internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFactory httpFactory)
{ {
public async Task<IResult> GetMineAsync(Guid playerId) public async Task<ServiceResult<IReadOnlyList<SuggestionDto>>> GetMineAsync(Guid playerId)
{ {
var mine = await db.Suggestions var mine = await db.Suggestions
.AsNoTracking() .AsNoTracking()
@@ -29,18 +29,19 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
}) })
.ToListAsync(); .ToListAsync();
var ordered = mine IReadOnlyList<SuggestionDto> ordered = mine
.OrderBy(s => s.CreatedAt) .OrderBy(s => s.CreatedAt)
.Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId)); .Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId))
.ToList();
return Results.Ok(ordered); return ServiceResult<IReadOnlyList<SuggestionDto>>.Success(ordered);
} }
public async Task<IResult> CreateAsync(Guid playerId, SuggestionInput input) public async Task<ServiceResult<SuggestionCreatedResponse>> CreateAsync(Guid playerId, SuggestionInput input)
{ {
var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory); var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory);
if (validationError is not null) if (validationError is not null)
return EndpointHelpers.BadRequestError(validationError); return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest(validationError));
var playerState = await db.Players var playerState = await db.Players
.AsNoTracking() .AsNoTracking()
@@ -55,14 +56,14 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
var usingJoker = phase == Phase.Vote && playerState.HasJoker; var usingJoker = phase == Phase.Vote && playerState.HasJoker;
if (phase != Phase.Suggest && !usingJoker) if (phase != Phase.Suggest && !usingJoker)
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
if (string.IsNullOrWhiteSpace(playerState.DisplayName)) if (string.IsNullOrWhiteSpace(playerState.DisplayName))
return EndpointHelpers.BadRequestError("Set a display name before submitting suggestions."); return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest("Set a display name before submitting suggestions."));
var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == playerId); var existingCount = await db.Suggestions.AsNoTracking().CountAsync(s => s.PlayerId == playerId);
if (!usingJoker && existingCount >= 5) if (!usingJoker && existingCount >= 5)
return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit."); return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest("You have reached the 5 suggestion limit."));
var suggestion = new Suggestion var suggestion = new Suggestion
{ {
@@ -81,21 +82,29 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
db.Suggestions.Add(suggestion); db.Suggestions.Add(suggestion);
if (usingJoker) try
{ {
await db.Players await db.SaveChangesAsync();
.Where(p => p.Id == playerId)
.ExecuteUpdateAsync(p => p.SetProperty(x => x.HasJoker, false)); if (usingJoker)
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false)); {
await db.Players
.Where(p => p.Id == playerId)
.ExecuteUpdateAsync(p => p.SetProperty(x => x.HasJoker, false));
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
}
await tx.CommitAsync();
}
catch (DbUpdateException ex) when (EndpointHelpers.IsSqliteConstraintViolation(ex, EndpointHelpers.SuggestionLimitTriggerError))
{
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest("You have reached the 5 suggestion limit."));
} }
await db.SaveChangesAsync(); return ServiceResult<SuggestionCreatedResponse>.Success(new SuggestionCreatedResponse(suggestion.Id));
await tx.CommitAsync();
return Results.Created($"/api/suggestions/{suggestion.Id}", new SuggestionCreatedResponse(suggestion.Id));
} }
public async Task<IResult> DeleteAsync(Guid playerId, int suggestionId) public async Task<ServiceResult<Unit>> DeleteAsync(Guid playerId, int suggestionId)
{ {
var actor = await db.Players var actor = await db.Players
.AsNoTracking() .AsNoTracking()
@@ -111,14 +120,14 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
{ {
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Suggest) if (phase != Phase.Suggest)
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); return ServiceResult<Unit>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
} }
var suggestion = isAdmin var suggestion = isAdmin
? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId) ? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId)
: await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId && s.PlayerId == playerId); : await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId && s.PlayerId == playerId);
if (suggestion == null) if (suggestion == null)
return EndpointHelpers.NotFoundError("Suggestion not found."); return ServiceResult<Unit>.Failure(ServiceError.NotFound("Suggestion not found."));
await using var tx = await db.Database.BeginTransactionAsync(); await using var tx = await db.Database.BeginTransactionAsync();
@@ -131,15 +140,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
db.Suggestions.Remove(suggestion); db.Suggestions.Remove(suggestion);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
await tx.CommitAsync(); await tx.CommitAsync();
return Results.NoContent(); return ServiceResult<Unit>.Success(default);
} }
public async Task<IResult> UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input) public async Task<ServiceResult<SuggestionUpdatedResponse>> UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input)
{ {
var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory);
if (validationError is not null)
return EndpointHelpers.BadRequestError(validationError);
var actor = await db.Players var actor = await db.Players
.AsNoTracking() .AsNoTracking()
.Where(p => p.Id == playerId) .Where(p => p.Id == playerId)
@@ -151,17 +156,22 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId); var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId);
if (suggestion == null) if (suggestion == null)
return EndpointHelpers.NotFoundError("Suggestion not found."); return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.NotFound("Suggestion not found."));
var shouldValidateScreenshot = ShouldValidateScreenshotReachability(input.ScreenshotUrl, suggestion.ScreenshotUrl);
var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory, shouldValidateScreenshot);
if (validationError is not null)
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.BadRequest(validationError));
var isAdmin = actor.IsAdmin; var isAdmin = actor.IsAdmin;
if (!isAdmin) if (!isAdmin)
{ {
if (suggestion.PlayerId != playerId) if (suggestion.PlayerId != playerId)
return EndpointHelpers.UnauthorizedError(); return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.Unauthorized());
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase == Phase.Results) if (phase == Phase.Results)
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
if (phase == Phase.Suggest) if (phase == Phase.Suggest)
{ {
@@ -169,7 +179,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
} }
else if (phase != Phase.Vote) else if (phase != Phase.Vote)
{ {
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
} }
ApplyEditableFields(suggestion, input); ApplyEditableFields(suggestion, input);
@@ -182,7 +192,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(new SuggestionUpdatedResponse( return ServiceResult<SuggestionUpdatedResponse>.Success(new SuggestionUpdatedResponse(
suggestion.Id, suggestion.Id,
suggestion.Name, suggestion.Name,
suggestion.Genre, suggestion.Genre,
@@ -195,11 +205,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
)); ));
} }
public async Task<IResult> GetAllAsync(Guid playerId) public async Task<ServiceResult<IReadOnlyList<SuggestionAllDto>>> GetAllAsync(Guid playerId)
{ {
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase < Phase.Vote) if (phase < Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); return ServiceResult<IReadOnlyList<SuggestionAllDto>>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
var all = await db.Suggestions var all = await db.Suggestions
.AsNoTracking() .AsNoTracking()
@@ -225,12 +235,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId))); var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId)));
var nameLookup = all.ToDictionary(s => s.Id, s => s.Name); var nameLookup = all.ToDictionary(s => s.Id, s => s.Name);
var ordered = all.OrderBy(s => s.CreatedAt).Select(s => IReadOnlyList<SuggestionAllDto> ordered = all.OrderBy(s => s.CreatedAt).Select(s =>
{ {
var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex).Where(id => id != s.Id).ToList(); var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex).Where(id => id != s.Id).ToList();
return new return new SuggestionAllDto(
{
s.Id, s.Id,
s.Name, s.Name,
s.Genre, s.Genre,
@@ -243,12 +252,12 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
s.Author, s.Author,
s.ParentSuggestionId, s.ParentSuggestionId,
s.IsOwner, s.IsOwner,
LinkedIds = linkedIds, linkedIds,
LinkedTitles = linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList() linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList()
}; );
}); }).ToList();
return Results.Ok(ordered); return ServiceResult<IReadOnlyList<SuggestionAllDto>>.Success(ordered);
} }
private static void ApplyEditableFields(Suggestion suggestion, SuggestionInput input) private static void ApplyEditableFields(Suggestion suggestion, SuggestionInput input)
@@ -261,4 +270,10 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
suggestion.MinPlayers = input.MinPlayers; suggestion.MinPlayers = input.MinPlayers;
suggestion.MaxPlayers = input.MaxPlayers; suggestion.MaxPlayers = input.MaxPlayers;
} }
private static bool ShouldValidateScreenshotReachability(string? requestedScreenshotUrl, string? existingScreenshotUrl)
{
var normalizedRequested = EndpointHelpers.TrimTo(requestedScreenshotUrl, 2048);
return !string.Equals(normalizedRequested, existingScreenshotUrl, StringComparison.Ordinal);
}
} }

View File

@@ -9,7 +9,7 @@ public static class VoteEndpoints
{ {
public static void MapVoteEndpoints(this IEndpointRouteBuilder app) 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) => group.MapGet("/mine", async (HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
{ {
@@ -17,16 +17,19 @@ public static class VoteEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.GetMineAsync(player.Id); 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) => group.MapPost("/", async (VoteRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
{ {
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.UpsertAsync(player.Id, request.SuggestionId, request.Score);
}); 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) => group.MapPost("/finalize", async (VoteFinalizeRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
{ {
@@ -34,8 +37,9 @@ public static class VoteEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.SetFinalizeAsync(player.Id, request.Final); var result = await service.SetFinalizeAsync(player.Id, request.Final);
}); return result.ToHttpResult(Results.Ok);
}).WithName("SetVotesFinalized");
} }
} }

View File

@@ -2,34 +2,31 @@ using GameList.Contracts;
using GameList.Data; using GameList.Data;
using GameList.Domain; using GameList.Domain;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.ChangeTracking;
namespace GameList.Endpoints; namespace GameList.Endpoints;
internal sealed class VoteWorkflowService(AppDbContext db) internal sealed class VoteWorkflowService(AppDbContext db)
{ {
public async Task<IResult> GetMineAsync(Guid playerId) public async Task<ServiceResult<IReadOnlyList<VoteRecordDto>>> GetMineAsync(Guid playerId)
{ {
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Vote) if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); return ServiceResult<IReadOnlyList<VoteRecordDto>>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
var votes = await db.Votes IReadOnlyList<VoteRecordDto> votes = await db.Votes
.AsNoTracking() .AsNoTracking()
.Where(v => v.PlayerId == playerId) .Where(v => v.PlayerId == playerId)
.Select(v => new .Select(v => new VoteRecordDto(v.SuggestionId, v.Score))
{
v.SuggestionId,
v.Score
})
.ToListAsync(); .ToListAsync();
return Results.Ok(votes); return ServiceResult<IReadOnlyList<VoteRecordDto>>.Success(votes);
} }
public async Task<IResult> UpsertAsync(Guid playerId, int suggestionId, int score) public async Task<ServiceResult<VoteUpsertResponse>> UpsertAsync(Guid playerId, int suggestionId, int score)
{ {
if (score is < 0 or > 10) if (score is < 0 or > 10)
return EndpointHelpers.BadRequestError("Score must be between 0 and 10."); return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Score must be between 0 and 10."));
var playerState = await db.Players var playerState = await db.Players
.AsNoTracking() .AsNoTracking()
@@ -42,14 +39,14 @@ internal sealed class VoteWorkflowService(AppDbContext db)
.FirstAsync(); .FirstAsync();
if (playerState.VotesFinal) if (playerState.VotesFinal)
return EndpointHelpers.BadRequestError("Votes are finalized. Unfinalize before changing scores."); return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Votes are finalized. Unfinalize before changing scores."));
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Vote) if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
if (string.IsNullOrWhiteSpace(playerState.DisplayName)) if (string.IsNullOrWhiteSpace(playerState.DisplayName))
return EndpointHelpers.BadRequestError("Set a display name before voting."); return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Set a display name before voting."));
var linkMap = await db.Suggestions var linkMap = await db.Suggestions
.AsNoTracking() .AsNoTracking()
@@ -61,7 +58,7 @@ internal sealed class VoteWorkflowService(AppDbContext db)
.ToListAsync(); .ToListAsync();
var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId))); var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId)));
if (!rootIndex.ContainsKey(suggestionId)) if (!rootIndex.ContainsKey(suggestionId))
return EndpointHelpers.BadRequestError("Suggestion not found."); return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Suggestion not found."));
var linkedIds = EndpointHelpers.LinkedIdsFor(suggestionId, rootIndex); var linkedIds = EndpointHelpers.LinkedIdsFor(suggestionId, rootIndex);
if (linkedIds.Count == 0) if (linkedIds.Count == 0)
@@ -71,38 +68,67 @@ internal sealed class VoteWorkflowService(AppDbContext db)
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId)) .Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
.ToListAsync(); .ToListAsync();
foreach (var linkedSuggestionId in linkedIds) for (var attempt = 0; attempt < 2; attempt++)
{ {
var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId); foreach (var linkedSuggestionId in linkedIds)
if (vote == null)
{ {
db.Votes.Add(new Vote var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId);
if (vote == null)
{ {
PlayerId = playerId, db.Votes.Add(new Vote
SuggestionId = linkedSuggestionId, {
Score = score PlayerId = playerId,
}); SuggestionId = linkedSuggestionId,
Score = score
});
}
else
{
vote.Score = score;
}
} }
else
try
{ {
vote.Score = score; await db.SaveChangesAsync();
return ServiceResult<VoteUpsertResponse>.Success(new VoteUpsertResponse(linkedIds, score));
}
catch (DbUpdateException ex) when (attempt == 0 && EndpointHelpers.IsSqliteConstraintViolation(ex))
{
DetachAddedVotes(db.ChangeTracker.Entries<Vote>());
await db.Votes
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
.ExecuteUpdateAsync(v => v.SetProperty(x => x.Score, score));
existingVotes = await db.Votes
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
.ToListAsync();
} }
} }
await db.SaveChangesAsync(); return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.Conflict("Vote update conflict. Please retry."));
return Results.Ok(new VoteUpsertResponse(linkedIds, score));
} }
public async Task<IResult> SetFinalizeAsync(Guid playerId, bool final) public async Task<ServiceResult<VoteFinalizeResponse>> SetFinalizeAsync(Guid playerId, bool final)
{ {
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Vote) if (phase != Phase.Vote)
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); return ServiceResult<VoteFinalizeResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
var player = await db.Players.FirstAsync(p => p.Id == playerId); var player = await db.Players.FirstAsync(p => p.Id == playerId);
player.VotesFinal = final; player.VotesFinal = final;
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return Results.Ok(new VoteFinalizeResponse(player.VotesFinal)); return ServiceResult<VoteFinalizeResponse>.Success(new VoteFinalizeResponse(player.VotesFinal));
}
private static void DetachAddedVotes(IEnumerable<EntityEntry<Vote>> voteEntries)
{
foreach (var entry in voteEntries)
{
if (entry.State == EntityState.Added)
entry.State = EntityState.Detached;
}
} }
} }

View File

@@ -94,6 +94,79 @@ public class AdminTests
}); });
} }
[Fact]
public async Task Admin_can_grant_and_revoke_admin_for_non_owner_accounts()
{
await using var factory = new TestWebApplicationFactory();
var owner = factory.CreateClientWithCookies();
await owner.RegisterAsync("owner", admin: true);
var player = factory.CreateClientWithCookies();
await player.RegisterAsync("player");
var playerId = await player.GetProfileIdAsync();
var grant = await owner.PostAsJsonAsync("/api/admin/player-admin", new
{
playerId,
isAdmin = true
});
grant.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db =>
{
var promoted = await db.Players.AsNoTracking().SingleAsync(p => p.Id == playerId);
Assert.True(promoted.IsAdmin);
Assert.False(promoted.IsOwner);
});
var revoke = await owner.PostAsJsonAsync("/api/admin/player-admin", new
{
playerId,
isAdmin = false
});
revoke.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(async db =>
{
var demoted = await db.Players.AsNoTracking().SingleAsync(p => p.Id == playerId);
Assert.False(demoted.IsAdmin);
});
}
[Fact]
public async Task Owner_admin_role_cannot_be_changed_or_deleted()
{
await using var factory = new TestWebApplicationFactory();
var owner = factory.CreateClientWithCookies();
await owner.RegisterAsync("owner", admin: true);
var ownerId = await owner.GetProfileIdAsync();
var toggleOwner = await owner.PostAsJsonAsync("/api/admin/player-admin", new
{
playerId = ownerId,
isAdmin = false
});
Assert.Equal(HttpStatusCode.BadRequest, toggleOwner.StatusCode);
var deleteOwner = await owner.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{ownerId}") { Content = JsonContent.Create(new { password = AdminPassword }) });
Assert.Equal(HttpStatusCode.BadRequest, deleteOwner.StatusCode);
}
[Fact]
public async Task Set_player_admin_returns_not_found_for_unknown_player()
{
await using var factory = new TestWebApplicationFactory();
var owner = factory.CreateClientWithCookies();
await owner.RegisterAsync("owner", admin: true);
var response = await owner.PostAsJsonAsync("/api/admin/player-admin", new
{
playerId = Guid.NewGuid(),
isAdmin = true
});
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
}
[Fact] [Fact]
public async Task Admin_player_phase_requires_vote_phase_and_suggest_target() public async Task Admin_player_phase_requires_vote_phase_and_suggest_target()
{ {
@@ -136,10 +209,7 @@ public class AdminTests
Score = 8 Score = 8
}); });
var resp = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{await player.GetProfileIdAsync()}") var resp = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{await player.GetProfileIdAsync()}") { Content = JsonContent.Create(new { password = AdminPassword }) });
{
Content = JsonContent.Create(new { password = AdminPassword })
});
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
await factory.WithDbContextAsync(db => await factory.WithDbContextAsync(db =>
@@ -554,10 +624,7 @@ public class AdminTests
var playerId = await factory.WithDbContextAsync(async db => await db.Players.Where(p => p.Username == "target").Select(p => p.Id).SingleAsync()); var playerId = await factory.WithDbContextAsync(async db => await db.Players.Where(p => p.Username == "target").Select(p => p.Id).SingleAsync());
var deleteWrongPassword = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{playerId}") var deleteWrongPassword = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{playerId}") { Content = JsonContent.Create(new { password = "wrong" }) });
{
Content = JsonContent.Create(new { password = "wrong" })
});
Assert.Equal(HttpStatusCode.BadRequest, deleteWrongPassword.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, deleteWrongPassword.StatusCode);
} }
} }

View File

@@ -1,6 +1,7 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using GameList.Domain;
using GameList.Infrastructure; using GameList.Infrastructure;
using GameList.Tests.Support; using GameList.Tests.Support;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -33,6 +34,7 @@ public class AuthTests
Assert.True(player.DisplayName!.Length <= 16); Assert.True(player.DisplayName!.Length <= 16);
Assert.NotEqual(Array.Empty<byte>(), player.PasswordHash); Assert.NotEqual(Array.Empty<byte>(), player.PasswordHash);
Assert.NotEqual(Array.Empty<byte>(), player.PasswordSalt); Assert.NotEqual(Array.Empty<byte>(), player.PasswordSalt);
Assert.Equal(PasswordHasher.CurrentVersion, player.PasswordHashVersion);
}); });
} }
@@ -62,6 +64,24 @@ public class AuthTests
Assert.Equal(HttpStatusCode.BadRequest, displayResp.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, displayResp.StatusCode);
} }
[Fact]
public async Task Register_rejects_weak_passwords()
{
await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
var weak = await client.PostAsJsonAsync("/api/auth/register", new
{
Username = "weakpw",
Password = "alllowercase1!",
DisplayName = "weak"
});
Assert.Equal(HttpStatusCode.BadRequest, weak.StatusCode);
var json = await weak.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("Password must include at least one uppercase and one lowercase characters and and digit.", json.GetProperty("error").GetString());
}
[Fact] [Fact]
public async Task Login_sets_last_login_and_fills_missing_display_name() public async Task Login_sets_last_login_and_fills_missing_display_name()
{ {
@@ -88,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] [Fact]
public async Task Register_with_admin_key_sets_admin_flag() public async Task Register_with_admin_key_sets_admin_flag()
{ {
@@ -99,6 +150,46 @@ public class AuthTests
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var json = await response.Content.ReadFromJsonAsync<JsonElement>(); var json = await response.Content.ReadFromJsonAsync<JsonElement>();
Assert.True(json.GetProperty("isAdmin").GetBoolean()); Assert.True(json.GetProperty("isAdmin").GetBoolean());
await factory.WithDbContextAsync(async db =>
{
var owner = await db.Players.AsNoTracking().SingleAsync(p => p.Username == "adminuser");
Assert.True(owner.IsOwner);
Assert.True(owner.IsAdmin);
});
}
[Fact]
public async Task Register_admin_key_is_bootstrap_only()
{
await using var factory = new TestWebApplicationFactory();
var first = factory.CreateClientWithCookies();
var second = factory.CreateClientWithCookies();
var firstAdmin = await first.RegisterAsync("firstadmin", admin: true);
firstAdmin.EnsureSuccessStatusCode();
var secondAdmin = await second.RegisterAsync("secondadmin", admin: true);
Assert.Equal(HttpStatusCode.BadRequest, secondAdmin.StatusCode);
var body = await secondAdmin.Content.ReadFromJsonAsync<JsonElement>();
Assert.Equal("Admin registration via admin key is disabled once an owner account exists.", body.GetProperty("error").GetString());
}
[Fact]
public async Task Auth_options_reports_owner_existence()
{
await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
var before = await client.GetFromJsonAsync<JsonElement>("/api/auth/options");
Assert.False(before.GetProperty("ownerExists").GetBoolean());
var ownerRegister = await client.RegisterAsync("owner", admin: true);
ownerRegister.EnsureSuccessStatusCode();
var after = await client.GetFromJsonAsync<JsonElement>("/api/auth/options");
Assert.True(after.GetProperty("ownerExists").GetBoolean());
} }
[Fact] [Fact]
@@ -152,6 +243,29 @@ public class AuthTests
Assert.Equal(HttpStatusCode.BadRequest, badKey.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, badKey.StatusCode);
} }
[Fact]
public async Task Register_and_login_with_null_fields_return_bad_request()
{
await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
var register = await client.PostAsJsonAsync("/api/auth/register", new
{
Username = (string?)null,
Password = (string?)null,
DisplayName = (string?)null,
AdminKey = (string?)null
});
Assert.Equal(HttpStatusCode.BadRequest, register.StatusCode);
var login = await client.PostAsJsonAsync("/api/auth/login", new
{
Username = (string?)null,
Password = (string?)null
});
Assert.Equal(HttpStatusCode.BadRequest, login.StatusCode);
}
[Fact] [Fact]
public async Task Non_admin_cannot_access_admin_routes() public async Task Non_admin_cannot_access_admin_routes()
{ {
@@ -189,4 +303,32 @@ public class AuthTests
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
Assert.True(resp.Headers.TryGetValues("Set-Cookie", out var cookies) && cookies.Any(c => c.Contains("player"))); Assert.True(resp.Headers.TryGetValues("Set-Cookie", out var cookies) && cookies.Any(c => c.Contains("player")));
} }
[Fact]
public async Task Owner_uniqueness_is_enforced_by_database_constraint()
{
await using var factory = new TestWebApplicationFactory();
var ownerClient = factory.CreateClientWithCookies();
await ownerClient.RegisterAsync("owner1", admin: true);
var thrown = await Assert.ThrowsAsync<DbUpdateException>(() => factory.WithDbContextAsync(async db =>
{
var (hash, salt) = PasswordHasher.HashPassword("Pass123!");
db.Players.Add(new Player
{
Id = Guid.NewGuid(),
Username = "owner2",
NormalizedUsername = "owner2",
PasswordHash = hash,
PasswordSalt = salt,
DisplayName = "Owner2",
IsOwner = true,
IsAdmin = true
});
await db.SaveChangesAsync();
}));
Assert.Contains("Players.IsOwner", thrown.InnerException?.Message ?? thrown.Message, StringComparison.OrdinalIgnoreCase);
}
} }

View File

@@ -9,7 +9,6 @@ using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using System.Text.Json; using System.Text.Json;
using System.Net.Http.Json; using System.Net.Http.Json;
@@ -22,40 +21,41 @@ public class HelperTests
public void PasswordHasher_roundtrip_and_empty_guard() public void PasswordHasher_roundtrip_and_empty_guard()
{ {
var (hash, salt) = PasswordHasher.HashPassword("secret"); 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.False(PasswordHasher.Verify("other", hash, salt));
Assert.Throws<ArgumentException>(() => PasswordHasher.HashPassword("")); Assert.Throws<ArgumentException>(() => PasswordHasher.HashPassword(""));
} }
[Fact] [Fact]
public void UpdateIndexMetaBase_rewrites_content_value() public void Program_does_not_include_runtime_index_rewrite_hook()
{ {
var webRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); var hasRewriteMethod = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).Any(m => m.Name.Contains("UpdateIndexMetaBase", StringComparison.Ordinal));
Directory.CreateDirectory(webRoot); Assert.False(hasRewriteMethod);
var index = Path.Combine(webRoot, "index.html");
File.WriteAllText(index, "<meta name=\"app-base\" content=\"\">");
var env = new FakeEnv { WebRootPath = webRoot };
var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("UpdateIndexMetaBase"));
method.Invoke(null, [env, "/pick"]);
var text = File.ReadAllText(index);
Assert.Contains("content=\"/pick\"", text);
} }
[Fact] [Fact]
public void UpdateIndexMetaBase_no_marker_no_change() public async Task OpenApi_document_exposes_stable_operation_ids()
{ {
var webRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); await using var factory = new TestWebApplicationFactory();
Directory.CreateDirectory(webRoot); var client = factory.CreateClient();
var index = Path.Combine(webRoot, "index.html");
File.WriteAllText(index, "<html></html>");
var env = new FakeEnv { WebRootPath = webRoot }; var response = await client.GetAsync("/openapi/v1.json");
var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("UpdateIndexMetaBase")); response.EnsureSuccessStatusCode();
method.Invoke(null, [env, "/pick"]);
Assert.Equal("<html></html>", File.ReadAllText(index)); 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] [Fact]
@@ -142,6 +142,31 @@ public class HelperTests
Assert.False(result); Assert.False(result);
} }
[Fact]
public async Task IsReachableImageAsync_rejects_private_and_reserved_ranges()
{
var factory = new StubHttpClientFactory(new StubHttpMessageHandler());
var blockedUrls = new[]
{
"http://0.0.0.1/img.png",
"http://10.0.0.1/img.png",
"http://100.64.1.1/img.png",
"http://169.254.169.254/img.png",
"http://192.168.0.20/img.png",
"http://198.51.100.2/img.png",
"http://203.0.113.8/img.png",
"http://[::1]/img.png",
"http://[fc00::1]/img.png",
"http://[::ffff:127.0.0.1]/img.png"
};
foreach (var url in blockedUrls)
{
var reachable = await EndpointHelpers.IsReachableImageAsync(url, factory);
Assert.False(reachable);
}
}
[Fact] [Fact]
public void Link_root_helpers_handle_groups() public void Link_root_helpers_handle_groups()
{ {
@@ -252,14 +277,80 @@ public class HelperTests
} }
} }
private class FakeEnv : IWebHostEnvironment [Fact]
public async Task Security_headers_are_applied_to_responses()
{ {
public string ApplicationName { get; set; } = ""; await using var factory = new TestWebApplicationFactory();
public IFileProvider WebRootFileProvider { get; set; } = null!; var client = factory.CreateClient();
public string WebRootPath { get; set; } = "";
public string EnvironmentName { get; set; } = ""; var response = await client.GetAsync("/health");
public string ContentRootPath { get; set; } = ""; response.EnsureSuccessStatusCode();
public IFileProvider ContentRootFileProvider { get; set; } = null!;
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());
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]
public async Task Auth_endpoints_are_rate_limited()
{
await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("ratelimit-user");
HttpResponseMessage? last = null;
for (var i = 0; i < 8; i++)
{
last = await client.PostAsJsonAsync("/api/auth/login", new
{
Username = "ratelimit-user",
Password = "wrong-pass"
});
}
Assert.NotNull(last);
Assert.Equal(HttpStatusCode.TooManyRequests, last!.StatusCode);
}
[Fact]
public async Task Admin_endpoints_are_rate_limited()
{
await using var factory = new TestWebApplicationFactory();
var admin = factory.CreateClientWithCookies();
await admin.RegisterAsync("ratelimit-admin", admin: true);
HttpResponseMessage? last = null;
for (var i = 0; i < 25; i++)
{
last = await admin.GetAsync("/api/admin/vote-status");
if (last.StatusCode == HttpStatusCode.TooManyRequests)
break;
}
Assert.NotNull(last);
Assert.Equal(HttpStatusCode.TooManyRequests, last!.StatusCode);
}
[Fact]
public void Frontend_regressions_prevent_modal_html_interpolation_for_untrusted_values()
{
var root = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
var modalJsPath = Path.Combine(root, "wwwroot", "js", "modals-ui.js");
var adminJsPath = Path.Combine(root, "wwwroot", "js", "admin-ui.js");
var modalJs = File.ReadAllText(modalJsPath);
var adminJs = File.ReadAllText(adminJsPath);
Assert.DoesNotContain("<h3>${title}</h3>", modalJs, StringComparison.Ordinal);
Assert.DoesNotContain("<p>${body}</p>", modalJs, StringComparison.Ordinal);
Assert.Contains("heading.textContent = title ?? \"\";", modalJs, StringComparison.Ordinal);
Assert.Contains("bodyText.textContent = body ?? \"\";", modalJs, StringComparison.Ordinal);
Assert.DoesNotContain("data-name=\"${v.name}\"", adminJs, StringComparison.Ordinal);
} }
private static ForwardedHeadersOptions BuildForwardedHeadersOptionsForTest(IConfiguration config) private static ForwardedHeadersOptions BuildForwardedHeadersOptionsForTest(IConfiguration config)

View File

@@ -1,4 +1,6 @@
using System.Net; using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using GameList.Tests.Support; using GameList.Tests.Support;
namespace GameList.Tests; namespace GameList.Tests;
@@ -36,4 +38,49 @@ public class MiddlewareTests
var resp = await client.GetAsync("/api/state"); var resp = await client.GetAsync("/api/state");
Assert.Equal(HttpStatusCode.OK, resp.StatusCode); 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());
}
} }

View File

@@ -95,6 +95,51 @@ public class ResultsTests
Assert.Equal("High", results[0].GetProperty("name").GetString()); Assert.Equal("High", results[0].GetProperty("name").GetString());
Assert.Equal(9, (int)results[0].GetProperty("average").GetDouble()); Assert.Equal(9, (int)results[0].GetProperty("average").GetDouble());
Assert.Equal(1, results[0].GetProperty("count").GetInt32()); 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("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);
} }
} }

View File

@@ -27,6 +27,11 @@ public class StateTests
var state = await client.GetFromJsonAsync<JsonElement>("/api/state"); 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.Equal(nameof(Phase.Suggest), state.GetProperty("currentPhase").GetString());
Assert.False(state.GetProperty("votesFinal").GetBoolean()); Assert.False(state.GetProperty("votesFinal").GetBoolean());
Assert.True(state.GetProperty("hasJoker").GetBoolean()); Assert.True(state.GetProperty("hasJoker").GetBoolean());
@@ -335,6 +340,118 @@ public class StateTests
Assert.Equal(Phase.Results, phase); 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;
}
}
} }

View File

@@ -1,6 +1,7 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using GameList.Domain;
using GameList.Tests.Support; using GameList.Tests.Support;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -347,6 +348,45 @@ public class SuggestionTests
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
} }
[Fact]
public async Task Update_does_not_revalidate_unchanged_screenshot_url()
{
await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("reval");
var create = await client.PostAsJsonAsync("/api/suggestions", new
{
Name = "Reachable once",
Genre = (string?)null,
Description = (string?)null,
ScreenshotUrl = "http://example.com/shot.png",
YoutubeUrl = (string?)null,
GameUrl = (string?)null,
MinPlayers = (int?)null,
MaxPlayers = (int?)null
});
create.EnsureSuccessStatusCode();
var createdPayload = await create.Content.ReadFromJsonAsync<JsonElement>();
var suggestionId = createdPayload.GetProperty("id").GetInt32();
factory.HttpHandler.SetResponder(_ => new HttpResponseMessage(HttpStatusCode.BadRequest));
var update = await client.PutAsJsonAsync($"/api/suggestions/{suggestionId}", new
{
Name = "Reachable once",
Genre = "Updated",
Description = (string?)null,
ScreenshotUrl = "http://example.com/shot.png",
YoutubeUrl = (string?)null,
GameUrl = (string?)null,
MinPlayers = (int?)null,
MaxPlayers = (int?)null
});
update.EnsureSuccessStatusCode();
}
[Fact] [Fact]
public async Task Get_all_requires_vote_phase() public async Task Get_all_requires_vote_phase()
{ {
@@ -626,4 +666,41 @@ public class SuggestionTests
Assert.False(db.Votes.Any(v => v.SuggestionId == id)); Assert.False(db.Votes.Any(v => v.SuggestionId == id));
}); });
} }
[Fact]
public async Task Suggestion_limit_is_enforced_by_database_trigger_without_joker()
{
await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
await client.RegisterAsync("dbcap");
var playerId = await factory.WithDbContextAsync(async db => await db.Players.Select(p => p.Id).SingleAsync());
await factory.WithDbContextAsync(async db =>
{
for (var i = 0; i < 5; i++)
{
db.Suggestions.Add(new Suggestion
{
PlayerId = playerId,
Name = $"Seed {i}"
});
}
await db.SaveChangesAsync();
});
var thrown = await Assert.ThrowsAsync<DbUpdateException>(() => factory.WithDbContextAsync(async db =>
{
db.Suggestions.Add(new Suggestion
{
PlayerId = playerId,
Name = "Blocked by trigger"
});
await db.SaveChangesAsync();
}));
Assert.Contains("suggestion_limit_exceeded", thrown.InnerException?.Message ?? thrown.Message, StringComparison.OrdinalIgnoreCase);
}
} }

View File

@@ -26,7 +26,7 @@ internal class TestWebApplicationFactory : WebApplicationFactory<Program>
services.Remove(descriptor); services.Remove(descriptor);
} }
_connection = new SqliteConnection("Data Source=:memory:;Cache=Shared"); _connection = new SqliteConnection($"Data Source=file:tests-{Guid.NewGuid():N}?mode=memory&cache=shared");
_connection.Open(); _connection.Open();
services.AddDbContext<AppDbContext>(options => { options.UseSqlite(_connection); }); services.AddDbContext<AppDbContext>(options => { options.UseSqlite(_connection); });
@@ -44,7 +44,6 @@ internal class TestWebApplicationFactory : WebApplicationFactory<Program>
using var scope = host.Services.CreateScope(); using var scope = host.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
db.Database.Migrate(); db.Database.Migrate();
return host; return host;
@@ -77,10 +76,18 @@ internal class TestWebApplicationFactory : WebApplicationFactory<Program>
public HttpClient CreateClientWithCookies() public HttpClient CreateClientWithCookies()
{ {
return CreateClient(new WebApplicationFactoryClientOptions var client = CreateClient(new WebApplicationFactoryClientOptions
{ {
HandleCookies = true, HandleCookies = true,
AllowAutoRedirect = false AllowAutoRedirect = false
}); });
if (client.BaseAddress is { } baseAddress)
{
var origin = $"{baseAddress.Scheme}://{baseAddress.Authority}";
client.DefaultRequestHeaders.TryAddWithoutValidation("Origin", origin);
}
return client;
} }
} }

View 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>

View File

@@ -4,14 +4,22 @@
<TargetFramework>net10.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
<OpenApiGenerateDocuments>true</OpenApiGenerateDocuments>
<OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)\openapi</OpenApiDocumentsDirectory>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <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"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" /> <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>
<ItemGroup> <ItemGroup>

15
IIS.md
View File

@@ -8,6 +8,7 @@
## Publish ## Publish
- From repo root: `dotnet publish -c Release -o publish` - From repo root: `dotnet publish -c Release -o publish`
- Before first start (and after every new migration): run `dotnet ef database update` from repo root against the target environment.
- Copy `publish/` contents to site directory (keep `App_Data` writable by the app pool user). - Copy `publish/` contents to site directory (keep `App_Data` writable by the app pool user).
- Set environment variables in web.config or IIS config: - Set environment variables in web.config or IIS config:
- `ASPNETCORE_ENVIRONMENT=Production` - `ASPNETCORE_ENVIRONMENT=Production`
@@ -16,10 +17,22 @@
- Configure trusted reverse proxies/networks for forwarded headers (do not trust all sources): - Configure trusted reverse proxies/networks for forwarded headers (do not trust all sources):
- `ForwardedHeaders__KnownProxies__0=10.0.0.10` - `ForwardedHeaders__KnownProxies__0=10.0.0.10`
- `ForwardedHeaders__KnownNetworks__0=10.0.0.0/24` - `ForwardedHeaders__KnownNetworks__0=10.0.0.0/24`
- Configure allowed hostnames explicitly (do not use wildcard in production):
- `AllowedHosts=picknplay.example.com;www.picknplay.example.com`
- Optional: enable stdout logging in `web.config` during troubleshooting only; disable afterward. - Optional: enable stdout logging in `web.config` during troubleshooting only; disable afterward.
- Data protection keys are persisted to `App_Data/keys`; ensure this folder is deployed and writable so auth cookies stay valid across app pool recycles. - Data protection keys are persisted to `App_Data/keys`; ensure this folder is deployed and writable so auth cookies stay valid across app pool recycles.
- Frontend base path: set `<meta name="app-base" content="/picknplay">` in `wwwroot/index.html` for production so API calls include the subpath (keep blank for local/root). - Frontend base path is injected during deployment by `scripts/deploy-ftp.ps1` using deploy profile `BasePath` (falls back to last `RemoteDir` segment if omitted). This keeps local `wwwroot/index.html` unchanged while production API calls target `/picknplay/api`.
- Deployment script: copy `scripts/deploy-ftp.profile.sample.psd1` to `scripts/deploy-ftp.profile.psd1`, fill environment values, then run `pwsh ./scripts/deploy-ftp.ps1 -ProfilePath ./scripts/deploy-ftp.profile.psd1`.
- Shortcut command: run `pwsh ./deploy.ps1` from repo root to deploy with the local profile directly.
- Prefer `WinScpSessionName` in the deploy profile to avoid embedding FTP credentials in scripted URLs.
## Permissions ## Permissions
- Grant modify rights to the app pool identity on `App_Data` (DB file + wal). - Grant modify rights to the app pool identity on `App_Data` (DB file + wal).
- Ensure firewall/HTTPS bindings match `applicationUrl` configured in IIS. - Ensure firewall/HTTPS bindings match `applicationUrl` configured in IIS.
## Security Checklist
- Verify HTTPS binding/certificate is active before exposing the site publicly.
- Confirm `Strict-Transport-Security` is present in production responses.
- Confirm baseline headers are present (`Content-Security-Policy`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`).
- Confirm `AllowedHosts` contains only your actual IIS hostnames.
- Confirm trusted proxy lists are explicit and minimal.

View File

@@ -0,0 +1,60 @@
using System.Collections.Concurrent;
namespace GameList.Infrastructure;
public sealed class AuthAttemptMonitor(ILogger<AuthAttemptMonitor> logger)
{
private static readonly TimeSpan FailureWindow = TimeSpan.FromMinutes(10);
private const int AlertThreshold = 5;
private static readonly Action<ILogger, string, string, string, string, int, Exception?> LogAuthFailure = LoggerMessage.Define<string, string, string, string, int>(LogLevel.Warning, new EventId(2001, nameof(LogAuthFailure)), "Auth failure scope={Scope} actor={Actor} ip={Ip} reason={Reason} failuresInWindow={Count}");
private static readonly Action<ILogger, string, string, string, int, double, Exception?> LogSecurityAlert = LoggerMessage.Define<string, string, string, int, double>(LogLevel.Error, new EventId(2002, nameof(LogSecurityAlert)), "Security alert: repeated auth failures scope={Scope} actor={Actor} ip={Ip} failuresInWindow={Count} windowMinutes={WindowMinutes}");
private static readonly Action<ILogger, string, string, string, Exception?> LogRateLimited = LoggerMessage.Define<string, string, string>(LogLevel.Warning, new EventId(2003, nameof(LogRateLimited)), "Rate limit rejection path={Path} ip={Ip} userId={UserId}");
private static readonly Action<ILogger, string, string, DateTimeOffset, Exception?> LogSessionExpired = LoggerMessage.Define<string, string, DateTimeOffset>(LogLevel.Warning, new EventId(2004, nameof(LogSessionExpired)), "Session expired by absolute lifetime path={Path} ip={Ip} startedAt={StartedAt:o}");
private readonly ConcurrentDictionary<string, AttemptState> _failures = new(StringComparer.Ordinal);
public void RecordFailure(HttpContext context, string scope, string actor, string reason)
{
var now = DateTimeOffset.UtcNow;
var key = BuildKey(context, scope, actor);
var state = _failures.AddOrUpdate(key, _ => new AttemptState(1, now, now), (_, previous) => previous.LastFailureAt + FailureWindow < now
? new AttemptState(1, now, now)
: previous with
{
Count = previous.Count + 1,
LastFailureAt = now
});
LogAuthFailure(logger, scope, actor, GetRemoteIp(context), reason, state.Count, null);
if (state.Count >= AlertThreshold && state.Count % AlertThreshold == 0)
{
LogSecurityAlert(logger, scope, actor, GetRemoteIp(context), state.Count, FailureWindow.TotalMinutes, null);
}
}
public void RecordSuccess(HttpContext context, string scope, string actor)
{
_failures.TryRemove(BuildKey(context, scope, actor), out _);
}
public void RecordRateLimited(HttpContext context)
{
LogRateLimited(logger, context.Request.Path.Value ?? string.Empty, GetRemoteIp(context), context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "anonymous", null);
}
public void RecordSessionExpired(HttpContext context, DateTimeOffset startedAt)
{
LogSessionExpired(logger, context.Request.Path.Value ?? string.Empty, GetRemoteIp(context), startedAt, null);
}
private static string BuildKey(HttpContext context, string scope, string actor)
{
return $"{scope}|{actor}|{GetRemoteIp(context)}";
}
private static string GetRemoteIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip";
private readonly record struct AttemptState(int Count, DateTimeOffset FirstFailureAt, DateTimeOffset LastFailureAt);
}

View 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);
}
}

View File

@@ -1,4 +1,5 @@
using GameList.Data; using GameList.Data;
using GameList.Domain;
using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Authentication;
namespace GameList.Infrastructure; namespace GameList.Infrastructure;
@@ -10,12 +11,22 @@ public class EnsurePlayerExistsMiddleware(RequestDelegate next)
if (context.User.Identity?.IsAuthenticated == true) if (context.User.Identity?.IsAuthenticated == true)
{ {
var id = context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value; 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(); await context.SignOutAsync();
context.Response.StatusCode = StatusCodes.Status401Unauthorized; context.Response.StatusCode = StatusCodes.Status401Unauthorized;
return; 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); await next(context);

View File

@@ -1,35 +1,105 @@
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text; using System.Text;
using Konscious.Security.Cryptography;
namespace GameList.Infrastructure; namespace GameList.Infrastructure;
public static class PasswordHasher 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 SaltSize = 16;
private const int KeySize = 32; private const int KeySize = 32;
private const int Iterations = 100_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) 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)) if (string.IsNullOrEmpty(password))
throw new ArgumentException("Password required", nameof(password)); throw new ArgumentException("Password required", nameof(password));
var normalizedVersion = NormalizeHashVersion(version);
var salt = RandomNumberGenerator.GetBytes(SaltSize); var salt = RandomNumberGenerator.GetBytes(SaltSize);
var hash = PBKDF2(password, salt); var hash = Derive(password, salt, normalizedVersion);
return (hash, salt); return (hash, salt);
} }
public static bool Verify(string password, byte[] hash, byte[] 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) if (hash.Length == 0 || salt.Length == 0)
return false; return false;
var computed = PBKDF2(password, salt); var normalizedVersion = NormalizeVerifyVersion(version);
return CryptographicOperations.FixedTimeEquals(computed, hash); 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);
} }
} }

View 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);
}
}

View 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);
}

View File

@@ -1,13 +1,18 @@
using GameList.Data; using GameList.Data;
using GameList.Endpoints; using GameList.Endpoints;
using GameList.Infrastructure; using GameList.Infrastructure;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.Cookies;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Data.Sqlite; using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Net; using System.Net;
using System.Security.Claims;
using System.Globalization;
using System.Threading.RateLimiting;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
@@ -40,11 +45,59 @@ builder.Services.AddScoped<VoteWorkflowService>();
builder.Services.AddScoped<AdminWorkflowService>(); builder.Services.AddScoped<AdminWorkflowService>();
builder.Services.AddScoped<ResultsWorkflowService>(); builder.Services.AddScoped<ResultsWorkflowService>();
builder.Services.AddScoped<StateWorkflowService>(); 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()); }); builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); });
builder.Services.AddHttpClient("imageValidation").ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler { AllowAutoRedirect = false }); builder.Services.AddHttpClient("imageValidation").ConfigurePrimaryHttpMessageHandler(EndpointHelpers.CreateImageValidationHandler);
builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(dataProtectionDirectory)); builder.Services.AddDataProtection().PersistKeysToFileSystem(new DirectoryInfo(dataProtectionDirectory));
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.OnRejected = async (context, token) =>
{
var monitor = context.HttpContext.RequestServices.GetRequiredService<AuthAttemptMonitor>();
monitor.RecordRateLimited(context.HttpContext);
if (context.HttpContext.Response.HasStarted)
return;
context.HttpContext.Response.ContentType = "application/problem+json";
var problem = new ProblemDetails
{
Status = StatusCodes.Status429TooManyRequests,
Title = "Too Many Requests",
Detail = "Too many requests. Please try again shortly.",
Extensions = { ["error"] = "Too many requests. Please try again shortly." }
};
await context.HttpContext.Response.WriteAsJsonAsync(problem, cancellationToken: token);
};
options.AddPolicy("auth-sensitive", context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: BuildAuthRateLimitKey(context),
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 6,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
AutoReplenishment = true
}));
options.AddPolicy("admin-sensitive", context =>
RateLimitPartition.GetSlidingWindowLimiter(
partitionKey: BuildAdminRateLimitKey(context),
factory: _ => new SlidingWindowRateLimiterOptions
{
PermitLimit = 20,
Window = TimeSpan.FromMinutes(1),
SegmentsPerWindow = 4,
QueueLimit = 0,
AutoReplenishment = true
}));
});
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options => builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options =>
{ {
@@ -53,9 +106,11 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
options.Cookie.SameSite = SameSiteMode.Strict; options.Cookie.SameSite = SameSiteMode.Strict;
options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always;
options.SlidingExpiration = true; options.SlidingExpiration = true;
options.ExpireTimeSpan = TimeSpan.FromDays(30); options.ExpireTimeSpan = TimeSpan.FromHours(12);
options.Events = new CookieAuthenticationEvents options.Events = new CookieAuthenticationEvents
{ {
OnSigningIn = EnsureSessionStartAsync,
OnValidatePrincipal = ValidateSessionLifetimeAsync,
OnRedirectToLogin = ctx => WriteUnauthorizedChallengeAsync(ctx.HttpContext), OnRedirectToLogin = ctx => WriteUnauthorizedChallengeAsync(ctx.HttpContext),
OnRedirectToAccessDenied = ctx => WriteUnauthorizedChallengeAsync(ctx.HttpContext) OnRedirectToAccessDenied = ctx => WriteUnauthorizedChallengeAsync(ctx.HttpContext)
}; };
@@ -66,30 +121,47 @@ builder.Services.AddAuthorization(options => { options.AddPolicy(PlayerIdentityE
var app = builder.Build(); var app = builder.Build();
app.UseForwardedHeaders(BuildForwardedHeadersOptions(builder.Configuration)); app.UseForwardedHeaders(BuildForwardedHeadersOptions(builder.Configuration));
app.UseRateLimiter();
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
app.UseHttpsRedirection();
}
app.Use(async (ctx, next) =>
{
ctx.Response.OnStarting(() =>
{
var headers = ctx.Response.Headers;
headers["X-Content-Type-Options"] = "nosniff";
headers["X-Frame-Options"] = "DENY";
headers["Referrer-Policy"] = "no-referrer";
headers["Permissions-Policy"] = "camera=(), geolocation=(), microphone=()";
headers["Content-Security-Policy"] =
"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;
});
await next();
});
var basePath = builder.Configuration["BasePath"]; var basePath = builder.Configuration["BasePath"];
if (!string.IsNullOrWhiteSpace(basePath)) if (!string.IsNullOrWhiteSpace(basePath))
{ {
app.UsePathBase(basePath); app.UsePathBase(basePath);
UpdateIndexMetaBase(app.Environment, basePath);
} }
app.UseGlobalExceptionLogging(); app.UseGlobalExceptionLogging();
app.UseAuthentication(); app.UseAuthentication();
app.UseMiddleware<EnsurePlayerExistsMiddleware>(); app.UseMiddleware<EnsurePlayerExistsMiddleware>();
app.UseMiddleware<CsrfProtectionMiddleware>();
app.UseAuthorization(); app.UseAuthorization();
app.UseMiddleware<StateChangeNotificationMiddleware>();
// Ensure database and migrations are applied on startup
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.Migrate();
}
app.UseDefaultFiles(); app.UseDefaultFiles();
app.UseStaticFiles(); app.UseStaticFiles();
app.MapHealthChecks(); app.MapHealthChecks();
app.MapOpenApi("/openapi/{documentName}.json");
app.MapAuthEndpoints(); app.MapAuthEndpoints();
app.MapStateEndpoints(); app.MapStateEndpoints();
app.MapSuggestEndpoints(); app.MapSuggestEndpoints();
@@ -99,6 +171,52 @@ app.MapAdminEndpoints();
app.Run(); app.Run();
static string BuildAuthRateLimitKey(HttpContext context)
{
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip";
return $"{context.Request.Path}|{ip}";
}
static string BuildAdminRateLimitKey(HttpContext context)
{
var userId = context.User.FindFirstValue(ClaimTypes.NameIdentifier) ?? "anon";
var ip = context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip";
return $"{context.Request.Path}|{userId}|{ip}";
}
const string SessionStartedAtKey = "session_started_at_unix";
const long AbsoluteSessionLifetimeSeconds = 7L * 24 * 60 * 60;
static Task EnsureSessionStartAsync(CookieSigningInContext context)
{
if (!context.Properties.Items.ContainsKey(SessionStartedAtKey))
{
context.Properties.Items[SessionStartedAtKey] = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture);
}
return Task.CompletedTask;
}
static async Task ValidateSessionLifetimeAsync(CookieValidatePrincipalContext context)
{
if (!context.Properties.Items.TryGetValue(SessionStartedAtKey, out var rawStart)
|| !long.TryParse(rawStart, out var unixStart))
{
context.RejectPrincipal();
await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
return;
}
var startedAt = DateTimeOffset.FromUnixTimeSeconds(unixStart);
if ((DateTimeOffset.UtcNow - startedAt).TotalSeconds <= AbsoluteSessionLifetimeSeconds)
return;
var monitor = context.HttpContext.RequestServices.GetRequiredService<AuthAttemptMonitor>();
monitor.RecordSessionExpired(context.HttpContext, startedAt);
context.RejectPrincipal();
await context.HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
}
static ForwardedHeadersOptions BuildForwardedHeadersOptions(IConfiguration config) static ForwardedHeadersOptions BuildForwardedHeadersOptions(IConfiguration config)
{ {
var options = new ForwardedHeadersOptions var options = new ForwardedHeadersOptions
@@ -153,42 +271,4 @@ static Task WriteUnauthorizedChallengeAsync(HttpContext context)
return context.Response.WriteAsJsonAsync(problem); return context.Response.WriteAsJsonAsync(problem);
} }
static void UpdateIndexMetaBase(IWebHostEnvironment env, string basePath)
{
try
{
var indexPath = Path.Combine(env.WebRootPath, "index.html");
if (!File.Exists(indexPath))
return;
var text = File.ReadAllText(indexPath);
var marker = "name=\"app-base\"";
var contentKey = "content=\"";
var markerIndex = text.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
if (markerIndex < 0)
return;
var contentIndex = text.IndexOf(contentKey, markerIndex, StringComparison.OrdinalIgnoreCase);
if (contentIndex < 0)
return;
var valueStart = contentIndex + contentKey.Length;
var valueEnd = text.IndexOf('"', valueStart);
if (valueEnd < 0)
return;
var current = text[valueStart..valueEnd];
var normalized = basePath.EndsWith('/') ? basePath.TrimEnd('/') : basePath;
if (current == normalized)
return;
var updated = text[..valueStart] + normalized + text[valueEnd..];
File.WriteAllText(indexPath, updated);
}
catch
{
// If we can't rewrite, continue; frontend can still be set manually.
}
}
public partial class Program; public partial class Program;

View File

@@ -6,16 +6,19 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
1. Restore and build: 1. Restore and build:
`dotnet build GameList.sln` `dotnet build GameList.sln`
2. Run tests: 2. Apply DB migrations explicitly:
`dotnet ef database update`
3. Run tests:
`dotnet test GameList.Tests/GameList.Tests.csproj` `dotnet test GameList.Tests/GameList.Tests.csproj`
3. Run locally: 4. Run locally:
`dotnet run --project GameList.csproj` `dotnet run --project GameList.csproj`
4. Open: 5. Open:
`http://localhost:5000` (or the URL shown by `dotnet run`) `http://localhost:5000` (or the URL shown by `dotnet run`)
## Frontend Tooling ## Frontend Tooling
- Install tooling: `npm install` - 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` - Lint JS: `npm run lint`
- Check formatting: `npm run format:check` - Check formatting: `npm run format:check`
- Apply formatting: `npm run format` - Apply formatting: `npm run format`
@@ -24,24 +27,40 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
- Authentication: username/password with HttpOnly `player` cookie. - Authentication: username/password with HttpOnly `player` cookie.
- Admin authorization: authenticated account with `IsAdmin=true`. - Admin authorization: authenticated account with `IsAdmin=true`.
- 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`. - 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`. - 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 ## Module Ownership
- `Program.cs`: startup wiring, middleware order, route registration. - `Program.cs`: startup wiring, middleware order, route registration.
- `Endpoints/`: HTTP endpoint transport + request orchestration. - `Endpoints/`: endpoint adapters plus application workflow services (`ServiceResult<T>` outputs mapped to HTTP at the edge).
- `Infrastructure/`: filters, middleware, identity helpers. - `Infrastructure/`: filters, middleware, identity helpers.
- `Data/`: EF Core `DbContext` and migrations. - `Data/`: EF Core `DbContext` and migrations.
- `Domain/`: entities and enums. - `Domain/`: entities and enums.
- `Contracts/`: request/response DTOs. - `Contracts/`: request/response DTOs.
- `wwwroot/`: static frontend assets. - `wwwroot/`: static frontend assets.
- `GameList.Tests/`: integration and helper tests. - `GameList.Tests/`: integration and helper tests.
- `scripts/`: deployment scripts. - `scripts/`: deployment scripts (`scripts/deploy-ftp.ps1`, `scripts/deploy-ftp1.ps1`).
- `deploy.ps1`: local shortcut wrapper that runs FTP deploy using `scripts/deploy-ftp.profile.psd1`.
Deploy sets frontend `<meta name="app-base">` automatically from deploy profile `BasePath` (or inferred from `RemoteDir`).
## Operations ## Operations
- API surface and endpoint contract: `API.md` - API surface and endpoint contract: `API.md`
- Generated OpenAPI document: `openapi/GameList.json` (runtime: `/openapi/v1.json`)
- Product/feature expectations: `SPEC.md` - Product/feature expectations: `SPEC.md`
- IIS deployment notes: `IIS.md` - IIS deployment notes: `IIS.md`
- Test strategy details: `TESTS.md` - Test strategy details: `TESTS.md`
@@ -51,6 +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` GitHub Actions workflow: `.github/workflows/ci.yml`
- Restores dependencies - Restores dependencies
- Runs frontend lint and format checks
- Builds with warnings treated as errors - Builds with warnings treated as errors
- Runs `GameList.Tests` - 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%)

169
REVIEW.md
View File

@@ -1,24 +1,165 @@
# Maintainability Review - Pick'n'Play # Review - Pick'n'Play (State-of-the-Art Assessment)
## A) Current focus Date: 2026-02-18
This document tracks only active work. Completed work is intentionally omitted and can be reviewed in git history. ## Scope
Active maintainability risks (priority order): - Evaluated backend (`Program.cs`, `Endpoints/*`, `Infrastructure/*`, `Data/*`), frontend (`wwwroot/*`), and CI/deployment scripts.
- Focused on risks in maintainability, extensibility, scalability, and security.
- None at the moment. ## Executive summary
## B) Active task list 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).
- None. ## Findings
## C) Suggested execution order ### 1) High - Scalability - Single-node SQLite bottleneck
1. Add new items when fresh risks are identified. Evidence:
- SQLite is the primary DB (`Program.cs:42`).
## D) Guardrails Risk:
- SQLite is excellent for small single-node deployments, but write concurrency and horizontal scale are limited for larger or bursty usage.
- Keep endpoint handlers transport-focused and move business rules into services/validators. Alternative:
- Keep reads side-effect free and isolate all persistence changes to explicit command paths. - Keep SQLite for local/dev and migrate production to PostgreSQL/SQL Server with provider-specific migrations and connection pooling.
- Maintain one source of truth per validation rule (backend authoritative, frontend UX hints only).
- Prefer typed DTOs over anonymous response shapes for non-trivial API payloads. ### 2) High - Scalability - Polling causes read amplification
Evidence:
- 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`).
Risk:
- As concurrent users increase, backend read load grows quickly and mostly serves unchanged data.
Alternative:
- Move to event-driven updates (SSE/WebSocket) plus conditional GET (`ETag`/`If-None-Match`) and/or a consolidated bootstrap endpoint.
### 3) High - Security - CSRF protection is implicit, not explicit
Evidence:
- 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.
Risk:
- `SameSite=Strict` helps (`Program.cs:104`) but is not a full long-term CSRF strategy across browser/proxy edge cases.
Alternative:
- Add explicit anti-forgery tokens for mutating requests (or move to bearer tokens for API calls) and verify origin headers server-side.
### 4) High - Extensibility - Workflow is hard-coded across backend and frontend
Evidence:
- 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`).
Risk:
- Adding a new phase or changing transitions requires touching many scattered branches, increasing regression risk.
Alternative:
- Introduce a shared workflow/state-machine model (transition table) and consume it in both backend and frontend.
### 5) High - Extensibility - Role model is fixed to booleans
Evidence:
- 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`).
Risk:
- Future needs (moderator, read-only admin, per-feature permissions) require schema and logic rewrites instead of additive changes.
Alternative:
- Move to role/permission tables (or claims-based capability model) and policy-based authorization.
### 6) Medium - Maintainability - Frontend is string-template heavy with global mutable state
Evidence:
- 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`).
Risk:
- Harder refactoring, weaker static guarantees, and easy XSS regressions when new contributors add templates.
Alternative:
- Incrementally move to TypeScript + componentized rendering (or at minimum typed JSDoc + stricter lint rules + centralized safe render helpers).
### 7) Medium - Scalability/Security - In-memory dictionaries are unbounded
Evidence:
- 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`).
Risk:
- High-cardinality traffic can grow memory and become a denial-of-service vector.
Alternative:
- Replace with bounded `MemoryCache` (size limits + eviction) or distributed cache (Redis) with TTL and cardinality controls.
### 8) Medium - Scalability - Linking/results workflows load full sets into memory
Evidence:
- 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`).
Risk:
- Memory and query cost rise non-linearly with larger datasets.
Alternative:
- Introduce persisted link-group IDs and push aggregation to SQL; add pagination/windowing for large result sets.
### 9) Medium - Security - Crypto is good but not state-of-the-art
Evidence:
- Password hashing uses PBKDF2-SHA256 (`Infrastructure/PasswordHasher.cs:33`) with fixed iteration count (`Infrastructure/PasswordHasher.cs:10`).
Risk:
- PBKDF2 remains acceptable, but modern guidance favors memory-hard KDFs (Argon2id/scrypt) against GPU/ASIC attacks.
Alternative:
- Add versioned password hashes and migrate to Argon2id on login/re-hash.
### 10) Medium - Security - CSP remains permissive for inline style and mixed image origins
Evidence:
- CSP allows `style-src 'unsafe-inline'` and `img-src ... https: http:` (`Program.cs:138`).
Risk:
- Wider policy surface than needed, especially for long-term hardening.
Alternative:
- Remove inline style dependence (e.g., CSS classes or nonce/hash), and restrict image sources to `https` and/or a media proxy allowlist.
### 11) Medium - Maintainability/Extensibility - API contract sync is manual
Evidence:
- 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`).
Risk:
- Contract drift between backend DTOs and frontend consumers over time.
Alternative:
- Generate OpenAPI from endpoints and produce typed client code for frontend consumption.
### 12) Medium - Scalability/Resilience - External image validation is synchronous on write path
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`).
Risk:
- User write latency is coupled to third-party host responsiveness.
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.

View File

@@ -35,8 +35,10 @@ Help a small Discord group (48 players) pick a co-op game via phased flow:
- Visible only after admin enables results; players auto-advance when opened - 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 - Admin controls results availability with a single toggle button whose label reflects enabled/disabled state
- Leaderboard sorted by average score; shows totals, counts, players own vote, and links/media - Leaderboard sorted by average score; shows totals, counts, players 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 - When results are closed again, only accounts with at least one suggestion return to Vote; accounts without suggestions return to Suggest
## Non-functional ## Non-functional
- Desktop + mobile friendly - Desktop + mobile friendly
- Runs on IIS; SQLite via EF Core - 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
View 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.

View File

@@ -32,14 +32,21 @@ stateDiagram-v2
### 1) Authentication & Identity ### 1) Authentication & Identity
- Register success (player, admin key path) issues cookie, trims fields, stores normalized username, hashes password. - Register success (player, admin key path) issues cookie, trims fields, stores normalized username, hashes password.
- Register rejects missing/long username, missing password, missing display name, duplicate username, bad admin key, >24 chars username, >16 display name. - Register rejects missing/long username, weak password policy violations, missing display name, duplicate username, bad admin key, >24 chars username, >16 display name.
- Register/login null payload fields fail closed with `400` (no `500` on malformed JSON bodies).
- Bootstrap-admin key path only works until the owner account exists; bootstrap admin is marked as owner.
- 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. - 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. - Logout clears cookie.
- EnsurePlayerExistsMiddleware: signed cookie for deleted player returns 401 and clears auth. - 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). - Cookie contains admin claim; non-admin cookie cannot access admin routes (401/403 via filter).
### 2) State & Phase Alignment (/api/state, /api/me) ### 2) State & Phase Alignment (/api/state, /api/me)
- /api/state returns player-specific phase, votesFinal, hasJoker, counts; unauthorized returns 401. - /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). - 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/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. - /me/phase/prev: admin only; moves back one step, clears votesFinal, rejects for player.
@@ -48,9 +55,10 @@ stateDiagram-v2
### 3) Suggestions ### 3) Suggestions
- GET /mine returns only callers suggestions ordered by CreatedAt. - GET /mine returns only callers suggestions ordered by CreatedAt.
- POST /: success with valid data; enforces ≤5 per player; trims optional fields; requires display name; rejects bad image URL/ext, unreachable image (mocked), invalid game/youtube URLs, invalid player counts, missing name/too long. - POST /: success with valid data; enforces ≤5 per player; trims optional fields; requires display name; rejects bad image URL/ext, unreachable image (mocked), invalid game/youtube URLs, invalid player counts, missing name/too long.
- DB trigger also enforces suggestion cap for non-joker inserts, protecting against concurrent over-limit writes.
- Joker path: when phase=Vote and HasJoker=true allows creation, consumes joker, resets VotesFinal for all players. - Joker path: when phase=Vote and HasJoker=true allows creation, consumes joker, resets VotesFinal for all players.
- Phase gating: non-admin cannot create/update/delete outside Suggest (except joker create); admin bypasses phase checks for update/delete. - Phase gating: non-admin cannot create/update/delete outside Suggest (except joker create); admin bypasses phase checks for update/delete.
- PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; validation mirrors create. - PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; screenshot reachability check is skipped when screenshot URL is unchanged.
- DELETE /{id}: player deletes own in Suggest; admin any time; also breaks child links and deletes related votes. - DELETE /{id}: player deletes own in Suggest; admin any time; also breaks child links and deletes related votes.
- GET /all: accessible from Vote+, orders by CreatedAt, includes link metadata, enforces phase mismatch before Vote. - GET /all: accessible from Vote+, orders by CreatedAt, includes link metadata, enforces phase mismatch before Vote.
@@ -58,10 +66,11 @@ stateDiagram-v2
- GET /mine: only in Vote, returns player votes; unauthorized/phase mismatch handled. - GET /mine: only in Vote, returns player votes; unauthorized/phase mismatch handled.
- POST /: creates or updates vote; rejects score outside 010; rejects when VotesFinal=true; enforces display name requirement and phase gating. - POST /: creates or updates vote; rejects score outside 010; rejects when VotesFinal=true; enforces display name requirement and phase gating.
- Linked votes: when suggestions are linked, a single post updates all linked IDs; invalid suggestionId returns 400; linking root detection works for nested links. - Linked votes: when suggestions are linked, a single post updates all linked IDs; invalid suggestionId returns 400; linking root detection works for nested links.
- Concurrent vote upserts are handled with retry logic around unique-key conflicts to avoid server errors.
- Finalize: POST /finalize toggles VotesFinal flag; allowed only in Vote. - Finalize: POST /finalize toggles VotesFinal flag; allowed only in Vote.
### 5) Results ### 5) Results
- GET /api/results: requires auth, resultsOpen=true, phase=Results; returns ordered leaderboard with totals/count/avg, callers 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, callers vote, link metadata, and handles empty vote lists (Average=0).
- Phase mismatch and locked results return 400; unauthorized 401. - Phase mismatch and locked results return 400; unauthorized 401.
### 6) Admin Operations ### 6) Admin Operations
@@ -69,7 +78,9 @@ stateDiagram-v2
- GET /admin/vote-status returns list ordered by display/username with suggestion counts, finalized flag, joker flag; ready/waiting derived correctly. - GET /admin/vote-status returns list ordered by display/username with suggestion counts, finalized flag, joker flag; ready/waiting derived correctly.
- POST /admin/joker grants joker only when target in Vote; resets VotesFinal for target. - POST /admin/joker grants joker only when target in Vote; resets VotesFinal for target.
- POST /admin/player-phase allows Vote->Suggest transitions only; rejects other targets/current phases; clears target VotesFinal. - POST /admin/player-phase allows Vote->Suggest transitions only; rejects other targets/current phases; clears target VotesFinal.
- POST /admin/player-admin grants/revokes admin role for non-owner accounts; owner role cannot be changed.
- DELETE /admin/players/{id}: requires valid admin password; removes player, cascades suggestions, breaks links to their suggestions, deletes related votes, wrapped in transaction. - DELETE /admin/players/{id}: requires valid admin password; removes player, cascades suggestions, breaks links to their suggestions, deletes related votes, wrapped in transaction.
- Owner account cannot be deleted.
- POST /admin/link-suggestions: only in Vote; errors on same ids/already linked/not found; re-parents groups correctly; deletes votes for affected group and unfinalizes affected players. - POST /admin/link-suggestions: only in Vote; errors on same ids/already linked/not found; re-parents groups correctly; deletes votes for affected group and unfinalizes affected players.
- POST /admin/unlink-suggestions: only in Vote; clears parents for group, deletes votes in group, unfinalizes affected players; no-op safe when missing. - POST /admin/unlink-suggestions: only in Vote; clears parents for group, deletes votes in group, unfinalizes affected players; no-op safe when missing.
- POST /admin/reset: requires valid admin password; wipes suggestions/votes, resets phases to Suggest, clears votesFinal/hasJoker, closes results, updates timestamp. - POST /admin/reset: requires valid admin password; wipes suggestions/votes, resets phases to Suggest, clears votesFinal/hasJoker, closes results, updates timestamp.
@@ -78,13 +89,23 @@ stateDiagram-v2
### 7) Infrastructure/Helpers ### 7) Infrastructure/Helpers
- PasswordHasher: hash+verify roundtrip, rejects empty password, constant-time compare (FixedTimeEquals usage). - PasswordHasher: hash+verify roundtrip, rejects empty password, constant-time compare (FixedTimeEquals usage).
- EndpointHelpers.IsValidImageUrl/IsValidHttpUrl: accepts empty, http/https; rejects others/invalid ext. - EndpointHelpers.IsValidImageUrl/IsValidHttpUrl: accepts empty, http/https; rejects others/invalid ext.
- IsReachableImageAsync: with mocked Http responses covers head success, get fallback, redirect rejection, size guard, invalid host (private/loopback) detection. - 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. - BuildLinkRoots/LinkedIdsFor/FindRootId: cover disjoint groups, chains, cycles guard (visited set), non-existent ids.
- UpdateIndexMetaBase (Program.cs): rewrites app-base meta when BasePath set; no change when matching/marker missing; safe exceptions swallowed. - 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. - Global exception handler returns 500 with JSON body and logs error.
- /health returns {status:"ok"}. - /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%.
## Execution Notes ## Execution Notes
- Use named test data builders for players/suggestions to keep cases small and isolated. - Use named test data builders for players/suggestions to keep cases small and isolated.
- Reset in-memory DB per test to avoid cross-contamination; assert timestamps using time providers or approximate windows. - Reset in-memory DB per test to avoid cross-contamination; assert timestamps using time providers or approximate windows.
- Cover success + failure for every endpoint status path to reach 100% line/branch coverage. - Cover success + failure for endpoint status paths and critical helper branches to stay above enforced thresholds.

View File

@@ -5,7 +5,7 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"AllowedHosts": "*", "AllowedHosts": "localhost;127.0.0.1;[::1]",
"BasePath": "", "BasePath": "",
"ConnectionStrings": { "ConnectionStrings": {
"Default": "Data Source=App_Data/gamelist.db" "Default": "Data Source=App_Data/gamelist.db"

17
deploy.ps1 Normal file
View File

@@ -0,0 +1,17 @@
param(
[string]$Password,
[switch]$SkipRecycle,
[switch]$SkipMigrations
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$scriptPath = Join-Path $PSScriptRoot "scripts/deploy-ftp1.ps1"
$profilePath = Join-Path $PSScriptRoot "scripts/deploy-ftp.profile.psd1"
& $scriptPath `
-ProfilePath $profilePath `
-Password $Password `
-SkipRecycle:$SkipRecycle `
-SkipMigrations:$SkipMigrations

867
openapi/GameList.json Normal file
View 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"
}
]
}

View File

@@ -3,9 +3,10 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"generate:api-client": "node ./scripts/generate-api-client.mjs",
"lint": "eslint \"wwwroot/**/*.js\"", "lint": "eslint \"wwwroot/**/*.js\"",
"format": "prettier --write \"eslint.config.js\" \"wwwroot/js/i18n.js\" \"wwwroot/js/{admin-ui,app-admin-handlers,app-auth-handlers,app-vote-nav-handlers,auth-ui,modals-ui,results-ui,suggestions-ui,ui-runtime,ui-utils,ui,votes-ui}.js\"", "format": "prettier --write \"eslint.config.js\" \"wwwroot/**/*.js\"",
"format:check": "prettier --check \"eslint.config.js\" \"wwwroot/js/i18n.js\" \"wwwroot/js/{admin-ui,app-admin-handlers,app-auth-handlers,app-vote-nav-handlers,auth-ui,modals-ui,results-ui,suggestions-ui,ui-runtime,ui-utils,ui,votes-ui}.js\"" "format:check": "prettier --check \"eslint.config.js\" \"wwwroot/**/*.js\""
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.21.0", "@eslint/js": "9.21.0",

View File

@@ -0,0 +1,43 @@
param(
[double]$MinLineRate = 0.90,
[double]$MinBranchRate = 0.70,
[string]$ResultsRoot = "GameList.Tests/TestResults"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
if (-not (Test-Path $ResultsRoot)) {
throw "Coverage results folder not found: $ResultsRoot"
}
$coverageFile = Get-ChildItem -Path $ResultsRoot -Recurse -Filter "coverage.cobertura.xml" |
Sort-Object LastWriteTimeUtc -Descending |
Select-Object -First 1
if ($null -eq $coverageFile) {
throw "No coverage.cobertura.xml found under $ResultsRoot"
}
[xml]$xml = Get-Content -Path $coverageFile.FullName
$coverage = $xml.coverage
if ($null -eq $coverage) {
throw "Coverage XML is missing root coverage node: $($coverageFile.FullName)"
}
[double]$lineRate = [double]$coverage.'line-rate'
[double]$branchRate = [double]$coverage.'branch-rate'
$linePercent = [Math]::Round($lineRate * 100, 2)
$branchPercent = [Math]::Round($branchRate * 100, 2)
$minLinePercent = [Math]::Round($MinLineRate * 100, 2)
$minBranchPercent = [Math]::Round($MinBranchRate * 100, 2)
Write-Host "Coverage source: $($coverageFile.FullName)"
Write-Host ("Line coverage: {0}% (required >= {1}%)" -f $linePercent, $minLinePercent)
Write-Host ("Branch coverage: {0}% (required >= {1}%)" -f $branchPercent, $minBranchPercent)
if ($lineRate -lt $MinLineRate -or $branchRate -lt $MinBranchRate) {
throw "Coverage thresholds failed."
}

View File

@@ -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) { if (-not $SkipDotnetRestore) {
Invoke-Step -Name "Restore .NET solution" -Action { Invoke-Step -Name "Restore .NET solution" -Action {
dotnet restore GameList.sln dotnet restore GameList.sln
@@ -51,15 +43,31 @@ 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 { Invoke-Step -Name "Run tests" -Action {
if ($SkipBuild) { if ($SkipBuild) {
dotnet test GameList.Tests/GameList.Tests.csproj --verbosity normal dotnet test GameList.Tests/GameList.Tests.csproj --verbosity normal --collect:"XPlat Code Coverage" --settings GameList.Tests/coverlet.runsettings
} }
else { else {
dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal --collect:"XPlat Code Coverage" --settings GameList.Tests/coverlet.runsettings
} }
} }
Invoke-Step -Name "Enforce coverage thresholds" -Action {
pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70
}
Write-Host "CI checks passed." Write-Host "CI checks passed."
} }
finally { finally {

View File

@@ -0,0 +1,32 @@
@{
# Required publish settings
ProjectPath = "..\GameList.csproj"
Configuration = "Release"
Runtime = "win-x64"
PublishDir = "%TEMP%\GameList-publish"
SelfContained = $false
# Required sync settings
WinScpPath = "C:\Program Files (x86)\WinSCP\WinSCP.com"
RemoteDir = "/httpdocs/picknplay"
BasePath = "/picknplay"
# Preferred: use a named WinSCP stored session (no credential string in script)
WinScpSessionName = "picknplay-prod"
# Optional FTP URL fallback if no stored session is configured
# FtpHost = "example.com"
# FtpUser = "deploy-user"
# Optional IIS recycle and WinRM controls
RecycleAppPool = $true
AppPoolName = "picknplay-app-pool"
WinRmComputer = "example.com"
WinRmCredentialUser = "Administrator"
UseWinRmHttps = $true
WinRmAuth = "Basic"
# Optional remote migration
RunEfMigrations = $false
RemoteSitePath = "C:\Inetpub\vhosts\example.com\httpdocs\picknplay"
}

View File

@@ -1,157 +1,340 @@
# Hard-coded deploy settings. Fill these in before running. param(
$FtpHost = "xTr1m.com" [string]$ProfilePath = (Join-Path $PSScriptRoot "deploy-ftp.profile.psd1"),
$FtpUser = "xTr1m" [string]$Password,
$Password = $null # prompted at runtime [switch]$SkipRecycle,
$RemoteDir = "/httpdocs/picknplay" [switch]$SkipMigrations
$ProjectPath = "..\\GameList.csproj" )
$Configuration = "Release"
$Runtime = "win-x64"
$PublishDir = "$env:TEMP\\GameList-publish"
$SelfContained = $false
$WinScpPath = "C:\\Users\\frank\\AppData\\Local\\Programs\\WinSCP\\WinSCP.com"
$RecycleAppPool = $true
$AppPoolName = "xTr1m.com(domain)(4.0)(pool)"
$WinRmComputer = "xTr1m.com"
$WinRmCredentialUser = "Administrator"
$UseWinRmHttps = $true # set false if using HTTP + TrustedHosts
$RemoteSitePath = "C:\Inetpub\vhosts\xTr1m.com\httpdocs\picknplay"
$RunEfMigrations = $false # set to $false to skip remote database update
<#! <#
.SYNOPSIS .SYNOPSIS
Publish the app and mirror the output to an FTP-deployed IIS site. Publish the app and mirror output to an FTP-deployed IIS site.
.DESCRIPTION .DESCRIPTION
- Reads environment-specific settings from a PowerShell data file profile.
- Builds with dotnet publish. - Builds with dotnet publish.
- Uses WinSCP (ftp) to mirror publish output into $RemoteDir (deletes extraneous remote files). - Uses WinSCP to mirror publish output into remote directory (deletes extraneous files).
- Optionally recycles the IIS app pool remotely via WinRM (no RDP needed). - Optionally recycles IIS app pool and runs EF migrations remotely over WinRM.
.PREREQS
- WinSCP.com available in PATH or set $WinScpPath.
- FTP user must have write/delete rights to $RemoteDir.
- WinRM must be enabled for remote app pool recycle (set $RecycleAppPool = $false otherwise).
.EXAMPLE .EXAMPLE
pwsh ./scripts/deploy-ftp.ps1 pwsh ./scripts/deploy-ftp.ps1 -ProfilePath ./scripts/deploy-ftp.profile.psd1
#> #>
Set-StrictMode -Version Latest Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
function Assert-Tool { function Assert-Tool {
param([string]$Name) param([Parameter(Mandatory = $true)][string]$Name)
if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "Required tool '$Name' not found. Install it or update paths." throw "Required tool '$Name' not found. Install it or update your deploy profile."
} }
} }
Assert-Tool "dotnet" function Require-ConfigValue {
Assert-Tool $WinScpPath param(
[Parameter(Mandatory = $true)][hashtable]$Config,
[Parameter(Mandatory = $true)][string]$Key
)
if (-not $Config.ContainsKey($Key) -or [string]::IsNullOrWhiteSpace([string]$Config[$Key])) {
throw "Missing required deploy profile value '$Key'."
}
}
function Resolve-ProfilePath {
param(
[Parameter(Mandatory = $true)][string]$BaseDirectory,
[Parameter(Mandatory = $true)][string]$PathValue
)
$expanded = [Environment]::ExpandEnvironmentVariables($PathValue)
if ([System.IO.Path]::IsPathRooted($expanded)) {
return $expanded
}
return [System.IO.Path]::GetFullPath((Join-Path $BaseDirectory $expanded))
}
function Normalize-BasePath {
param([string]$Value)
if ([string]::IsNullOrWhiteSpace($Value)) {
return ""
}
$normalized = $Value.Trim()
if (-not $normalized.StartsWith("/")) {
$normalized = "/$normalized"
}
if ($normalized.Length -gt 1) {
$normalized = $normalized.TrimEnd("/")
}
return $normalized
}
function Infer-BasePathFromRemoteDir {
param([string]$RemoteDir)
if ([string]::IsNullOrWhiteSpace($RemoteDir)) {
return ""
}
$segments = @($RemoteDir -split "[/\\]" | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
if ($segments.Count -eq 0) {
return ""
}
$candidate = $segments[$segments.Count - 1]
if ($candidate -in @("httpdocs", "wwwroot", "www", "public_html", "site")) {
return ""
}
return Normalize-BasePath $candidate
}
function Resolve-AppBasePath {
param([Parameter(Mandatory = $true)][hashtable]$Config)
if ($Config.ContainsKey("BasePath")) {
$configured = Normalize-BasePath ([string]$Config.BasePath)
if (-not [string]::IsNullOrWhiteSpace($configured)) {
return $configured
}
}
return Infer-BasePathFromRemoteDir ([string]$Config.RemoteDir)
}
function Set-FrontendAppBaseMeta {
param(
[Parameter(Mandatory = $true)][string]$PublishDir,
[Parameter(Mandatory = $true)][string]$BasePath
)
$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*/?>'
$content = Get-Content -Path $indexPath -Raw
if ($content -notmatch $pattern) {
throw "Could not find <meta name=`"app-base`"> in '$indexPath'."
}
$replacement = "<meta name=`"app-base`" content=`"$BasePath`">"
$updated = [System.Text.RegularExpressions.Regex]::Replace(
$content,
$pattern,
[System.Text.RegularExpressions.MatchEvaluator]{ param($match) $replacement },
1
)
Set-Content -Path $indexPath -Value $updated -Encoding UTF8
}
function Read-PlainOrPrompt {
param(
[string]$Value,
[Parameter(Mandatory = $true)][string]$Prompt,
[bool]$Secure = $false
)
if (-not [string]::IsNullOrWhiteSpace($Value)) {
return $Value
}
function Read-PlainOrPrompt([object]$Value, [string]$Prompt, [bool]$Secure = $false) {
if ($Value -is [string] -and -not [string]::IsNullOrWhiteSpace($Value)) { return $Value }
if ($Secure) { if ($Secure) {
$pwd = Read-Host -Prompt $Prompt -AsSecureString $pwd = Read-Host -Prompt $Prompt -AsSecureString
$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($pwd) $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($pwd)
try { return [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr) } try {
return [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr)
}
finally { finally {
if ($ptr -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) } if ($ptr -ne [IntPtr]::Zero) {
[Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr)
}
} }
} }
return Read-Host -Prompt $Prompt return Read-Host -Prompt $Prompt
} }
$Password = Read-PlainOrPrompt $Password "Password" $true function Invoke-WinRmScript {
$WinRmAuth = "Basic" # Basic for local admin over HTTPS; use Default/Kerberos if joined to domain param(
[Parameter(Mandatory = $true)][hashtable]$Config,
[Parameter(Mandatory = $true)][string]$PasswordValue,
[Parameter(Mandatory = $true)][scriptblock]$ScriptBlock,
[object[]]$ArgumentList = @()
)
Require-ConfigValue $Config "WinRmComputer"
Require-ConfigValue $Config "WinRmCredentialUser"
$secure = ConvertTo-SecureString $PasswordValue -AsPlainText -Force
$cred = New-Object pscredential($Config.WinRmCredentialUser, $secure)
$invokeParams = @{
ComputerName = $Config.WinRmComputer
Credential = $cred
ScriptBlock = $ScriptBlock
ArgumentList = $ArgumentList
}
if ($Config.ContainsKey("UseWinRmHttps") -and [bool]$Config.UseWinRmHttps) {
$invokeParams["UseSSL"] = $true
}
if ($Config.ContainsKey("WinRmAuth") -and -not [string]::IsNullOrWhiteSpace([string]$Config.WinRmAuth)) {
$invokeParams["Authentication"] = [string]$Config.WinRmAuth
}
Invoke-Command @invokeParams
}
if (-not (Test-Path $ProfilePath)) {
throw "Deploy profile not found: $ProfilePath. Copy scripts/deploy-ftp.profile.sample.psd1 and fill environment-specific values."
}
$resolvedProfilePath = (Resolve-Path $ProfilePath).Path
$profileDirectory = Split-Path -Parent $resolvedProfilePath
$config = Import-PowerShellDataFile -Path $resolvedProfilePath
Require-ConfigValue $config "ProjectPath"
Require-ConfigValue $config "Configuration"
Require-ConfigValue $config "Runtime"
Require-ConfigValue $config "PublishDir"
Require-ConfigValue $config "WinScpPath"
Require-ConfigValue $config "RemoteDir"
$winScpSessionName = if ($config.ContainsKey("WinScpSessionName")) { [string]$config.WinScpSessionName } else { "" }
$useStoredSession = -not [string]::IsNullOrWhiteSpace($winScpSessionName)
if (-not $useStoredSession) {
Require-ConfigValue $config "FtpHost"
Require-ConfigValue $config "FtpUser"
}
$projectPath = Resolve-ProfilePath $profileDirectory ([string]$config.ProjectPath)
$publishDir = Resolve-ProfilePath $profileDirectory ([string]$config.PublishDir)
$winScpPath = Resolve-ProfilePath $profileDirectory ([string]$config.WinScpPath)
$selfContained = if ($config.ContainsKey("SelfContained")) { [bool]$config.SelfContained } else { $false }
$recycleAppPool = if ($config.ContainsKey("RecycleAppPool")) { [bool]$config.RecycleAppPool } else { $false }
$runEfMigrations = if ($config.ContainsKey("RunEfMigrations")) { [bool]$config.RunEfMigrations } else { $false }
$recycleAppPool = $recycleAppPool -and -not $SkipRecycle
$runEfMigrations = $runEfMigrations -and -not $SkipMigrations
$passwordFromEnv = $env:PICKNPLAY_FTP_PASSWORD
$passwordFromInput = if (-not [string]::IsNullOrWhiteSpace($Password)) { $Password } else { $passwordFromEnv }
$needsFtpPassword = -not $useStoredSession
$needsWinRmPassword = $recycleAppPool -or $runEfMigrations
$sharedPassword = ""
if ($needsFtpPassword -or $needsWinRmPassword) {
$prompt = if ($needsFtpPassword -and $needsWinRmPassword) { "FTP/WinRM password" } elseif ($needsFtpPassword) { "FTP password" } else { "WinRM password" }
$sharedPassword = Read-PlainOrPrompt -Value $passwordFromInput -Prompt $prompt -Secure $true
}
$passwordForSession = if ($needsFtpPassword) { $sharedPassword } else { "" }
$passwordForWinRm = if ($needsWinRmPassword) { $sharedPassword } else { "" }
Assert-Tool "dotnet"
Assert-Tool $winScpPath
Write-Host "1) Publishing..." -ForegroundColor Cyan Write-Host "1) Publishing..." -ForegroundColor Cyan
if (Test-Path $PublishDir) { Remove-Item $PublishDir -Recurse -Force -ErrorAction SilentlyContinue } if (Test-Path $publishDir) {
New-Item -ItemType Directory -Force -Path $PublishDir | Out-Null Remove-Item $publishDir -Recurse -Force -ErrorAction SilentlyContinue
$publishArgs = @("publish", $ProjectPath, "-c", $Configuration, "-r", $Runtime, "-o", $PublishDir) }
if (-not $SelfContained) { $publishArgs += "--self-contained=false" } New-Item -ItemType Directory -Force -Path $publishDir | Out-Null
$publishArgs = @("publish", $projectPath, "-c", [string]$config.Configuration, "-r", [string]$config.Runtime, "-o", $publishDir)
if (-not $selfContained) {
$publishArgs += "--self-contained=false"
}
dotnet @publishArgs dotnet @publishArgs
if ($RecycleAppPool) { $appBasePath = Resolve-AppBasePath -Config $config
Set-FrontendAppBaseMeta -PublishDir $publishDir -BasePath $appBasePath
Write-Host "2) Frontend app-base configured as '$appBasePath'." -ForegroundColor Cyan
if ($recycleAppPool) {
Require-ConfigValue $config "AppPoolName"
$appPoolName = [string]$config.AppPoolName
Write-Host "2) Stopping IIS app pool via WinRM..." -ForegroundColor Cyan Write-Host "2) Stopping IIS app pool via WinRM..." -ForegroundColor Cyan
$sec = ConvertTo-SecureString $Password -AsPlainText -Force
$cred = New-Object pscredential($WinRmCredentialUser, $sec)
$invokeParams = @{
ComputerName = $WinRmComputer
Credential = $cred
ScriptBlock = {
Import-Module WebAdministration
Stop-WebAppPool -Name $using:AppPoolName -ErrorAction SilentlyContinue
Get-Process GameList -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
Get-Process dotnet -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*picknplay*" } | Stop-Process -Force -ErrorAction SilentlyContinue
}
}
if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true }
if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth }
try { try {
Invoke-Command @invokeParams Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
} catch { param($poolName)
Import-Module WebAdministration
Stop-WebAppPool -Name $poolName -ErrorAction SilentlyContinue
Get-Process GameList -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
Get-Process dotnet -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*picknplay*" } | Stop-Process -Force -ErrorAction SilentlyContinue
} -ArgumentList @($appPoolName)
}
catch {
Write-Warning "WinRM stop failed: $($_.Exception.Message)." Write-Warning "WinRM stop failed: $($_.Exception.Message)."
} }
} }
Write-Host "3) Syncing via WinSCP (FTP mirror with delete)..." -ForegroundColor Cyan Write-Host "3) Syncing via WinSCP..." -ForegroundColor Cyan
$tempScript = New-TemporaryFile $openCommand = if ($useStoredSession) {
@" "open `"$winScpSessionName`""
option batch continue }
option confirm off else {
open ftp://$($FtpUser):$($Password.Replace('`n','').Replace('`r',''))@$FtpHost $ftpUser = [Uri]::EscapeDataString([string]$config.FtpUser)
lcd $PublishDir $ftpPassword = [Uri]::EscapeDataString($passwordForSession.Replace("`n", "").Replace("`r", ""))
cd $RemoteDir $ftpHost = [string]$config.FtpHost
synchronize remote . -delete -filemask="|web.config;App_Data/;logs/;GameList.Tests/" "open ftp://$ftpUser`:$ftpPassword@$ftpHost"
exit }
"@ | Set-Content -Path $tempScript -Encoding UTF8
& $WinScpPath "/ini=nul" "/script=$tempScript" $tempScript = New-TemporaryFile
@(
"option batch continue"
"option confirm off"
$openCommand
"lcd `"$publishDir`""
"cd $([string]$config.RemoteDir)"
"synchronize remote . -delete -filemask=`"|web.config;App_Data/;logs/;GameList.Tests/`""
"exit"
) | Set-Content -Path $tempScript -Encoding UTF8
& $winScpPath "/ini=nul" "/script=$tempScript"
Remove-Item $tempScript -ErrorAction SilentlyContinue Remove-Item $tempScript -ErrorAction SilentlyContinue
if ($RecycleAppPool) { if ($recycleAppPool) {
Write-Host "4) Starting IIS app pool via WinRM..." -ForegroundColor Cyan Write-Host "4) Starting IIS app pool via WinRM..." -ForegroundColor Cyan
$sec = ConvertTo-SecureString $Password -AsPlainText -Force
$cred = New-Object pscredential($WinRmCredentialUser, $sec)
$invokeParams = @{
ComputerName = $WinRmComputer
Credential = $cred
ScriptBlock = {
Import-Module WebAdministration
Start-WebAppPool -Name $using:AppPoolName
}
}
if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true }
if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth }
try { try {
Invoke-Command @invokeParams Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
} catch { param($poolName)
Import-Module WebAdministration
Start-WebAppPool -Name $poolName
} -ArgumentList @($appPoolName)
}
catch {
Write-Warning "WinRM start failed: $($_.Exception.Message)." Write-Warning "WinRM start failed: $($_.Exception.Message)."
} }
} }
if ($RunEfMigrations) { if ($runEfMigrations) {
Require-ConfigValue $config "RemoteSitePath"
Write-Host "5) Running EF Core migrations on remote site..." -ForegroundColor Cyan Write-Host "5) Running EF Core migrations on remote site..." -ForegroundColor Cyan
$sec = ConvertTo-SecureString $Password -AsPlainText -Force try {
$cred = New-Object pscredential($WinRmCredentialUser, $sec) Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
$invokeParams = @{
ComputerName = $WinRmComputer
Credential = $cred
ScriptBlock = {
param($sitePath) param($sitePath)
Set-Location $sitePath Set-Location $sitePath
if (-not (Get-Command dotnet ef -ErrorAction SilentlyContinue)) { if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
throw "dotnet ef not available on remote host. Install SDK or set `$RunEfMigrations = $false." throw "dotnet is not available on remote host."
} }
dotnet ef database update --no-build dotnet ef database update --no-build
} } -ArgumentList @([string]$config.RemoteSitePath)
ArgumentList = @($RemoteSitePath)
} }
if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true } catch {
if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth }
try {
Invoke-Command @invokeParams
} catch {
Write-Warning "WinRM migrations failed: $($_.Exception.Message)." Write-Warning "WinRM migrations failed: $($_.Exception.Message)."
} }
} }

14
scripts/deploy-ftp1.ps1 Normal file
View File

@@ -0,0 +1,14 @@
param(
[string]$ProfilePath = (Join-Path $PSScriptRoot "deploy-ftp.profile.psd1"),
[string]$Password,
[switch]$SkipRecycle,
[switch]$SkipMigrations
)
$scriptPath = Join-Path $PSScriptRoot "deploy-ftp.ps1"
& $scriptPath `
-ProfilePath $ProfilePath `
-Password $Password `
-SkipRecycle:$SkipRecycle `
-SkipMigrations:$SkipMigrations

View 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)}`;
}

View File

@@ -1,246 +1,359 @@
import { t, setLanguage, getLanguage, initI18n, onLanguageChange, faqMarkdown } from "./js/i18n.js"; import {
t,
setLanguage,
getLanguage,
initI18n,
onLanguageChange,
faqMarkdown,
} from "./js/i18n.js";
import { state, clearUserState } from "./js/state.js"; import { state, clearUserState } from "./js/state.js";
import { toast } from "./js/dom.js"; import { toast } from "./js/dom.js";
import { import {
handleAuthError, handleAuthError,
renderWelcome, renderWelcome,
renderPhasePill, renderPhasePill,
renderCounts, renderCounts,
renderMySuggestions, renderMySuggestions,
renderAllSuggestions, renderAllSuggestions,
renderVotes, renderVotes,
syncVoteScores, syncVoteScores,
renderResults, renderResults,
renderPhaseTitles, renderPhaseTitles,
updatePhaseNav, updatePhaseNav,
configureUiRuntime, configureUiRuntime,
} from "./js/ui.js"; } from "./js/ui.js";
import { import { api } from "./js/api.js";
loadSuggestData, import { loadSuggestData, loadVoteData, refreshPhaseData } from "./js/data.js";
loadVoteData,
refreshPhaseData,
} from "./js/data.js";
import { setupAuthHandlers } from "./js/app-auth-handlers.js"; import { setupAuthHandlers } from "./js/app-auth-handlers.js";
import { setupAdminHandlers } from "./js/app-admin-handlers.js"; import { setupAdminHandlers } from "./js/app-admin-handlers.js";
import { setupVoteNavigationHandlers } from "./js/app-vote-nav-handlers.js"; import { setupVoteNavigationHandlers } from "./js/app-vote-nav-handlers.js";
const REFRESH_INTERVAL_MS = 4000; 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 refreshInFlight = null;
let refreshTimerId = null; let refreshTimerId = null;
let refreshSchedulerStarted = false; 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() { async function runSerializedRefresh() {
if (refreshInFlight) return refreshInFlight; if (refreshInFlight) return refreshInFlight;
refreshInFlight = refreshPhaseData().finally(() => { refreshInFlight = refreshPhaseData().finally(() => {
refreshInFlight = null; refreshInFlight = null;
}); });
return refreshInFlight; return refreshInFlight;
} }
async function refreshWithUiErrorHandling() { async function refreshWithUiErrorHandling() {
try { try {
await runSerializedRefresh(); const changed = await runSerializedRefresh();
} catch (err) { updateRefreshCadence(changed === true);
if (!handleAuthError(err, clearUserState)) toast(err.message, 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)) {
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() { function scheduleNextRefresh() {
refreshTimerId = window.setTimeout(async () => { refreshTimerId = window.setTimeout(async () => {
if (!document.hidden && !state.adminStatusSelectActive) { if (!document.hidden && !state.adminStatusSelectActive) {
await refreshWithUiErrorHandling(); await refreshWithUiErrorHandling();
} }
scheduleNextRefresh(); scheduleNextRefresh();
}, REFRESH_INTERVAL_MS); }, nextRefreshDelayMs);
} }
function startRefreshScheduler() { function startRefreshScheduler() {
if (refreshSchedulerStarted) return; if (refreshSchedulerStarted) return;
refreshSchedulerStarted = true; refreshSchedulerStarted = true;
document.addEventListener("visibilitychange", () => { document.addEventListener("visibilitychange", () => {
if (!document.hidden && !state.adminStatusSelectActive) { if (!document.hidden && !state.adminStatusSelectActive) {
refreshWithUiErrorHandling(); unchangedRefreshCycles = 0;
nextRefreshDelayMs = baseRefreshDelayForPhase();
refreshWithUiErrorHandling();
}
});
if (refreshTimerId !== null) {
window.clearTimeout(refreshTimerId);
} }
}); scheduleNextRefresh();
}
if (refreshTimerId !== null) { function updateRefreshCadence(changed) {
window.clearTimeout(refreshTimerId); const base = baseRefreshDelayForPhase();
} if (changed) {
scheduleNextRefresh(); unchangedRefreshCycles = 0;
nextRefreshDelayMs = base;
return;
}
unchangedRefreshCycles = Math.min(unchangedRefreshCycles + 1, 8);
const growth = Math.pow(1.35, unchangedRefreshCycles);
nextRefreshDelayMs = Math.min(Math.round(base * growth), REFRESH_MAX_MS);
}
function baseRefreshDelayForPhase() {
switch (state.phase) {
case "Vote":
return REFRESH_MIN_MS;
case "Suggest":
return 5000;
case "Results":
return 7000;
default:
return 5000;
}
} }
configureUiRuntime({ configureUiRuntime({
refreshPhaseData: runSerializedRefresh, refreshPhaseData: runSerializedRefresh,
loadSuggestData, loadSuggestData,
loadVoteData, loadVoteData,
handleAuthError: (err) => handleAuthError(err, clearUserState), handleAuthError: (err) => handleAuthError(err, clearUserState),
}); });
function setupHandlers() { function setupHandlers() {
setupAuthHandlers({ runSerializedRefresh }); setupAuthHandlers({ runSerializedRefresh });
setupAdminHandlers({ runSerializedRefresh }); setupAdminHandlers({ runSerializedRefresh });
setupVoteNavigationHandlers({ runSerializedRefresh }); setupVoteNavigationHandlers({ runSerializedRefresh });
setupLanguageSwitchers(); setupLanguageSwitchers();
document.getElementById("logout")?.addEventListener("click", () => {
closeStateEventStream();
});
onLanguageChange(() => { onLanguageChange(() => {
updateLanguageButtons(); updateLanguageButtons();
renderWelcome(); renderWelcome();
renderPhasePill(); renderPhasePill();
renderCounts(); renderCounts();
renderPhaseTitles(); renderPhaseTitles();
renderMySuggestions(); renderMySuggestions();
renderAllSuggestions(); renderAllSuggestions();
if (state.phase === "Vote") { if (state.phase === "Vote") {
renderVotes(); renderVotes();
state.votesRendered = true; state.votesRendered = true;
syncVoteScores(); syncVoteScores();
} }
if (state.phase === "Results") { if (state.phase === "Results") {
renderResults(); renderResults();
} }
updatePhaseNav(); updatePhaseNav();
}); });
document.querySelectorAll(".help-chip").forEach((chip) => { document.querySelectorAll(".help-chip").forEach((chip) => {
chip.addEventListener("click", () => openFaqModal()); chip.addEventListener("click", () => openFaqModal());
}); });
} }
async function main() { async function main() {
await initI18n(); await initI18n();
setupHandlers(); setupHandlers();
await refreshWithUiErrorHandling(); await refreshWithUiErrorHandling();
startRefreshScheduler(); startRefreshScheduler();
} }
main(); main();
function updateLanguageButtons() { function updateLanguageButtons() {
document.querySelectorAll(".lang-button").forEach((btn) => { document.querySelectorAll(".lang-button").forEach((btn) => {
btn.textContent = "🌐"; btn.textContent = "🌐";
btn.title = t("lang.label"); btn.title = t("lang.label");
btn.setAttribute("aria-label", t("lang.label")); btn.setAttribute("aria-label", t("lang.label"));
}); });
} }
function setupLanguageSwitchers() { function setupLanguageSwitchers() {
const switches = document.querySelectorAll(".lang-switch"); const switches = document.querySelectorAll(".lang-switch");
const closeAll = () => const closeAll = () =>
switches.forEach((wrap) => wrap.querySelector(".lang-menu")?.classList.add("hidden")); switches.forEach((wrap) =>
wrap.querySelector(".lang-menu")?.classList.add("hidden"),
);
switches.forEach((wrap) => { switches.forEach((wrap) => {
const btn = wrap.querySelector(".lang-button"); const btn = wrap.querySelector(".lang-button");
const menu = wrap.querySelector(".lang-menu"); const menu = wrap.querySelector(".lang-menu");
if (!btn || !menu) return; if (!btn || !menu) return;
btn.addEventListener("click", (e) => { btn.addEventListener("click", (e) => {
e.preventDefault(); e.preventDefault();
const isHidden = menu.classList.contains("hidden"); const isHidden = menu.classList.contains("hidden");
closeAll(); closeAll();
if (isHidden) menu.classList.remove("hidden"); if (isHidden) menu.classList.remove("hidden");
});
menu.querySelectorAll("[data-lang]").forEach((item) =>
item.addEventListener("click", () => {
const lang = item.dataset.lang;
if (lang) setLanguage(lang);
closeAll();
}),
);
}); });
menu.querySelectorAll("[data-lang]").forEach((item) =>
item.addEventListener("click", () => {
const lang = item.dataset.lang;
if (lang) setLanguage(lang);
closeAll();
}),
);
});
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
if (!e.target.closest(".lang-switch")) closeAll(); if (!e.target.closest(".lang-switch")) closeAll();
}); });
updateLanguageButtons(); updateLanguageButtons();
} }
function markdownToHtml(md) { function markdownToHtml(md) {
const lines = md.trim().split(/\r?\n/); const lines = md.trim().split(/\r?\n/);
const html = []; const html = [];
let inList = false; let inList = false;
let inParagraph = false; let inParagraph = false;
const escapeHtml = (text) => const escapeHtml = (text) =>
text text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const formatInline = (text) => const formatInline = (text) =>
escapeHtml(text) escapeHtml(text)
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>") .replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
.replace(/`([^`]+)`/g, "<code>$1</code>"); .replace(/`([^`]+)`/g, "<code>$1</code>");
const closeParagraph = () => { const closeParagraph = () => {
if (inParagraph) { if (inParagraph) {
html.push("</p>"); html.push("</p>");
inParagraph = false; inParagraph = false;
} }
}; };
const closeList = () => { const closeList = () => {
if (inList) { if (inList) {
html.push("</ul>"); html.push("</ul>");
inList = false; inList = false;
} }
}; };
lines.forEach((rawLine) => { lines.forEach((rawLine) => {
const line = rawLine.trimEnd(); const line = rawLine.trimEnd();
const trimmed = line.trim(); const trimmed = line.trim();
if (!trimmed) { if (!trimmed) {
closeParagraph(); closeParagraph();
closeList(); closeList();
return; return;
} }
if (/^-{5,}$/.test(trimmed)) { if (/^-{5,}$/.test(trimmed)) {
closeParagraph(); closeParagraph();
closeList(); closeList();
html.push('<hr class="faq-divider" />'); html.push('<hr class="faq-divider" />');
return; return;
} }
const heading = trimmed.match(/^(#{1,3})\s+(.*)$/); const heading = trimmed.match(/^(#{1,3})\s+(.*)$/);
if (heading) { if (heading) {
closeParagraph(); closeParagraph();
closeList(); closeList();
const level = heading[1].length; const level = heading[1].length;
const tag = level === 1 ? "h2" : level === 2 ? "h3" : "h4"; const tag = level === 1 ? "h2" : level === 2 ? "h3" : "h4";
html.push(`<${tag}>${formatInline(heading[2].trim())}</${tag}>`); html.push(`<${tag}>${formatInline(heading[2].trim())}</${tag}>`);
return; return;
} }
if (/^[*-]\s+/.test(trimmed)) { if (/^[*-]\s+/.test(trimmed)) {
closeParagraph(); closeParagraph();
if (!inList) { if (!inList) {
html.push("<ul>"); html.push("<ul>");
inList = true; inList = true;
} }
const text = trimmed.replace(/^[*-]\s+/, ""); const text = trimmed.replace(/^[*-]\s+/, "");
html.push(`<li>${formatInline(text)}</li>`); html.push(`<li>${formatInline(text)}</li>`);
return; return;
} }
if (!inParagraph) { if (!inParagraph) {
html.push("<p>"); html.push("<p>");
inParagraph = true; inParagraph = true;
} }
html.push(formatInline(trimmed)); html.push(formatInline(trimmed));
}); });
closeParagraph(); closeParagraph();
closeList(); closeList();
return html.join("\n"); return html.join("\n");
} }
function openFaqModal() { function openFaqModal() {
const overlay = document.createElement("div"); const overlay = document.createElement("div");
overlay.className = "edit-modal"; overlay.className = "edit-modal";
const panel = document.createElement("div"); const panel = document.createElement("div");
panel.className = "edit-panel faq-panel"; panel.className = "edit-panel faq-panel";
panel.innerHTML = ` panel.innerHTML = `
<div class="edit-header"> <div class="edit-header">
<h3>${t("help.title")}</h3> <h3>${t("help.title")}</h3>
<button class="lightbox-close" aria-label="${t("modal.close")}">x</button> <button class="lightbox-close" aria-label="${t("modal.close")}">x</button>
@@ -250,16 +363,20 @@ function openFaqModal() {
</div> </div>
`; `;
const list = panel.querySelector(".faq-list"); const list = panel.querySelector(".faq-list");
const lang = getLanguage(); const lang = getLanguage();
const md = faqMarkdown[lang] ?? faqMarkdown.en; const md = faqMarkdown[lang] ?? faqMarkdown.en;
list.innerHTML = markdownToHtml(md); list.innerHTML = markdownToHtml(md);
const close = () => overlay.remove(); const close = () => overlay.remove();
overlay.addEventListener("click", (e) => { overlay.addEventListener("click", (e) => {
if (e.target.classList.contains("edit-modal") || e.target.classList.contains("lightbox-close")) close(); if (
}); e.target.classList.contains("edit-modal") ||
e.target.classList.contains("lightbox-close")
)
close();
});
overlay.appendChild(panel); overlay.appendChild(panel);
document.body.appendChild(overlay); document.body.appendChild(overlay);
} }

View File

@@ -47,6 +47,16 @@
display: block; display: block;
padding: 0; 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 { .card-visual.hovering {
cursor: zoom-in; cursor: zoom-in;
} }
@@ -269,3 +279,10 @@ input[type="range"].full-slider:disabled::-moz-range-thumb {
background: #f1f1f1; background: #f1f1f1;
border-color: #c1c1c1; border-color: #c1c1c1;
} }
.fx-canvas {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 120;
}

View File

@@ -11,9 +11,14 @@ Registriere dich mit:
Dein Anzeigename ist erforderlich er erscheint neben all deinen Vorschlägen und Bewertungen. 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? ### 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. Admin-Rechte können später nicht hinzugefügt werden. Um Admin zu werden, musst du dich mit dem korrekten Schlüssel neu registrieren. 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.
Sobald ein Owner-Konto existiert, wird das Admin-Schlüssel-Feld in der Registrierung nicht mehr angezeigt.
## Phasen im Überblick ## Phasen im Überblick
@@ -26,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. 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. 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 ## Spiele vorschlagen
### Wie viele Spiele kann ich vorschlagen? ### Wie viele Spiele kann ich vorschlagen?
@@ -52,7 +61,7 @@ Wenn du eine Screenshot-URL angibst, muss sie:
- Direkt erreichbar sein (keine Weiterleitungen) - Direkt erreichbar sein (keine Weiterleitungen)
- Innerhalb von ~3 Sekunden laden - Innerhalb von ~3 Sekunden laden
- Unter **5 MB**groß sein - Unter **5 MB**groß sein
- Nicht auf lokale oder private Hosts verweisen - Nicht auf lokale, private oder reservierte Hosts verweisen
Screenshots sind optional. Screenshots sind optional.
@@ -145,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. 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) ## Admin-Tools (Für Hosts)
### Was können Admin-Konten tun? ### Was können Admin-Konten tun?
@@ -152,6 +165,7 @@ Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf
- Joker während der Abstimmung vergeben - Joker während der Abstimmung vergeben
- Einen Bewerter zurück in die Vorschlagsphase setzen (stärker als ein Joker; sparsam einsetzen) - Einen Bewerter zurück in die Vorschlagsphase setzen (stärker als ein Joker; sparsam einsetzen)
- Ergebniszugriff mit einem einzelnen Button umschalten (Beschriftung wechselt je nach Zustand) - Ergebniszugriff mit einem einzelnen Button umschalten (Beschriftung wechselt je nach Zustand)
- Admin-Rechte für Nicht-Owner-Konten in der Spielertabelle vergeben oder entziehen
- Doppelte Vorschläge verknüpfen oder trennen - Doppelte Vorschläge verknüpfen oder trennen
- Vorschläge löschen - Vorschläge löschen
- Abstimmungsstatus einsehen (wer finalisiert hat) - Abstimmungsstatus einsehen (wer finalisiert hat)
@@ -163,6 +177,7 @@ Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf
### Was können Admin-Konten nicht tun? ### Was können Admin-Konten nicht tun?
- Einzelne Spielerbewertungen einsehen - Einzelne Spielerbewertungen einsehen
- Owner-Rechte entziehen oder das Owner-Konto löschen
Die Abstimmung bleibt anonym und fair. Die Abstimmung bleibt anonym und fair.
@@ -189,9 +204,19 @@ Bis dahin zeigt die Navigation in der Vorschlagsphase einen Hinweis statt eines
Registriere dich erneut mit dem korrekten Schlüssel vom Host oder lasse das Feld leer, um ein normales Konto zu erstellen. Registriere dich erneut mit dem korrekten Schlüssel vom Host oder lasse das Feld leer, um ein normales Konto zu erstellen.
### „Zu viele Anfragen. Bitte versuche es in Kürze erneut."
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 ## Daten & Datenschutz
- Vorschläge, Stimmen und Phasenstatus werden in einer gemeinsamen **SQLite-Datenbank** gespeichert. - Vorschläge, Stimmen und Phasenstatus werden in einer gemeinsamen Datenbank gespeichert.
- Passwörtwer werden mit einer SHA256 Verschlüsselung gespeichert. - Passwörter werden als gesalzene Hashes gespeichert (nicht im Klartext).
- Beim Abmelden wird dein Authentifizierungs-Cookie gelöscht und die Eingaben in Login/Registrierung werden zurückgesetzt. - Beim Abmelden wird dein Authentifizierungs-Cookie gelöscht und die Eingaben in Login/Registrierung werden zurückgesetzt.
- Wenn ein Admin dein Spielerkonto löscht, werden auch deine Vorschläge und Stimmen entfernt. - Wenn ein Admin dein Spielerkonto löscht, werden auch deine Vorschläge und Stimmen entfernt.

View File

@@ -11,10 +11,15 @@ Register with:
Your display name is required it appears next to all of your suggestions and scores. 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? ### 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. If you've been given an **admin key**, enter it during registration. If the key is invalid, the request is rejected.
Admin access cannot be added later. To become an admin, you must re-register with the correct key. Admin-key bootstrap is only available until the first admin account exists. Admin access cannot be added later. To become an admin afterward, an existing admin must create/manage access outside the public registration flow.
Once an owner account exists, the registration form no longer shows the admin-key field.
## Phases at a Glance ## Phases at a Glance
@@ -27,6 +32,10 @@ Each player progresses independently through the phases:
Click **"Next"** to move forward. Admins can move themselves backward if needed. 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. 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 ## Suggesting Games
### How many games can I suggest? ### How many games can I suggest?
@@ -54,7 +63,7 @@ If you include a screenshot URL, it must:
- Be directly accessible (no redirects) - Be directly accessible (no redirects)
- Load within ~3 seconds - Load within ~3 seconds
- Be under **5 MB** - Be under **5 MB**
- Not point to local or private hosts - Not point to local, private, or reserved hosts
Screenshots are optional. Screenshots are optional.
@@ -149,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. 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) ## Admin Tools (For Hosts)
### What can admin accounts do? ### What can admin accounts do?
@@ -156,6 +169,7 @@ No. Suggestions and votes are read-only. Contact an admin for assistance.
- Grant jokers during Vote - Grant jokers during Vote
- Move a voter back to Suggest (stronger than a joker; use sparingly) - Move a voter back to Suggest (stronger than a joker; use sparingly)
- Toggle results access with a single button (label switches by current state) - Toggle results access with a single button (label switches by current state)
- Grant or revoke admin role for any non-owner account from the player table
- Link or unlink duplicate suggestions - Link or unlink duplicate suggestions
- Delete suggestions - Delete suggestions
- View vote readiness (who has finalized) - View vote readiness (who has finalized)
@@ -167,6 +181,7 @@ No. Suggestions and votes are read-only. Contact an admin for assistance.
### What can't admin accounts do? ### What can't admin accounts do?
- View individual player votes - View individual player votes
- Revoke owner permissions or delete the owner account
Voting remains anonymous and fair. Voting remains anonymous and fair.
@@ -193,9 +208,19 @@ Until then, the Suggest navigation shows a hint instead of a Next button, and sw
Register again using the correct key from the host or leave it blank to create a regular account. Register again using the correct key from the host or leave it blank to create a regular account.
### "Too many requests. Please try again shortly."
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 ## Data & Privacy
- Suggestions, votes, and phase states are stored in a shared **SQLite database**. - Suggestions, votes, and phase states are stored in a shared database.
- Passwords are stored with a SHA256 encryption. - Passwords are stored as salted hashes (not plaintext).
- Logging out clears your authentication cookie and resets login/register form inputs. - Logging out clears your authentication cookie and resets login/register form inputs.
- If an admin deletes your player account, your suggestions and votes are removed as well. - If an admin deletes your player account, your suggestions and votes are removed as well.

View File

@@ -91,6 +91,8 @@
"results.average": "Ø", "results.average": "Ø",
"results.votesList": "All votes", "results.votesList": "All votes",
"results.myVote": "Your vote", "results.myVote": "Your vote",
"results.votersTooltip": "Voted by: {users}",
"results.votersTooltipEmpty": "No votes yet",
"results.links": "Links", "results.links": "Links",
"results.link.site": "Site&nbsp;↗", "results.link.site": "Site&nbsp;↗",
"results.link.youtube": "YouTube&nbsp;↗", "results.link.youtube": "YouTube&nbsp;↗",
@@ -124,13 +126,16 @@
"admin.playerStatus": "Status", "admin.playerStatus": "Status",
"admin.playerGames": "Games", "admin.playerGames": "Games",
"admin.playerJoker": "Joker", "admin.playerJoker": "Joker",
"admin.playerAdmin": "Admin",
"admin.playerDelete": "Delete", "admin.playerDelete": "Delete",
"admin.owner": "owner",
"admin.grantJokerChip": "Grant", "admin.grantJokerChip": "Grant",
"admin.statusSuggesting": "Suggesting", "admin.statusSuggesting": "Suggesting",
"admin.statusVoting": "Voting", "admin.statusVoting": "Voting",
"admin.statusFinished": "Finished", "admin.statusFinished": "Finished",
"admin.statusMoveToSuggest": "Move to Suggest", "admin.statusMoveToSuggest": "Move to Suggest",
"admin.statusUpdated": "Player phase updated", "admin.statusUpdated": "Player phase updated",
"admin.roleUpdated": "Admin role updated",
"admin.deleteTitle": "Delete account?", "admin.deleteTitle": "Delete account?",
"admin.deleteBody": "Delete player \"{name}\" and all their games and votes? This cannot be undone.", "admin.deleteBody": "Delete player \"{name}\" and all their games and votes? This cannot be undone.",
"admin.deleteConfirm": "Delete", "admin.deleteConfirm": "Delete",
@@ -261,6 +266,8 @@
"results.average": "Ø", "results.average": "Ø",
"results.votesList": "Alle Stimmen", "results.votesList": "Alle Stimmen",
"results.myVote": "Deine Stimme", "results.myVote": "Deine Stimme",
"results.votersTooltip": "Abgestimmt von: {users}",
"results.votersTooltipEmpty": "Noch keine Stimmen",
"results.links": "Links", "results.links": "Links",
"results.link.site": "Webseite&nbsp;↗", "results.link.site": "Webseite&nbsp;↗",
"results.link.youtube": "YouTube&nbsp;↗", "results.link.youtube": "YouTube&nbsp;↗",
@@ -294,13 +301,16 @@
"admin.playerStatus": "Status", "admin.playerStatus": "Status",
"admin.playerGames": "Spiele", "admin.playerGames": "Spiele",
"admin.playerJoker": "Joker", "admin.playerJoker": "Joker",
"admin.playerAdmin": "Admin",
"admin.playerDelete": "Löschen", "admin.playerDelete": "Löschen",
"admin.owner": "owner",
"admin.grantJokerChip": "Joker", "admin.grantJokerChip": "Joker",
"admin.statusSuggesting": "Vorschlagen", "admin.statusSuggesting": "Vorschlagen",
"admin.statusVoting": "Bewerten", "admin.statusVoting": "Bewerten",
"admin.statusFinished": "Fertig", "admin.statusFinished": "Fertig",
"admin.statusMoveToSuggest": "Zur Vorschlagsphase", "admin.statusMoveToSuggest": "Zur Vorschlagsphase",
"admin.statusUpdated": "Spielerphase aktualisiert", "admin.statusUpdated": "Spielerphase aktualisiert",
"admin.roleUpdated": "Admin-Rolle aktualisiert",
"admin.deleteTitle": "Konto löschen?", "admin.deleteTitle": "Konto löschen?",
"admin.deleteBody": "Spieler \"{name}\" samt Spielen und Stimmen löschen? Dies kann nicht rückgängig gemacht werden.", "admin.deleteBody": "Spieler \"{name}\" samt Spielen und Stimmen löschen? Dies kann nicht rückgängig gemacht werden.",
"admin.deleteConfirm": "Löschen", "admin.deleteConfirm": "Löschen",

View File

@@ -62,7 +62,7 @@
<span class="label" data-i18n="auth.displayName">Display name (shows to group)</span> <span class="label" data-i18n="auth.displayName">Display name (shows to group)</span>
<input id="register-displayName" name="displayName" maxlength="16" required /> <input id="register-displayName" name="displayName" maxlength="16" required />
</label> </label>
<label class="stack"> <label class="stack" id="register-admin-key-field">
<span class="label" data-i18n="auth.adminKey">Admin key (optional)</span> <span class="label" data-i18n="auth.adminKey">Admin key (optional)</span>
<input id="register-adminkey" name="adminKey" type="password" maxlength="128" /> <input id="register-adminkey" name="adminKey" type="password" maxlength="128" />
</label> </label>
@@ -99,6 +99,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</section> </section>
<main class="grid"> <main class="grid">
@@ -172,6 +173,7 @@
<th data-i18n="admin.playerStatus">Status</th> <th data-i18n="admin.playerStatus">Status</th>
<th data-i18n="admin.playerGames">Games</th> <th data-i18n="admin.playerGames">Games</th>
<th data-i18n="admin.playerJoker">Joker</th> <th data-i18n="admin.playerJoker">Joker</th>
<th data-i18n="admin.playerAdmin">Admin</th>
<th data-i18n="admin.playerDelete">Delete</th> <th data-i18n="admin.playerDelete">Delete</th>
</tr> </tr>
</thead> </thead>

View File

@@ -1,7 +1,7 @@
import { t } from "./i18n.js"; import { t } from "./i18n.js";
import { state } from "./state.js"; import { state } from "./state.js";
import { $ } from "./dom.js"; import { $ } from "./dom.js";
import { buildLinkOptionLabel, escapeHtml, truncate } from "./ui-utils.js"; import { buildLinkOptionLabel, truncate } from "./ui-utils.js";
function displayPlayerStatus(player) { function displayPlayerStatus(player) {
if (!player) return ""; if (!player) return "";
@@ -16,14 +16,24 @@ function displayPlayerStatus(player) {
} }
function buildStatusSelect(player) { function buildStatusSelect(player) {
const statusText = displayPlayerStatus(player);
const canMoveToSuggest = player.phase === "Vote"; const canMoveToSuggest = player.phase === "Vote";
return ` const select = document.createElement("select");
<select class="chip admin-status-select" data-set-player-phase="${player.playerId}" aria-label="${t("admin.playerStatus")}"> select.className = "chip admin-status-select";
<option value="" selected>${statusText}</option> select.dataset.setPlayerPhase = player.playerId;
<option value="Suggest" ${canMoveToSuggest ? "" : "disabled"}>${t("admin.statusMoveToSuggest")}</option> select.setAttribute("aria-label", t("admin.playerStatus"));
</select>
`; const current = document.createElement("option");
current.value = "";
current.selected = true;
current.textContent = displayPlayerStatus(player);
const suggest = document.createElement("option");
suggest.value = "Suggest";
suggest.disabled = !canMoveToSuggest;
suggest.textContent = t("admin.statusMoveToSuggest");
select.append(current, suggest);
return select;
} }
export function renderAdminVoteStatus() { export function renderAdminVoteStatus() {
@@ -36,17 +46,65 @@ export function renderAdminVoteStatus() {
table.innerHTML = ""; table.innerHTML = "";
state.adminVoteStatus.voters.forEach((v) => { state.adminVoteStatus.voters.forEach((v) => {
const tr = document.createElement("tr"); const tr = document.createElement("tr");
const gamesTooltip = escapeHtml((v.suggestionTitles || []).join(", ")); const gamesTooltip = (v.suggestionTitles || []).join(", ");
const nameText = escapeHtml(truncate(v.name, 28));
const userText = escapeHtml(truncate(v.username, 24)); const nameCell = document.createElement("td");
tr.innerHTML = ` nameCell.title = v.name ?? "";
<td title="${escapeHtml(v.name)}">${nameText}</td> nameCell.textContent = truncate(v.name, 28);
<td class="muted small" title="${escapeHtml(v.username)}">${userText}</td>
<td>${buildStatusSelect(v)}</td> const usernameCell = document.createElement("td");
<td title="${gamesTooltip}">${v.suggestionCount ?? 0}</td> usernameCell.className = "muted small";
<td><button class="chip" data-grant-joker="${v.playerId}" type="button">${v.hasJoker ? "🎟" : t("admin.grantJokerChip")}</button></td> usernameCell.title = v.username ?? "";
<td><button class="chip danger-chip" data-delete-player="${v.playerId}" data-name="${v.name}" type="button">✕</button></td> usernameCell.textContent = truncate(v.username, 24);
`;
const statusCell = document.createElement("td");
statusCell.appendChild(buildStatusSelect(v));
const countCell = document.createElement("td");
countCell.title = gamesTooltip;
countCell.textContent = String(v.suggestionCount ?? 0);
const jokerCell = document.createElement("td");
const jokerButton = document.createElement("button");
jokerButton.className = "chip";
jokerButton.dataset.grantJoker = v.playerId;
jokerButton.type = "button";
jokerButton.textContent = v.hasJoker ? "🎟" : t("admin.grantJokerChip");
jokerCell.appendChild(jokerButton);
const adminCell = document.createElement("td");
if (v.isOwner) {
const ownerLabel = document.createElement("span");
ownerLabel.className = "muted small";
ownerLabel.textContent = t("admin.owner");
adminCell.appendChild(ownerLabel);
} else {
const adminCheckbox = document.createElement("input");
adminCheckbox.type = "checkbox";
adminCheckbox.dataset.setPlayerAdmin = v.playerId;
adminCheckbox.checked = !!v.isAdmin;
adminCheckbox.setAttribute("aria-label", t("admin.playerAdmin"));
adminCell.appendChild(adminCheckbox);
}
const deleteCell = document.createElement("td");
const deleteButton = document.createElement("button");
deleteButton.className = "chip danger-chip";
deleteButton.dataset.deletePlayer = v.playerId;
deleteButton.dataset.name = v.name ?? "";
deleteButton.type = "button";
deleteButton.textContent = "✕";
deleteCell.appendChild(deleteButton);
tr.append(
nameCell,
usernameCell,
statusCell,
countCell,
jokerCell,
adminCell,
deleteCell,
);
table.appendChild(tr); table.appendChild(tr);
}); });

View 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),
});

View File

@@ -1,75 +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 || ""; async function requestState(ifNoneMatch) {
const basePath = normalizeBase(rawBase); const headers = {};
const withBase = (path) => `${basePath}${path}`; if (ifNoneMatch) headers["If-None-Match"] = ifNoneMatch;
function normalizeBase(value) { const res = await apiClient.getState({
if (!value) return ""; headers,
if (!value.startsWith("/")) return `/${value}`; raw: true,
return value.endsWith("/") ? value.slice(0, -1) : value; acceptStatuses: [304],
} });
async function request(path, { method = "GET", body } = {}) { if (res.status === 304) {
const res = await fetch(withBase(path), { return {
method, notModified: true,
credentials: "same-origin", etag: res.headers.get("ETag"),
headers: defaultHeaders, data: null,
body: body ? JSON.stringify(body) : undefined, };
}); }
if (!res.ok) { return {
let msg = `${res.status}`; notModified: false,
try { etag: res.headers.get("ETag"),
const data = await res.json(); 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;
}
return res.status === 204 ? null : res.json();
} }
export const api = { export const api = {
state: () => request("/api/state"), state: (ifNoneMatch) => requestState(ifNoneMatch),
me: () => request("/api/me"), stateEventsUrl: () => resolveOperationPath("GetStateEvents"),
register: (payload) => request("/api/auth/register", { method: "POST", body: payload }), me: () => apiClient.getMe(),
login: (payload) => request("/api/auth/login", { method: "POST", body: payload }), authOptions: () => apiClient.getAuthOptions(),
logout: () => request("/api/auth/logout", { method: "POST" }), 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 }), createSuggestion: (payload) =>
deleteSuggestion: (id) => request(`/api/suggestions/${id}`, { method: "DELETE" }), apiClient.createSuggestion({ body: payload }),
updateSuggestion: (id, payload) => request(`/api/suggestions/${id}`, { method: "PUT", body: payload }), deleteSuggestion: (id) =>
allSuggestions: () => request("/api/suggestions/all"), apiClient.deleteSuggestion({ pathParameters: { id } }),
updateSuggestion: (id, payload) =>
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", body: { suggestionId, score } }), vote: (suggestionId, score) =>
finalizeVotes: (final) => request("/api/votes/finalize", { method: "POST", body: { final } }), apiClient.upsertVote({
body: { suggestionId, score },
}),
finalizeVotes: (final) => apiClient.setVotesFinalized({ body: { final } }),
results: () => request("/api/results"), results: () => apiClient.getResults(),
nextPhase: () => request("/api/me/phase/next", { method: "POST" }), nextPhase: () => apiClient.nextPhase(),
prevPhase: () => request("/api/me/phase/prev", { method: "POST" }), prevPhase: () => apiClient.prevPhase(),
}; };
export const adminApi = { export const adminApi = {
setResultsOpen: (resultsOpen) => request("/api/admin/results", { method: "POST", body: { resultsOpen } }), setResultsOpen: (resultsOpen) =>
voteStatus: () => request("/api/admin/vote-status"), apiClient.setResultsOpen({
reset: (password) => body: { resultsOpen },
request("/api/admin/reset", { method: "POST", body: { password } }), }),
factoryReset: (password) => voteStatus: () => apiClient.getVoteStatus(),
request("/api/admin/factory-reset", { method: "POST", body: { password } }), reset: (password) => apiClient.reset({ body: { password } }),
grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }), factoryReset: (password) =>
setPlayerPhase: (playerId, phase) => apiClient.factoryReset({
request("/api/admin/player-phase", { method: "POST", body: { playerId, phase } }), body: { password },
deletePlayer: (playerId, password) => }),
request(`/api/admin/players/${playerId}`, { grantJoker: (playerId) => apiClient.grantJoker({ body: { playerId } }),
method: "DELETE", setPlayerAdmin: (playerId, isAdmin) =>
body: { password }, apiClient.setPlayerAdmin({
}), body: { playerId, isAdmin },
linkSuggestions: (sourceSuggestionId, targetSuggestionId) => }),
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }), setPlayerPhase: (playerId, phase) =>
unlinkSuggestions: (suggestionId) => apiClient.setPlayerPhase({
request("/api/admin/unlink-suggestions", { method: "POST", body: { suggestionId } }), body: { playerId, phase },
}),
deletePlayer: (playerId, password) =>
apiClient.deletePlayer({
pathParameters: { playerId },
body: { password },
}),
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
apiClient.linkSuggestions({
body: { sourceSuggestionId, targetSuggestionId },
}),
unlinkSuggestions: (suggestionId) =>
apiClient.unlinkSuggestions({
body: { suggestionId },
}),
}; };

View File

@@ -127,6 +127,7 @@ function setupPlayerTableActions(runSerializedRefresh) {
const playerTable = $("admin-player-table"); const playerTable = $("admin-player-table");
if (!playerTable) return; if (!playerTable) return;
const phaseSelectSelector = "[data-set-player-phase]"; const phaseSelectSelector = "[data-set-player-phase]";
const adminCheckboxSelector = "[data-set-player-admin]";
playerTable.addEventListener("focusin", (e) => { playerTable.addEventListener("focusin", (e) => {
if (e.target.matches?.(phaseSelectSelector)) { if (e.target.matches?.(phaseSelectSelector)) {
@@ -144,6 +145,25 @@ function setupPlayerTableActions(runSerializedRefresh) {
}); });
playerTable.addEventListener("change", async (e) => { playerTable.addEventListener("change", async (e) => {
const adminCheckbox = e.target.closest(adminCheckboxSelector);
if (adminCheckbox) {
const playerId = adminCheckbox.dataset.setPlayerAdmin;
if (!playerId) return;
const previous = !adminCheckbox.checked;
adminCheckbox.disabled = true;
try {
await adminApi.setPlayerAdmin(playerId, adminCheckbox.checked);
toast(t("admin.roleUpdated"));
await runSerializedRefresh();
} catch (err) {
adminCheckbox.checked = previous;
toast(err.message, true);
} finally {
adminCheckbox.disabled = false;
}
return;
}
const select = e.target.closest(phaseSelectSelector); const select = e.target.closest(phaseSelectSelector);
if (!select) return; if (!select) return;
const playerId = select.dataset.setPlayerPhase; const playerId = select.dataset.setPlayerPhase;

View File

@@ -46,6 +46,29 @@ function setupAuthModeToggle() {
setAuthMode(state.authMode); setAuthMode(state.authMode);
} }
function applyRegistrationOptions(ownerExists) {
state.ownerExists = !!ownerExists;
const adminKeyField = $("register-admin-key-field");
const adminKeyInput = $("register-adminkey");
if (!adminKeyField || !adminKeyInput) return;
const hideAdminKeyInput = state.ownerExists;
adminKeyField.classList.toggle("hidden", hideAdminKeyInput);
adminKeyInput.disabled = hideAdminKeyInput;
if (hideAdminKeyInput) {
adminKeyInput.value = "";
}
}
async function refreshRegistrationOptions() {
try {
const options = await api.authOptions();
applyRegistrationOptions(options?.ownerExists);
} catch {
applyRegistrationOptions(false);
}
}
function setupLoginUserEditingHint() { function setupLoginUserEditingHint() {
const loginUser = $("login-username"); const loginUser = $("login-username");
if (!loginUser) return; if (!loginUser) return;
@@ -91,6 +114,7 @@ function setupLoginFormHandlers({
if (err?.status === 401) if (err?.status === 401)
return toast(t("auth.invalidCredentials"), true); return toast(t("auth.invalidCredentials"), true);
if (handleAuthError(err, clearUserState)) return; if (handleAuthError(err, clearUserState)) return;
toast(err?.message || t("toast.unexpected"), true);
} }
}); });
} }
@@ -121,6 +145,7 @@ function setupRegisterFormHandlers({
return toast(t("auth.cookieRequired"), true); return toast(t("auth.cookieRequired"), true);
try { try {
await api.register({ username, password, displayName, adminKey }); await api.register({ username, password, displayName, adminKey });
await refreshRegistrationOptions();
setConsent(); setConsent();
toggleConsentRows(); toggleConsentRows();
setSavedUsername(username); setSavedUsername(username);
@@ -152,6 +177,7 @@ function setupLogoutHandler() {
clearUserState(); clearUserState();
state.isAuthenticated = false; state.isAuthenticated = false;
setAuthUI(false); setAuthUI(false);
await refreshRegistrationOptions();
}); });
} }
@@ -178,6 +204,7 @@ function setupSuggestionEntryButtons() {
export function setupAuthHandlers({ runSerializedRefresh }) { export function setupAuthHandlers({ runSerializedRefresh }) {
setupAuthModeToggle(); setupAuthModeToggle();
refreshRegistrationOptions();
const consent = setupConsentRows(); const consent = setupConsentRows();
setupLoginUserEditingHint(); setupLoginUserEditingHint();
setupLoginFormHandlers({ ...consent, runSerializedRefresh }); setupLoginFormHandlers({ ...consent, runSerializedRefresh });

View File

@@ -1,16 +1,44 @@
import { api, adminApi } from "./api.js"; import { api, adminApi } from "./api.js";
import { handleAuthError, renderAllSuggestions, renderCounts, renderMySuggestions, renderPhasePill, renderPhaseTitles, renderResults, renderVotes, renderWelcome, setAuthUI, syncVoteScores, updatePhaseNav, openResultsRelockModal, openSuggestionsChangedModal } from "./ui.js"; import {
handleAuthError,
renderAllSuggestions,
renderCounts,
renderMySuggestions,
renderPhasePill,
renderPhaseTitles,
renderResults,
renderVotes,
renderWelcome,
setAuthUI,
syncVoteScores,
updatePhaseNav,
openResultsRelockModal,
openSuggestionsChangedModal,
} from "./ui.js";
import { state, clearUserState } from "./state.js"; import { state, clearUserState } from "./state.js";
export async function loadState() { 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.isAuthenticated = true;
state.me = me; state.me = {
state.hasJoker = me.hasJoker ?? false; 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.prevPhase = state.phase;
state.phase = stateData.currentPhase; state.phase = stateData.currentPhase;
state.resultsOpen = stateData.resultsOpen; state.resultsOpen = stateData.resultsOpen;
state.votesFinal = stateData.votesFinal ?? me?.votesFinal ?? false; state.votesFinal = stateData.votesFinal ?? false;
state.counts = stateData; state.counts = stateData;
if (state.prevPhase !== state.phase && state.phase === "Vote") { if (state.prevPhase !== state.phase && state.phase === "Vote") {
state.votesRendered = false; state.votesRendered = false;
@@ -19,6 +47,7 @@ export async function loadState() {
renderWelcome(); renderWelcome();
renderPhasePill(); renderPhasePill();
renderCounts(); renderCounts();
return true;
} }
export async function loadSuggestData() { export async function loadSuggestData() {
@@ -86,18 +115,35 @@ export async function loadResults() {
} }
export async function refreshPhaseData() { export async function refreshPhaseData() {
const before = buildRefreshSnapshot();
try { try {
const prevPhase = state.phase; const prevPhase = state.phase;
const prevResultsOpen = state.resultsOpen; const prevResultsOpen = state.resultsOpen;
await loadState(); const stateChanged = await loadState();
await Promise.all([loadSuggestData(), loadSuggestionsData(), loadResults()]); 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(),
loadResults(),
]);
if (state.phase === "Vote") { if (state.phase === "Vote") {
if (!state.votesRendered) await loadVoteData(); if (!state.votesRendered) await loadVoteData();
} else { } else {
state.votesRendered = false; state.votesRendered = false;
await loadVoteData(); await loadVoteData();
} }
if (state.me?.isAdmin) { if (state.me?.isAdmin && adminPanelVisible) {
state.adminVoteStatus = await adminApi.voteStatus(); state.adminVoteStatus = await adminApi.voteStatus();
} }
if ( if (
@@ -109,12 +155,34 @@ export async function refreshPhaseData() {
openResultsRelockModal(); openResultsRelockModal();
} }
updatePhaseNav(); updatePhaseNav();
const after = buildRefreshSnapshot();
return before !== after;
} catch (err) { } catch (err) {
if (handleAuthError(err, clearUserState)) return; if (handleAuthError(err, clearUserState)) return;
throw err; throw err;
} }
} }
function buildRefreshSnapshot() {
return JSON.stringify({
phase: state.phase,
resultsOpen: state.resultsOpen,
votesFinal: state.votesFinal,
hasJoker: state.hasJoker,
counts: state.counts
? [
state.counts.players,
state.counts.suggestions,
state.counts.votes,
]
: null,
mineCount: state.mySuggestions?.length ?? 0,
allSig: state.allSuggestionsSig ?? "",
voteCount: state.myVotes?.length ?? 0,
resultsCount: state.results?.length ?? 0,
});
}
export function signatureSuggestions(list) { export function signatureSuggestions(list) {
return JSON.stringify( return JSON.stringify(
list.map((s) => [ list.map((s) => [

View File

@@ -1,6 +1,7 @@
export const $ = (id) => document.getElementById(id); export const $ = (id) => document.getElementById(id);
const toastEl = typeof document !== "undefined" ? document.getElementById("toast") : null; const toastEl =
typeof document !== "undefined" ? document.getElementById("toast") : null;
export function toast(msg, isError = false) { export function toast(msg, isError = false) {
if (!toastEl) return; if (!toastEl) return;

47
wwwroot/js/effects.js vendored
View File

@@ -3,48 +3,15 @@
// Screenshot hover --------------------------------------------------- // Screenshot hover ---------------------------------------------------
export function setupCardVisualHover(el, url) { export function setupCardVisualHover(el, url) {
if (!el || !url) return; 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.addEventListener("mouseenter", () => {
el.classList.add("hovering"); el.classList.add("hovering");
el.style.backgroundSize = "auto";
el.style.backgroundRepeat = "no-repeat";
el.style.backgroundPosition = "center";
}); });
el.addEventListener("mousemove", (e) => { ["mouseleave", "blur"].forEach((evt) =>
if (!loaded) return; el.addEventListener(evt, () => {
const rect = el.getBoundingClientRect(); el.classList.remove("hovering");
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));
} }
// Celebration FX ----------------------------------------------------- // Celebration FX -----------------------------------------------------
@@ -57,10 +24,6 @@ function ensureFxCanvas() {
if (fxCanvas) return; if (fxCanvas) return;
fxCanvas = document.createElement("canvas"); fxCanvas = document.createElement("canvas");
fxCanvas.className = "fx-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.width = window.innerWidth;
fxCanvas.height = window.innerHeight; fxCanvas.height = window.innerHeight;
fxCtx = fxCanvas.getContext("2d"); fxCtx = fxCanvas.getContext("2d");

View File

@@ -1,18 +1,28 @@
import { t } from "./i18n.js"; import { t } from "./i18n.js";
import { toast } from "./dom.js"; import { toast } from "./dom.js";
import { escapeHtml } from "./ui-utils.js";
export function openLightbox(url, title) { export function openLightbox(url, title) {
const overlay = document.createElement("div"); const overlay = document.createElement("div");
overlay.className = "lightbox"; overlay.className = "lightbox";
const safeTitle = escapeHtml(title || "");
overlay.innerHTML = ` const content = document.createElement("div");
<div class="lightbox-content"> content.className = "lightbox-content";
<button class="lightbox-close" aria-label="${t("lightbox.close")}">✕</button>
<img src="${url}" alt="${safeTitle}" /> const closeBtn = document.createElement("button");
<p>${safeTitle}</p> closeBtn.className = "lightbox-close";
</div> closeBtn.setAttribute("aria-label", t("lightbox.close"));
`; closeBtn.type = "button";
closeBtn.textContent = "✕";
const image = document.createElement("img");
image.src = url ?? "";
image.alt = title ?? "";
const caption = document.createElement("p");
caption.textContent = title ?? "";
content.append(closeBtn, image, caption);
overlay.appendChild(content);
overlay.addEventListener("click", (e) => { overlay.addEventListener("click", (e) => {
if ( if (
e.target.classList.contains("lightbox") || e.target.classList.contains("lightbox") ||
@@ -38,15 +48,28 @@ export function openConfirmModal({
overlay.className = "edit-modal"; overlay.className = "edit-modal";
const panel = document.createElement("div"); const panel = document.createElement("div");
panel.className = "edit-panel"; panel.className = "edit-panel";
panel.innerHTML = `
<div class="edit-header"> const header = document.createElement("div");
<h3>${title}</h3> header.className = "edit-header";
<button class="lightbox-close" aria-label="${t("modal.close")}">x</button>
</div> const heading = document.createElement("h3");
<div class="edit-body"> heading.textContent = title ?? "";
<p>${body}</p>
</div> const closeBtn = document.createElement("button");
`; closeBtn.className = "lightbox-close";
closeBtn.setAttribute("aria-label", t("modal.close"));
closeBtn.type = "button";
closeBtn.textContent = "x";
header.append(heading, closeBtn);
const bodyWrap = document.createElement("div");
bodyWrap.className = "edit-body";
const bodyText = document.createElement("p");
bodyText.textContent = body ?? "";
bodyWrap.appendChild(bodyText);
panel.append(header, bodyWrap);
const close = () => overlay.remove(); const close = () => overlay.remove();
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "stack horizontal confirm-actions"; actions.className = "stack horizontal confirm-actions";
@@ -63,7 +86,7 @@ export function openConfirmModal({
actions.append(cancelBtn); actions.append(cancelBtn);
cancelBtn.addEventListener("click", close); cancelBtn.addEventListener("click", close);
} }
const bodyContainer = panel.querySelector(".edit-body"); const bodyContainer = bodyWrap;
let passwordInput = null; let passwordInput = null;
if (requirePassword && bodyContainer) { if (requirePassword && bodyContainer) {
const field = document.createElement("label"); const field = document.createElement("label");

View File

@@ -63,6 +63,12 @@ export function renderResults() {
const safeShot = safeUrl(r.screenshotUrl); const safeShot = safeUrl(r.screenshotUrl);
const safeGameUrl = safeUrl(r.gameUrl); const safeGameUrl = safeUrl(r.gameUrl);
const safeYoutubeUrl = safeUrl(r.youtubeUrl); 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 = ` row.innerHTML = `
<td class="rank-cell"><span class="medal">${medal}</span></td> <td class="rank-cell"><span class="medal">${medal}</span></td>
<td class="game-cell"> <td class="game-cell">
@@ -76,9 +82,9 @@ export function renderResults() {
</div> </div>
</td> </td>
<td class="author-cell">${safeAuthor || "—"}</td> <td class="author-cell">${safeAuthor || "—"}</td>
<td>${r.average?.toFixed ? r.average.toFixed(1) : r.average}</td> <td><span title="${safeVotersTooltip}">${averageScore}</span></td>
<td>${formatVotes(r.votes)}</td> <td>${formatVotes(r.votes, votersTooltip)}</td>
<td>${formatMyVote(r.myVote)}</td> <td>${formatMyVote(r.myVote, votersTooltip)}</td>
<td> <td>
${safeGameUrl ? `<a class="link compact" href="${safeGameUrl}" target="_blank" rel="noopener">${t("results.link.site")}</a><br>` : ""} ${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>` : ""} ${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>`; return `<div class="muted small">${bits.join(" • ")}</div>`;
} }
function formatVotes(votes) { function formatVotes(votes, tooltip) {
if (!Array.isArray(votes) || votes.length === 0) return "⚠️"; 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); 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 "—"; 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(", ") });
} }

View File

@@ -1,5 +1,6 @@
export const state = { export const state = {
isAuthenticated: false, isAuthenticated: false,
ownerExists: false,
authMode: "login", authMode: "login",
me: null, me: null,
phase: null, phase: null,
@@ -16,9 +17,11 @@ export const state = {
votesRendered: false, votesRendered: false,
adminVoteStatus: null, adminVoteStatus: null,
adminStatusSelectActive: false, adminStatusSelectActive: false,
stateEtag: null,
}; };
export function clearUserState() { export function clearUserState() {
state.ownerExists = false;
state.me = null; state.me = null;
state.phase = null; state.phase = null;
state.prevPhase = null; state.prevPhase = null;
@@ -32,7 +35,9 @@ export function clearUserState() {
state.myVotes = []; state.myVotes = [];
state.results = []; state.results = [];
state.votesRendered = false; state.votesRendered = false;
state.adminVoteStatus = null;
state.adminStatusSelectActive = false; state.adminStatusSelectActive = false;
state.stateEtag = null;
const adminCard = document.getElementById("admin-card"); const adminCard = document.getElementById("admin-card");
if (adminCard) adminCard.classList.add("hidden"); if (adminCard) adminCard.classList.add("hidden");
} }

View File

@@ -6,7 +6,6 @@ import { setupCardVisualHover, triggerCelebration } from "./effects.js";
import { renderAdminLinker } from "./admin-ui.js"; import { renderAdminLinker } from "./admin-ui.js";
import { getUiRuntime } from "./ui-runtime.js"; import { getUiRuntime } from "./ui-runtime.js";
import { import {
cssEscapeUrl,
escapeHtml, escapeHtml,
isLinked, isLinked,
linkedPeerTitles, linkedPeerTitles,
@@ -49,16 +48,6 @@ export function renderMySuggestions() {
export function renderAllSuggestions() { export function renderAllSuggestions() {
renderAdminLinker(); renderAdminLinker();
const list = $("all-suggestions");
if (!list) return;
list.innerHTML = "";
const allowEdit = true;
const allowDelete = !!state.me?.isAdmin;
sortByName(state.allSuggestions).forEach((s) =>
list.appendChild(
buildCard(s, { showAuthor: true, allowEdit, allowDelete }),
),
);
renderPhaseTitles(); renderPhaseTitles();
} }
@@ -105,7 +94,7 @@ export function buildCard(
: ""; : "";
const visual = const visual =
hasImage && safeShot 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>`; : `<div class="card-visual"></div>`;
const hasPlayers = s.minPlayers || s.maxPlayers; const hasPlayers = s.minPlayers || s.maxPlayers;
const players = hasPlayers const players = hasPlayers

View File

@@ -261,15 +261,6 @@ export function updatePhaseNav() {
} }
} }
const voteNext = $("nav-vote-next");
if (voteNext) {
const locked = !state.resultsOpen && !isAdmin;
voteNext.disabled = locked;
voteNext.textContent = locked
? t("nav.waitingForResults")
: t("nav.next");
}
const adminResultsToggle = $("results-open"); const adminResultsToggle = $("results-open");
if (adminResultsToggle) { if (adminResultsToggle) {
adminResultsToggle.textContent = state.resultsOpen adminResultsToggle.textContent = state.resultsOpen