Compare commits
76 Commits
chore/revi
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| ae85c369ba | |||
| 1802fd6607 | |||
| e55a1b01f4 | |||
| a130cba41a | |||
| 3c7f3d2114 | |||
| 5b921063ec | |||
| 608c5368b3 | |||
| 06ae85f427 | |||
| 26379eef1a | |||
| 4d62d0bf50 | |||
| 6e5bbec86e | |||
| 78dccff90f | |||
| a6408979ee | |||
| 018fc47d9c | |||
| bc0245c1d4 | |||
| 6eea5dcf32 | |||
| de9123b260 | |||
| d375b942ff | |||
| 726ba79fdf | |||
| 368b4877bc | |||
| d2ab8a676f | |||
| acffbc199d | |||
| 2d2201d0a2 | |||
| fe6a9d5da4 | |||
| 569cea161f | |||
| d62ccdcf53 | |||
| 1bb34c51bf | |||
| 1c59d68a50 | |||
| 97f1b30b75 | |||
| 42e60d2a5a | |||
| a6364b0802 | |||
| e922afacdf | |||
| 46cb1dcb1e | |||
| 6a5f1c5890 | |||
| d9466d9194 | |||
| d534fc256b | |||
| e666e7c603 | |||
| 96a47020d8 | |||
| fadd72d5c4 | |||
| 02d15e9c50 | |||
| 0c888e5a5d | |||
| 5ec18d20ea | |||
| 5595bfd3b1 | |||
| 4ee327fb4e | |||
| ddd26369dd | |||
| 41d9a3b571 | |||
| 3104ddc601 | |||
| 47fbec4512 | |||
| 86310804fa | |||
| 83cea11c64 | |||
| b67753ff9e | |||
| abb9874c98 | |||
| 260dd5ab17 | |||
| 9d3947714a | |||
| c3951b95ac | |||
| 08163a7ee2 | |||
| a281f4acaf | |||
| ced72ccd84 | |||
| d4072da430 | |||
| 124fb62657 | |||
| 536e6392f0 | |||
| 5f31455651 | |||
| c765dd322b | |||
| 5b06e279f3 | |||
| 34d274d244 | |||
| b16bf8007f | |||
| 567502d665 | |||
| 5e84686678 | |||
| 78701cebf2 | |||
| 37db70e67e | |||
| 20daecd3eb | |||
| f615ef3a4a | |||
| 79dc8f899f | |||
| 35d842d6ee | |||
| 0d60108036 | |||
| 5d40d555d1 |
7
.editorconfig
Normal file
7
.editorconfig
Normal file
@@ -0,0 +1,7 @@
|
||||
root = true
|
||||
|
||||
[*.cs]
|
||||
dotnet_diagnostic.CA1707.severity = none
|
||||
dotnet_diagnostic.CA1852.severity = none
|
||||
dotnet_diagnostic.CA1825.severity = none
|
||||
dotnet_diagnostic.CA1861.severity = none
|
||||
19
.github/workflows/ci.yml
vendored
19
.github/workflows/ci.yml
vendored
@@ -14,6 +14,20 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Install frontend tooling
|
||||
run: npm install
|
||||
|
||||
- name: Lint frontend
|
||||
run: npm run lint
|
||||
|
||||
- name: Check frontend formatting
|
||||
run: npm run format:check
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
@@ -26,4 +40,7 @@ jobs:
|
||||
run: dotnet build GameList.sln --no-restore -warnaserror
|
||||
|
||||
- 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
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -7,15 +7,21 @@ artifacts/
|
||||
# IDE
|
||||
.vs/
|
||||
.vscode/
|
||||
node_modules/
|
||||
|
||||
# User secrets / configs
|
||||
appsettings.Development.json
|
||||
scripts/deploy-ftp.profile.psd1
|
||||
*.user
|
||||
*.suo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Test results / coverage artifacts
|
||||
TestResults/
|
||||
coverage.cobertura.xml
|
||||
|
||||
# SQLite data
|
||||
App_Data/
|
||||
*.db
|
||||
|
||||
27
AGENTS.md
27
AGENTS.md
@@ -1,26 +1,17 @@
|
||||
# Agent Guide — Pick'n'Play
|
||||
|
||||
Also see the other related files: API.md, IIS.md, SPEC.md
|
||||
Also see the other related technical documentation: API.md, IIS.md, SPEC.md, TESTS.md, REVIEW.md and README.md.
|
||||
Also see the user-facing documentation: per-language md files in wwwroot/data/i18n/faq
|
||||
|
||||
## Rules
|
||||
|
||||
- This is a Windows environment, WSL is not installed (i.e. sed is not available). You're running under PowerShell 7.5.4. Due to platform restrictions, file deletions are not possible. Replacing the entire file content via a context diff is a viable alternative. PowerShell doesn't support bash-style heredocs, run Python code using python -c with inline commands instead of python - <<'PY'.
|
||||
- If complex scripts need to be executed, consider using python. It's installed.
|
||||
- This is a Windows environment, WSL is not installed (i.e. sed is not available). You're running under PowerShell 7.5.4. Due to platform restrictions, file deletions are not possible. Replacing the entire file content via a context diff is a viable alternative.
|
||||
- PowerShell doesn't support bash-style heredocs. If complex scripts need to be executed, consider using python. Run Python code using python -c with inline commands instead of python - <<'PY'.
|
||||
- web.config in the server is different than locally, it must be exluded from deployment.
|
||||
- After every iteration, evaluate if the test coverage would fall below 100%, and write tests if necessary.
|
||||
- After every iteration, run "dotnet test GameList.Tests/GameList.Tests.csproj" and make sure 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, do a git commit with a brief summary of the changes as a commit message.
|
||||
- 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 backend, feel free run "dotnet build" and "dotnet ef database update". If this is blocked by a running dotnet process, feel free to kill the process and retry the operations once.
|
||||
- Keep changes small and testable
|
||||
- Avoid introducing new dependencies unless they remove complexity.
|
||||
- Keep endpoint logic in `Endpoints/` and shared helpers/DTOs in their folders to avoid Program.cs bloat.
|
||||
- Keep css and js files diversified to avoid styles.css or app.js bloat.
|
||||
|
||||
## 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)
|
||||
- Keep changes small and commit often. If one iteration encompasses many smaller tasks with more than one commit, create a git branch and do the commits there. Let me review the branch before merging it back to master.
|
||||
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent.
|
||||
- After changing the database, run "dotnet ef database update". If this is blocked by a running dotnet process, feel free to kill the process and retry the operations once.
|
||||
|
||||
37
API.md
37
API.md
@@ -1,19 +1,27 @@
|
||||
# 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).
|
||||
Auth and admin-sensitive routes are rate-limited and return HTTP `429` on excessive requests.
|
||||
The machine-readable source of truth is the generated OpenAPI document at `openapi/GameList.json` (runtime endpoint: `GET /openapi/v1.json`).
|
||||
Frontend API calls are generated from that document into `wwwroot/js/api-client.generated.js` via `npm run generate:api-client`.
|
||||
|
||||
## Auth
|
||||
POST /api/auth/register — accepts optional `adminKey` to set `IsAdmin=true`
|
||||
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/logout
|
||||
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)
|
||||
GET /api/state — returns currentPhase (for caller), votesFinal, resultsOpen, updatedAt, counts (players/suggestions/votes)
|
||||
GET /api/me — id, displayName, username, isAdmin, currentPhase, votesFinal
|
||||
GET /api/state — returns caller identity (`id`, `username`, `displayName`, `isAdmin`, `isOwner`) plus currentPhase, votesFinal, resultsOpen, updatedAt, counts (players/suggestions/votes). Supports conditional reads with `ETag`/`If-None-Match`; unchanged state returns HTTP `304`.
|
||||
GET /api/events/state — server-sent events stream for state invalidation (`ready` and `state` events with monotonic version payload) for event-driven client refresh.
|
||||
GET /api/me — id, displayName, username, isAdmin, isOwner, currentPhase, votesFinal
|
||||
|
||||
## Player (requires auth)
|
||||
POST /api/me/phase/next — advance caller to next phase (Suggest→Vote→Results; Results 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)
|
||||
POST /api/me/phase/prev — admin-only move caller backward (Results→Vote→Suggest)
|
||||
|
||||
## Suggestions (requires auth + phase gating)
|
||||
@@ -22,19 +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)
|
||||
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
|
||||
Suggestion limit is enforced in both app logic and DB trigger; concurrent writes that exceed limit return `400`.
|
||||
|
||||
## Votes (requires auth + Vote phase)
|
||||
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/finalize — `{ final: bool }` toggles caller’s 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)
|
||||
GET /api/results — leaderboard with totals, counts, averages, caller’s vote, media/links, link metadata
|
||||
GET /api/results — leaderboard with totals, counts, averages, vote values, alphabetically sorted `voterNames`, caller’s vote, media/links, link metadata
|
||||
|
||||
## Admin (requires authenticated admin user)
|
||||
POST /api/admin/results — `{ resultsOpen: bool }` locks/unlocks results and aligns player phases
|
||||
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/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
|
||||
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/reset — clear suggestions/votes; keep players; reset phases/vote-final flags
|
||||
POST /api/admin/factory-reset — wipe players, suggestions, votes, state
|
||||
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
|
||||
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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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);
|
||||
|
||||
@@ -6,16 +6,26 @@ 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 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 VoteRecordDto(int SuggestionId, int Score);
|
||||
|
||||
public record ResultsOpenRequest(bool ResultsOpen);
|
||||
|
||||
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 UnlinkSuggestionsRequest(int SuggestionId);
|
||||
|
||||
public record GrantJokerRequest(Guid PlayerId);
|
||||
|
||||
public record SetPlayerPhaseRequest(Guid PlayerId, Phase Phase);
|
||||
|
||||
public record SetPlayerAdminRequest(Guid PlayerId, bool IsAdmin);
|
||||
|
||||
public record AdminPasswordRequest(string Password);
|
||||
|
||||
41
Contracts/Responses.cs
Normal file
41
Contracts/Responses.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using GameList.Domain;
|
||||
|
||||
namespace GameList.Contracts;
|
||||
|
||||
public record SuggestionCreatedResponse(int Id);
|
||||
|
||||
public record SuggestionUpdatedResponse(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 VoteFinalizeResponse(bool VotesFinal);
|
||||
|
||||
public record AdminResultsStateResponse(bool ResultsOpen, DateTimeOffset UpdatedAt);
|
||||
|
||||
public record AdminGrantJokerResponse(Guid Id, bool HasJoker);
|
||||
|
||||
public record AdminSetPlayerPhaseResponse(Guid PlayerId, Phase CurrentPhase, bool VotesFinal);
|
||||
|
||||
public record AdminSetPlayerAdminResponse(Guid PlayerId, bool IsAdmin);
|
||||
|
||||
public record AdminDeletePlayerResponse(Guid DeletedPlayerId);
|
||||
|
||||
public record AdminLinkSuggestionsResponse(int RootId, IReadOnlyList<int> LinkedSuggestionIds, int UnfinalizedPlayers);
|
||||
|
||||
public record AdminUnlinkSuggestionsResponse(IReadOnlyList<int> UnlinkedSuggestionIds, int UnfinalizedPlayers);
|
||||
|
||||
public record AdminResetStateResponse(Phase Phase, bool ResultsOpen, DateTimeOffset UpdatedAt);
|
||||
|
||||
public record VoteStatusResponse(IReadOnlyList<VoteStatusDto> Voters, bool Ready, IReadOnlyList<string> Waiting);
|
||||
|
||||
public record ResultItemDto(int Id, string Name, string? Author, int? MinPlayers, int? MaxPlayers, int Total, int Count, double Average, IReadOnlyList<int> Votes, IReadOnlyList<string> VoterNames, int? MyVote, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, string? Description, string? Genre, int? ParentSuggestionId, IReadOnlyList<int> LinkedIds, IReadOnlyList<string> LinkedTitles);
|
||||
|
||||
public record AuthSessionResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin);
|
||||
|
||||
public record AuthOptionsResponse(bool OwnerExists);
|
||||
|
||||
public record StateSummaryResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin, bool IsOwner, Phase CurrentPhase, bool VotesFinal, bool HasJoker, bool ResultsOpen, DateTimeOffset UpdatedAt, int Players, int Suggestions, int Votes);
|
||||
|
||||
public record MeResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin, bool IsOwner, Phase CurrentPhase, bool VotesFinal, bool HasJoker);
|
||||
|
||||
public record PhaseTransitionResponse(Phase CurrentPhase, bool ResultsOpen);
|
||||
@@ -21,7 +21,10 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
|
||||
builder.HasIndex(p => p.NormalizedUsername).IsUnique();
|
||||
builder.Property(p => p.PasswordHash).IsRequired();
|
||||
builder.Property(p => p.PasswordSalt).IsRequired();
|
||||
builder.Property(p => p.PasswordHashVersion).HasDefaultValue(1);
|
||||
builder.Property(p => p.IsAdmin).HasDefaultValue(false);
|
||||
builder.Property(p => p.IsOwner).HasDefaultValue(false);
|
||||
builder.HasIndex(p => p.IsOwner).HasFilter($"{nameof(Player.IsOwner)} = 1").IsUnique();
|
||||
builder.Property(p => p.HasJoker).HasDefaultValue(false);
|
||||
builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest);
|
||||
builder.Property(p => p.VotesFinal).HasDefaultValue(false);
|
||||
|
||||
251
Data/Migrations/20260208175912_AddOwnerRole.Designer.cs
generated
Normal file
251
Data/Migrations/20260208175912_AddOwnerRole.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
42
Data/Migrations/20260208175912_AddOwnerRole.cs
Normal file
42
Data/Migrations/20260208175912_AddOwnerRole.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
255
Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.Designer.cs
generated
Normal file
255
Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
260
Data/Migrations/20260218194640_AddPasswordHashVersion.Designer.cs
generated
Normal file
260
Data/Migrations/20260218194640_AddPasswordHashVersion.Designer.cs
generated
Normal file
@@ -0,0 +1,260 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using GameList.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GameList.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260218194640_AddPasswordHashVersion")]
|
||||
partial class AddPasswordHashVersion
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.AppState", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("ResultsOpen")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AppState");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
ResultsOpen = false,
|
||||
UpdatedAt = new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Player", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrentPhase")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("HasJoker")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("IsOwner")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedUsername")
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte[]>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<int>("PasswordHashVersion")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<byte[]>("PasswordSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("VotesFinal")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsOwner")
|
||||
.IsUnique()
|
||||
.HasFilter("IsOwner = 1");
|
||||
|
||||
b.HasIndex("NormalizedUsername")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Players");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GameUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("MaxPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("MinPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParentSuggestionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PlayerId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ScreenshotUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("YoutubeUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentSuggestionId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("Suggestions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Vote", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("PlayerId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SuggestionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SuggestionId");
|
||||
|
||||
b.HasIndex("PlayerId", "SuggestionId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Votes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||
{
|
||||
b.HasOne("GameList.Domain.Suggestion", "ParentSuggestion")
|
||||
.WithMany("LinkedSuggestions")
|
||||
.HasForeignKey("ParentSuggestionId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("GameList.Domain.Player", "Player")
|
||||
.WithMany("Suggestions")
|
||||
.HasForeignKey("PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ParentSuggestion");
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Vote", b =>
|
||||
{
|
||||
b.HasOne("GameList.Domain.Player", "Player")
|
||||
.WithMany("Votes")
|
||||
.HasForeignKey("PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("GameList.Domain.Suggestion", "Suggestion")
|
||||
.WithMany("Votes")
|
||||
.HasForeignKey("SuggestionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Player");
|
||||
|
||||
b.Navigation("Suggestion");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Player", b =>
|
||||
{
|
||||
b.Navigation("Suggestions");
|
||||
|
||||
b.Navigation("Votes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||
{
|
||||
b.Navigation("LinkedSuggestions");
|
||||
|
||||
b.Navigation("Votes");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
29
Data/Migrations/20260218194640_AddPasswordHashVersion.cs
Normal file
29
Data/Migrations/20260218194640_AddPasswordHashVersion.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GameList.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddPasswordHashVersion : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<int>(
|
||||
name: "PasswordHashVersion",
|
||||
table: "Players",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: 1);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PasswordHashVersion",
|
||||
table: "Players");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,11 @@ namespace GameList.Data.Migrations
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("IsOwner")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
@@ -82,6 +87,11 @@ namespace GameList.Data.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<int>("PasswordHashVersion")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(1);
|
||||
|
||||
b.Property<byte[]>("PasswordSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB");
|
||||
@@ -98,6 +108,10 @@ namespace GameList.Data.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsOwner")
|
||||
.IsUnique()
|
||||
.HasFilter("IsOwner = 1");
|
||||
|
||||
b.HasIndex("NormalizedUsername")
|
||||
.IsUnique();
|
||||
|
||||
|
||||
7
Directory.Build.props
Normal file
7
Directory.Build.props
Normal file
@@ -0,0 +1,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<EnableNETAnalyzers>true</EnableNETAnalyzers>
|
||||
<AnalysisLevel>latest-recommended</AnalysisLevel>
|
||||
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -3,7 +3,6 @@ namespace GameList.Domain;
|
||||
public enum Phase
|
||||
{
|
||||
Suggest = 0,
|
||||
Reveal = 1,
|
||||
Vote = 2,
|
||||
Results = 3
|
||||
}
|
||||
|
||||
@@ -17,9 +17,11 @@ public class Player
|
||||
|
||||
public byte[] PasswordHash { get; set; } = [];
|
||||
public byte[] PasswordSalt { get; set; } = [];
|
||||
public int PasswordHashVersion { get; set; } = 1;
|
||||
|
||||
public DateTimeOffset? LastLoginAt { get; set; }
|
||||
public bool IsAdmin { get; set; }
|
||||
public bool IsOwner { get; set; }
|
||||
public Phase CurrentPhase { get; set; } = Phase.Suggest;
|
||||
public bool VotesFinal { get; set; }
|
||||
public bool HasJoker { get; set; }
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using GameList.Contracts;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using GameList.Infrastructure;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
@@ -11,253 +9,86 @@ public static class AdminEndpoints
|
||||
{
|
||||
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, HttpContext _, AppDbContext db) =>
|
||||
admin.MapPost("/results", async ([FromBody] ResultsOpenRequest request, AdminWorkflowService service) =>
|
||||
{
|
||||
var state = await db.AppState.FirstAsync();
|
||||
state.ResultsOpen = request.ResultsOpen;
|
||||
state.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
var result = await service.SetResultsOpenAsync(request.ResultsOpen);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("SetResultsOpen");
|
||||
|
||||
if (request.ResultsOpen)
|
||||
admin.MapGet("/vote-status", async (AdminWorkflowService service) =>
|
||||
{
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Results));
|
||||
}
|
||||
else
|
||||
var result = await service.GetVoteStatusAsync();
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("GetVoteStatus");
|
||||
|
||||
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) =>
|
||||
{
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Vote).SetProperty(x => x.VotesFinal, false));
|
||||
}
|
||||
var result = await service.GrantJokerAsync(request.PlayerId);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("GrantJoker");
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
var currentState = await db.AppState.AsNoTracking().FirstAsync();
|
||||
return Results.Ok(new
|
||||
admin.MapPost("/player-phase", async ([FromBody] SetPlayerPhaseRequest request, AdminWorkflowService service) =>
|
||||
{
|
||||
currentState.ResultsOpen,
|
||||
currentState.UpdatedAt
|
||||
});
|
||||
});
|
||||
|
||||
admin.MapGet("/vote-status", async (HttpContext _, AppDbContext db) =>
|
||||
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 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.Suggestions.Count, p.Suggestions.Select(s => s.Name).ToList())).ToListAsync();
|
||||
var result = await service.SetPlayerAdminAsync(request.PlayerId, request.IsAdmin);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("SetPlayerAdmin");
|
||||
|
||||
var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList();
|
||||
var ready = waiting.Count == 0;
|
||||
return Results.Ok(new
|
||||
{
|
||||
voters,
|
||||
ready,
|
||||
waiting
|
||||
});
|
||||
});
|
||||
|
||||
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, HttpContext _, AppDbContext db) =>
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == request.PlayerId);
|
||||
if (player is null)
|
||||
return Results.NotFound(new { error = "Player not found." });
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
return Results.BadRequest(new { error = "Player must be in the Vote phase to receive a joker." });
|
||||
|
||||
player.HasJoker = true;
|
||||
player.VotesFinal = false;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
player.Id,
|
||||
player.HasJoker
|
||||
});
|
||||
});
|
||||
|
||||
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, HttpContext _, AppDbContext db) =>
|
||||
{
|
||||
var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId);
|
||||
if (player is null)
|
||||
return Results.NotFound(new { error = "Player not found." });
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
// Remove votes cast by the player
|
||||
await db.Votes.Where(v => v.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
|
||||
// Collect suggestions authored by the player
|
||||
var suggestionIds = player.Suggestions.Select(s => s.Id).ToList();
|
||||
if (suggestionIds.Count > 0)
|
||||
{
|
||||
// Break links pointing to these suggestions
|
||||
await db.Suggestions.Where(s => s.ParentSuggestionId != null && suggestionIds.Contains(s.ParentSuggestionId.Value)).ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
|
||||
|
||||
// Remove votes for these suggestions to avoid orphaned rows
|
||||
await db.Votes.Where(v => suggestionIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
|
||||
}
|
||||
|
||||
// Delete player (cascades suggestions)
|
||||
db.Players.Remove(player);
|
||||
await db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
return Results.Ok(new { DeletedPlayerId = playerId });
|
||||
});
|
||||
|
||||
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, [FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
var result = await service.DeletePlayerAsync(playerId, player.Id, request.Password, ctx);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("DeletePlayer");
|
||||
|
||||
if (request.SourceSuggestionId == request.TargetSuggestionId)
|
||||
return Results.BadRequest(new { error = "Pick two different games to link." });
|
||||
|
||||
var suggestions = await db.Suggestions.ToListAsync();
|
||||
var source = suggestions.FirstOrDefault(s => s.Id == request.SourceSuggestionId);
|
||||
var target = suggestions.FirstOrDefault(s => s.Id == request.TargetSuggestionId);
|
||||
if (source is null || target is null)
|
||||
return Results.NotFound(new { error = "Suggestion not found." });
|
||||
|
||||
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))
|
||||
return Results.NotFound(new { error = "Suggestion not found." });
|
||||
|
||||
if (sourceRoot == targetRoot)
|
||||
return Results.BadRequest(new { error = "These games are already linked." });
|
||||
|
||||
var affectedRootIds = new HashSet<int>
|
||||
{
|
||||
sourceRoot,
|
||||
targetRoot
|
||||
};
|
||||
var affectedIds = rootIndex.Where(kv => affectedRootIds.Contains(kv.Value)).Select(kv => kv.Key).ToList();
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
foreach (var suggestion in suggestions)
|
||||
{
|
||||
var root = rootIndex.GetValueOrDefault(suggestion.Id);
|
||||
if (root == targetRoot)
|
||||
{
|
||||
suggestion.ParentSuggestionId = suggestion.Id == targetRoot ? null : targetRoot;
|
||||
}
|
||||
else if (root == sourceRoot)
|
||||
{
|
||||
suggestion.ParentSuggestionId = targetRoot;
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await db.Votes.Where(v => affectedIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
|
||||
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
|
||||
|
||||
await tx.CommitAsync();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
RootId = targetRoot,
|
||||
LinkedSuggestionIds = affectedIds,
|
||||
UnfinalizedPlayers = await db.Players.CountAsync()
|
||||
});
|
||||
});
|
||||
|
||||
admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
var result = await service.LinkSuggestionsAsync(player.Id, request.SourceSuggestionId, request.TargetSuggestionId);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("LinkSuggestions");
|
||||
|
||||
var suggestions = await db.Suggestions.ToListAsync();
|
||||
var target = suggestions.FirstOrDefault(s => s.Id == request.SuggestionId);
|
||||
if (target is null)
|
||||
return Results.Ok(new
|
||||
admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||
{
|
||||
UnlinkedSuggestionIds = Array.Empty<int>(),
|
||||
UnfinalizedPlayers = 0
|
||||
});
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
if (!rootIndex.TryGetValue(target.Id, out var rootId))
|
||||
return Results.Ok(new
|
||||
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) =>
|
||||
{
|
||||
UnlinkedSuggestionIds = Array.Empty<int>(),
|
||||
UnfinalizedPlayers = 0
|
||||
});
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
var groupIds = rootIndex.Where(kv => kv.Value == rootId).Select(kv => kv.Key).ToList();
|
||||
var result = await service.ResetAsync(player.Id, request.Password, ctx);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("Reset");
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
foreach (var suggestion in suggestions.Where(s => groupIds.Contains(s.Id)))
|
||||
admin.MapPost("/factory-reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||
{
|
||||
suggestion.ParentSuggestionId = null;
|
||||
}
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await db.Votes.Where(v => groupIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
|
||||
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
|
||||
|
||||
await tx.CommitAsync();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
UnlinkedSuggestionIds = groupIds,
|
||||
UnfinalizedPlayers = await db.Players.CountAsync()
|
||||
});
|
||||
});
|
||||
|
||||
admin.MapPost("/reset", async (HttpContext _, AppDbContext db) =>
|
||||
{
|
||||
await db.Votes.ExecuteDeleteAsync();
|
||||
await db.Suggestions.ExecuteDeleteAsync();
|
||||
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false).SetProperty(x => x.HasJoker, false));
|
||||
var state = await db.AppState.FirstAsync();
|
||||
state.ResultsOpen = false;
|
||||
state.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
Phase = Phase.Suggest,
|
||||
state.ResultsOpen,
|
||||
state.UpdatedAt
|
||||
});
|
||||
});
|
||||
|
||||
admin.MapPost("/factory-reset", async (HttpContext _, AppDbContext db) =>
|
||||
{
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
await db.Votes.ExecuteDeleteAsync();
|
||||
await db.Suggestions.ExecuteDeleteAsync();
|
||||
await db.Players.ExecuteDeleteAsync();
|
||||
await db.AppState.ExecuteDeleteAsync();
|
||||
|
||||
var fresh = EndpointHelpers.NewAppState();
|
||||
db.AppState.Add(fresh);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await tx.CommitAsync();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
Phase = Phase.Suggest,
|
||||
fresh.ResultsOpen,
|
||||
fresh.UpdatedAt
|
||||
});
|
||||
});
|
||||
var result = await service.FactoryResetAsync(player.Id, request.Password, ctx);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("FactoryReset");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
289
Endpoints/AdminWorkflowService.cs
Normal file
289
Endpoints/AdminWorkflowService.cs
Normal file
@@ -0,0 +1,289 @@
|
||||
using GameList.Contracts;
|
||||
using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using GameList.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
internal sealed class AdminWorkflowService(AppDbContext db)
|
||||
{
|
||||
public async Task<ServiceResult<AdminResultsStateResponse>> SetResultsOpenAsync(bool resultsOpen)
|
||||
{
|
||||
var state = await db.AppState.SingleAsync();
|
||||
state.ResultsOpen = resultsOpen;
|
||||
state.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
if (resultsOpen)
|
||||
{
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Results));
|
||||
}
|
||||
else
|
||||
{
|
||||
await db.Players.Where(p => p.Suggestions.Any()).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 tx.CommitAsync();
|
||||
var currentState = await db.AppState.AsNoTracking().SingleAsync();
|
||||
return ServiceResult<AdminResultsStateResponse>.Success(new AdminResultsStateResponse(currentState.ResultsOpen, currentState.UpdatedAt));
|
||||
}
|
||||
|
||||
public async Task<ServiceResult<VoteStatusResponse>> GetVoteStatusAsync()
|
||||
{
|
||||
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();
|
||||
|
||||
var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList();
|
||||
var ready = waiting.Count == 0;
|
||||
return ServiceResult<VoteStatusResponse>.Success(new VoteStatusResponse(voters, ready, waiting));
|
||||
}
|
||||
|
||||
public async Task<ServiceResult<AdminGrantJokerResponse>> GrantJokerAsync(Guid playerId)
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||
if (player is null)
|
||||
return ServiceResult<AdminGrantJokerResponse>.Failure(ServiceError.NotFound("Player not found."));
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
return ServiceResult<AdminGrantJokerResponse>.Failure(ServiceError.BadRequest("Player must be in the Vote phase to receive a joker."));
|
||||
|
||||
player.HasJoker = true;
|
||||
player.VotesFinal = false;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return ServiceResult<AdminGrantJokerResponse>.Success(new AdminGrantJokerResponse(player.Id, player.HasJoker));
|
||||
}
|
||||
|
||||
public async Task<ServiceResult<AdminSetPlayerPhaseResponse>> SetPlayerPhaseAsync(Guid playerId, Phase phase)
|
||||
{
|
||||
if (phase != Phase.Suggest)
|
||||
return ServiceResult<AdminSetPlayerPhaseResponse>.Failure(ServiceError.BadRequest("Only transition to Suggest is supported."));
|
||||
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||
if (player is null)
|
||||
return ServiceResult<AdminSetPlayerPhaseResponse>.Failure(ServiceError.NotFound("Player not found."));
|
||||
|
||||
var currentPhase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
||||
if (currentPhase != Phase.Vote)
|
||||
return ServiceResult<AdminSetPlayerPhaseResponse>.Failure(ServiceError.BadRequest("Player must currently be in the Vote phase."));
|
||||
|
||||
player.CurrentPhase = Phase.Suggest;
|
||||
player.VotesFinal = false;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return ServiceResult<AdminSetPlayerPhaseResponse>.Success(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal));
|
||||
}
|
||||
|
||||
public async Task<ServiceResult<AdminSetPlayerAdminResponse>> SetPlayerAdminAsync(Guid playerId, bool isAdmin)
|
||||
{
|
||||
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)
|
||||
return ServiceResult<AdminDeletePlayerResponse>.Failure(passwordError);
|
||||
|
||||
var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId);
|
||||
if (player is null)
|
||||
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 db.Votes.Where(v => v.PlayerId == playerId).ExecuteDeleteAsync();
|
||||
|
||||
var suggestionIds = player.Suggestions.Select(s => s.Id).ToList();
|
||||
if (suggestionIds.Count > 0)
|
||||
{
|
||||
await db.Suggestions.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();
|
||||
}
|
||||
|
||||
db.Players.Remove(player);
|
||||
await db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
return ServiceResult<AdminDeletePlayerResponse>.Success(new AdminDeletePlayerResponse(playerId));
|
||||
}
|
||||
|
||||
public async Task<ServiceResult<AdminLinkSuggestionsResponse>> LinkSuggestionsAsync(Guid adminPlayerId, int sourceSuggestionId, int targetSuggestionId)
|
||||
{
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId);
|
||||
if (phase != Phase.Vote)
|
||||
return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||
|
||||
if (sourceSuggestionId == targetSuggestionId)
|
||||
return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.BadRequest("Pick two different games to link."));
|
||||
|
||||
var suggestions = await db.Suggestions.ToListAsync();
|
||||
var source = suggestions.FirstOrDefault(s => s.Id == sourceSuggestionId);
|
||||
var target = suggestions.FirstOrDefault(s => s.Id == targetSuggestionId);
|
||||
if (source is null || target is null)
|
||||
return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.NotFound("Suggestion not found."));
|
||||
|
||||
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))
|
||||
return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.NotFound("Suggestion not found."));
|
||||
|
||||
if (sourceRoot == targetRoot)
|
||||
return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.BadRequest("These games are already linked."));
|
||||
|
||||
var affectedRootIds = new HashSet<int>
|
||||
{
|
||||
sourceRoot,
|
||||
targetRoot
|
||||
};
|
||||
var affectedIds = rootIndex.Where(kv => affectedRootIds.Contains(kv.Value)).Select(kv => kv.Key).ToList();
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
foreach (var suggestion in suggestions)
|
||||
{
|
||||
var root = rootIndex.GetValueOrDefault(suggestion.Id);
|
||||
if (root == targetRoot)
|
||||
{
|
||||
suggestion.ParentSuggestionId = suggestion.Id == targetRoot ? null : targetRoot;
|
||||
}
|
||||
else if (root == sourceRoot)
|
||||
{
|
||||
suggestion.ParentSuggestionId = targetRoot;
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await db.Votes.Where(v => affectedIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
|
||||
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
|
||||
|
||||
await tx.CommitAsync();
|
||||
|
||||
return ServiceResult<AdminLinkSuggestionsResponse>.Success(new AdminLinkSuggestionsResponse(targetRoot, affectedIds, await db.Players.CountAsync()));
|
||||
}
|
||||
|
||||
public async Task<ServiceResult<AdminUnlinkSuggestionsResponse>> UnlinkSuggestionsAsync(Guid adminPlayerId, int suggestionId)
|
||||
{
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId);
|
||||
if (phase != Phase.Vote)
|
||||
return ServiceResult<AdminUnlinkSuggestionsResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||
|
||||
var suggestions = await db.Suggestions.ToListAsync();
|
||||
var target = suggestions.FirstOrDefault(s => s.Id == suggestionId);
|
||||
if (target is null)
|
||||
return ServiceResult<AdminUnlinkSuggestionsResponse>.Success(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0));
|
||||
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
if (!rootIndex.TryGetValue(target.Id, out var rootId))
|
||||
return ServiceResult<AdminUnlinkSuggestionsResponse>.Success(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0));
|
||||
|
||||
var groupIds = rootIndex.Where(kv => kv.Value == rootId).Select(kv => kv.Key).ToList();
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
foreach (var suggestion in suggestions.Where(s => groupIds.Contains(s.Id)))
|
||||
{
|
||||
suggestion.ParentSuggestionId = null;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await db.Votes.Where(v => groupIds.Contains(v.SuggestionId)).ExecuteDeleteAsync();
|
||||
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
|
||||
|
||||
await tx.CommitAsync();
|
||||
|
||||
return ServiceResult<AdminUnlinkSuggestionsResponse>.Success(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync()));
|
||||
}
|
||||
|
||||
public async Task<ServiceResult<AdminResetStateResponse>> ResetAsync(Guid adminPlayerId, string password, HttpContext ctx)
|
||||
{
|
||||
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
|
||||
if (passwordError is not null)
|
||||
return ServiceResult<AdminResetStateResponse>.Failure(passwordError);
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
await db.Votes.ExecuteDeleteAsync();
|
||||
await db.Suggestions.ExecuteDeleteAsync();
|
||||
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.CurrentPhase, Phase.Suggest).SetProperty(x => x.VotesFinal, false).SetProperty(x => x.HasJoker, false));
|
||||
var state = await db.AppState.SingleAsync();
|
||||
state.ResultsOpen = false;
|
||||
state.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
return ServiceResult<AdminResetStateResponse>.Success(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt));
|
||||
}
|
||||
|
||||
public async Task<ServiceResult<AdminResetStateResponse>> FactoryResetAsync(Guid adminPlayerId, string password, HttpContext ctx)
|
||||
{
|
||||
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
|
||||
if (passwordError is not null)
|
||||
return ServiceResult<AdminResetStateResponse>.Failure(passwordError);
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
await db.Votes.ExecuteDeleteAsync();
|
||||
await db.Suggestions.ExecuteDeleteAsync();
|
||||
await db.Players.ExecuteDeleteAsync();
|
||||
await db.AppState.ExecuteDeleteAsync();
|
||||
|
||||
var fresh = EndpointHelpers.NewAppState();
|
||||
db.AppState.Add(fresh);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
await tx.CommitAsync();
|
||||
|
||||
return ServiceResult<AdminResetStateResponse>.Success(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt));
|
||||
}
|
||||
|
||||
private async Task<ServiceError?> ValidateAdminPasswordAsync(Guid adminPlayerId, string password, HttpContext ctx)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
return ServiceError.BadRequest("Admin password is required.");
|
||||
|
||||
var admin = await db.Players.FirstOrDefaultAsync(p => p.Id == adminPlayerId && p.IsAdmin);
|
||||
if (admin is null)
|
||||
return ServiceError.Unauthorized();
|
||||
|
||||
var monitor = ctx.RequestServices.GetRequiredService<AuthAttemptMonitor>();
|
||||
var verified = PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt, admin.PasswordHashVersion, out var needsRehash);
|
||||
if (!verified)
|
||||
{
|
||||
monitor.RecordFailure(ctx, "admin-password", admin.NormalizedUsername, "invalid-password");
|
||||
return ServiceError.BadRequest("Invalid admin password.");
|
||||
}
|
||||
|
||||
if (needsRehash)
|
||||
{
|
||||
var (upgradedHash, upgradedSalt) = PasswordHasher.HashPassword(password);
|
||||
admin.PasswordHash = upgradedHash;
|
||||
admin.PasswordSalt = upgradedSalt;
|
||||
admin.PasswordHashVersion = PasswordHasher.CurrentVersion;
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
monitor.RecordSuccess(ctx, "admin-password", admin.NormalizedUsername);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -11,27 +11,47 @@ public static class AuthEndpoints
|
||||
{
|
||||
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))
|
||||
return Results.BadRequest(new { error = registrationError });
|
||||
{
|
||||
authAttemptMonitor.RecordFailure(ctx, "auth-register", NormalizeActor(request.Username), "validation-failed");
|
||||
return EndpointHelpers.BadRequestError(registrationError);
|
||||
}
|
||||
|
||||
var exists = await db.Players.AnyAsync(p => p.NormalizedUsername == validated.NormalizedUsername);
|
||||
if (exists)
|
||||
return Results.Conflict(new { error = "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 wantsAdmin = !string.IsNullOrWhiteSpace(validated.AdminKey);
|
||||
if (wantsAdmin)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(expectedAdminKey) || validated.AdminKey != expectedAdminKey)
|
||||
return Results.BadRequest(new { error = "Invalid admin key." });
|
||||
{
|
||||
authAttemptMonitor.RecordFailure(ctx, "auth-register-admin", validated.NormalizedUsername, "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 isOwner = wantsAdmin;
|
||||
|
||||
var player = new Player
|
||||
{
|
||||
@@ -40,34 +60,65 @@ public static class AuthEndpoints
|
||||
NormalizedUsername = validated.NormalizedUsername,
|
||||
PasswordHash = hash,
|
||||
PasswordSalt = salt,
|
||||
PasswordHashVersion = PasswordHasher.CurrentVersion,
|
||||
DisplayName = validated.DisplayName,
|
||||
IsAdmin = isAdmin,
|
||||
IsOwner = isOwner,
|
||||
CreatedAt = DateTimeOffset.UtcNow,
|
||||
LastLoginAt = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
db.Players.Add(player);
|
||||
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);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
return Results.Ok(new AuthSessionResponse(
|
||||
player.Id,
|
||||
player.Username,
|
||||
player.DisplayName,
|
||||
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))
|
||||
return Results.BadRequest(new { error = loginError });
|
||||
{
|
||||
authAttemptMonitor.RecordFailure(ctx, "auth-login", NormalizeActor(request.Username), "validation-failed");
|
||||
return EndpointHelpers.BadRequestError(loginError);
|
||||
}
|
||||
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.NormalizedUsername == normalizedUsername);
|
||||
if (player == null || !PasswordHasher.Verify(request.Password, player.PasswordHash, player.PasswordSalt))
|
||||
return Results.Json(new { error = "Invalid username or password." }, statusCode: StatusCodes.Status401Unauthorized);
|
||||
if (player == null
|
||||
|| !PasswordHasher.Verify(request.Password ?? string.Empty, player.PasswordHash, player.PasswordSalt, player.PasswordHashVersion, out var needsRehash))
|
||||
{
|
||||
authAttemptMonitor.RecordFailure(ctx, "auth-login", normalizedUsername, "invalid-credentials");
|
||||
return EndpointHelpers.UnauthorizedError("Invalid username or password.");
|
||||
}
|
||||
|
||||
if (needsRehash)
|
||||
{
|
||||
var (upgradedHash, upgradedSalt) = PasswordHasher.HashPassword(request.Password ?? string.Empty);
|
||||
player.PasswordHash = upgradedHash;
|
||||
player.PasswordSalt = upgradedSalt;
|
||||
player.PasswordHashVersion = PasswordHasher.CurrentVersion;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(player.DisplayName))
|
||||
{
|
||||
@@ -77,21 +128,23 @@ public static class AuthEndpoints
|
||||
player.LastLoginAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
authAttemptMonitor.RecordSuccess(ctx, "auth-login", normalizedUsername);
|
||||
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
return Results.Ok(new AuthSessionResponse(
|
||||
player.Id,
|
||||
player.Username,
|
||||
player.DisplayName,
|
||||
player.IsAdmin
|
||||
});
|
||||
});
|
||||
));
|
||||
}).WithName("Login");
|
||||
|
||||
group.MapPost("/logout", async (HttpContext ctx) =>
|
||||
{
|
||||
await PlayerIdentityExtensions.SignOutPlayerAsync(ctx);
|
||||
return Results.NoContent();
|
||||
});
|
||||
}).WithName("Logout");
|
||||
}
|
||||
|
||||
private static string NormalizeActor(string? username) => string.IsNullOrWhiteSpace(username) ? "(missing)" : username.Trim();
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ internal static class AuthValidator
|
||||
public const int MaxUsernameLength = 24;
|
||||
public const int MaxDisplayNameLength = 16;
|
||||
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)
|
||||
{
|
||||
@@ -25,6 +27,24 @@ internal static class AuthValidator
|
||||
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)
|
||||
{
|
||||
validated = default;
|
||||
@@ -41,7 +61,7 @@ internal static class AuthValidator
|
||||
}
|
||||
|
||||
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;
|
||||
return true;
|
||||
}
|
||||
@@ -63,15 +83,16 @@ internal static class AuthValidator
|
||||
return false;
|
||||
}
|
||||
|
||||
if (request.Password.Length > MaxPasswordLength)
|
||||
{
|
||||
error = $"Password must be <= {MaxPasswordLength} characters.";
|
||||
return false;
|
||||
}
|
||||
|
||||
normalizedUsername = username.ToLowerInvariant();
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
public readonly record struct ValidatedRegistration(
|
||||
string Username,
|
||||
string NormalizedUsername,
|
||||
string DisplayName,
|
||||
string? AdminKey
|
||||
);
|
||||
public readonly record struct ValidatedRegistration(string Username, string NormalizedUsername, string Password, string DisplayName, string? AdminKey);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
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)
|
||||
{
|
||||
if (ctx.User.Identity?.IsAuthenticated != true)
|
||||
@@ -44,13 +50,13 @@ internal static class EndpointHelpers
|
||||
if (playerPhase is null)
|
||||
return Phase.Suggest;
|
||||
|
||||
var resultsOpen = await db.AppState.AsNoTracking().Select(s => s.ResultsOpen).FirstAsync();
|
||||
var resultsOpen = await db.AppState.AsNoTracking().Select(s => s.ResultsOpen).SingleAsync();
|
||||
return GetCurrentPhase(playerPhase.Value, resultsOpen);
|
||||
}
|
||||
|
||||
public static Phase GetCurrentPhase(Phase phase, bool resultsOpen)
|
||||
{
|
||||
var normalized = phase == Phase.Reveal ? Phase.Vote : phase;
|
||||
var normalized = NormalizePhase(phase);
|
||||
|
||||
if (resultsOpen)
|
||||
return Phase.Results;
|
||||
@@ -62,9 +68,10 @@ internal static class EndpointHelpers
|
||||
{
|
||||
var changed = false;
|
||||
|
||||
if (player.CurrentPhase == Phase.Reveal)
|
||||
var normalized = NormalizePhase(player.CurrentPhase);
|
||||
if (player.CurrentPhase != normalized)
|
||||
{
|
||||
player.CurrentPhase = Phase.Vote;
|
||||
player.CurrentPhase = normalized;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
@@ -83,8 +90,70 @@ internal static class EndpointHelpers
|
||||
return changed;
|
||||
}
|
||||
|
||||
private static Phase NormalizePhase(Phase phase)
|
||||
{
|
||||
return phase switch
|
||||
{
|
||||
Phase.Suggest => Phase.Suggest,
|
||||
Phase.Vote => Phase.Vote,
|
||||
Phase.Results => Phase.Results,
|
||||
_ => Phase.Vote // legacy/invalid phase fallback
|
||||
};
|
||||
}
|
||||
|
||||
public static IResult PhaseMismatch(Phase required, Phase current) =>
|
||||
Results.BadRequest(new { error = $"This endpoint is available in the {required} phase. Your current phase is {current}." });
|
||||
BadRequestError($"This endpoint is available in the {required} phase. Your current phase is {current}.");
|
||||
|
||||
public static IResult BadRequestError(string detail) => Problem(StatusCodes.Status400BadRequest, "Bad Request", detail);
|
||||
|
||||
public static IResult NotFoundError(string detail) => Problem(StatusCodes.Status404NotFound, "Not Found", detail);
|
||||
|
||||
public static IResult ConflictError(string detail) => Problem(StatusCodes.Status409Conflict, "Conflict", 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)
|
||||
{
|
||||
return Results.Problem(
|
||||
statusCode: statusCode,
|
||||
title: title,
|
||||
detail: detail,
|
||||
extensions: new Dictionary<string, object?>
|
||||
{
|
||||
["error"] = detail
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public static string? TrimTo(string? input, int max) =>
|
||||
string.IsNullOrWhiteSpace(input) ? null : input.Trim() is { Length: > 0 } t ? t[..Math.Min(t.Length, max)] : null;
|
||||
@@ -99,7 +168,54 @@ internal static class EndpointHelpers
|
||||
return false;
|
||||
|
||||
var path = uri.AbsolutePath.ToLowerInvariant();
|
||||
return path.EndsWith(".png") || path.EndsWith(".jpg") || path.EndsWith(".jpeg") || path.EndsWith(".gif") || path.EndsWith(".webp") || path.EndsWith(".avif");
|
||||
return path.EndsWith(".png", StringComparison.Ordinal)
|
||||
|| path.EndsWith(".jpg", StringComparison.Ordinal)
|
||||
|| path.EndsWith(".jpeg", StringComparison.Ordinal)
|
||||
|| path.EndsWith(".gif", StringComparison.Ordinal)
|
||||
|| path.EndsWith(".webp", 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)
|
||||
@@ -110,13 +226,21 @@ internal static class EndpointHelpers
|
||||
return false;
|
||||
if (uri.Scheme is not ("http" or "https"))
|
||||
return false;
|
||||
if (handler is null)
|
||||
{
|
||||
if (!await IsSafePublicHostAsync(uri, ct))
|
||||
return false;
|
||||
}
|
||||
else if (IPAddress.TryParse(uri.Host, out var literal) && IsBlockedAddress(literal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
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
|
||||
{
|
||||
@@ -159,7 +283,7 @@ internal static class EndpointHelpers
|
||||
|
||||
await using var stream = await resp.Content.ReadAsStreamAsync(cts.Token);
|
||||
var rented = new byte[12];
|
||||
var read = await stream.ReadAsync(rented, 0, rented.Length, cts.Token);
|
||||
var read = await stream.ReadAsync(rented.AsMemory(0, rented.Length), cts.Token);
|
||||
var sig = new ReadOnlySpan<byte>(rented, 0, read);
|
||||
|
||||
if (IsMagic(sig, "PNG"))
|
||||
@@ -196,24 +320,8 @@ internal static class EndpointHelpers
|
||||
{
|
||||
try
|
||||
{
|
||||
var host = uri.Host;
|
||||
if (Uri.CheckHostName(host) == UriHostNameType.Dns || Uri.CheckHostName(host) == UriHostNameType.IPv4 || Uri.CheckHostName(host) == UriHostNameType.IPv6)
|
||||
{
|
||||
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;
|
||||
var addresses = await ResolveSafePublicAddressesAsync(uri.Host, ct);
|
||||
return addresses.Count > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -221,25 +329,89 @@ 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();
|
||||
return bytes[0] switch
|
||||
resolved = [literal];
|
||||
}
|
||||
else
|
||||
{
|
||||
10 => true,
|
||||
172 when bytes[1] >= 16 && bytes[1] <= 31 => true,
|
||||
192 when bytes[1] == 168 => true,
|
||||
127 => true,
|
||||
resolved = await Dns.GetHostAddressesAsync(host, ct);
|
||||
}
|
||||
|
||||
var safe = new List<IPAddress>(resolved.Length);
|
||||
foreach (var ip in resolved)
|
||||
{
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
|
||||
private static bool IsBlockedIpv6(IPAddress ip)
|
||||
{
|
||||
return ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal || ip.IsIPv6Multicast || System.Net.IPAddress.IsLoopback(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;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using GameList.Infrastructure;
|
||||
using GameList.Domain;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
@@ -10,90 +9,19 @@ public static class ResultsEndpoints
|
||||
public static void MapResultsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/results")
|
||||
.WithTags("Results")
|
||||
.RequireAuthorization()
|
||||
.AddEndpointFilter(new PhaseRequirementFilter(Phase.Results));
|
||||
|
||||
group.MapGet(
|
||||
"/",
|
||||
async (HttpContext ctx, AppDbContext db) =>
|
||||
group.MapGet("/", async (HttpContext ctx, AppDbContext db, ResultsWorkflowService service) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
var appState = await db.AppState.AsNoTracking().FirstAsync();
|
||||
if (!appState.ResultsOpen)
|
||||
return Results.BadRequest(new { error = "Results are locked until the admin enables them." });
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
||||
if (phase != Phase.Results)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Results, phase);
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
var results = await db
|
||||
.Suggestions.AsNoTracking()
|
||||
.Include(s => s.Player)
|
||||
.Include(s => s.Votes)
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
Author = s.Player!.DisplayName,
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
Total = s.Votes.Sum(v => v.Score),
|
||||
s.Votes.Count,
|
||||
Average = s.Votes.Count == 0 ? 0 : s.Votes.Average(v => v.Score),
|
||||
Votes = s.Votes.Select(v => v.Score).ToList(),
|
||||
MyVote = s.Votes
|
||||
.Where(v => v.PlayerId == player.Id)
|
||||
.Select(v => (int?)v.Score)
|
||||
.FirstOrDefault(),
|
||||
s.ScreenshotUrl,
|
||||
s.YoutubeUrl,
|
||||
s.GameUrl,
|
||||
s.Description,
|
||||
s.Genre,
|
||||
s.ParentSuggestionId
|
||||
})
|
||||
.OrderByDescending(r => r.Average)
|
||||
.ToListAsync();
|
||||
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(results.Select(r => (r.Id, r.ParentSuggestionId)));
|
||||
var nameLookup = results.ToDictionary(r => r.Id, r => r.Name);
|
||||
|
||||
var shaped = results.Select(r =>
|
||||
{
|
||||
var linkedIds = EndpointHelpers.LinkedIdsFor(r.Id, rootIndex)
|
||||
.Where(id => id != r.Id)
|
||||
.ToList();
|
||||
|
||||
return new
|
||||
{
|
||||
r.Id,
|
||||
r.Name,
|
||||
r.Author,
|
||||
r.MinPlayers,
|
||||
r.MaxPlayers,
|
||||
r.Total,
|
||||
r.Count,
|
||||
r.Average,
|
||||
r.Votes,
|
||||
r.MyVote,
|
||||
r.ScreenshotUrl,
|
||||
r.YoutubeUrl,
|
||||
r.GameUrl,
|
||||
r.Description,
|
||||
r.Genre,
|
||||
r.ParentSuggestionId,
|
||||
LinkedIds = linkedIds,
|
||||
LinkedTitles = linkedIds
|
||||
.Where(nameLookup.ContainsKey)
|
||||
.Select(id => nameLookup[id])
|
||||
.ToList()
|
||||
};
|
||||
});
|
||||
|
||||
return Results.Ok(shaped);
|
||||
}
|
||||
);
|
||||
var result = await service.GetResultsAsync(player.Id);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("GetResults");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
96
Endpoints/ResultsWorkflowService.cs
Normal file
96
Endpoints/ResultsWorkflowService.cs
Normal file
@@ -0,0 +1,96 @@
|
||||
using GameList.Contracts;
|
||||
using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
internal sealed class ResultsWorkflowService(AppDbContext db)
|
||||
{
|
||||
public async Task<ServiceResult<IReadOnlyList<ResultItemDto>>> GetResultsAsync(Guid playerId)
|
||||
{
|
||||
var appState = await db.AppState.AsNoTracking().SingleAsync();
|
||||
if (!appState.ResultsOpen)
|
||||
return ServiceResult<IReadOnlyList<ResultItemDto>>.Failure(ServiceError.BadRequest("Results are locked until the admin enables them."));
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
if (phase != Phase.Results)
|
||||
return ServiceResult<IReadOnlyList<ResultItemDto>>.Failure(ServiceError.PhaseMismatch(Phase.Results, phase));
|
||||
|
||||
var results = await db
|
||||
.Suggestions.AsNoTracking()
|
||||
.Include(s => s.Player)
|
||||
.Include(s => s.Votes)
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
Author = s.Player!.DisplayName,
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
Total = s.Votes.Sum(v => v.Score),
|
||||
s.Votes.Count,
|
||||
Average = s.Votes.Count == 0 ? 0 : s.Votes.Average(v => v.Score),
|
||||
Votes = s.Votes.Select(v => v.Score).ToList(),
|
||||
VoterNames = s.Votes
|
||||
.Select(v => v.Player!.DisplayName ?? v.Player!.Username)
|
||||
.ToList(),
|
||||
MyVote = s.Votes
|
||||
.Where(v => v.PlayerId == playerId)
|
||||
.Select(v => (int?)v.Score)
|
||||
.FirstOrDefault(),
|
||||
s.ScreenshotUrl,
|
||||
s.YoutubeUrl,
|
||||
s.GameUrl,
|
||||
s.Description,
|
||||
s.Genre,
|
||||
s.ParentSuggestionId
|
||||
})
|
||||
.OrderByDescending(r => r.Average)
|
||||
.ToListAsync();
|
||||
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(results.Select(r => (r.Id, r.ParentSuggestionId)));
|
||||
var nameLookup = results.ToDictionary(r => r.Id, r => r.Name);
|
||||
|
||||
IReadOnlyList<ResultItemDto> shaped = results.Select(r =>
|
||||
{
|
||||
var linkedIds = EndpointHelpers.LinkedIdsFor(r.Id, rootIndex)
|
||||
.Where(id => id != r.Id)
|
||||
.ToList();
|
||||
|
||||
var linkedTitles = linkedIds
|
||||
.Where(nameLookup.ContainsKey)
|
||||
.Select(id => nameLookup[id])
|
||||
.ToList();
|
||||
var voterNames = r.VoterNames
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return new ResultItemDto(
|
||||
r.Id,
|
||||
r.Name,
|
||||
r.Author,
|
||||
r.MinPlayers,
|
||||
r.MaxPlayers,
|
||||
r.Total,
|
||||
r.Count,
|
||||
r.Average,
|
||||
r.Votes,
|
||||
voterNames,
|
||||
r.MyVote,
|
||||
r.ScreenshotUrl,
|
||||
r.YoutubeUrl,
|
||||
r.GameUrl,
|
||||
r.Description,
|
||||
r.Genre,
|
||||
r.ParentSuggestionId,
|
||||
linkedIds,
|
||||
linkedTitles
|
||||
);
|
||||
}).ToList();
|
||||
|
||||
return ServiceResult<IReadOnlyList<ResultItemDto>>.Success(shaped);
|
||||
}
|
||||
}
|
||||
36
Endpoints/ServiceResult.cs
Normal file
36
Endpoints/ServiceResult.cs
Normal 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);
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using GameList.Infrastructure;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
@@ -8,117 +7,110 @@ public static class StateEndpoints
|
||||
{
|
||||
public static void MapStateEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api").RequireAuthorization();
|
||||
var group = app.MapGroup("/api").WithTags("State").RequireAuthorization();
|
||||
|
||||
group.MapGet("/state", async (HttpContext ctx, AppDbContext db) =>
|
||||
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);
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
var state = await db.AppState.AsNoTracking().FirstAsync();
|
||||
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
|
||||
var summary = new
|
||||
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)
|
||||
{
|
||||
CurrentPhase = phase,
|
||||
player.VotesFinal,
|
||||
player.HasJoker,
|
||||
state.ResultsOpen,
|
||||
state.UpdatedAt,
|
||||
Players = await db.Players.CountAsync(),
|
||||
Suggestions = await db.Suggestions.CountAsync(),
|
||||
Votes = await db.Votes.CountAsync()
|
||||
};
|
||||
return Results.Ok(summary);
|
||||
});
|
||||
try
|
||||
{
|
||||
var changeTask = notifier.WaitForChangeAsync(observedVersion, ctx.RequestAborted);
|
||||
var heartbeatTask = Task.Delay(TimeSpan.FromSeconds(20), ctx.RequestAborted);
|
||||
var completed = await Task.WhenAny(changeTask, heartbeatTask);
|
||||
|
||||
group.MapGet("/me", async (HttpContext ctx, AppDbContext db) =>
|
||||
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) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
var state = await db.AppState.AsNoTracking().FirstAsync();
|
||||
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
|
||||
return Results.Ok(new
|
||||
{
|
||||
player.Id,
|
||||
player.DisplayName,
|
||||
player.Username,
|
||||
player.IsAdmin,
|
||||
CurrentPhase = phase,
|
||||
player.VotesFinal,
|
||||
player.HasJoker
|
||||
});
|
||||
});
|
||||
var result = await service.GetMeAsync(player);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("GetMe");
|
||||
|
||||
group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db) =>
|
||||
group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
var appState = await db.AppState.FirstAsync();
|
||||
var reconciled = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
|
||||
var next = NextPhase(player.CurrentPhase);
|
||||
var result = await service.NextPhaseAsync(player);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("NextPhase");
|
||||
|
||||
if (next == Phase.Results && !appState.ResultsOpen)
|
||||
{
|
||||
if (reconciled)
|
||||
await db.SaveChangesAsync();
|
||||
return Results.BadRequest(new { error = "Results are locked until the admin enables them." });
|
||||
}
|
||||
|
||||
player.CurrentPhase = next;
|
||||
player.VotesFinal = false; // moving forward clears any prior finalize
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new
|
||||
{
|
||||
player.CurrentPhase,
|
||||
appState.ResultsOpen
|
||||
});
|
||||
});
|
||||
|
||||
group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db) =>
|
||||
group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db);
|
||||
if (!isAdmin)
|
||||
{
|
||||
return Results.BadRequest(new { error = "Only admins can move backward." });
|
||||
}
|
||||
|
||||
var appState = await db.AppState.FirstAsync();
|
||||
EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
|
||||
player.CurrentPhase = PrevPhase(player.CurrentPhase);
|
||||
player.VotesFinal = false;
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new
|
||||
{
|
||||
player.CurrentPhase,
|
||||
appState.ResultsOpen
|
||||
});
|
||||
});
|
||||
var result = await service.PrevPhaseAsync(player);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("PrevPhase");
|
||||
|
||||
}
|
||||
|
||||
private static Phase NextPhase(Phase current) => current switch
|
||||
private static async Task WriteStateEventAsync(HttpContext ctx, string eventName, long version, CancellationToken cancellationToken)
|
||||
{
|
||||
Phase.Suggest => Phase.Vote,
|
||||
Phase.Reveal => Phase.Vote, // legacy safety
|
||||
Phase.Vote => Phase.Results,
|
||||
_ => Phase.Results
|
||||
};
|
||||
|
||||
private static Phase PrevPhase(Phase current) => current switch
|
||||
{
|
||||
Phase.Results => Phase.Vote,
|
||||
Phase.Vote => Phase.Suggest,
|
||||
Phase.Reveal => Phase.Suggest, // legacy safety
|
||||
_ => Phase.Suggest
|
||||
};
|
||||
await ctx.Response.WriteAsync($"event: {eventName}\n", cancellationToken);
|
||||
await ctx.Response.WriteAsync($"data: {version}\n\n", cancellationToken);
|
||||
await ctx.Response.Body.FlushAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
106
Endpoints/StateWorkflowService.cs
Normal file
106
Endpoints/StateWorkflowService.cs
Normal file
@@ -0,0 +1,106 @@
|
||||
using GameList.Contracts;
|
||||
using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
internal sealed class StateWorkflowService(AppDbContext db)
|
||||
{
|
||||
public async Task<ServiceResult<StateSummaryResponse>> GetStateAsync(Player player)
|
||||
{
|
||||
var state = await db.AppState
|
||||
.AsNoTracking()
|
||||
.Select(s => new
|
||||
{
|
||||
s.ResultsOpen,
|
||||
s.UpdatedAt,
|
||||
Players = db.Players.Count(),
|
||||
Suggestions = db.Suggestions.Count(),
|
||||
Votes = db.Votes.Count()
|
||||
})
|
||||
.SingleAsync();
|
||||
|
||||
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
|
||||
var summary = new StateSummaryResponse(
|
||||
player.Id,
|
||||
player.Username,
|
||||
player.DisplayName,
|
||||
player.IsAdmin,
|
||||
player.IsOwner,
|
||||
phase,
|
||||
player.VotesFinal,
|
||||
player.HasJoker,
|
||||
state.ResultsOpen,
|
||||
state.UpdatedAt,
|
||||
state.Players,
|
||||
state.Suggestions,
|
||||
state.Votes
|
||||
);
|
||||
return ServiceResult<StateSummaryResponse>.Success(summary);
|
||||
}
|
||||
|
||||
public async Task<ServiceResult<MeResponse>> GetMeAsync(Player player)
|
||||
{
|
||||
var state = await db.AppState.AsNoTracking().SingleAsync();
|
||||
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
|
||||
return ServiceResult<MeResponse>.Success(new MeResponse(player.Id, player.Username, player.DisplayName, player.IsAdmin, player.IsOwner, phase, player.VotesFinal, player.HasJoker));
|
||||
}
|
||||
|
||||
public async Task<ServiceResult<PhaseTransitionResponse>> NextPhaseAsync(Player player)
|
||||
{
|
||||
var appState = await db.AppState.SingleAsync();
|
||||
var shouldSave = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
|
||||
|
||||
try
|
||||
{
|
||||
var next = NextPhase(player.CurrentPhase);
|
||||
|
||||
if (next == Phase.Vote)
|
||||
{
|
||||
var hasSuggestions = await db.Suggestions.AnyAsync(s => s.PlayerId == player.Id);
|
||||
if (!hasSuggestions)
|
||||
return ServiceResult<PhaseTransitionResponse>.Failure(ServiceError.BadRequest("Add at least one suggestion before entering the Vote phase."));
|
||||
}
|
||||
|
||||
if (next == Phase.Results && !appState.ResultsOpen)
|
||||
return ServiceResult<PhaseTransitionResponse>.Failure(ServiceError.BadRequest("Results are locked until the admin enables them."));
|
||||
|
||||
player.CurrentPhase = next;
|
||||
player.VotesFinal = false; // moving forward clears any prior finalize
|
||||
shouldSave = true;
|
||||
return ServiceResult<PhaseTransitionResponse>.Success(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (shouldSave)
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<ServiceResult<PhaseTransitionResponse>> PrevPhaseAsync(Player player)
|
||||
{
|
||||
if (!player.IsAdmin)
|
||||
return ServiceResult<PhaseTransitionResponse>.Failure(ServiceError.BadRequest("Only admins can move backward."));
|
||||
|
||||
var appState = await db.AppState.SingleAsync();
|
||||
_ = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
|
||||
|
||||
player.CurrentPhase = PrevPhase(player.CurrentPhase);
|
||||
player.VotesFinal = false;
|
||||
await db.SaveChangesAsync();
|
||||
return ServiceResult<PhaseTransitionResponse>.Success(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
|
||||
}
|
||||
|
||||
private static Phase NextPhase(Phase current) => current switch
|
||||
{
|
||||
Phase.Suggest => Phase.Vote,
|
||||
_ => Phase.Results
|
||||
};
|
||||
|
||||
private static Phase PrevPhase(Phase current) => current switch
|
||||
{
|
||||
Phase.Results => Phase.Vote,
|
||||
_ => Phase.Suggest
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
using GameList.Contracts;
|
||||
using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using GameList.Infrastructure;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
@@ -11,250 +9,84 @@ public static class SuggestEndpoints
|
||||
{
|
||||
public static void MapSuggestEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/suggestions").RequireAuthorization();
|
||||
var group = app.MapGroup("/api/suggestions").WithTags("Suggestions").RequireAuthorization();
|
||||
|
||||
group.MapGet("/mine", async (HttpContext ctx, AppDbContext db) =>
|
||||
group.MapGet("/mine", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
var mine = await db.Suggestions.AsNoTracking().Where(s => s.PlayerId == player.Id).Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.PlayerId,
|
||||
s.Name,
|
||||
s.Genre,
|
||||
s.Description,
|
||||
s.ScreenshotUrl,
|
||||
s.YoutubeUrl,
|
||||
s.GameUrl,
|
||||
s.CreatedAt,
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
s.ParentSuggestionId
|
||||
}).ToListAsync();
|
||||
var result = await service.GetMineAsync(player.Id);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("GetMySuggestions");
|
||||
|
||||
var ordered = mine.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));
|
||||
|
||||
return Results.Ok(ordered);
|
||||
});
|
||||
|
||||
group.MapPost("/", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, IHttpClientFactory http) =>
|
||||
{
|
||||
var validationError = await SuggestionValidator.ValidateAsync(request, http);
|
||||
if (validationError is not null)
|
||||
return Results.BadRequest(new { error = validationError });
|
||||
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
||||
var usingJoker = phase == Phase.Vote && player.HasJoker;
|
||||
if (phase != Phase.Suggest && !usingJoker)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(player.DisplayName))
|
||||
{
|
||||
return Results.BadRequest(new { error = "Set a display name before submitting suggestions." });
|
||||
}
|
||||
|
||||
var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == player.Id);
|
||||
if (!usingJoker && existingCount >= 5)
|
||||
{
|
||||
return Results.BadRequest(new { error = "You have reached the 5 suggestion limit." });
|
||||
}
|
||||
|
||||
var suggestion = new Suggestion
|
||||
{
|
||||
PlayerId = player.Id,
|
||||
Name = request.Name.Trim(),
|
||||
Genre = EndpointHelpers.TrimTo(request.Genre, 50),
|
||||
Description = EndpointHelpers.TrimTo(request.Description, 500),
|
||||
ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048),
|
||||
YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 2048),
|
||||
GameUrl = EndpointHelpers.TrimTo(request.GameUrl, 2048),
|
||||
MinPlayers = request.MinPlayers,
|
||||
MaxPlayers = request.MaxPlayers
|
||||
};
|
||||
|
||||
db.Suggestions.Add(suggestion);
|
||||
|
||||
if (usingJoker)
|
||||
{
|
||||
player.HasJoker = false;
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Created($"/api/suggestions/{suggestion.Id}", new { suggestion.Id });
|
||||
}).AddEndpointFilter(new PhaseOrJokerFilter());
|
||||
|
||||
group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db) =>
|
||||
group.MapPost("/", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db);
|
||||
var result = await service.CreateAsync(
|
||||
player.Id,
|
||||
new SuggestionInput(
|
||||
request.Name,
|
||||
request.Genre,
|
||||
request.Description,
|
||||
request.ScreenshotUrl,
|
||||
request.YoutubeUrl,
|
||||
request.GameUrl,
|
||||
request.MinPlayers,
|
||||
request.MaxPlayers
|
||||
)
|
||||
);
|
||||
|
||||
if (!isAdmin)
|
||||
{
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
||||
if (phase != Phase.Suggest)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
}
|
||||
return result.ToHttpResult(payload => Results.Created($"/api/suggestions/{payload.Id}", payload));
|
||||
}).AddEndpointFilter(new PhaseOrJokerFilter()).WithName("CreateSuggestion");
|
||||
|
||||
var suggestion = isAdmin ? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id) : await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id && s.PlayerId == player.Id);
|
||||
if (suggestion == null)
|
||||
return Results.NotFound(new { error = "Suggestion not found." });
|
||||
|
||||
// Break any links that pointed at this suggestion
|
||||
await db.Suggestions.Where(s => s.ParentSuggestionId == suggestion.Id).ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
|
||||
|
||||
// Remove votes for this suggestion to avoid orphaned vote rows or FK errors
|
||||
await db.Votes.Where(v => v.SuggestionId == suggestion.Id).ExecuteDeleteAsync();
|
||||
|
||||
db.Suggestions.Remove(suggestion);
|
||||
await db.SaveChangesAsync();
|
||||
return Results.NoContent();
|
||||
});
|
||||
|
||||
group.MapPut("/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, IHttpClientFactory http) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
var isAdmin = await EndpointHelpers.IsAdmin(ctx, db);
|
||||
|
||||
if (!isAdmin && player is null)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var validationError = await SuggestionValidator.ValidateAsync(request, http);
|
||||
if (validationError is not null)
|
||||
return Results.BadRequest(new { error = validationError });
|
||||
|
||||
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == id);
|
||||
if (suggestion == null)
|
||||
return Results.NotFound(new { error = "Suggestion not found." });
|
||||
|
||||
if (!isAdmin)
|
||||
{
|
||||
if (suggestion.PlayerId != player!.Id)
|
||||
return Results.Unauthorized();
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
||||
if (phase == Phase.Results)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
|
||||
var inSuggest = phase == Phase.Suggest;
|
||||
var inVote = phase == Phase.Vote;
|
||||
|
||||
if (inSuggest)
|
||||
{
|
||||
suggestion.Name = request.Name.Trim();
|
||||
}
|
||||
else if (inVote)
|
||||
{
|
||||
// Title locked in vote; allow other fields
|
||||
}
|
||||
else
|
||||
{
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
}
|
||||
|
||||
suggestion.Genre = EndpointHelpers.TrimTo(request.Genre, 50);
|
||||
suggestion.Description = EndpointHelpers.TrimTo(request.Description, 500);
|
||||
suggestion.ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048);
|
||||
suggestion.YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 2048);
|
||||
suggestion.GameUrl = EndpointHelpers.TrimTo(request.GameUrl, 2048);
|
||||
suggestion.MinPlayers = request.MinPlayers;
|
||||
suggestion.MaxPlayers = request.MaxPlayers;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Admins can edit anytime
|
||||
suggestion.Name = request.Name.Trim();
|
||||
suggestion.Genre = EndpointHelpers.TrimTo(request.Genre, 50);
|
||||
suggestion.Description = EndpointHelpers.TrimTo(request.Description, 500);
|
||||
suggestion.ScreenshotUrl = EndpointHelpers.TrimTo(request.ScreenshotUrl, 2048);
|
||||
suggestion.YoutubeUrl = EndpointHelpers.TrimTo(request.YoutubeUrl, 2048);
|
||||
suggestion.GameUrl = EndpointHelpers.TrimTo(request.GameUrl, 2048);
|
||||
suggestion.MinPlayers = request.MinPlayers;
|
||||
suggestion.MaxPlayers = request.MaxPlayers;
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new
|
||||
{
|
||||
suggestion.Id,
|
||||
suggestion.Name,
|
||||
suggestion.Genre,
|
||||
suggestion.Description,
|
||||
suggestion.ScreenshotUrl,
|
||||
suggestion.YoutubeUrl,
|
||||
suggestion.GameUrl,
|
||||
suggestion.MinPlayers,
|
||||
suggestion.MaxPlayers
|
||||
});
|
||||
});
|
||||
|
||||
group.MapGet("/all", async (HttpContext ctx, AppDbContext db) =>
|
||||
group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
||||
if (phase < Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
var result = await service.DeleteAsync(player.Id, id);
|
||||
return result.ToHttpResult(Results.NoContent);
|
||||
}).WithName("DeleteSuggestion");
|
||||
|
||||
var all = await db.Suggestions.AsNoTracking().Include(s => s.Player).Select(s => new
|
||||
group.MapPut("/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.Genre,
|
||||
s.Description,
|
||||
s.ScreenshotUrl,
|
||||
s.YoutubeUrl,
|
||||
s.GameUrl,
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
Author = s.Player!.DisplayName,
|
||||
s.CreatedAt,
|
||||
s.ParentSuggestionId,
|
||||
IsOwner = s.PlayerId == player.Id
|
||||
}).ToListAsync();
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
var nameLookup = all.ToDictionary(s => s.Id, s => s.Name);
|
||||
var result = await service.UpdateAsync(
|
||||
player.Id,
|
||||
id,
|
||||
new SuggestionInput(
|
||||
request.Name,
|
||||
request.Genre,
|
||||
request.Description,
|
||||
request.ScreenshotUrl,
|
||||
request.YoutubeUrl,
|
||||
request.GameUrl,
|
||||
request.MinPlayers,
|
||||
request.MaxPlayers
|
||||
)
|
||||
);
|
||||
|
||||
var ordered = all.OrderBy(s => s.CreatedAt).Select(s =>
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("UpdateSuggestion");
|
||||
|
||||
group.MapGet("/all", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
|
||||
{
|
||||
var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex).Where(id => id != s.Id).ToList();
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
return new
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.Genre,
|
||||
s.Description,
|
||||
s.ScreenshotUrl,
|
||||
s.YoutubeUrl,
|
||||
s.GameUrl,
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
s.Author,
|
||||
s.ParentSuggestionId,
|
||||
s.IsOwner,
|
||||
LinkedIds = linkedIds,
|
||||
LinkedTitles = linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList()
|
||||
};
|
||||
});
|
||||
|
||||
return Results.Ok(ordered);
|
||||
});
|
||||
var result = await service.GetAllAsync(player.Id);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("GetAllSuggestions");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
12
Endpoints/SuggestionInput.cs
Normal file
12
Endpoints/SuggestionInput.cs
Normal file
@@ -0,0 +1,12 @@
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
internal readonly record struct SuggestionInput(
|
||||
string Name,
|
||||
string? Genre,
|
||||
string? Description,
|
||||
string? ScreenshotUrl,
|
||||
string? YoutubeUrl,
|
||||
string? GameUrl,
|
||||
int? MinPlayers,
|
||||
int? MaxPlayers
|
||||
);
|
||||
@@ -1,27 +1,46 @@
|
||||
using GameList.Contracts;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
internal static class SuggestionValidator
|
||||
{
|
||||
public static async Task<string?> ValidateAsync(SuggestionRequest request, 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(request.Name) || request.Name.Length > 100)
|
||||
if (string.IsNullOrWhiteSpace(input.Name) || input.Name.Length > 100)
|
||||
return "Name is required and must be <= 100 characters.";
|
||||
|
||||
if (!EndpointHelpers.IsValidImageUrl(request.ScreenshotUrl))
|
||||
if (!EndpointHelpers.IsValidImageUrl(input.ScreenshotUrl))
|
||||
return "Screenshot URL must be http(s) and end with an image file extension.";
|
||||
|
||||
if (!await EndpointHelpers.IsReachableImageAsync(request.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).";
|
||||
|
||||
if (!EndpointHelpers.IsValidHttpUrl(request.GameUrl))
|
||||
if (!EndpointHelpers.IsValidHttpUrl(input.GameUrl))
|
||||
return "Game URL must be http or https.";
|
||||
|
||||
if (!EndpointHelpers.IsValidHttpUrl(request.YoutubeUrl))
|
||||
if (!EndpointHelpers.IsValidHttpUrl(input.YoutubeUrl))
|
||||
return "YouTube URL must be http or https.";
|
||||
|
||||
return ValidatePlayers(request.MinPlayers, request.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)
|
||||
|
||||
279
Endpoints/SuggestionWorkflowService.cs
Normal file
279
Endpoints/SuggestionWorkflowService.cs
Normal file
@@ -0,0 +1,279 @@
|
||||
using GameList.Contracts;
|
||||
using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFactory httpFactory)
|
||||
{
|
||||
public async Task<ServiceResult<IReadOnlyList<SuggestionDto>>> GetMineAsync(Guid playerId)
|
||||
{
|
||||
var mine = await db.Suggestions
|
||||
.AsNoTracking()
|
||||
.Where(s => s.PlayerId == playerId)
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.PlayerId,
|
||||
s.Name,
|
||||
s.Genre,
|
||||
s.Description,
|
||||
s.ScreenshotUrl,
|
||||
s.YoutubeUrl,
|
||||
s.GameUrl,
|
||||
s.CreatedAt,
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
s.ParentSuggestionId
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
IReadOnlyList<SuggestionDto> ordered = mine
|
||||
.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))
|
||||
.ToList();
|
||||
|
||||
return ServiceResult<IReadOnlyList<SuggestionDto>>.Success(ordered);
|
||||
}
|
||||
|
||||
public async Task<ServiceResult<SuggestionCreatedResponse>> CreateAsync(Guid playerId, SuggestionInput input)
|
||||
{
|
||||
var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory);
|
||||
if (validationError is not null)
|
||||
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest(validationError));
|
||||
|
||||
var playerState = await db.Players
|
||||
.AsNoTracking()
|
||||
.Where(p => p.Id == playerId)
|
||||
.Select(p => new
|
||||
{
|
||||
p.DisplayName,
|
||||
p.HasJoker
|
||||
})
|
||||
.FirstAsync();
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
var usingJoker = phase == Phase.Vote && playerState.HasJoker;
|
||||
if (phase != Phase.Suggest && !usingJoker)
|
||||
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(playerState.DisplayName))
|
||||
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest("Set a display name before submitting suggestions."));
|
||||
|
||||
var existingCount = await db.Suggestions.AsNoTracking().CountAsync(s => s.PlayerId == playerId);
|
||||
if (!usingJoker && existingCount >= 5)
|
||||
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest("You have reached the 5 suggestion limit."));
|
||||
|
||||
var suggestion = new Suggestion
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Name = input.Name.Trim(),
|
||||
Genre = EndpointHelpers.TrimTo(input.Genre, 50),
|
||||
Description = EndpointHelpers.TrimTo(input.Description, 500),
|
||||
ScreenshotUrl = EndpointHelpers.TrimTo(input.ScreenshotUrl, 2048),
|
||||
YoutubeUrl = EndpointHelpers.TrimTo(input.YoutubeUrl, 2048),
|
||||
GameUrl = EndpointHelpers.TrimTo(input.GameUrl, 2048),
|
||||
MinPlayers = input.MinPlayers,
|
||||
MaxPlayers = input.MaxPlayers
|
||||
};
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
db.Suggestions.Add(suggestion);
|
||||
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (usingJoker)
|
||||
{
|
||||
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."));
|
||||
}
|
||||
|
||||
return ServiceResult<SuggestionCreatedResponse>.Success(new SuggestionCreatedResponse(suggestion.Id));
|
||||
}
|
||||
|
||||
public async Task<ServiceResult<Unit>> DeleteAsync(Guid playerId, int suggestionId)
|
||||
{
|
||||
var actor = await db.Players
|
||||
.AsNoTracking()
|
||||
.Where(p => p.Id == playerId)
|
||||
.Select(p => new
|
||||
{
|
||||
p.IsAdmin
|
||||
})
|
||||
.FirstAsync();
|
||||
|
||||
var isAdmin = actor.IsAdmin;
|
||||
if (!isAdmin)
|
||||
{
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
if (phase != Phase.Suggest)
|
||||
return ServiceResult<Unit>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
|
||||
}
|
||||
|
||||
var suggestion = isAdmin
|
||||
? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId)
|
||||
: await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId && s.PlayerId == playerId);
|
||||
if (suggestion == null)
|
||||
return ServiceResult<Unit>.Failure(ServiceError.NotFound("Suggestion not found."));
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
await db.Suggestions
|
||||
.Where(s => s.ParentSuggestionId == suggestion.Id)
|
||||
.ExecuteUpdateAsync(s => s.SetProperty(x => x.ParentSuggestionId, (int?)null));
|
||||
|
||||
await db.Votes.Where(v => v.SuggestionId == suggestion.Id).ExecuteDeleteAsync();
|
||||
|
||||
db.Suggestions.Remove(suggestion);
|
||||
await db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
return ServiceResult<Unit>.Success(default);
|
||||
}
|
||||
|
||||
public async Task<ServiceResult<SuggestionUpdatedResponse>> UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input)
|
||||
{
|
||||
var actor = await db.Players
|
||||
.AsNoTracking()
|
||||
.Where(p => p.Id == playerId)
|
||||
.Select(p => new
|
||||
{
|
||||
p.IsAdmin
|
||||
})
|
||||
.FirstAsync();
|
||||
|
||||
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId);
|
||||
if (suggestion == null)
|
||||
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;
|
||||
if (!isAdmin)
|
||||
{
|
||||
if (suggestion.PlayerId != playerId)
|
||||
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.Unauthorized());
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
if (phase == Phase.Results)
|
||||
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
|
||||
|
||||
if (phase == Phase.Suggest)
|
||||
{
|
||||
suggestion.Name = input.Name.Trim();
|
||||
}
|
||||
else if (phase != Phase.Vote)
|
||||
{
|
||||
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
|
||||
}
|
||||
|
||||
ApplyEditableFields(suggestion, input);
|
||||
}
|
||||
else
|
||||
{
|
||||
suggestion.Name = input.Name.Trim();
|
||||
ApplyEditableFields(suggestion, input);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return ServiceResult<SuggestionUpdatedResponse>.Success(new SuggestionUpdatedResponse(
|
||||
suggestion.Id,
|
||||
suggestion.Name,
|
||||
suggestion.Genre,
|
||||
suggestion.Description,
|
||||
suggestion.ScreenshotUrl,
|
||||
suggestion.YoutubeUrl,
|
||||
suggestion.GameUrl,
|
||||
suggestion.MinPlayers,
|
||||
suggestion.MaxPlayers
|
||||
));
|
||||
}
|
||||
|
||||
public async Task<ServiceResult<IReadOnlyList<SuggestionAllDto>>> GetAllAsync(Guid playerId)
|
||||
{
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
if (phase < Phase.Vote)
|
||||
return ServiceResult<IReadOnlyList<SuggestionAllDto>>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||
|
||||
var all = await db.Suggestions
|
||||
.AsNoTracking()
|
||||
.Include(s => s.Player)
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.Genre,
|
||||
s.Description,
|
||||
s.ScreenshotUrl,
|
||||
s.YoutubeUrl,
|
||||
s.GameUrl,
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
Author = s.Player!.DisplayName,
|
||||
s.CreatedAt,
|
||||
s.ParentSuggestionId,
|
||||
IsOwner = s.PlayerId == playerId
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
var nameLookup = all.ToDictionary(s => s.Id, s => s.Name);
|
||||
|
||||
IReadOnlyList<SuggestionAllDto> ordered = all.OrderBy(s => s.CreatedAt).Select(s =>
|
||||
{
|
||||
var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex).Where(id => id != s.Id).ToList();
|
||||
|
||||
return new SuggestionAllDto(
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.Genre,
|
||||
s.Description,
|
||||
s.ScreenshotUrl,
|
||||
s.YoutubeUrl,
|
||||
s.GameUrl,
|
||||
s.MinPlayers,
|
||||
s.MaxPlayers,
|
||||
s.Author,
|
||||
s.ParentSuggestionId,
|
||||
s.IsOwner,
|
||||
linkedIds,
|
||||
linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList()
|
||||
);
|
||||
}).ToList();
|
||||
|
||||
return ServiceResult<IReadOnlyList<SuggestionAllDto>>.Success(ordered);
|
||||
}
|
||||
|
||||
private static void ApplyEditableFields(Suggestion suggestion, SuggestionInput input)
|
||||
{
|
||||
suggestion.Genre = EndpointHelpers.TrimTo(input.Genre, 50);
|
||||
suggestion.Description = EndpointHelpers.TrimTo(input.Description, 500);
|
||||
suggestion.ScreenshotUrl = EndpointHelpers.TrimTo(input.ScreenshotUrl, 2048);
|
||||
suggestion.YoutubeUrl = EndpointHelpers.TrimTo(input.YoutubeUrl, 2048);
|
||||
suggestion.GameUrl = EndpointHelpers.TrimTo(input.GameUrl, 2048);
|
||||
suggestion.MinPlayers = input.MinPlayers;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
using GameList.Contracts;
|
||||
using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using GameList.Infrastructure;
|
||||
using GameList.Domain;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
@@ -11,100 +9,37 @@ public static class VoteEndpoints
|
||||
{
|
||||
public static void MapVoteEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/votes").RequireAuthorization().AddEndpointFilter(new PhaseRequirementFilter(Phase.Vote));
|
||||
var group = app.MapGroup("/api/votes").WithTags("Votes").RequireAuthorization().AddEndpointFilter(new PhaseRequirementFilter(Phase.Vote));
|
||||
|
||||
group.MapGet("/mine", async (HttpContext ctx, AppDbContext db) =>
|
||||
group.MapGet("/mine", async (HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
var result = await service.GetMineAsync(player.Id);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("GetMyVotes");
|
||||
|
||||
var votes = await db.Votes.AsNoTracking().Where(v => v.PlayerId == player.Id).Select(v => new
|
||||
{
|
||||
v.SuggestionId,
|
||||
v.Score
|
||||
}).ToListAsync();
|
||||
|
||||
return Results.Ok(votes);
|
||||
});
|
||||
|
||||
group.MapPost("/", async ([FromBody] VoteRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
{
|
||||
if (request.Score is < 0 or > 10)
|
||||
return Results.BadRequest(new { error = "Score must be between 0 and 10." });
|
||||
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
if (player.VotesFinal)
|
||||
return Results.BadRequest(new { error = "Votes are finalized. Unfinalize before changing scores." });
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(player.DisplayName))
|
||||
return Results.BadRequest(new { error = "Set a display name before voting." });
|
||||
|
||||
var linkMap = await db.Suggestions.AsNoTracking().Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.ParentSuggestionId
|
||||
}).ToListAsync();
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
if (!rootIndex.ContainsKey(request.SuggestionId))
|
||||
return Results.BadRequest(new { error = "Suggestion not found." });
|
||||
|
||||
var linkedIds = EndpointHelpers.LinkedIdsFor(request.SuggestionId, rootIndex);
|
||||
if (linkedIds.Count == 0)
|
||||
linkedIds.Add(request.SuggestionId);
|
||||
|
||||
var existingVotes = await db.Votes.Where(v => v.PlayerId == player.Id && linkedIds.Contains(v.SuggestionId)).ToListAsync();
|
||||
|
||||
foreach (var suggestionId in linkedIds)
|
||||
{
|
||||
var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == suggestionId);
|
||||
if (vote == null)
|
||||
{
|
||||
db.Votes.Add(new Vote
|
||||
{
|
||||
PlayerId = player.Id,
|
||||
SuggestionId = suggestionId,
|
||||
Score = request.Score
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
vote.Score = request.Score;
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new
|
||||
{
|
||||
SuggestionIds = linkedIds,
|
||||
request.Score
|
||||
});
|
||||
});
|
||||
|
||||
group.MapPost("/finalize", async ([FromBody] VoteFinalizeRequest request, HttpContext ctx, AppDbContext db) =>
|
||||
group.MapPost("/", async (VoteRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
var result = await service.UpsertAsync(player.Id, request.SuggestionId, request.Score);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("UpsertVote");
|
||||
|
||||
player.VotesFinal = request.Final;
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new { player.VotesFinal });
|
||||
});
|
||||
group.MapPost("/finalize", async (VoteFinalizeRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
var result = await service.SetFinalizeAsync(player.Id, request.Final);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("SetVotesFinalized");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
134
Endpoints/VoteWorkflowService.cs
Normal file
134
Endpoints/VoteWorkflowService.cs
Normal file
@@ -0,0 +1,134 @@
|
||||
using GameList.Contracts;
|
||||
using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
internal sealed class VoteWorkflowService(AppDbContext db)
|
||||
{
|
||||
public async Task<ServiceResult<IReadOnlyList<VoteRecordDto>>> GetMineAsync(Guid playerId)
|
||||
{
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
if (phase != Phase.Vote)
|
||||
return ServiceResult<IReadOnlyList<VoteRecordDto>>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||
|
||||
IReadOnlyList<VoteRecordDto> votes = await db.Votes
|
||||
.AsNoTracking()
|
||||
.Where(v => v.PlayerId == playerId)
|
||||
.Select(v => new VoteRecordDto(v.SuggestionId, v.Score))
|
||||
.ToListAsync();
|
||||
|
||||
return ServiceResult<IReadOnlyList<VoteRecordDto>>.Success(votes);
|
||||
}
|
||||
|
||||
public async Task<ServiceResult<VoteUpsertResponse>> UpsertAsync(Guid playerId, int suggestionId, int score)
|
||||
{
|
||||
if (score is < 0 or > 10)
|
||||
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Score must be between 0 and 10."));
|
||||
|
||||
var playerState = await db.Players
|
||||
.AsNoTracking()
|
||||
.Where(p => p.Id == playerId)
|
||||
.Select(p => new
|
||||
{
|
||||
p.VotesFinal,
|
||||
p.DisplayName
|
||||
})
|
||||
.FirstAsync();
|
||||
|
||||
if (playerState.VotesFinal)
|
||||
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Votes are finalized. Unfinalize before changing scores."));
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
if (phase != Phase.Vote)
|
||||
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(playerState.DisplayName))
|
||||
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Set a display name before voting."));
|
||||
|
||||
var linkMap = await db.Suggestions
|
||||
.AsNoTracking()
|
||||
.Select(s => new
|
||||
{
|
||||
s.Id,
|
||||
s.ParentSuggestionId
|
||||
})
|
||||
.ToListAsync();
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
if (!rootIndex.ContainsKey(suggestionId))
|
||||
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Suggestion not found."));
|
||||
|
||||
var linkedIds = EndpointHelpers.LinkedIdsFor(suggestionId, rootIndex);
|
||||
if (linkedIds.Count == 0)
|
||||
linkedIds.Add(suggestionId);
|
||||
|
||||
var existingVotes = await db.Votes
|
||||
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
|
||||
.ToListAsync();
|
||||
|
||||
for (var attempt = 0; attempt < 2; attempt++)
|
||||
{
|
||||
foreach (var linkedSuggestionId in linkedIds)
|
||||
{
|
||||
var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId);
|
||||
if (vote == null)
|
||||
{
|
||||
db.Votes.Add(new Vote
|
||||
{
|
||||
PlayerId = playerId,
|
||||
SuggestionId = linkedSuggestionId,
|
||||
Score = score
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
vote.Score = score;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.Conflict("Vote update conflict. Please retry."));
|
||||
}
|
||||
|
||||
public async Task<ServiceResult<VoteFinalizeResponse>> SetFinalizeAsync(Guid playerId, bool final)
|
||||
{
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
if (phase != Phase.Vote)
|
||||
return ServiceResult<VoteFinalizeResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||
|
||||
var player = await db.Players.FirstAsync(p => p.Id == playerId);
|
||||
|
||||
player.VotesFinal = final;
|
||||
await db.SaveChangesAsync();
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,19 +9,21 @@ namespace GameList.Tests;
|
||||
|
||||
public class AdminTests
|
||||
{
|
||||
private const string AdminPassword = "Pass123!";
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_vote_status_marks_ready_when_all_finalized()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // move to Vote
|
||||
await admin.AdvanceToVoteAsync("Admin seed"); // move to Vote
|
||||
|
||||
var p1 = factory.CreateClientWithCookies();
|
||||
await p1.RegisterAsync("alice");
|
||||
var p2 = factory.CreateClientWithCookies();
|
||||
await p2.RegisterAsync("bob");
|
||||
await p2.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await p2.AdvanceToVoteAsync("Bob seed");
|
||||
|
||||
var s1 = await p1.CreateSuggestionAsync("A");
|
||||
await p1.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
@@ -59,6 +61,136 @@ public class AdminTests
|
||||
Assert.Equal(HttpStatusCode.BadRequest, give.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_can_move_vote_player_back_to_suggest()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
var player = factory.CreateClientWithCookies();
|
||||
await player.RegisterAsync("player");
|
||||
await player.CreateSuggestionAsync("Game");
|
||||
await player.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var p = await db.Players.SingleAsync(x => x.Username == "player");
|
||||
p.VotesFinal = true;
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
|
||||
var resp = await admin.PostAsJsonAsync("/api/admin/player-phase", new
|
||||
{
|
||||
playerId = await player.GetProfileIdAsync(),
|
||||
phase = nameof(Phase.Suggest)
|
||||
});
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var p = await db.Players.SingleAsync(x => x.Username == "player");
|
||||
Assert.Equal(Phase.Suggest, p.CurrentPhase);
|
||||
Assert.False(p.VotesFinal);
|
||||
});
|
||||
}
|
||||
|
||||
[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]
|
||||
public async Task Admin_player_phase_requires_vote_phase_and_suggest_target()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
var player = factory.CreateClientWithCookies();
|
||||
await player.RegisterAsync("player");
|
||||
|
||||
var wrongTarget = await admin.PostAsJsonAsync("/api/admin/player-phase", new
|
||||
{
|
||||
playerId = await player.GetProfileIdAsync(),
|
||||
phase = nameof(Phase.Results)
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, wrongTarget.StatusCode);
|
||||
|
||||
var wrongCurrentPhase = await admin.PostAsJsonAsync("/api/admin/player-phase", new
|
||||
{
|
||||
playerId = await player.GetProfileIdAsync(),
|
||||
phase = nameof(Phase.Suggest)
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, wrongCurrentPhase.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_player_cascades_suggestions_and_votes()
|
||||
{
|
||||
@@ -77,7 +209,7 @@ public class AdminTests
|
||||
Score = 8
|
||||
});
|
||||
|
||||
var resp = await admin.DeleteAsync($"/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 }) });
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
||||
await factory.WithDbContextAsync(db =>
|
||||
@@ -111,7 +243,7 @@ public class AdminTests
|
||||
var b = await player.CreateSuggestionAsync("Game B");
|
||||
|
||||
await player.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await admin.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await admin.AdvanceToVoteAsync("Admin link seed");
|
||||
|
||||
var same = await admin.PostAsJsonAsync("/api/admin/link-suggestions", new
|
||||
{
|
||||
@@ -147,7 +279,7 @@ public class AdminTests
|
||||
var a = await player.CreateSuggestionAsync("Game A");
|
||||
var b = await player.CreateSuggestionAsync("Game B");
|
||||
await player.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await admin.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await admin.AdvanceToVoteAsync("Admin unlink seed");
|
||||
await admin.PostAsJsonAsync("/api/admin/link-suggestions", new
|
||||
{
|
||||
SourceSuggestionId = a,
|
||||
@@ -189,7 +321,7 @@ public class AdminTests
|
||||
await player.RegisterAsync("player");
|
||||
await player.CreateSuggestionAsync("Keep");
|
||||
|
||||
var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { });
|
||||
var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { password = AdminPassword });
|
||||
reset.EnsureSuccessStatusCode();
|
||||
|
||||
await factory.WithDbContextAsync(db =>
|
||||
@@ -209,7 +341,7 @@ public class AdminTests
|
||||
}
|
||||
});
|
||||
|
||||
var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { });
|
||||
var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { password = AdminPassword });
|
||||
factoryReset.EnsureSuccessStatusCode();
|
||||
|
||||
await factory.WithDbContextAsync(db =>
|
||||
@@ -236,6 +368,7 @@ public class AdminTests
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
var player = factory.CreateClientWithCookies();
|
||||
await player.RegisterAsync("player");
|
||||
await player.CreateSuggestionAsync("Player game");
|
||||
|
||||
var open = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
|
||||
open.EnsureSuccessStatusCode();
|
||||
@@ -244,11 +377,11 @@ public class AdminTests
|
||||
{
|
||||
var p = await db.Players.FirstAsync(x => !x.IsAdmin);
|
||||
p.VotesFinal = true;
|
||||
var state = await db.AppState.SingleAsync();
|
||||
state.UpdatedAt = DateTimeOffset.UnixEpoch;
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
|
||||
var beforeState = await factory.WithDbContextAsync(async db => await db.AppState.AsNoTracking().FirstAsync());
|
||||
await Task.Delay(5);
|
||||
var close = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = false });
|
||||
close.EnsureSuccessStatusCode();
|
||||
|
||||
@@ -257,9 +390,40 @@ public class AdminTests
|
||||
var p = await db.Players.FirstAsync(x => !x.IsAdmin);
|
||||
Assert.Equal(Phase.Vote, p.CurrentPhase);
|
||||
Assert.False(p.VotesFinal);
|
||||
var state = await db.AppState.AsNoTracking().FirstAsync();
|
||||
var state = await db.AppState.AsNoTracking().SingleAsync();
|
||||
Assert.False(state.ResultsOpen);
|
||||
Assert.True(state.UpdatedAt > beforeState.UpdatedAt);
|
||||
Assert.True(state.UpdatedAt > DateTimeOffset.UnixEpoch);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_results_closing_sends_players_without_suggestions_to_suggest_phase()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
|
||||
var voter = factory.CreateClientWithCookies();
|
||||
await voter.RegisterAsync("voter");
|
||||
await voter.CreateSuggestionAsync("Voter game");
|
||||
|
||||
var open = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
|
||||
open.EnsureSuccessStatusCode();
|
||||
|
||||
var lateJoiner = factory.CreateClientWithCookies();
|
||||
await lateJoiner.RegisterAsync("late");
|
||||
|
||||
var close = await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = false });
|
||||
close.EnsureSuccessStatusCode();
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var voterPlayer = await db.Players.SingleAsync(p => p.Username == "voter");
|
||||
var latePlayer = await db.Players.SingleAsync(p => p.Username == "late");
|
||||
Assert.Equal(Phase.Vote, voterPlayer.CurrentPhase);
|
||||
Assert.Equal(Phase.Suggest, latePlayer.CurrentPhase);
|
||||
Assert.False(voterPlayer.VotesFinal);
|
||||
Assert.False(latePlayer.VotesFinal);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -269,7 +433,7 @@ public class AdminTests
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
await admin.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await admin.AdvanceToVoteAsync("Admin vote status seed");
|
||||
|
||||
var p1 = factory.CreateClientWithCookies();
|
||||
await p1.RegisterAsync("alice");
|
||||
@@ -277,7 +441,7 @@ public class AdminTests
|
||||
await p2.RegisterAsync("bob");
|
||||
var s = await p1.CreateSuggestionAsync("Game");
|
||||
await p1.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await p2.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await p2.AdvanceToVoteAsync("Bob vote seed");
|
||||
await p1.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = s,
|
||||
@@ -300,7 +464,7 @@ public class AdminTests
|
||||
|
||||
var p = factory.CreateClientWithCookies();
|
||||
await p.RegisterAsync("player");
|
||||
await p.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await p.AdvanceToVoteAsync("Player joker seed");
|
||||
await p.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
|
||||
|
||||
var give = await admin.PostAsJsonAsync("/api/admin/joker", new { playerId = (await p.GetProfileIdAsync()) });
|
||||
@@ -333,7 +497,7 @@ public class AdminTests
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, beforeVotePhase.StatusCode);
|
||||
|
||||
await admin.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await admin.AdvanceToVoteAsync("Admin link-phase seed");
|
||||
await player.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
|
||||
await player.PostAsJsonAsync("/api/votes", new
|
||||
@@ -373,9 +537,9 @@ public class AdminTests
|
||||
var a = await p1.CreateSuggestionAsync("A");
|
||||
var b = await p1.CreateSuggestionAsync("B");
|
||||
|
||||
await admin.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await admin.AdvanceToVoteAsync("Admin unfinalize seed");
|
||||
await p1.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await p2.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await p2.AdvanceToVoteAsync("P2 unfinalize seed");
|
||||
|
||||
await p1.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
|
||||
await p2.PostAsJsonAsync("/api/votes/finalize", new { Final = true });
|
||||
@@ -400,7 +564,7 @@ public class AdminTests
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
await admin.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await admin.AdvanceToVoteAsync("Admin unlink not-found seed");
|
||||
|
||||
var resp = await admin.PostAsJsonAsync("/api/admin/unlink-suggestions", new { suggestionId = 9999 });
|
||||
resp.EnsureSuccessStatusCode();
|
||||
@@ -425,7 +589,7 @@ public class AdminTests
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
|
||||
var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { });
|
||||
var reset = await admin.PostAsJsonAsync("/api/admin/reset", new { password = AdminPassword });
|
||||
reset.EnsureSuccessStatusCode();
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
@@ -433,16 +597,34 @@ public class AdminTests
|
||||
var player = await db.Players.SingleAsync(x => x.Username == "flags");
|
||||
Assert.False(player.HasJoker);
|
||||
Assert.False(player.VotesFinal);
|
||||
var state = await db.AppState.AsNoTracking().FirstAsync();
|
||||
var state = await db.AppState.AsNoTracking().SingleAsync();
|
||||
Assert.False(state.ResultsOpen);
|
||||
});
|
||||
|
||||
var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { });
|
||||
var factoryReset = await admin.PostAsJsonAsync("/api/admin/factory-reset", new { password = AdminPassword });
|
||||
factoryReset.EnsureSuccessStatusCode();
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var state = await db.AppState.AsNoTracking().FirstAsync();
|
||||
var state = await db.AppState.AsNoTracking().SingleAsync();
|
||||
Assert.False(state.ResultsOpen);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Destructive_admin_actions_require_valid_admin_password()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
var player = factory.CreateClientWithCookies();
|
||||
await player.RegisterAsync("target");
|
||||
|
||||
var resetWrongPassword = await admin.PostAsJsonAsync("/api/admin/reset", new { password = "wrong" });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, resetWrongPassword.StatusCode);
|
||||
|
||||
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}") { Content = JsonContent.Create(new { password = "wrong" }) });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, deleteWrongPassword.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GameList.Domain;
|
||||
using GameList.Infrastructure;
|
||||
using GameList.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -33,6 +34,7 @@ public class AuthTests
|
||||
Assert.True(player.DisplayName!.Length <= 16);
|
||||
Assert.NotEqual(Array.Empty<byte>(), player.PasswordHash);
|
||||
Assert.NotEqual(Array.Empty<byte>(), player.PasswordSalt);
|
||||
Assert.Equal(PasswordHasher.CurrentVersion, player.PasswordHashVersion);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,6 +64,24 @@ public class AuthTests
|
||||
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]
|
||||
public async Task Login_sets_last_login_and_fills_missing_display_name()
|
||||
{
|
||||
@@ -71,7 +91,7 @@ public class AuthTests
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var player = await db.Players.FirstAsync();
|
||||
var player = await db.Players.SingleAsync();
|
||||
player.DisplayName = null;
|
||||
player.LastLoginAt = DateTimeOffset.UnixEpoch;
|
||||
await db.SaveChangesAsync();
|
||||
@@ -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]
|
||||
public async Task Register_with_admin_key_sets_admin_flag()
|
||||
{
|
||||
@@ -99,6 +150,46 @@ public class AuthTests
|
||||
response.EnsureSuccessStatusCode();
|
||||
var json = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
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]
|
||||
@@ -152,6 +243,29 @@ public class AuthTests
|
||||
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]
|
||||
public async Task Non_admin_cannot_access_admin_routes()
|
||||
{
|
||||
@@ -161,6 +275,10 @@ public class AuthTests
|
||||
|
||||
var resp = await player.GetAsync("/api/admin/vote-status");
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||
var json = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("Unauthorized", json.GetProperty("title").GetString());
|
||||
Assert.Equal("Unauthorized", json.GetProperty("detail").GetString());
|
||||
Assert.Equal("Unauthorized", json.GetProperty("error").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -185,4 +303,32 @@ public class AuthTests
|
||||
resp.EnsureSuccessStatusCode();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ public class FiltersTests
|
||||
DisplayName = "User"
|
||||
};
|
||||
db.Players.Add(player);
|
||||
var state = await db.AppState.FirstAsync();
|
||||
var state = await db.AppState.SingleAsync();
|
||||
state.ResultsOpen = resultsOpen;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@ using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Text.Json;
|
||||
using System.Net.Http.Json;
|
||||
@@ -22,40 +21,41 @@ public class HelperTests
|
||||
public void PasswordHasher_roundtrip_and_empty_guard()
|
||||
{
|
||||
var (hash, salt) = PasswordHasher.HashPassword("secret");
|
||||
Assert.True(PasswordHasher.Verify("secret", hash, salt));
|
||||
Assert.True(PasswordHasher.Verify("secret", hash, salt, PasswordHasher.CurrentVersion, out var currentNeedsRehash));
|
||||
Assert.False(currentNeedsRehash);
|
||||
|
||||
var (legacyHash, legacySalt) = PasswordHasher.HashPassword("secret", PasswordHasher.LegacyVersion);
|
||||
Assert.True(PasswordHasher.Verify("secret", legacyHash, legacySalt, PasswordHasher.LegacyVersion, out var legacyNeedsRehash));
|
||||
Assert.True(legacyNeedsRehash);
|
||||
Assert.False(PasswordHasher.Verify("secret", hash, salt, 999, out _));
|
||||
|
||||
Assert.False(PasswordHasher.Verify("other", hash, salt));
|
||||
Assert.Throws<ArgumentException>(() => PasswordHasher.HashPassword(""));
|
||||
}
|
||||
|
||||
[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());
|
||||
Directory.CreateDirectory(webRoot);
|
||||
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);
|
||||
var hasRewriteMethod = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).Any(m => m.Name.Contains("UpdateIndexMetaBase", StringComparison.Ordinal));
|
||||
Assert.False(hasRewriteMethod);
|
||||
}
|
||||
|
||||
[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());
|
||||
Directory.CreateDirectory(webRoot);
|
||||
var index = Path.Combine(webRoot, "index.html");
|
||||
File.WriteAllText(index, "<html></html>");
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
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 response = await client.GetAsync("/openapi/v1.json");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
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]
|
||||
@@ -142,6 +142,31 @@ public class HelperTests
|
||||
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]
|
||||
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; } = "";
|
||||
public IFileProvider WebRootFileProvider { get; set; } = null!;
|
||||
public string WebRootPath { get; set; } = "";
|
||||
public string EnvironmentName { get; set; } = "";
|
||||
public string ContentRootPath { get; set; } = "";
|
||||
public IFileProvider ContentRootFileProvider { get; set; } = null!;
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClient();
|
||||
|
||||
var response = await client.GetAsync("/health");
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
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)
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GameList.Tests.Support;
|
||||
|
||||
namespace GameList.Tests;
|
||||
@@ -36,4 +38,49 @@ public class MiddlewareTests
|
||||
var resp = await client.GetAsync("/api/state");
|
||||
Assert.Equal(HttpStatusCode.OK, resp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mutating_authenticated_request_without_origin_is_rejected()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
var register = await client.RegisterAsync("csrfm");
|
||||
register.EnsureSuccessStatusCode();
|
||||
await client.CreateSuggestionAsync("Seed");
|
||||
await client.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
|
||||
client.DefaultRequestHeaders.Remove("Origin");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/votes/finalize", new
|
||||
{
|
||||
Final = true
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("CSRF validation failed.", body.GetProperty("error").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Mutating_authenticated_request_with_cross_origin_is_rejected()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
var register = await client.RegisterAsync("csrfx");
|
||||
register.EnsureSuccessStatusCode();
|
||||
await client.CreateSuggestionAsync("Seed");
|
||||
await client.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
|
||||
client.DefaultRequestHeaders.Remove("Origin");
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("Origin", "https://evil.example");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/votes/finalize", new
|
||||
{
|
||||
Final = true
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("CSRF validation failed.", body.GetProperty("error").GetString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ public class ResultsTests
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("user");
|
||||
await client.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await client.AdvanceToVoteAsync("Results locked seed");
|
||||
var resp = await client.GetAsync("/api/results");
|
||||
Assert.Equal(System.Net.HttpStatusCode.BadRequest, resp.StatusCode);
|
||||
}
|
||||
@@ -95,6 +95,51 @@ public class ResultsTests
|
||||
Assert.Equal("High", results[0].GetProperty("name").GetString());
|
||||
Assert.Equal(9, (int)results[0].GetProperty("average").GetDouble());
|
||||
Assert.Equal(1, results[0].GetProperty("count").GetInt32());
|
||||
Assert.Equal("player-name", results[0].GetProperty("voterNames")[0].GetString());
|
||||
Assert.Equal(0, results[1].GetProperty("average").GetDouble());
|
||||
Assert.Equal(0, results[1].GetProperty("voterNames").GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Results_payload_contains_alphabetically_sorted_voter_names()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
|
||||
var author = factory.CreateClientWithCookies();
|
||||
await author.RegisterAsync("author");
|
||||
var targetSuggestionId = await author.CreateSuggestionAsync("Target");
|
||||
|
||||
var zeta = factory.CreateClientWithCookies();
|
||||
await zeta.RegisterAsync("zeta");
|
||||
await zeta.AdvanceToVoteAsync("zeta-seed");
|
||||
await zeta.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = targetSuggestionId,
|
||||
Score = 7
|
||||
});
|
||||
|
||||
var alpha = factory.CreateClientWithCookies();
|
||||
await alpha.RegisterAsync("alpha");
|
||||
await alpha.AdvanceToVoteAsync("alpha-seed");
|
||||
await alpha.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = targetSuggestionId,
|
||||
Score = 8
|
||||
});
|
||||
|
||||
await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
|
||||
|
||||
var results = await alpha.GetFromJsonAsync<List<JsonElement>>("/api/results");
|
||||
Assert.NotNull(results);
|
||||
var target = results.Single(r => r.GetProperty("name").GetString() == "Target");
|
||||
|
||||
var voterNames = target
|
||||
.GetProperty("voterNames")
|
||||
.EnumerateArray()
|
||||
.Select(n => n.GetString())
|
||||
.ToList();
|
||||
Assert.Equal(new[] { "alpha-name", "zeta-name" }, voterNames);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ public class StateTests
|
||||
await client.RegisterAsync("payload");
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var player = await db.Players.FirstAsync();
|
||||
var player = await db.Players.SingleAsync();
|
||||
player.HasJoker = true;
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
@@ -27,6 +27,11 @@ public class StateTests
|
||||
|
||||
var state = await client.GetFromJsonAsync<JsonElement>("/api/state");
|
||||
|
||||
Assert.True(Guid.TryParse(state.GetProperty("id").GetString(), out _));
|
||||
Assert.Equal("payload", state.GetProperty("username").GetString());
|
||||
Assert.Equal("payload-name", state.GetProperty("displayName").GetString());
|
||||
Assert.False(state.GetProperty("isAdmin").GetBoolean());
|
||||
Assert.False(state.GetProperty("isOwner").GetBoolean());
|
||||
Assert.Equal(nameof(Phase.Suggest), state.GetProperty("currentPhase").GetString());
|
||||
Assert.False(state.GetProperty("votesFinal").GetBoolean());
|
||||
Assert.True(state.GetProperty("hasJoker").GetBoolean());
|
||||
@@ -50,12 +55,12 @@ public class StateTests
|
||||
PasswordHash = [1],
|
||||
PasswordSalt = [1],
|
||||
DisplayName = "Legacy",
|
||||
CurrentPhase = Phase.Reveal,
|
||||
CurrentPhase = (Phase)1,
|
||||
VotesFinal = true
|
||||
};
|
||||
playerId = player.Id;
|
||||
db.Players.Add(player);
|
||||
var state = await db.AppState.FirstAsync();
|
||||
var state = await db.AppState.SingleAsync();
|
||||
state.ResultsOpen = true;
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
@@ -69,7 +74,7 @@ public class StateTests
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var state = await db.AppState.FirstAsync();
|
||||
var state = await db.AppState.SingleAsync();
|
||||
state.ResultsOpen = false;
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
@@ -80,7 +85,7 @@ public class StateTests
|
||||
var phase = await Endpoints.EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
var player = await db.Players.FindAsync(playerId);
|
||||
Assert.Equal(Phase.Vote, phase);
|
||||
Assert.Equal(Phase.Reveal, player!.CurrentPhase);
|
||||
Assert.Equal((Phase)1, player!.CurrentPhase);
|
||||
Assert.True(player.VotesFinal);
|
||||
}
|
||||
}
|
||||
@@ -91,10 +96,11 @@ public class StateTests
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("advance");
|
||||
await client.CreateSuggestionAsync("Advance game");
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var player = await db.Players.FirstAsync();
|
||||
var player = await db.Players.SingleAsync();
|
||||
player.VotesFinal = true;
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
@@ -121,11 +127,12 @@ public class StateTests
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
await admin.CreateSuggestionAsync("Admin game");
|
||||
|
||||
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // Vote
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var player = await db.Players.FirstAsync();
|
||||
var player = await db.Players.SingleAsync();
|
||||
player.VotesFinal = true;
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
@@ -143,6 +150,7 @@ public class StateTests
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("player");
|
||||
await client.CreateSuggestionAsync("Player game");
|
||||
|
||||
var toVote = await client.PostAsync("/api/me/phase/next", JsonContent.Create(new { }));
|
||||
toVote.EnsureSuccessStatusCode();
|
||||
@@ -152,6 +160,20 @@ public class StateTests
|
||||
Assert.Equal(HttpStatusCode.BadRequest, toResults.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Phase_next_from_suggest_requires_at_least_one_suggestion()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("nosuggest");
|
||||
|
||||
var response = await client.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
var me = await client.GetFromJsonAsync<JsonElement>("/api/me");
|
||||
Assert.Equal(nameof(Phase.Suggest), me.GetProperty("currentPhase").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_opening_results_moves_players_to_results_phase()
|
||||
{
|
||||
@@ -199,6 +221,7 @@ public class StateTests
|
||||
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
await admin.CreateSuggestionAsync("Admin phase game");
|
||||
await admin.PostAsJsonAsync("/api/me/phase/next", new { }); // to Vote
|
||||
var back = await admin.PostAsJsonAsync("/api/me/phase/prev", new { });
|
||||
back.EnsureSuccessStatusCode();
|
||||
@@ -212,7 +235,11 @@ public class StateTests
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var anon = factory.CreateClient();
|
||||
var unauthorized = await anon.GetAsync("/api/state");
|
||||
Assert.NotEqual(HttpStatusCode.OK, unauthorized.StatusCode);
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, unauthorized.StatusCode);
|
||||
var unauthorizedJson = await unauthorized.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("Unauthorized", unauthorizedJson.GetProperty("title").GetString());
|
||||
Assert.Equal("Unauthorized", unauthorizedJson.GetProperty("detail").GetString());
|
||||
Assert.Equal("Unauthorized", unauthorizedJson.GetProperty("error").GetString());
|
||||
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("counting");
|
||||
@@ -224,6 +251,27 @@ public class StateTests
|
||||
Assert.True(suggestions.GetInt32() >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task State_endpoint_with_stale_cookie_returns_unauthorized_and_clears_cookie()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("stale");
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var player = await db.Players.SingleAsync();
|
||||
db.Players.Remove(player);
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
|
||||
var resp = await client.GetAsync("/api/state");
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, resp.StatusCode);
|
||||
Assert.True(resp.Headers.TryGetValues("Set-Cookie", out var cookies));
|
||||
Assert.Contains(cookies, c => c.Contains("player=", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Health_endpoint_ok()
|
||||
{
|
||||
@@ -241,10 +289,10 @@ public class StateTests
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var player = await db.Players.FirstAsync();
|
||||
var player = await db.Players.SingleAsync();
|
||||
player.CurrentPhase = Phase.Results;
|
||||
player.VotesFinal = true;
|
||||
var state = await db.AppState.FirstAsync();
|
||||
var state = await db.AppState.SingleAsync();
|
||||
state.ResultsOpen = false;
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
@@ -257,7 +305,7 @@ public class StateTests
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var player = await db.Players.AsNoTracking().FirstAsync();
|
||||
var player = await db.Players.AsNoTracking().SingleAsync();
|
||||
Assert.Equal(Phase.Results, player.CurrentPhase);
|
||||
Assert.True(player.VotesFinal);
|
||||
});
|
||||
@@ -280,17 +328,130 @@ public class StateTests
|
||||
CurrentPhase = Phase.Vote
|
||||
};
|
||||
db.Players.Add(player);
|
||||
var state = await db.AppState.FirstAsync();
|
||||
var state = await db.AppState.SingleAsync();
|
||||
state.ResultsOpen = true;
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
|
||||
using var scope = factory.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
var playerId = await db.Players.Select(p => p.Id).FirstAsync();
|
||||
var playerId = await db.Players.Select(p => p.Id).SingleAsync();
|
||||
var phase = await Endpoints.EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GameList.Domain;
|
||||
using GameList.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -91,7 +92,7 @@ public class SuggestionTests
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var p = await db.Players.FirstAsync();
|
||||
var p = await db.Players.SingleAsync(x => x.Username == "joker");
|
||||
p.HasJoker = true;
|
||||
p.CurrentPhase = Domain.Phase.Vote;
|
||||
var o = await db.Players.SingleAsync(x => x.Username == "other");
|
||||
@@ -114,7 +115,7 @@ public class SuggestionTests
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var p = await db.Players.FirstAsync();
|
||||
var p = await db.Players.SingleAsync(x => x.Username == "joker");
|
||||
Assert.False(p.HasJoker);
|
||||
Assert.False(p.VotesFinal);
|
||||
var o = await db.Players.SingleAsync(x => x.Username == "other");
|
||||
@@ -187,9 +188,9 @@ public class SuggestionTests
|
||||
// Move everyone to Results
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var state = await db.AppState.FirstAsync();
|
||||
var state = await db.AppState.SingleAsync();
|
||||
state.ResultsOpen = true;
|
||||
var p = await db.Players.FirstAsync();
|
||||
var p = await db.Players.SingleAsync();
|
||||
p.CurrentPhase = Domain.Phase.Results;
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
@@ -267,7 +268,7 @@ public class SuggestionTests
|
||||
await client.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var p = await db.Players.FirstAsync();
|
||||
var p = await db.Players.SingleAsync();
|
||||
p.HasJoker = true;
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
@@ -288,7 +289,7 @@ public class SuggestionTests
|
||||
// Grant another joker and add a seventh suggestion
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var p = await db.Players.FirstAsync();
|
||||
var p = await db.Players.SingleAsync();
|
||||
p.HasJoker = true;
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
@@ -347,6 +348,45 @@ public class SuggestionTests
|
||||
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]
|
||||
public async Task Get_all_requires_vote_phase()
|
||||
{
|
||||
@@ -365,28 +405,15 @@ public class SuggestionTests
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("mine");
|
||||
|
||||
await client.PostAsJsonAsync("/api/suggestions", new
|
||||
var secondId = await client.CreateSuggestionAsync("Second");
|
||||
var thirdId = await client.CreateSuggestionAsync("Third");
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
Name = "Second",
|
||||
Genre = (string?)null,
|
||||
Description = (string?)null,
|
||||
ScreenshotUrl = (string?)null,
|
||||
YoutubeUrl = (string?)null,
|
||||
GameUrl = (string?)null,
|
||||
MinPlayers = (int?)null,
|
||||
MaxPlayers = (int?)null
|
||||
});
|
||||
await Task.Delay(10);
|
||||
await client.PostAsJsonAsync("/api/suggestions", new
|
||||
{
|
||||
Name = "Third",
|
||||
Genre = (string?)null,
|
||||
Description = (string?)null,
|
||||
ScreenshotUrl = (string?)null,
|
||||
YoutubeUrl = (string?)null,
|
||||
GameUrl = (string?)null,
|
||||
MinPlayers = (int?)null,
|
||||
MaxPlayers = (int?)null
|
||||
var second = await db.Suggestions.FindAsync(secondId);
|
||||
var third = await db.Suggestions.FindAsync(thirdId);
|
||||
second!.CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1);
|
||||
third!.CreatedAt = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
|
||||
var mine = await client.GetFromJsonAsync<List<JsonElement>>("/api/suggestions/mine");
|
||||
@@ -402,7 +429,7 @@ public class SuggestionTests
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var p = await db.Players.FirstAsync();
|
||||
var p = await db.Players.SingleAsync();
|
||||
p.CurrentPhase = Domain.Phase.Vote;
|
||||
p.DisplayName = null;
|
||||
await db.SaveChangesAsync();
|
||||
@@ -423,7 +450,7 @@ public class SuggestionTests
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var p = await db.Players.FirstAsync();
|
||||
var p = await db.Players.SingleAsync();
|
||||
p.CurrentPhase = Domain.Phase.Suggest;
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
@@ -539,7 +566,7 @@ public class SuggestionTests
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var s = await db.Suggestions.AsNoTracking().FirstAsync();
|
||||
var s = await db.Suggestions.AsNoTracking().SingleAsync();
|
||||
Assert.Equal(50, s.Genre!.Length);
|
||||
Assert.Equal(500, s.Description!.Length);
|
||||
Assert.Equal("http://example.com/img.png", s.ScreenshotUrl);
|
||||
@@ -572,11 +599,13 @@ public class SuggestionTests
|
||||
await client.RegisterAsync("owner");
|
||||
|
||||
var id1 = await client.CreateSuggestionAsync("Alpha");
|
||||
await Task.Delay(10);
|
||||
var id2 = await client.CreateSuggestionAsync("Beta");
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var alpha = await db.Suggestions.FindAsync(id1);
|
||||
var beta = await db.Suggestions.FindAsync(id2);
|
||||
alpha!.CreatedAt = DateTimeOffset.UtcNow.AddMinutes(-1);
|
||||
beta!.CreatedAt = DateTimeOffset.UtcNow;
|
||||
beta!.ParentSuggestionId = id1;
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
@@ -613,6 +642,7 @@ public class SuggestionTests
|
||||
});
|
||||
|
||||
await owner.PostAsJsonAsync("/api/me/phase/next", new { }); // Vote
|
||||
await other.CreateSuggestionAsync("Other vote seed");
|
||||
await other.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await other.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
@@ -636,4 +666,41 @@ public class SuggestionTests
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,4 +49,11 @@ internal static class TestClientExtensions
|
||||
var me = await client.GetFromJsonAsync<JsonElement>("/api/me");
|
||||
return Guid.Parse(me.GetProperty("id").GetString()!);
|
||||
}
|
||||
|
||||
public static async Task AdvanceToVoteAsync(this HttpClient client, string suggestionName = "Seed game")
|
||||
{
|
||||
await client.CreateSuggestionAsync(suggestionName);
|
||||
var response = await client.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
response.EnsureSuccessStatusCode();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ internal class TestWebApplicationFactory : WebApplicationFactory<Program>
|
||||
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();
|
||||
|
||||
services.AddDbContext<AppDbContext>(options => { options.UseSqlite(_connection); });
|
||||
@@ -44,7 +44,6 @@ internal class TestWebApplicationFactory : WebApplicationFactory<Program>
|
||||
|
||||
using var scope = host.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
db.Database.EnsureCreated();
|
||||
db.Database.Migrate();
|
||||
|
||||
return host;
|
||||
@@ -77,10 +76,18 @@ internal class TestWebApplicationFactory : WebApplicationFactory<Program>
|
||||
|
||||
public HttpClient CreateClientWithCookies()
|
||||
{
|
||||
return CreateClient(new WebApplicationFactoryClientOptions
|
||||
var client = CreateClient(new WebApplicationFactoryClientOptions
|
||||
{
|
||||
HandleCookies = true,
|
||||
AllowAutoRedirect = false
|
||||
});
|
||||
|
||||
if (client.BaseAddress is { } baseAddress)
|
||||
{
|
||||
var origin = $"{baseAddress.Scheme}://{baseAddress.Authority}";
|
||||
client.DefaultRequestHeaders.TryAddWithoutValidation("Origin", origin);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ public class VoteTests
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("invalid");
|
||||
await client.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await client.AdvanceToVoteAsync("Invalid seed");
|
||||
|
||||
var resp = await client.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
@@ -98,7 +98,7 @@ public class VoteTests
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var p = await db.Players.FirstAsync();
|
||||
var p = await db.Players.SingleAsync();
|
||||
p.DisplayName = null;
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
@@ -152,7 +152,7 @@ public class VoteTests
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
await admin.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await admin.AdvanceToVoteAsync("Admin link seed");
|
||||
|
||||
var player = factory.CreateClientWithCookies();
|
||||
await player.RegisterAsync("linker");
|
||||
@@ -189,7 +189,7 @@ public class VoteTests
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
await admin.PostAsJsonAsync("/api/me/phase/next", new { });
|
||||
await admin.AdvanceToVoteAsync("Admin chain seed");
|
||||
|
||||
var player = factory.CreateClientWithCookies();
|
||||
await player.RegisterAsync("chain");
|
||||
|
||||
13
GameList.Tests/coverlet.runsettings
Normal file
13
GameList.Tests/coverlet.runsettings
Normal file
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RunSettings>
|
||||
<DataCollectionRunSettings>
|
||||
<DataCollectors>
|
||||
<DataCollector friendlyName="XPlat Code Coverage">
|
||||
<Configuration>
|
||||
<Format>cobertura</Format>
|
||||
<ExcludeByFile>**/obj/**/Microsoft.AspNetCore.OpenApi.SourceGenerators/**/*.cs</ExcludeByFile>
|
||||
</Configuration>
|
||||
</DataCollector>
|
||||
</DataCollectors>
|
||||
</DataCollectionRunSettings>
|
||||
</RunSettings>
|
||||
@@ -4,14 +4,22 @@
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<OpenApiGenerateDocuments>true</OpenApiGenerateDocuments>
|
||||
<OpenApiDocumentsDirectory>$(MSBuildProjectDirectory)\openapi</OpenApiDocumentsDirectory>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Konscious.Security.Cryptography.Argon2" Version="1.3.1" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.Extensions.ApiDescription.Server" Version="10.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
15
IIS.md
15
IIS.md
@@ -8,6 +8,7 @@
|
||||
|
||||
## 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).
|
||||
- Set environment variables in web.config or IIS config:
|
||||
- `ASPNETCORE_ENVIRONMENT=Production`
|
||||
@@ -16,10 +17,22 @@
|
||||
- Configure trusted reverse proxies/networks for forwarded headers (do not trust all sources):
|
||||
- `ForwardedHeaders__KnownProxies__0=10.0.0.10`
|
||||
- `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.
|
||||
- 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
|
||||
- Grant modify rights to the app pool identity on `App_Data` (DB file + wal).
|
||||
- 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.
|
||||
|
||||
@@ -12,7 +12,7 @@ public class AdminOnlyFilter : IEndpointFilter
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(httpContext, db);
|
||||
if (player?.IsAdmin != true)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
}
|
||||
|
||||
return await next(context);
|
||||
|
||||
60
Infrastructure/AuthAttemptMonitor.cs
Normal file
60
Infrastructure/AuthAttemptMonitor.cs
Normal 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);
|
||||
}
|
||||
105
Infrastructure/CsrfProtectionMiddleware.cs
Normal file
105
Infrastructure/CsrfProtectionMiddleware.cs
Normal file
@@ -0,0 +1,105 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace GameList.Infrastructure;
|
||||
|
||||
public sealed class CsrfProtectionMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context)
|
||||
{
|
||||
if (!ShouldValidate(context))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
if (IsSameOriginRequest(context))
|
||||
{
|
||||
await next(context);
|
||||
return;
|
||||
}
|
||||
|
||||
await WriteCsrfFailureAsync(context);
|
||||
}
|
||||
|
||||
private static bool ShouldValidate(HttpContext context)
|
||||
{
|
||||
if (!context.Request.Path.StartsWithSegments("/api", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
if (!HttpMethods.IsPost(context.Request.Method)
|
||||
&& !HttpMethods.IsPut(context.Request.Method)
|
||||
&& !HttpMethods.IsDelete(context.Request.Method)
|
||||
&& !HttpMethods.IsPatch(context.Request.Method))
|
||||
return false;
|
||||
|
||||
return context.User.Identity?.IsAuthenticated == true;
|
||||
}
|
||||
|
||||
private static bool IsSameOriginRequest(HttpContext context)
|
||||
{
|
||||
var originValues = context.Request.Headers.Origin;
|
||||
if (!StringValues.IsNullOrEmpty(originValues))
|
||||
{
|
||||
foreach (var origin in originValues)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(origin))
|
||||
return false;
|
||||
|
||||
if (!IsSameOrigin(origin, context))
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
var referer = context.Request.Headers.Referer.ToString();
|
||||
if (string.IsNullOrWhiteSpace(referer))
|
||||
return false;
|
||||
|
||||
return IsSameOrigin(referer, context);
|
||||
}
|
||||
|
||||
private static bool IsSameOrigin(string raw, HttpContext context)
|
||||
{
|
||||
if (!Uri.TryCreate(raw, UriKind.Absolute, out var uri))
|
||||
return false;
|
||||
|
||||
var requestScheme = context.Request.Scheme;
|
||||
if (!string.Equals(uri.Scheme, requestScheme, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
var requestHost = context.Request.Host.Host;
|
||||
if (!string.Equals(uri.Host, requestHost, StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
var uriPort = uri.IsDefaultPort ? GetDefaultPort(uri.Scheme) : uri.Port;
|
||||
var requestPort = context.Request.Host.Port ?? GetDefaultPort(requestScheme);
|
||||
|
||||
return uriPort == requestPort;
|
||||
}
|
||||
|
||||
private static int GetDefaultPort(string scheme)
|
||||
{
|
||||
return string.Equals(scheme, "https", StringComparison.OrdinalIgnoreCase) ? 443 : 80;
|
||||
}
|
||||
|
||||
private static Task WriteCsrfFailureAsync(HttpContext context)
|
||||
{
|
||||
if (context.Response.HasStarted)
|
||||
return Task.CompletedTask;
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status400BadRequest;
|
||||
context.Response.ContentType = "application/problem+json";
|
||||
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status400BadRequest,
|
||||
Title = "Bad Request",
|
||||
Detail = "CSRF validation failed.",
|
||||
Extensions = { ["error"] = "CSRF validation failed." }
|
||||
};
|
||||
|
||||
return context.Response.WriteAsJsonAsync(problem);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
|
||||
namespace GameList.Infrastructure;
|
||||
@@ -10,12 +11,22 @@ public class EnsurePlayerExistsMiddleware(RequestDelegate next)
|
||||
if (context.User.Identity?.IsAuthenticated == true)
|
||||
{
|
||||
var id = context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value;
|
||||
if (string.IsNullOrWhiteSpace(id) || !Guid.TryParse(id, out var playerId) || await db.Players.FindAsync(playerId) is null)
|
||||
if (string.IsNullOrWhiteSpace(id) || !Guid.TryParse(id, out var playerId))
|
||||
{
|
||||
await context.SignOutAsync();
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return;
|
||||
}
|
||||
|
||||
var player = await db.Players.FindAsync(playerId);
|
||||
if (player is null)
|
||||
{
|
||||
await context.SignOutAsync();
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return;
|
||||
}
|
||||
|
||||
context.Items[nameof(Player)] = player;
|
||||
}
|
||||
|
||||
await next(context);
|
||||
|
||||
@@ -1,35 +1,105 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Konscious.Security.Cryptography;
|
||||
|
||||
namespace GameList.Infrastructure;
|
||||
|
||||
public static class PasswordHasher
|
||||
{
|
||||
public const int LegacyVersion = 1;
|
||||
public const int Pbkdf2Version = 2;
|
||||
public const int CurrentVersion = 3;
|
||||
|
||||
private const int SaltSize = 16;
|
||||
private const int KeySize = 32;
|
||||
private const int Iterations = 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)
|
||||
=> HashPassword(password, CurrentVersion);
|
||||
|
||||
public static (byte[] Hash, byte[] Salt) HashPassword(string password, int version)
|
||||
{
|
||||
if (string.IsNullOrEmpty(password))
|
||||
throw new ArgumentException("Password required", nameof(password));
|
||||
|
||||
var normalizedVersion = NormalizeHashVersion(version);
|
||||
var salt = RandomNumberGenerator.GetBytes(SaltSize);
|
||||
var hash = PBKDF2(password, salt);
|
||||
var hash = Derive(password, salt, normalizedVersion);
|
||||
return (hash, salt);
|
||||
}
|
||||
|
||||
public static bool Verify(string password, byte[] hash, byte[] salt)
|
||||
=> Verify(password, hash, salt, CurrentVersion, out _);
|
||||
|
||||
public static bool Verify(string password, byte[] hash, byte[] salt, int version, out bool needsRehash)
|
||||
{
|
||||
needsRehash = false;
|
||||
if (hash.Length == 0 || salt.Length == 0)
|
||||
return false;
|
||||
|
||||
var computed = PBKDF2(password, salt);
|
||||
return CryptographicOperations.FixedTimeEquals(computed, hash);
|
||||
var normalizedVersion = NormalizeVerifyVersion(version);
|
||||
if (normalizedVersion == 0)
|
||||
return false;
|
||||
|
||||
var computed = Derive(password, salt, normalizedVersion);
|
||||
var verified = CryptographicOperations.FixedTimeEquals(computed, hash);
|
||||
|
||||
needsRehash = verified && normalizedVersion < CurrentVersion;
|
||||
return verified;
|
||||
}
|
||||
|
||||
private static byte[] PBKDF2(string password, byte[] salt)
|
||||
private static int NormalizeHashVersion(int version)
|
||||
{
|
||||
return Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, Iterations, HashAlgorithmName.SHA256, KeySize);
|
||||
return version switch
|
||||
{
|
||||
<= LegacyVersion => LegacyVersion,
|
||||
Pbkdf2Version => Pbkdf2Version,
|
||||
CurrentVersion => CurrentVersion,
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(version), "Unsupported password hash version.")
|
||||
};
|
||||
}
|
||||
|
||||
private static int NormalizeVerifyVersion(int version)
|
||||
{
|
||||
return version switch
|
||||
{
|
||||
<= LegacyVersion => LegacyVersion,
|
||||
Pbkdf2Version => Pbkdf2Version,
|
||||
CurrentVersion => CurrentVersion,
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] Derive(string password, byte[] salt, int version)
|
||||
{
|
||||
return version switch
|
||||
{
|
||||
LegacyVersion => PBKDF2(password, salt, IterationsV1),
|
||||
Pbkdf2Version => PBKDF2(password, salt, IterationsV2),
|
||||
CurrentVersion => Argon2id(password, salt),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(version), "Unsupported password hash version.")
|
||||
};
|
||||
}
|
||||
|
||||
private static byte[] PBKDF2(string password, byte[] salt, int iterations)
|
||||
{
|
||||
return Rfc2898DeriveBytes.Pbkdf2(Encoding.UTF8.GetBytes(password), salt, iterations, HashAlgorithmName.SHA256, KeySize);
|
||||
}
|
||||
|
||||
private static byte[] Argon2id(string password, byte[] salt)
|
||||
{
|
||||
using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(password))
|
||||
{
|
||||
Salt = salt,
|
||||
Iterations = Argon2Iterations,
|
||||
MemorySize = Argon2MemoryKiB,
|
||||
DegreeOfParallelism = Argon2DegreeOfParallelism
|
||||
};
|
||||
|
||||
return argon2.GetBytes(KeySize);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ public class PhaseOrJokerFilter : IEndpointFilter
|
||||
var db = httpContext.RequestServices.GetRequiredService<AppDbContext>();
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(httpContext, db);
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
||||
var allow = phase == Phase.Suggest || (phase == Phase.Vote && player.HasJoker);
|
||||
|
||||
@@ -12,7 +12,7 @@ public class PhaseRequirementFilter(Phase required, bool allowAdminOverride = fa
|
||||
var db = httpContext.RequestServices.GetRequiredService<AppDbContext>();
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(httpContext, db);
|
||||
if (player is null)
|
||||
return Results.Unauthorized();
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
||||
if (phase != required && !(allowAdminOverride && player.IsAdmin))
|
||||
|
||||
@@ -3,6 +3,7 @@ using GameList.Domain;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Diagnostics;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace GameList.Infrastructure;
|
||||
|
||||
@@ -11,6 +12,11 @@ public static class PlayerIdentityExtensions
|
||||
public const string PlayerCookieName = "player";
|
||||
public const string AdminClaim = "is_admin";
|
||||
public const string AdminPolicy = "AdminOnly";
|
||||
private static readonly Action<ILogger, Exception?> LogUnhandledException =
|
||||
LoggerMessage.Define(
|
||||
LogLevel.Error,
|
||||
new EventId(1001, nameof(LogUnhandledException)),
|
||||
"Unhandled exception");
|
||||
|
||||
public static async Task SignInPlayerAsync(HttpContext ctx, Player player)
|
||||
{
|
||||
@@ -39,12 +45,19 @@ public static class PlayerIdentityExtensions
|
||||
var logger = context.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("GlobalException");
|
||||
if (feature?.Error != null)
|
||||
{
|
||||
logger.LogError(feature.Error, "Unhandled exception");
|
||||
LogUnhandledException(logger, feature.Error);
|
||||
}
|
||||
|
||||
context.Response.StatusCode = StatusCodes.Status500InternalServerError;
|
||||
context.Response.ContentType = "application/json";
|
||||
await context.Response.WriteAsJsonAsync(new { error = "Unexpected server error" });
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
Status = StatusCodes.Status500InternalServerError,
|
||||
Title = "Internal Server Error",
|
||||
Detail = "Unexpected server error"
|
||||
};
|
||||
problem.Extensions["error"] = "Unexpected server error";
|
||||
await context.Response.WriteAsJsonAsync(problem);
|
||||
});
|
||||
});
|
||||
return app;
|
||||
|
||||
31
Infrastructure/StateChangeNotificationMiddleware.cs
Normal file
31
Infrastructure/StateChangeNotificationMiddleware.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
namespace GameList.Infrastructure;
|
||||
|
||||
public sealed class StateChangeNotificationMiddleware(RequestDelegate next)
|
||||
{
|
||||
public async Task InvokeAsync(HttpContext context, StateChangeNotifier notifier)
|
||||
{
|
||||
await next(context);
|
||||
|
||||
if (ShouldNotify(context))
|
||||
notifier.NotifyChange();
|
||||
}
|
||||
|
||||
private static bool ShouldNotify(HttpContext context)
|
||||
{
|
||||
if (context.Response.StatusCode >= StatusCodes.Status400BadRequest)
|
||||
return false;
|
||||
|
||||
if (!HttpMethods.IsPost(context.Request.Method)
|
||||
&& !HttpMethods.IsPut(context.Request.Method)
|
||||
&& !HttpMethods.IsDelete(context.Request.Method))
|
||||
return false;
|
||||
|
||||
var path = context.Request.Path;
|
||||
|
||||
return path.StartsWithSegments("/api/suggestions", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.StartsWithSegments("/api/votes", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.StartsWithSegments("/api/admin", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.StartsWithSegments("/api/me/phase", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.StartsWithSegments("/api/auth/register", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
69
Infrastructure/StateChangeNotifier.cs
Normal file
69
Infrastructure/StateChangeNotifier.cs
Normal file
@@ -0,0 +1,69 @@
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace GameList.Infrastructure;
|
||||
|
||||
public sealed class StateChangeNotifier
|
||||
{
|
||||
private readonly string _instanceId = Guid.NewGuid().ToString("N");
|
||||
private long _version = 1;
|
||||
private TaskCompletionSource<long> _nextChange = CreateWaiter();
|
||||
|
||||
public long CurrentVersion => Interlocked.Read(ref _version);
|
||||
|
||||
public string CurrentEtag => $"\"{_instanceId}:{CurrentVersion}\"";
|
||||
|
||||
public long NotifyChange()
|
||||
{
|
||||
var newVersion = Interlocked.Increment(ref _version);
|
||||
|
||||
while (true)
|
||||
{
|
||||
var waiter = Volatile.Read(ref _nextChange);
|
||||
var replacement = CreateWaiter();
|
||||
if (Interlocked.CompareExchange(ref _nextChange, replacement, waiter) != waiter)
|
||||
continue;
|
||||
|
||||
waiter.TrySetResult(newVersion);
|
||||
return newVersion;
|
||||
}
|
||||
}
|
||||
|
||||
public bool MatchesCurrentEtag(StringValues ifNoneMatchValues)
|
||||
{
|
||||
if (StringValues.IsNullOrEmpty(ifNoneMatchValues))
|
||||
return false;
|
||||
|
||||
var current = CurrentEtag;
|
||||
foreach (var raw in ifNoneMatchValues)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(raw))
|
||||
continue;
|
||||
|
||||
var parts = raw.Split(',', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries);
|
||||
foreach (var part in parts)
|
||||
{
|
||||
if (part == "*" || string.Equals(part, current, StringComparison.Ordinal))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public async Task<long> WaitForChangeAsync(long observedVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
var current = CurrentVersion;
|
||||
if (current > observedVersion)
|
||||
return current;
|
||||
|
||||
var waiter = Volatile.Read(ref _nextChange);
|
||||
var signaled = await waiter.Task.WaitAsync(cancellationToken);
|
||||
if (signaled > observedVersion)
|
||||
return signaled;
|
||||
}
|
||||
}
|
||||
|
||||
private static TaskCompletionSource<long> CreateWaiter() => new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
}
|
||||
206
Program.cs
206
Program.cs
@@ -1,12 +1,18 @@
|
||||
using GameList.Data;
|
||||
using GameList.Endpoints;
|
||||
using GameList.Infrastructure;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Globalization;
|
||||
using System.Threading.RateLimiting;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -34,11 +40,64 @@ else if (!Path.IsPathRooted(connectionBuilder.DataSource))
|
||||
var connectionString = connectionBuilder.ToString();
|
||||
|
||||
builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlite(connectionString));
|
||||
builder.Services.AddScoped<SuggestionWorkflowService>();
|
||||
builder.Services.AddScoped<VoteWorkflowService>();
|
||||
builder.Services.AddScoped<AdminWorkflowService>();
|
||||
builder.Services.AddScoped<ResultsWorkflowService>();
|
||||
builder.Services.AddScoped<StateWorkflowService>();
|
||||
builder.Services.AddSingleton<AuthAttemptMonitor>();
|
||||
builder.Services.AddSingleton<StateChangeNotifier>();
|
||||
builder.Services.AddOpenApi("v1");
|
||||
|
||||
builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); });
|
||||
|
||||
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.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 =>
|
||||
{
|
||||
@@ -47,19 +106,13 @@ builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationSc
|
||||
options.Cookie.SameSite = SameSiteMode.Strict;
|
||||
options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always;
|
||||
options.SlidingExpiration = true;
|
||||
options.ExpireTimeSpan = TimeSpan.FromDays(30);
|
||||
options.ExpireTimeSpan = TimeSpan.FromHours(12);
|
||||
options.Events = new CookieAuthenticationEvents
|
||||
{
|
||||
OnRedirectToLogin = ctx =>
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
OnRedirectToAccessDenied = ctx =>
|
||||
{
|
||||
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
OnSigningIn = EnsureSessionStartAsync,
|
||||
OnValidatePrincipal = ValidateSessionLifetimeAsync,
|
||||
OnRedirectToLogin = ctx => WriteUnauthorizedChallengeAsync(ctx.HttpContext),
|
||||
OnRedirectToAccessDenied = ctx => WriteUnauthorizedChallengeAsync(ctx.HttpContext)
|
||||
};
|
||||
});
|
||||
|
||||
@@ -68,30 +121,47 @@ builder.Services.AddAuthorization(options => { options.AddPolicy(PlayerIdentityE
|
||||
var app = builder.Build();
|
||||
|
||||
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"];
|
||||
if (!string.IsNullOrWhiteSpace(basePath))
|
||||
{
|
||||
app.UsePathBase(basePath);
|
||||
UpdateIndexMetaBase(app.Environment, basePath);
|
||||
}
|
||||
|
||||
app.UseGlobalExceptionLogging();
|
||||
app.UseAuthentication();
|
||||
app.UseMiddleware<EnsurePlayerExistsMiddleware>();
|
||||
app.UseMiddleware<CsrfProtectionMiddleware>();
|
||||
app.UseAuthorization();
|
||||
|
||||
// Ensure database and migrations are applied on startup
|
||||
using (var scope = app.Services.CreateScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
db.Database.Migrate();
|
||||
}
|
||||
app.UseMiddleware<StateChangeNotificationMiddleware>();
|
||||
|
||||
app.UseDefaultFiles();
|
||||
app.UseStaticFiles();
|
||||
|
||||
app.MapHealthChecks();
|
||||
app.MapOpenApi("/openapi/{documentName}.json");
|
||||
app.MapAuthEndpoints();
|
||||
app.MapStateEndpoints();
|
||||
app.MapSuggestEndpoints();
|
||||
@@ -101,6 +171,52 @@ app.MapAdminEndpoints();
|
||||
|
||||
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)
|
||||
{
|
||||
var options = new ForwardedHeadersOptions
|
||||
@@ -135,42 +251,24 @@ static ForwardedHeadersOptions BuildForwardedHeadersOptions(IConfiguration confi
|
||||
return options;
|
||||
}
|
||||
|
||||
static void UpdateIndexMetaBase(IWebHostEnvironment env, string basePath)
|
||||
static Task WriteUnauthorizedChallengeAsync(HttpContext context)
|
||||
{
|
||||
try
|
||||
context.Response.StatusCode = StatusCodes.Status401Unauthorized;
|
||||
if (!context.Request.Path.StartsWithSegments("/api"))
|
||||
return Task.CompletedTask;
|
||||
|
||||
if (context.Response.HasStarted)
|
||||
return Task.CompletedTask;
|
||||
|
||||
context.Response.ContentType = "application/problem+json";
|
||||
var problem = new ProblemDetails
|
||||
{
|
||||
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.
|
||||
}
|
||||
Status = StatusCodes.Status401Unauthorized,
|
||||
Title = "Unauthorized",
|
||||
Detail = "Unauthorized",
|
||||
Extensions = { ["error"] = "Unauthorized" }
|
||||
};
|
||||
return context.Response.WriteAsJsonAsync(problem);
|
||||
}
|
||||
|
||||
public partial class Program;
|
||||
|
||||
41
README.md
41
README.md
@@ -6,35 +6,61 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
|
||||
|
||||
1. Restore and build:
|
||||
`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`
|
||||
3. Run locally:
|
||||
4. Run locally:
|
||||
`dotnet run --project GameList.csproj`
|
||||
4. Open:
|
||||
5. Open:
|
||||
`http://localhost:5000` (or the URL shown by `dotnet run`)
|
||||
|
||||
## Frontend Tooling
|
||||
|
||||
- Install tooling: `npm install`
|
||||
- Generate API client from OpenAPI: `npm run generate:api-client` (expects `openapi/GameList.json` generated by `dotnet build`)
|
||||
- Lint JS: `npm run lint`
|
||||
- Check formatting: `npm run format:check`
|
||||
- Apply formatting: `npm run format`
|
||||
|
||||
## Core Behavior
|
||||
|
||||
- Authentication: username/password with HttpOnly `player` cookie.
|
||||
- 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`.
|
||||
- Realtime sync: `/api/events/state` (SSE) plus `ETag`-based conditional `/api/state` reads to reduce polling load.
|
||||
- Storage: SQLite database under `App_Data/gamelist.db`.
|
||||
- Migrations are deployment-time operations (`dotnet ef database update`); app startup does not auto-migrate.
|
||||
- Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement.
|
||||
- CSRF baseline: authenticated mutating API requests require same-origin `Origin`/`Referer` headers.
|
||||
- Password hashes are versioned and upgraded on successful login/admin-password verification; current rollout uses Argon2id for new hashes while transparently upgrading legacy PBKDF2 hashes.
|
||||
|
||||
## Password Hash Migration Plan
|
||||
|
||||
1. Existing hashes remain valid under versioned verification (`LegacyVersion=1`).
|
||||
2. Successful authentication transparently rehashes credentials to `CurrentVersion=3` (Argon2id) and persists the upgraded hash metadata.
|
||||
3. Legacy versions can be retired after full rollout once no remaining accounts depend on them.
|
||||
|
||||
## Module Ownership
|
||||
|
||||
- `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.
|
||||
- `Data/`: EF Core `DbContext` and migrations.
|
||||
- `Domain/`: entities and enums.
|
||||
- `Contracts/`: request/response DTOs.
|
||||
- `wwwroot/`: static frontend assets.
|
||||
- `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
|
||||
|
||||
- API surface and endpoint contract: `API.md`
|
||||
- Generated OpenAPI document: `openapi/GameList.json` (runtime: `/openapi/v1.json`)
|
||||
- Product/feature expectations: `SPEC.md`
|
||||
- IIS deployment notes: `IIS.md`
|
||||
- Test strategy details: `TESTS.md`
|
||||
@@ -45,4 +71,7 @@ GitHub Actions workflow: `.github/workflows/ci.yml`
|
||||
|
||||
- Restores dependencies
|
||||
- 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%)
|
||||
|
||||
318
REVIEW.md
318
REVIEW.md
@@ -1,231 +1,165 @@
|
||||
# Maintainability Review - Pick'n'Play
|
||||
# Review - Pick'n'Play (State-of-the-Art Assessment)
|
||||
|
||||
## A) Executive summary
|
||||
Date: 2026-02-18
|
||||
|
||||
This codebase is functional and reasonably tested on backend behavior, but long-term change safety is currently limited by concentration risk (god modules), hidden side effects in read paths, and drifting operational contracts.
|
||||
## Scope
|
||||
|
||||
Progress update (as of February 6, 2026):
|
||||
- Completed: phase reads are side-effect free with explicit reconciliation on write routes (`Endpoints/EndpointHelpers.cs:37`, `Endpoints/EndpointHelpers.cs:61`, `Endpoints/StateEndpoints.cs:62`).
|
||||
- Completed: admin auth docs aligned to account-based admin sessions (`API.md:3`).
|
||||
- Completed: build/test guardrails added (`.github/workflows/ci.yml`) and root ownership/setup docs added (`README.md:1`).
|
||||
- Completed: backend validators centralized for suggestions and auth (`Endpoints/SuggestionValidator.cs:7`, `Endpoints/AuthValidator.cs:11`).
|
||||
- Completed: request safety hardened for redirects and forwarded headers (`Program.cs:40`, `Program.cs:104`, `Endpoints/EndpointHelpers.cs:105`, `GameList.Tests/HelperTests.cs:121`, `GameList.Tests/HelperTests.cs:219`).
|
||||
- Evaluated backend (`Program.cs`, `Endpoints/*`, `Infrastructure/*`, `Data/*`), frontend (`wwwroot/*`), and CI/deployment scripts.
|
||||
- Focused on risks in maintainability, extensibility, scalability, and security.
|
||||
|
||||
Top 5 maintainability risks (priority order):
|
||||
## Executive summary
|
||||
|
||||
1. Frontend module concentration and global coupling (Critical)
|
||||
- `wwwroot/js/ui.js` is still the dominant hotspot and owns rendering, validation, modal orchestration, admin flows, and vote logic.
|
||||
- Hidden module coupling through globals: `wwwroot/js/data.js:131`-`wwwroot/js/data.js:134`, plus `window` callbacks consumed in `wwwroot/js/ui.js:473`, `wwwroot/js/ui.js:696`, `wwwroot/js/ui.js:1009`.
|
||||
- Impact: hard-to-debug regressions and fragile refactors in UI workflows.
|
||||
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).
|
||||
|
||||
2. Rule duplication still present between backend/frontend validations (High)
|
||||
- Suggestion validation is centralized on the backend (`Endpoints/SuggestEndpoints.cs:45`, `Endpoints/SuggestEndpoints.cs:133`, `Endpoints/SuggestionValidator.cs:7`) but frontend still duplicates parts (`wwwroot/js/ui.js:648`, `wwwroot/js/ui.js:1019`).
|
||||
- Auth validation is centralized on the backend (`Endpoints/AuthEndpoints.cs:18`, `Endpoints/AuthEndpoints.cs:65`, `Endpoints/AuthValidator.cs:11`) while frontend length checks remain duplicated (`wwwroot/app.js:92`, `wwwroot/app.js:121`).
|
||||
- Impact: inconsistent behavior and repeated fixes across server/client.
|
||||
## Findings
|
||||
|
||||
3. High-change, high-complexity frontend hotspots (High)
|
||||
- Git churn: `wwwroot/app.js` (76 changes), `wwwroot/js/ui.js` (55), `wwwroot/js/i18n.js` (50).
|
||||
- `wwwroot/js/ui.js` is 1123 lines and owns rendering, validation, modal orchestration, admin flows, and vote logic.
|
||||
- Hidden module coupling through globals: `wwwroot/js/data.js:131`-`wwwroot/js/data.js:134`, plus `window` callbacks consumed in `wwwroot/js/ui.js:473`, `wwwroot/js/ui.js:696`, `wwwroot/js/ui.js:1009`.
|
||||
- Impact: every UI change risks regressions outside its feature area.
|
||||
### 1) High - Scalability - Single-node SQLite bottleneck
|
||||
|
||||
4. Service-layer extraction is still pending in large endpoint files (High)
|
||||
- Endpoint lambdas still own orchestration and persistence logic in `Endpoints/SuggestEndpoints.cs`, `Endpoints/AdminEndpoints.cs`, `Endpoints/VoteEndpoints.cs`, and `Endpoints/ResultsEndpoints.cs`.
|
||||
- Impact: high cognitive load and slower, riskier feature changes.
|
||||
Evidence:
|
||||
- SQLite is the primary DB (`Program.cs:42`).
|
||||
|
||||
5. Static-analysis and frontend lint guardrails remain incomplete (Medium)
|
||||
- Build/test CI exists (`.github/workflows/ci.yml`) and project content rules are fixed (`GameList.csproj:17`-`GameList.csproj:21`), but analyzers/lint/format gates are still absent.
|
||||
- Impact: regressions and style drift can still slip through.
|
||||
Risk:
|
||||
- SQLite is excellent for small single-node deployments, but write concurrency and horizontal scale are limited for larger or bursty usage.
|
||||
|
||||
Where to start (recommended sequence):
|
||||
- Start with P0 tasks that reduce surprise and operational risk: phase-read side effects, auth contract drift, build hygiene, and validation centralization.
|
||||
- Apply these guiding principles:
|
||||
- Keep reads pure and writes explicit.
|
||||
- Consolidate business rules in one place per rule.
|
||||
- Reduce module fan-in/fan-out before adding features.
|
||||
- Add small guardrails (build warnings, CI gates) before larger refactors.
|
||||
Alternative:
|
||||
- Keep SQLite for local/dev and migrate production to PostgreSQL/SQL Server with provider-specific migrations and connection pooling.
|
||||
|
||||
Assumptions and validation:
|
||||
- Assumption A1: local git history reflects meaningful churn/risk. Validate by comparing with remote `main`/PR stats if different branch practices exist.
|
||||
- Assumption A2: build warnings are reproducible environment signals, not one-off file locks. Validate by running clean build in CI and on a fresh clone.
|
||||
### 2) High - Scalability - Polling causes read amplification
|
||||
|
||||
## B) Maintainability map
|
||||
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`).
|
||||
|
||||
Major modules/components and responsibilities:
|
||||
- `Program.cs`: app bootstrap, infrastructure wiring, middleware order, DB migration on startup, route registration.
|
||||
- `Endpoints/*.cs`: HTTP endpoint definitions and most business logic (auth, phase, suggestions, votes, results, admin).
|
||||
- `Endpoints/EndpointHelpers.cs`: cross-cutting helper hub (auth lookup, phase alignment, URL checks, image probing, link graph utilities).
|
||||
- `Infrastructure/*.cs`: filters/middleware/auth helpers (`AdminOnlyFilter`, phase filters, cookie identity helpers, global exception wrapper).
|
||||
- `Data/AppDbContext.cs`: EF Core model and persistence mappings.
|
||||
- `Domain/*.cs`: entity data structures (`Player`, `Suggestion`, `Vote`, `AppState`, `Phase`).
|
||||
- `wwwroot/app.js` + `wwwroot/js/*.js`: frontend orchestration, shared state, API calls, rendering, i18n, effects.
|
||||
- `GameList.Tests/*.cs`: integration-heavy endpoint tests plus helper/unit tests.
|
||||
- `scripts/*.ps1`: deployment automation.
|
||||
Risk:
|
||||
- As concurrent users increase, backend read load grows quickly and mostly serves unchanged data.
|
||||
|
||||
Boundary quality:
|
||||
- Backend boundaries are leaky: endpoint layer owns domain rules, persistence orchestration, and security checks directly.
|
||||
- `Infrastructure` depends on `Endpoints` (`Infrastructure/PhaseRequirementFilter.cs:3`) while `Endpoints` depend back on `Infrastructure` (`Endpoints/EndpointHelpers.cs:22`), creating conceptual circular ownership.
|
||||
- Frontend boundaries are leaky: `ui.js` mixes rendering, form validation, and server mutation calls; shared mutable state is directly mutated across modules.
|
||||
Alternative:
|
||||
- Move to event-driven updates (SSE/WebSocket) plus conditional GET (`ETag`/`If-None-Match`) and/or a consolidated bootstrap endpoint.
|
||||
|
||||
Worst coupling points:
|
||||
- `Endpoints/EndpointHelpers.cs` (84 call sites): de facto god module.
|
||||
- `wwwroot/js/ui.js` + global `state` object (131 direct state references across JS modules).
|
||||
- Suggestion and phase workflows span endpoint functions, helper functions, filters, and duplicated client-side checks.
|
||||
- Operational behavior is split across `API.md` and runtime filters with contradictory contracts.
|
||||
### 3) High - Security - CSRF protection is implicit, not explicit
|
||||
|
||||
## C) Critical task list
|
||||
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.
|
||||
|
||||
[P0][Done] Make phase reads side-effect free and move reconciliation to explicit writes
|
||||
- Problem: Severity `Critical`, Category `Architecture`. Read endpoints/filters previously relied on mutating phase reads. Impact: unsafe refactors and non-deterministic behavior.
|
||||
- Evidence: `Endpoints/EndpointHelpers.cs:37`, `Endpoints/EndpointHelpers.cs:61`, `Endpoints/StateEndpoints.cs:20`, `Infrastructure/PhaseRequirementFilter.cs:17`, `Endpoints/ResultsEndpoints.cs:26`, `GameList.Tests/StateTests.cs:236`, `GameList.Tests/FiltersTests.cs:55`.
|
||||
- Recommendation: Split into `GetCurrentPhaseAsync` (pure read) and explicit `ReconcilePhaseAsync` (write command). Run reconciliation only on intentional transition points (admin toggle, phase change commands, migration job), not on GET paths.
|
||||
- Acceptance criteria (testable): GET `/api/state` and GET `/api/me` never call `SaveChangesAsync`; integration tests verify no phase mutations occur during read-only requests; filters perform one phase check path without side effects.
|
||||
- Effort / Risk: `M / Med`.
|
||||
- Dependencies (if any): none.
|
||||
Risk:
|
||||
- `SameSite=Strict` helps (`Program.cs:104`) but is not a full long-term CSRF strategy across browser/proxy edge cases.
|
||||
|
||||
[P0][Done] Repair admin auth contract drift across code, docs, and smoke automation
|
||||
- Problem: Severity `High`, Category `Documentation/Tooling`. Documentation and smoke script specify header-based admin auth, runtime requires authenticated admin cookie. Impact: runbooks are misleading during incidents/releases.
|
||||
- Evidence: `API.md:3`, `Infrastructure/AdminOnlyFilter.cs:12`.
|
||||
- Recommendation: Make one contract authoritative (account-based admin role), update docs to follow it, and add one integration smoke test path that validates real auth flow.
|
||||
- Acceptance criteria (testable): `API.md` no longer references `X-Admin-Key`; one automated test verifies admin route rejection/acceptance behavior.
|
||||
- Effort / Risk: `S / Low`.
|
||||
- Dependencies (if any): none.
|
||||
Alternative:
|
||||
- Add explicit anti-forgery tokens for mutating requests (or move to bearer tokens for API calls) and verify origin headers server-side.
|
||||
|
||||
[P0][Done] Eliminate build instability and warning noise from project layout/content rules
|
||||
- Problem: Severity `High`, Category `Tooling`. Current build emits MSB3026 warnings and recursive copy paths under test output, reducing trust in build outputs and masking real issues.
|
||||
- Evidence: content exclusions now include `Content` in `GameList.csproj:17`-`GameList.csproj:21`; CI gate added in `.github/workflows/ci.yml`.
|
||||
- Recommendation: Ensure test assets are fully excluded from web content pipeline (or move tests outside web project root), then enforce clean build (`0 warnings`) in CI.
|
||||
- Acceptance criteria (testable): `dotnet build GameList.sln` emits zero warnings; publish output contains only expected runtime artifacts; CI fails on warning regressions.
|
||||
- Effort / Risk: `M / Med`.
|
||||
- Dependencies (if any): none.
|
||||
### 4) High - Extensibility - Workflow is hard-coded across backend and frontend
|
||||
|
||||
[P0][Partial] Centralize validation rules to stop backend/frontend drift
|
||||
- Problem: Severity `High`, Category `Complexity/Duplication`. Validation rules are duplicated in multiple backend endpoints and frontend forms. Impact: inconsistent behavior and repeated fixes.
|
||||
- Evidence: backend centralized in `Endpoints/SuggestEndpoints.cs:45`, `Endpoints/SuggestEndpoints.cs:133`, `Endpoints/SuggestionValidator.cs:7`, `Endpoints/AuthEndpoints.cs:18`, `Endpoints/AuthEndpoints.cs:65`, `Endpoints/AuthValidator.cs:11`; frontend duplicates remain in `wwwroot/js/ui.js:648`, `wwwroot/js/ui.js:1019`, `wwwroot/app.js:92`.
|
||||
- Recommendation: Extract backend validators (e.g., `SuggestionValidator`, `AuthValidator`) and reuse in create/update paths; simplify frontend to UX-only prechecks and rely on server responses for source-of-truth.
|
||||
- Acceptance criteria (testable): create/update share one backend validator path; tests cover validator once and both endpoints; frontend no longer re-implements server-only security rules.
|
||||
- Effort / Risk: `M / Med`.
|
||||
- Dependencies (if any): none.
|
||||
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`).
|
||||
|
||||
[P0][Done] Harden request safety defaults (forwarded headers and redirect handling)
|
||||
- Problem: Severity `High`, Category `Security`. Forwarded headers are trusted without explicit proxy/network allowlist, and image validation likely follows redirects despite "no redirects" policy.
|
||||
- Evidence: `Program.cs:40`, `Program.cs:70`, `Program.cs:104`, `Endpoints/EndpointHelpers.cs:105`, `GameList.Tests/HelperTests.cs:121`, `GameList.Tests/HelperTests.cs:219`, `IIS.md:17`.
|
||||
- Recommendation: Configure known proxies/networks for forwarded headers; enforce `AllowAutoRedirect = false` in image validation client and add tests for redirect-chain and private-host edge cases.
|
||||
- Acceptance criteria (testable): integration tests prove redirected URLs are rejected; forwarded header spoofing test fails when source is untrusted; documentation updated with trusted proxy requirements.
|
||||
- Effort / Risk: `M / Med`.
|
||||
- Dependencies (if any): none.
|
||||
Risk:
|
||||
- Adding a new phase or changing transitions requires touching many scattered branches, increasing regression risk.
|
||||
|
||||
[P1] Extract service-layer workflows from endpoint lambdas
|
||||
- Problem: Severity `High`, Category `Architecture`. Endpoint files contain business orchestration, persistence, and policy logic inline; large lambdas are hard to reason about and reuse.
|
||||
- Evidence: `Endpoints/SuggestEndpoints.cs:43`, `Endpoints/AdminEndpoints.cs:105`, `Endpoints/VoteEndpoints.cs:35`, `Endpoints/ResultsEndpoints.cs:30`.
|
||||
- Recommendation: Introduce focused application services (`SuggestionService`, `VoteService`, `AdminWorkflowService`) and keep endpoints as transport adapters.
|
||||
- Acceptance criteria (testable): endpoint handlers reduced to routing + DTO mapping + service calls; domain rule tests target service methods directly; endpoint tests remain green.
|
||||
- Effort / Risk: `L / Med`.
|
||||
- Dependencies (if any): P0 phase-read cleanup recommended first.
|
||||
Alternative:
|
||||
- Introduce a shared workflow/state-machine model (transition table) and consume it in both backend and frontend.
|
||||
|
||||
[P1] Decompose frontend UI monolith and remove `window` cross-module hooks
|
||||
- Problem: Severity `High`, Category `Architecture/Complexity`. UI logic is concentrated in `ui.js`; global `window` callbacks hide dependencies and increase accidental complexity.
|
||||
- Evidence: `wwwroot/js/ui.js:390`, `wwwroot/js/data.js:131`, `wwwroot/js/data.js:134`, `wwwroot/js/ui.js:473`, `wwwroot/js/ui.js:1009`.
|
||||
- Recommendation: Split UI by feature (`suggestions-ui`, `votes-ui`, `admin-ui`, `modals-ui`) and use explicit imports/events instead of `window` globals.
|
||||
- Acceptance criteria (testable): no feature code depends on `window.refreshPhaseData`/`window.loadVoteData`; module boundaries documented; smoke behavior unchanged.
|
||||
- Effort / Risk: `L / Med`.
|
||||
- Dependencies (if any): none.
|
||||
### 5) High - Extensibility - Role model is fixed to booleans
|
||||
|
||||
[P1] Replace uncontrolled polling with serialized refresh scheduling
|
||||
- Problem: Severity `Medium`, Category `Reliability/Complexity`. A fixed 4-second interval can overlap async refreshes and race state updates.
|
||||
- Evidence: `wwwroot/app.js:293`-`wwwroot/app.js:297`, `wwwroot/js/data.js:82`.
|
||||
- Recommendation: Add a scheduler that avoids overlapping refreshes (single-flight), pauses when tab hidden, and supports explicit event-triggered refresh.
|
||||
- Acceptance criteria (testable): at most one in-flight refresh at any time; no duplicate toasts/state flicker during slow network simulation; tests for scheduler behavior.
|
||||
- Effort / Risk: `M / Low`.
|
||||
- Dependencies (if any): none.
|
||||
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`).
|
||||
|
||||
[P1] Remove legacy/dead paths to reduce cognitive load
|
||||
- Problem: Severity `Medium`, Category `Other`. Legacy `Reveal` phase and dead UI hooks remain in active code, increasing confusion.
|
||||
- Evidence: `Domain/Phase.cs:6`, `Endpoints/StateEndpoints.cs:107`, `wwwroot/js/data.js:30`, `wwwroot/js/ui.js:156`, `wwwroot/js/ui.js:1191`.
|
||||
- Recommendation: Remove obsolete phase enum/value handling and dead UI references (`all-suggestions`, `nav-vote-next`).
|
||||
- Acceptance criteria (testable): no references to removed phase/UI ids remain; tests validate expected phase transitions only (`Suggest`, `Vote`, `Results`).
|
||||
- Effort / Risk: `S / Low`.
|
||||
- Dependencies (if any): P0 phase cleanup.
|
||||
Risk:
|
||||
- Future needs (moderator, read-only admin, per-feature permissions) require schema and logic rewrites instead of additive changes.
|
||||
|
||||
[P1] Make write workflows transaction-consistent and explicit
|
||||
- Problem: Severity `Medium`, Category `Correctness/Architecture`. Several multi-step state changes rely on multiple DB commands without explicit transaction grouping.
|
||||
- Evidence: `Endpoints/SuggestEndpoints.cs:103`-`Endpoints/SuggestEndpoints.cs:109`, `Endpoints/AdminEndpoints.cs:16`-`Endpoints/AdminEndpoints.cs:31`, `Endpoints/AdminEndpoints.cs:220`-`Endpoints/AdminEndpoints.cs:229`.
|
||||
- Recommendation: Wrap multi-entity updates in explicit transactions where consistency matters, or refactor into idempotent command handlers with compensating behavior.
|
||||
- Acceptance criteria (testable): fault-injection tests prove no partial state after exceptions; transaction boundaries documented per workflow.
|
||||
- Effort / Risk: `M / Med`.
|
||||
- Dependencies (if any): service-layer extraction helpful but not required.
|
||||
Alternative:
|
||||
- Move to role/permission tables (or claims-based capability model) and policy-based authorization.
|
||||
|
||||
[P1] Strengthen test quality for flaky/time-sensitive cases and security edges
|
||||
- Problem: Severity `Medium`, Category `Testing`. Some tests depend on sleeps and do not cover realistic redirect behavior or overlapping refresh flows.
|
||||
- Evidence: `GameList.Tests/SuggestionTests.cs:379`, `GameList.Tests/SuggestionTests.cs:575`, `GameList.Tests/HelperTests.cs:121`.
|
||||
- Recommendation: replace `Task.Delay` ordering checks with deterministic seeded timestamps where feasible; add explicit redirect-follow tests and concurrency-path tests.
|
||||
- Acceptance criteria (testable): no timing sleeps in endpoint tests for ordering; new tests cover redirect-chain rejection and race-sensitive refresh logic.
|
||||
- Effort / Risk: `M / Low`.
|
||||
- Dependencies (if any): P0 redirect-hardening task.
|
||||
### 6) Medium - Maintainability - Frontend is string-template heavy with global mutable state
|
||||
|
||||
[P2] Externalize i18n/FAQ content from executable JS modules
|
||||
- Problem: Severity `Low`, Category `Complexity/Documentation`. Translation and FAQ payloads are embedded in code, making review and localization hard.
|
||||
- Evidence: `wwwroot/js/i18n.js:1`-`wwwroot/js/i18n.js:799`.
|
||||
- Recommendation: move translation dictionaries and FAQ markdown into versioned JSON/MD assets, load via data module, keep code focused on i18n mechanics.
|
||||
- Acceptance criteria (testable): `i18n.js` contains behavior only; language assets are schema-validated; app renders identical strings.
|
||||
- Effort / Risk: `M / Low`.
|
||||
- Dependencies (if any): frontend module split helps.
|
||||
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`).
|
||||
|
||||
[P2] Improve repository-level engineering docs and ownership map
|
||||
- Problem: Severity `Low`, Category `Documentation`. There is no root README/architecture map/runbook tying module ownership, local setup, and deployment flow together.
|
||||
- Evidence: root README absent; operational docs fragmented across `API.md`, `SPEC.md`, `IIS.md`, `scripts/deploy-ftp.ps1`.
|
||||
- Recommendation: add concise `README.md` with architecture diagram, local run/test commands, operational boundaries, and links to deeper docs.
|
||||
- Acceptance criteria (testable): new contributors can run app + tests using README only; docs align with live auth/deploy behavior.
|
||||
- Effort / Risk: `S / Low`.
|
||||
- Dependencies (if any): P0 auth contract and build hygiene fixes.
|
||||
Risk:
|
||||
- Harder refactoring, weaker static guarantees, and easy XSS regressions when new contributors add templates.
|
||||
|
||||
## D) Quick wins vs strategic refactors
|
||||
Alternative:
|
||||
- Incrementally move to TypeScript + componentized rendering (or at minimum typed JSDoc + stricter lint rules + centralized safe render helpers).
|
||||
|
||||
Quick wins (hours to 1 day each):
|
||||
- Remove stale admin key wording in `API.md` and align with actual auth behavior.
|
||||
- Add `Content Remove="GameList.Tests\**\*"` (or equivalent) and verify clean build output.
|
||||
- Add a build check script that fails on warnings and run it locally/CI.
|
||||
- Completed: removed dead `DeletePlayerRequest` from `Contracts/Dtos.cs`.
|
||||
- Completed: removed unused endpoint handler parameters in `Endpoints/StateEndpoints.cs`.
|
||||
- Remove dead UI references (`all-suggestions`, `nav-vote-next`) or add explicit TODO with owner/date.
|
||||
- Replace `Task.Delay` test ordering hacks with deterministic setup in affected tests.
|
||||
- Completed: added module ownership section in `README.md`.
|
||||
### 7) Medium - Scalability/Security - In-memory dictionaries are unbounded
|
||||
|
||||
Strategic refactors (multi-day/week), staged:
|
||||
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`).
|
||||
|
||||
1) Phase and policy workflow refactor
|
||||
- Stage 1: introduce pure phase reader API and separate reconciliation command.
|
||||
- Stage 2: migrate filters/endpoints to pure reads; remove write side effects from GET.
|
||||
- Stage 3: enforce via tests that read endpoints do not mutate persistence.
|
||||
Risk:
|
||||
- High-cardinality traffic can grow memory and become a denial-of-service vector.
|
||||
|
||||
2) Backend application service extraction
|
||||
- Stage 1: extract suggestion create/update/delete logic to one service.
|
||||
- Stage 2: extract vote/link/unlink workflows with explicit transaction semantics.
|
||||
- Stage 3: standardize endpoint responses with typed DTOs/ProblemDetails.
|
||||
Alternative:
|
||||
- Replace with bounded `MemoryCache` (size limits + eviction) or distributed cache (Redis) with TTL and cardinality controls.
|
||||
|
||||
3) Frontend architecture split
|
||||
- Stage 1: split `ui.js` into modal/forms/vote/admin render modules without behavior changes.
|
||||
- Stage 2: remove `window.*` bridges and introduce explicit orchestration module.
|
||||
- Stage 3: add lightweight frontend tests for state transitions and API error handling.
|
||||
### 8) Medium - Scalability - Linking/results workflows load full sets into memory
|
||||
|
||||
4) Engineering guardrail rollout
|
||||
- Stage 1: introduce analyzer/lint config and formatting scripts.
|
||||
- Stage 2: add CI pipeline gates (build, test, coverage, vulnerability check).
|
||||
- Stage 3: enable warning budgets and file-size complexity thresholds for hotspot files.
|
||||
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`).
|
||||
|
||||
## E) Suggested guardrails
|
||||
Risk:
|
||||
- Memory and query cost rise non-linearly with larger datasets.
|
||||
|
||||
Recommended tooling and standards:
|
||||
- C# static analysis: enable .NET analyzers (`AnalysisLevel latest`), nullable warnings strict, and treat warnings as errors in CI.
|
||||
- JS quality: add ESLint + Prettier for `wwwroot/**/*.js` and fail CI on lint errors.
|
||||
- Contract safety: standardize error responses using RFC7807 `ProblemDetails` and validate API DTO schema.
|
||||
- CI gates: run `dotnet build`, `dotnet test GameList.Tests/GameList.Tests.csproj`, coverage collection, and `dotnet list ... --vulnerable`.
|
||||
- Churn guardrails: add a simple check warning when files exceed thresholds (e.g., >500 LOC for JS UI modules, >250 LOC endpoint files).
|
||||
- Documentation gate: PR checklist requires updates to `API.md`/runbooks when auth/endpoints/deploy scripts change.
|
||||
- Security defaults: require known proxy config for forwarded headers and explicit no-redirect HTTP client behavior for external URL validation.
|
||||
Alternative:
|
||||
- Introduce persisted link-group IDs and push aggregation to SQL; add pagination/windowing for large result sets.
|
||||
|
||||
Pragmatic coding standards to prevent backsliding:
|
||||
- Keep endpoint handlers transport-focused; place business rules in services/validators.
|
||||
- Keep reads side-effect free; state changes only in explicit commands.
|
||||
- Avoid duplicate rule definitions across server/client; one source of truth per rule.
|
||||
- Prefer typed DTOs over anonymous response shapes for non-trivial payloads.
|
||||
### 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.
|
||||
|
||||
12
SPEC.md
12
SPEC.md
@@ -10,12 +10,16 @@ Help a small Discord group (4–8 players) pick a co-op game via phased flow:
|
||||
- Single shared instance
|
||||
- Username/password login (cookie auth)
|
||||
- Admins flagged via admin key at registration
|
||||
- Per-user phase tracking; admins can move themselves backward, everyone can move forward (subject to admin “results open” toggle)
|
||||
- Logout returns to the login form and clears all auth form fields
|
||||
- Destructive admin actions (player delete, reset, factory reset) require admin password confirmation
|
||||
- Per-user phase tracking; admins can move themselves backward, everyone can move forward (subject to admin “results open” toggle and Suggest→Vote requiring at least one own suggestion)
|
||||
|
||||
## Suggest Phase
|
||||
- Up to **5 suggestions** per player
|
||||
- Name required; optional genre, description, screenshot URL, YouTube URL, external game link, min/max players
|
||||
- Players see only their own suggestions until voting
|
||||
- A player can enter Vote only after submitting at least one own suggestion
|
||||
- The Suggest phase shows a non-interactive “add a game first” hint until the first successful suggestion, then immediately shows the `Next` button
|
||||
- Screenshots validated as reachable images
|
||||
|
||||
## Vote Phase
|
||||
@@ -24,11 +28,17 @@ Help a small Discord group (4–8 players) pick a co-op game via phased flow:
|
||||
- Players see only their own votes; can finalize/unfinalize their ballot
|
||||
- **Linked games**: admins can link duplicates; linked games share a vote group. Moving a slider on one updates all linked siblings.
|
||||
- Linking or unlinking games clears votes for the linked group and unfinalizes **all** players so ballots can be reviewed again
|
||||
- Admin status controls can move a player from Vote back to Suggest for exceptional cases
|
||||
- The “new/linked games” vote popup appears only when the vote list changes after the player has already seen that vote list
|
||||
|
||||
## Results Phase
|
||||
- Visible only after admin enables results; players auto-advance when opened
|
||||
- Admin controls results availability with a single toggle button whose label reflects enabled/disabled state
|
||||
- Leaderboard sorted by average score; shows totals, counts, player’s own vote, and links/media
|
||||
- Average score and score emojis expose the same tooltip showing the game's voters in alphabetical order
|
||||
- When results are closed again, only accounts with at least one suggestion return to Vote; accounts without suggestions return to Suggest
|
||||
|
||||
## Non-functional
|
||||
- Desktop + mobile friendly
|
||||
- Runs on IIS; SQLite via EF Core
|
||||
- Browser security baseline: strict CSP (no inline styles, no insecure image origins) and same-origin protection for authenticated mutating API requests
|
||||
|
||||
319
TECH.md
Normal file
319
TECH.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# TECH - Kickoff Blueprint from Pick'n'Play
|
||||
|
||||
This document extracts the implementation patterns, conventions, and guardrails from the current codebase so a new webapp can start with proven structure and avoid known pitfalls.
|
||||
|
||||
Scope covered:
|
||||
- Backend (`Program.cs`, `Endpoints/*`, `Infrastructure/*`, `Data/*`, `Domain/*`, `Contracts/*`)
|
||||
- Frontend (`wwwroot/*`)
|
||||
- Tooling and CI (`scripts/*`, `.github/workflows/ci.yml`, npm tooling)
|
||||
- Tests (`GameList.Tests/*`)
|
||||
- Review carryover (`REVIEW.md` unresolved topics)
|
||||
|
||||
## 1) Stack and baseline choices
|
||||
|
||||
- ASP.NET Core Minimal API on .NET 10.
|
||||
- EF Core + SQLite in current project (single-node deployment).
|
||||
- Cookie authentication (`HttpOnly`, `SameSite=Strict`, secure in production).
|
||||
- Static frontend (HTML/CSS/JS modules), no frontend framework.
|
||||
- OpenAPI generated from backend and consumed by generated JS client.
|
||||
- xUnit integration-heavy test suite with in-memory SQLite and coverage gates.
|
||||
|
||||
## 2) Architecture patterns to keep
|
||||
|
||||
### 2.1 API shape and layering
|
||||
|
||||
- Route mapping in thin endpoint modules (`MapXEndpoints` per feature area).
|
||||
- Domain logic in workflow services (`*WorkflowService`) instead of endpoint lambdas.
|
||||
- Service responses normalized via `ServiceResult<T>` + `ServiceError`, then mapped to HTTP at the edge.
|
||||
- Consistent `ProblemDetails` payloads with `error` extension for machine-usable errors.
|
||||
- Endpoint-level concerns handled by endpoint filters (`AdminOnlyFilter`, `PhaseRequirementFilter`, `PhaseOrJokerFilter`).
|
||||
|
||||
Keep this split:
|
||||
- Endpoint adapters: auth, deserialization, HTTP mapping only.
|
||||
- Workflow services: validation, query/update rules, transactions.
|
||||
- Helpers: shared utility and security-sensitive routines.
|
||||
|
||||
### 2.2 Middleware pipeline discipline
|
||||
|
||||
- Security and behavior depend on middleware ordering; keep explicit ordering.
|
||||
- Important current order:
|
||||
1. Forwarded headers
|
||||
2. Rate limiter
|
||||
3. HSTS + HTTPS redirect (prod)
|
||||
4. Security headers writer
|
||||
5. Base path
|
||||
6. Global exception handling
|
||||
7. Authentication
|
||||
8. Ensure player still exists
|
||||
9. CSRF origin/referer checks
|
||||
10. Authorization
|
||||
11. State change notifier middleware
|
||||
12. Static files
|
||||
13. Endpoint mapping
|
||||
|
||||
### 2.3 State synchronization
|
||||
|
||||
- Event-driven invalidation with SSE (`/api/events/state`) plus heartbeats.
|
||||
- Conditional reads for state (`ETag` + `If-None-Match`) to return `304`.
|
||||
- In-process notifier (`StateChangeNotifier`) with monotonic version and etag stamp.
|
||||
- Mutation middleware (`StateChangeNotificationMiddleware`) emits invalidation only for successful mutating API calls.
|
||||
|
||||
This pattern is a strong baseline for low to medium scale and should be the default in the new app.
|
||||
|
||||
### 2.4 Security baseline
|
||||
|
||||
- Cookie auth with short/medium session sliding expiration plus absolute lifetime cap.
|
||||
- Explicit same-origin CSRF checks for authenticated mutating API calls.
|
||||
- Rate limiting on auth-sensitive and admin-sensitive surfaces with custom `429` payload.
|
||||
- Security headers on all responses (`CSP`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`).
|
||||
- Forwarded headers restricted to configured trusted proxies/networks only.
|
||||
- Owner/admin protection rules enforced in business logic and DB constraints.
|
||||
- Destructive admin operations require password re-confirmation.
|
||||
- Password hashing is versioned and supports transparent upgrade on successful auth.
|
||||
- Current hash defaults to Argon2id, with legacy compatibility retained.
|
||||
|
||||
### 2.5 Data and invariants
|
||||
|
||||
- Strong DB model with explicit constraints:
|
||||
- Unique normalized username
|
||||
- Unique owner partial index
|
||||
- Unique vote key `(PlayerId, SuggestionId)`
|
||||
- Seeded singleton app state row
|
||||
- DB-level guardrails (trigger) complement app-level checks:
|
||||
- Suggestion limit enforced in DB (`suggestion_limit_exceeded`) to survive concurrent writes.
|
||||
- EF patterns:
|
||||
- `AsNoTracking()` for read-only queries
|
||||
- `ExecuteUpdateAsync` / `ExecuteDeleteAsync` for efficient bulk operations
|
||||
- Explicit transactions for multi-step destructive/admin operations
|
||||
- Conflict handling around unique constraints
|
||||
|
||||
### 2.6 Workflow and permission model
|
||||
|
||||
- Phases (`Suggest`, `Vote`, `Results`) drive endpoint access and UX behavior.
|
||||
- Effective phase can be derived from persisted phase + global `resultsOpen`.
|
||||
- Reconciliation helper functions centralize phase alignment rules.
|
||||
- Admin abilities are intentionally constrained per operation (e.g., only specific transitions allowed).
|
||||
|
||||
Keep centralization of workflow rules. Avoid spreading phase/permission checks inline.
|
||||
|
||||
### 2.7 Frontend architecture
|
||||
|
||||
- ES module split by concern:
|
||||
- API wrapper
|
||||
- Data loaders
|
||||
- UI composition
|
||||
- Feature-specific renderers/handlers
|
||||
- Shared utils and runtime dependency injection
|
||||
- Single runtime state object with deliberate clear/reset logic.
|
||||
- Refresh scheduler:
|
||||
- Serialized refreshes (no overlap)
|
||||
- Adaptive polling backoff
|
||||
- SSE-triggered immediate refresh for state mutations
|
||||
- Visibility-aware refresh suppression
|
||||
- API client is generated from OpenAPI operation ids, not handwritten endpoints.
|
||||
- Internationalization:
|
||||
- translation file validation at startup
|
||||
- language-specific FAQ markdown loading with fallback to default language
|
||||
|
||||
### 2.8 Frontend safety and rendering hygiene
|
||||
|
||||
- Safe rendering helpers:
|
||||
- `escapeHtml` for template interpolation
|
||||
- `safeUrl` for links/media
|
||||
- Sensitive modal content set via `textContent`, not interpolated HTML.
|
||||
- Trusted output patterns covered by regression tests.
|
||||
|
||||
Maintain this as a non-negotiable standard for any user-supplied content path.
|
||||
|
||||
### 2.9 Testing strategy patterns
|
||||
|
||||
- Full-stack integration tests via `WebApplicationFactory`.
|
||||
- Real migrations applied to in-memory SQLite during test host startup.
|
||||
- HTTP side effects mocked deterministically (`StubHttpMessageHandler` and `IHttpClientFactory` replacement).
|
||||
- Coverage-focused tests for:
|
||||
- auth/security rules
|
||||
- middleware behavior
|
||||
- filter behavior
|
||||
- link/vote/result edge cases
|
||||
- OpenAPI operation id stability
|
||||
- CI-local parity script (`scripts/ci-local.ps1`) mirrors pipeline flow.
|
||||
|
||||
### 2.10 Tooling and contract discipline
|
||||
|
||||
- OpenAPI generated during build (`openapi/GameList.json`).
|
||||
- JS client generated from OpenAPI with required operation-id checks.
|
||||
- Separate lint + format + tests + coverage threshold checks.
|
||||
- Build configured with warnings as errors in CI/local script.
|
||||
|
||||
## 3) Concrete feature set currently implemented
|
||||
|
||||
Use this as a reusable "starter scope menu" for the new app:
|
||||
|
||||
- Auth:
|
||||
- register/login/logout
|
||||
- owner bootstrap via admin key
|
||||
- immutable display name post-registration
|
||||
- auth options endpoint for registration UX
|
||||
- Identity/session:
|
||||
- cookie claim identity with admin claim
|
||||
- stale/deleted-account cookie invalidation
|
||||
- absolute session lifetime enforcement
|
||||
- State:
|
||||
- `/api/state`, `/api/me`
|
||||
- phase next/prev
|
||||
- SSE state invalidation
|
||||
- etag conditional state reads
|
||||
- Suggestions:
|
||||
- create/update/delete/mine/all
|
||||
- phase gating + admin override behavior
|
||||
- suggestion cap + joker path
|
||||
- link metadata exposure
|
||||
- screenshot URL and reachability validation
|
||||
- Votes:
|
||||
- vote upsert
|
||||
- finalize/unfinalize
|
||||
- linked suggestion vote fan-out
|
||||
- conflict handling on concurrent insert/update
|
||||
- Results:
|
||||
- gated by phase + admin-open flag
|
||||
- ordered leaderboard with aggregates and voter metadata
|
||||
- per-user own vote context
|
||||
- Admin:
|
||||
- results open/close toggle with phase realignment
|
||||
- vote status panel
|
||||
- joker grant
|
||||
- player phase correction
|
||||
- admin role grant/revoke (owner protected)
|
||||
- player delete with cascades and password confirmation
|
||||
- link/unlink suggestions with vote reset and unfinalize behavior
|
||||
- reset/factory reset with password confirmation
|
||||
|
||||
## 4) REVIEW.md unresolved topics -> new-project design defaults
|
||||
|
||||
This section translates outstanding review risks into early decisions for the new app.
|
||||
|
||||
### 4.1 Data store scalability
|
||||
|
||||
Review concern:
|
||||
- SQLite bottlenecks under higher write concurrency and multi-node scaling.
|
||||
|
||||
New-project default:
|
||||
- Start with PostgreSQL (or SQL Server) for production profile.
|
||||
- Keep provider abstraction and provider-specific migration strategy from day one.
|
||||
- Keep SQLite only as local dev/test convenience if needed.
|
||||
|
||||
### 4.2 Workflow extensibility
|
||||
|
||||
Review concern:
|
||||
- Workflow transitions are hard-coded in many places.
|
||||
|
||||
New-project default:
|
||||
- Define transitions in a single state-machine table/model.
|
||||
- Drive backend authorization and frontend navigation from same transition metadata.
|
||||
- Add tests that validate the transition table itself.
|
||||
|
||||
### 4.3 Authorization model growth
|
||||
|
||||
Review concern:
|
||||
- Role booleans (`IsAdmin`, `IsOwner`) limit future permission expansion.
|
||||
|
||||
New-project default:
|
||||
- Use role/permission tables or claims-based capabilities.
|
||||
- Keep owner as a protected capability, not a special-case boolean spread across code.
|
||||
- Use policy-based authorization with explicit capability names.
|
||||
|
||||
### 4.4 Frontend maintainability
|
||||
|
||||
Review concern:
|
||||
- String-template-heavy UI + global mutable state can become fragile.
|
||||
|
||||
New-project default:
|
||||
- Move to TypeScript (or strict JSDoc typing) early.
|
||||
- Keep module boundaries by feature.
|
||||
- Keep explicit escaping/safe-url guards and DOM `textContent` standards.
|
||||
- If not using framework, introduce a small typed view-model layer.
|
||||
|
||||
### 4.5 In-memory cache bounds
|
||||
|
||||
Review concern:
|
||||
- Unbounded dictionaries may grow under high-cardinality traffic.
|
||||
|
||||
New-project default:
|
||||
- Replace unbounded maps with bounded `MemoryCache` (size + TTL + eviction).
|
||||
- For distributed deployments, use Redis with cardinality and TTL controls.
|
||||
|
||||
### 4.6 Linking/results query scaling
|
||||
|
||||
Review concern:
|
||||
- Link and result workflows currently pull full sets into memory.
|
||||
|
||||
New-project default:
|
||||
- Persist link-group ids and compute aggregates in SQL.
|
||||
- Add pagination/windowing for large result sets.
|
||||
- Benchmark query plans on realistic volumes.
|
||||
|
||||
### 4.7 External URL validation latency
|
||||
|
||||
Review concern:
|
||||
- Reachability validation happens synchronously on write path.
|
||||
|
||||
New-project default:
|
||||
- Accept user URL quickly, validate asynchronously (job queue/background worker), store validation status.
|
||||
- Optionally proxy or prefetch media through controlled media service.
|
||||
|
||||
## 5) Topics from REVIEW that are already fixed and should be carried forward
|
||||
|
||||
- Polling amplification has been reduced through SSE + etag conditional reads.
|
||||
- CSRF protection is explicit (same-origin validation for authenticated mutating requests).
|
||||
- Password hashing is versioned and modernized (Argon2id current, transparent upgrades).
|
||||
- CSP is tightened compared to prior permissive baseline (no inline style allowance, no insecure image origins).
|
||||
- API drift risk reduced with OpenAPI generation + generated frontend client.
|
||||
|
||||
These are not optional add-ons; they should be baseline in the new app.
|
||||
|
||||
## 6) New-project starter checklist
|
||||
|
||||
- Bootstrap:
|
||||
- choose production DB provider first
|
||||
- define transition/state-machine model before endpoint coding
|
||||
- define permission/capability model before admin features
|
||||
- Security:
|
||||
- cookie or token strategy finalized with CSRF model
|
||||
- rate limiting partitions and thresholds defined
|
||||
- strict CSP and security headers in first commit
|
||||
- versioned password hashing with migration strategy
|
||||
- trusted proxy/host settings explicit
|
||||
- Contract:
|
||||
- OpenAPI generation enabled in build
|
||||
- generated client wired into frontend
|
||||
- operation-id stability tested
|
||||
- Data integrity:
|
||||
- enforce critical invariants both app-side and DB-side
|
||||
- transaction boundaries for multi-entity admin actions
|
||||
- Frontend:
|
||||
- module boundaries and state refresh model defined
|
||||
- escaping/url-safe helpers mandatory
|
||||
- i18n structure and fallback behavior in place
|
||||
- Testing:
|
||||
- integration test host with real migrations
|
||||
- deterministic stubs for network dependencies
|
||||
- coverage gate enforced in local + CI scripts
|
||||
|
||||
## 7) Keep/avoid quick reference
|
||||
|
||||
Keep:
|
||||
- Thin endpoints + workflow services.
|
||||
- Shared service result abstraction.
|
||||
- Explicit middleware order.
|
||||
- SSE + ETag state sync.
|
||||
- Generated API client from OpenAPI.
|
||||
- DB-enforced invariants.
|
||||
- Regression tests for security-sensitive UI rendering.
|
||||
|
||||
Avoid:
|
||||
- Hard-coded workflow transitions scattered in backend/frontend.
|
||||
- Boolean-only role model for long-term products.
|
||||
- Unbounded in-memory caches.
|
||||
- Synchronous external network checks on hot write paths.
|
||||
- Manual API contract duplication between docs/frontend/backend.
|
||||
|
||||
42
TESTS.md
42
TESTS.md
@@ -8,7 +8,7 @@ Purpose: full coverage of backend + critical UI flows using a mock (in-memory) S
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Unauthenticated visitor | No API access; only static assets | — | — | Health check only |
|
||||
| Player (non-admin) | Create/see own suggestions (≤5), edit all fields, delete own; can advance to Vote; title locks after leaving phase | View all suggestions, vote 0–10, finalize/unfinalize, use joker once to add a game; cannot go backward | Read leaderboard only when resultsOpen=true; no writes | Login/logout, read /state and /me |
|
||||
| Admin (isAdmin=true) | Same as player; may edit/delete any suggestion | All player actions; may grant jokers, link/unlink games, delete players | Open/close results; sees leaderboard like player | Toggle results, reset/factory-reset DB, fetch vote status, move self backward |
|
||||
| Admin (isAdmin=true) | Same as player; may edit/delete any suggestion | All player actions; may grant jokers, link/unlink games, delete players, move a voter back to Suggest | Open/close results; sees leaderboard like player | Toggle results, reset/factory-reset DB, fetch vote status, move self backward |
|
||||
|
||||
## Phase/Permission Chart (for tests)
|
||||
```mermaid
|
||||
@@ -32,14 +32,21 @@ stateDiagram-v2
|
||||
|
||||
### 1) Authentication & Identity
|
||||
- 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.
|
||||
- Successful login upgrades legacy password-hash versions to current Argon2id parameters.
|
||||
- Logout clears cookie.
|
||||
- EnsurePlayerExistsMiddleware: signed cookie for deleted player returns 401 and clears auth.
|
||||
- Cookie contains admin claim; non-admin cookie cannot access admin routes (401/403 via filter).
|
||||
|
||||
### 2) State & Phase Alignment (/api/state, /api/me)
|
||||
- /api/state returns player-specific phase, votesFinal, hasJoker, counts; unauthorized returns 401.
|
||||
- /api/state supports `ETag`/`If-None-Match` and returns 304 when unchanged.
|
||||
- /api/events/state (SSE) emits invalidation events after successful state mutations.
|
||||
- GetPhase auto-upgrades legacy Reveal -> Vote and realigns when resultsOpen toggles (to Results and back to Vote clearing votesFinal).
|
||||
- /me/phase/next: moves Suggest->Vote, Vote->Results only when resultsOpen true; clears votesFinal; rejects when results locked.
|
||||
- /me/phase/prev: admin only; moves back one step, clears votesFinal, rejects for player.
|
||||
@@ -48,9 +55,10 @@ stateDiagram-v2
|
||||
### 3) Suggestions
|
||||
- GET /mine returns only caller’s 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.
|
||||
- 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.
|
||||
- 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.
|
||||
- GET /all: accessible from Vote+, orders by CreatedAt, includes link metadata, enforces phase mismatch before Vote.
|
||||
|
||||
@@ -58,32 +66,46 @@ stateDiagram-v2
|
||||
- GET /mine: only in Vote, returns player votes; unauthorized/phase mismatch handled.
|
||||
- POST /: creates or updates vote; rejects score outside 0–10; 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.
|
||||
- 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.
|
||||
|
||||
### 5) Results
|
||||
- GET /api/results: requires auth, resultsOpen=true, phase=Results; returns ordered leaderboard with totals/count/avg, caller’s vote, link metadata, and handles empty vote lists (Average=0).
|
||||
- GET /api/results: requires auth, resultsOpen=true, phase=Results; returns ordered leaderboard with totals/count/avg, vote values, alphabetically sorted voter names, caller’s vote, link metadata, and handles empty vote lists (Average=0).
|
||||
- Phase mismatch and locked results return 400; unauthorized 401.
|
||||
|
||||
### 6) Admin Operations
|
||||
- POST /admin/results toggles resultsOpen and aligns all player phases (to Results or back to Vote clearing votesFinal); updates UpdatedAt.
|
||||
- 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.
|
||||
- DELETE /admin/players/{id}: removes player, cascades suggestions, breaks links to their suggestions, deletes related votes, wrapped in transaction.
|
||||
- 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.
|
||||
- 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/unlink-suggestions: only in Vote; clears parents for group, deletes votes in group, unfinalizes affected players; no-op safe when missing.
|
||||
- POST /admin/reset: wipes suggestions/votes, resets phases to Suggest, clears votesFinal/hasJoker, closes results, updates timestamp.
|
||||
- POST /admin/factory-reset: wipes all players/suggestions/votes/state; reseeds AppState with defaults; transactional.
|
||||
- POST /admin/reset: requires valid admin password; wipes suggestions/votes, resets phases to Suggest, clears votesFinal/hasJoker, closes results, updates timestamp.
|
||||
- POST /admin/factory-reset: requires valid admin password; wipes all players/suggestions/votes/state; reseeds AppState with defaults; transactional.
|
||||
|
||||
### 7) Infrastructure/Helpers
|
||||
- PasswordHasher: hash+verify roundtrip, rejects empty password, constant-time compare (FixedTimeEquals usage).
|
||||
- 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.
|
||||
- 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.
|
||||
- /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
|
||||
- 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.
|
||||
- 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.
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Default": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"AllowedHosts": "localhost;127.0.0.1;[::1]",
|
||||
"BasePath": "",
|
||||
"ConnectionStrings": {
|
||||
"Default": "Data Source=App_Data/gamelist.db"
|
||||
|
||||
17
deploy.ps1
Normal file
17
deploy.ps1
Normal 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
|
||||
21
eslint.config.js
Normal file
21
eslint.config.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import js from "@eslint/js";
|
||||
import globals from "globals";
|
||||
|
||||
export default [
|
||||
{
|
||||
files: ["wwwroot/**/*.js"],
|
||||
...js.configs.recommended,
|
||||
languageOptions: {
|
||||
...js.configs.recommended.languageOptions,
|
||||
ecmaVersion: 2024,
|
||||
sourceType: "module",
|
||||
globals: {
|
||||
...globals.browser,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...js.configs.recommended.rules,
|
||||
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
867
openapi/GameList.json
Normal file
867
openapi/GameList.json
Normal file
@@ -0,0 +1,867 @@
|
||||
{
|
||||
"openapi": "3.1.1",
|
||||
"info": {
|
||||
"title": "GameList | v1",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"paths": {
|
||||
"/health": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"GameList"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/options": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"operationId": "GetAuthOptions",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/register": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"operationId": "Register",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RegisterRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/login": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"operationId": "Login",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LoginRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/logout": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"operationId": "Logout",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/state": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"State"
|
||||
],
|
||||
"operationId": "GetState",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/events/state": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"State"
|
||||
],
|
||||
"operationId": "GetStateEvents",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/me": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"State"
|
||||
],
|
||||
"operationId": "GetMe",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/me/phase/next": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"State"
|
||||
],
|
||||
"operationId": "NextPhase",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/me/phase/prev": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"State"
|
||||
],
|
||||
"operationId": "PrevPhase",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/suggestions/mine": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Suggestions"
|
||||
],
|
||||
"operationId": "GetMySuggestions",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/suggestions": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Suggestions"
|
||||
],
|
||||
"operationId": "CreateSuggestion",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SuggestionRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/suggestions/{id}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Suggestions"
|
||||
],
|
||||
"operationId": "DeleteSuggestion",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"Suggestions"
|
||||
],
|
||||
"operationId": "UpdateSuggestion",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SuggestionRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/suggestions/all": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Suggestions"
|
||||
],
|
||||
"operationId": "GetAllSuggestions",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/votes/mine": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Votes"
|
||||
],
|
||||
"operationId": "GetMyVotes",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/votes": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Votes"
|
||||
],
|
||||
"operationId": "UpsertVote",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/VoteRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/votes/finalize": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Votes"
|
||||
],
|
||||
"operationId": "SetVotesFinalized",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/VoteFinalizeRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/results": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Results"
|
||||
],
|
||||
"operationId": "GetResults",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/results": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "SetResultsOpen",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ResultsOpenRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/vote-status": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "GetVoteStatus",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/joker": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "GrantJoker",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GrantJokerRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/player-phase": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "SetPlayerPhase",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SetPlayerPhaseRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/player-admin": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "SetPlayerAdmin",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SetPlayerAdminRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/players/{playerId}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "DeletePlayer",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "playerId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AdminPasswordRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/link-suggestions": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "LinkSuggestions",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LinkSuggestionsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/unlink-suggestions": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "UnlinkSuggestions",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnlinkSuggestionsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/reset": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "Reset",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AdminPasswordRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/factory-reset": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "FactoryReset",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AdminPasswordRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"AdminPasswordRequest": {
|
||||
"required": [
|
||||
"password"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"GrantJokerRequest": {
|
||||
"required": [
|
||||
"playerId"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"playerId": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
},
|
||||
"LinkSuggestionsRequest": {
|
||||
"required": [
|
||||
"sourceSuggestionId",
|
||||
"targetSuggestionId"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sourceSuggestionId": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"targetSuggestionId": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoginRequest": {
|
||||
"required": [
|
||||
"username",
|
||||
"password"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"password": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Phase": {
|
||||
"enum": [
|
||||
"Suggest",
|
||||
"Vote",
|
||||
"Results"
|
||||
]
|
||||
},
|
||||
"RegisterRequest": {
|
||||
"required": [
|
||||
"username",
|
||||
"password",
|
||||
"displayName",
|
||||
"adminKey"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"password": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"displayName": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"adminKey": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ResultsOpenRequest": {
|
||||
"required": [
|
||||
"resultsOpen"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resultsOpen": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SetPlayerAdminRequest": {
|
||||
"required": [
|
||||
"playerId",
|
||||
"isAdmin"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"playerId": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"isAdmin": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SetPlayerPhaseRequest": {
|
||||
"required": [
|
||||
"playerId",
|
||||
"phase"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"playerId": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"phase": {
|
||||
"$ref": "#/components/schemas/Phase"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SuggestionRequest": {
|
||||
"required": [
|
||||
"name",
|
||||
"genre",
|
||||
"description",
|
||||
"screenshotUrl",
|
||||
"youtubeUrl",
|
||||
"gameUrl",
|
||||
"minPlayers",
|
||||
"maxPlayers"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"genre": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"screenshotUrl": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"youtubeUrl": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"gameUrl": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"minPlayers": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"null",
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"maxPlayers": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"null",
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UnlinkSuggestionsRequest": {
|
||||
"required": [
|
||||
"suggestionId"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suggestionId": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"VoteFinalizeRequest": {
|
||||
"required": [
|
||||
"final"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"final": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"VoteRequest": {
|
||||
"required": [
|
||||
"suggestionId",
|
||||
"score"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suggestionId": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"score": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "GameList"
|
||||
},
|
||||
{
|
||||
"name": "Auth"
|
||||
},
|
||||
{
|
||||
"name": "State"
|
||||
},
|
||||
{
|
||||
"name": "Suggestions"
|
||||
},
|
||||
{
|
||||
"name": "Votes"
|
||||
},
|
||||
{
|
||||
"name": "Results"
|
||||
},
|
||||
{
|
||||
"name": "Admin"
|
||||
}
|
||||
]
|
||||
}
|
||||
1101
package-lock.json
generated
Normal file
1101
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
package.json
Normal file
17
package.json
Normal file
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "picknplay-frontend",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"generate:api-client": "node ./scripts/generate-api-client.mjs",
|
||||
"lint": "eslint \"wwwroot/**/*.js\"",
|
||||
"format": "prettier --write \"eslint.config.js\" \"wwwroot/**/*.js\"",
|
||||
"format:check": "prettier --check \"eslint.config.js\" \"wwwroot/**/*.js\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.21.0",
|
||||
"eslint": "9.21.0",
|
||||
"globals": "15.15.0",
|
||||
"prettier": "3.5.0"
|
||||
}
|
||||
}
|
||||
43
scripts/check-coverage.ps1
Normal file
43
scripts/check-coverage.ps1
Normal 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."
|
||||
}
|
||||
75
scripts/ci-local.ps1
Normal file
75
scripts/ci-local.ps1
Normal file
@@ -0,0 +1,75 @@
|
||||
param(
|
||||
[switch]$SkipNpmInstall,
|
||||
[switch]$SkipDotnetRestore,
|
||||
[switch]$SkipBuild
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Invoke-Step {
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$Name,
|
||||
[Parameter(Mandatory = $true)][scriptblock]$Action
|
||||
)
|
||||
|
||||
Write-Host "==> $Name"
|
||||
& $Action
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Step failed: $Name"
|
||||
}
|
||||
}
|
||||
|
||||
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||
$repoRoot = Split-Path -Parent $scriptDir
|
||||
|
||||
Push-Location $repoRoot
|
||||
try {
|
||||
if (-not $SkipNpmInstall) {
|
||||
Invoke-Step -Name "Install frontend tooling (npm install)" -Action {
|
||||
npm install
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $SkipDotnetRestore) {
|
||||
Invoke-Step -Name "Restore .NET solution" -Action {
|
||||
dotnet restore GameList.sln
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $SkipBuild) {
|
||||
Invoke-Step -Name "Build .NET solution (warnings as errors)" -Action {
|
||||
dotnet build GameList.sln --no-restore -warnaserror
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Generate frontend API client from OpenAPI" -Action {
|
||||
npm run generate:api-client
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Lint frontend" -Action {
|
||||
npm run lint
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Check frontend formatting" -Action {
|
||||
npm run format:check
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Run tests" -Action {
|
||||
if ($SkipBuild) {
|
||||
dotnet test GameList.Tests/GameList.Tests.csproj --verbosity normal --collect:"XPlat Code Coverage" --settings GameList.Tests/coverlet.runsettings
|
||||
}
|
||||
else {
|
||||
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."
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
32
scripts/deploy-ftp.profile.sample.psd1
Normal file
32
scripts/deploy-ftp.profile.sample.psd1
Normal 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"
|
||||
}
|
||||
@@ -1,157 +1,340 @@
|
||||
# Hard-coded deploy settings. Fill these in before running.
|
||||
$FtpHost = "xTr1m.com"
|
||||
$FtpUser = "xTr1m"
|
||||
$Password = $null # prompted at runtime
|
||||
$RemoteDir = "/httpdocs/picknplay"
|
||||
$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
|
||||
param(
|
||||
[string]$ProfilePath = (Join-Path $PSScriptRoot "deploy-ftp.profile.psd1"),
|
||||
[string]$Password,
|
||||
[switch]$SkipRecycle,
|
||||
[switch]$SkipMigrations
|
||||
)
|
||||
|
||||
<#!
|
||||
<#
|
||||
.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
|
||||
- Reads environment-specific settings from a PowerShell data file profile.
|
||||
- Builds with dotnet publish.
|
||||
- Uses WinSCP (ftp) to mirror publish output into $RemoteDir (deletes extraneous remote files).
|
||||
- Optionally recycles the IIS app pool remotely via WinRM (no RDP needed).
|
||||
|
||||
.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).
|
||||
- Uses WinSCP to mirror publish output into remote directory (deletes extraneous files).
|
||||
- Optionally recycles IIS app pool and runs EF migrations remotely over WinRM.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh ./scripts/deploy-ftp.ps1
|
||||
pwsh ./scripts/deploy-ftp.ps1 -ProfilePath ./scripts/deploy-ftp.profile.psd1
|
||||
#>
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
function Assert-Tool {
|
||||
param([string]$Name)
|
||||
param([Parameter(Mandatory = $true)][string]$Name)
|
||||
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"
|
||||
Assert-Tool $WinScpPath
|
||||
function Require-ConfigValue {
|
||||
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) {
|
||||
$pwd = Read-Host -Prompt $Prompt -AsSecureString
|
||||
$ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($pwd)
|
||||
try { return [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr) }
|
||||
try {
|
||||
return [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr)
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
$Password = Read-PlainOrPrompt $Password "Password" $true
|
||||
$WinRmAuth = "Basic" # Basic for local admin over HTTPS; use Default/Kerberos if joined to domain
|
||||
function Invoke-WinRmScript {
|
||||
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
|
||||
if (Test-Path $PublishDir) { Remove-Item $PublishDir -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
New-Item -ItemType Directory -Force -Path $PublishDir | Out-Null
|
||||
$publishArgs = @("publish", $ProjectPath, "-c", $Configuration, "-r", $Runtime, "-o", $PublishDir)
|
||||
if (-not $SelfContained) { $publishArgs += "--self-contained=false" }
|
||||
if (Test-Path $publishDir) {
|
||||
Remove-Item $publishDir -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
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
|
||||
|
||||
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
|
||||
$sec = ConvertTo-SecureString $Password -AsPlainText -Force
|
||||
$cred = New-Object pscredential($WinRmCredentialUser, $sec)
|
||||
$invokeParams = @{
|
||||
ComputerName = $WinRmComputer
|
||||
Credential = $cred
|
||||
ScriptBlock = {
|
||||
try {
|
||||
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
|
||||
param($poolName)
|
||||
Import-Module WebAdministration
|
||||
Stop-WebAppPool -Name $using:AppPoolName -ErrorAction SilentlyContinue
|
||||
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)
|
||||
}
|
||||
}
|
||||
if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true }
|
||||
if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth }
|
||||
try {
|
||||
Invoke-Command @invokeParams
|
||||
} catch {
|
||||
catch {
|
||||
Write-Warning "WinRM stop failed: $($_.Exception.Message)."
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "3) Syncing via WinSCP (FTP mirror with delete)..." -ForegroundColor Cyan
|
||||
$tempScript = New-TemporaryFile
|
||||
@"
|
||||
option batch continue
|
||||
option confirm off
|
||||
open ftp://$($FtpUser):$($Password.Replace('`n','').Replace('`r',''))@$FtpHost
|
||||
lcd $PublishDir
|
||||
cd $RemoteDir
|
||||
synchronize remote . -delete -filemask="|web.config;App_Data/;logs/;GameList.Tests/"
|
||||
exit
|
||||
"@ | Set-Content -Path $tempScript -Encoding UTF8
|
||||
Write-Host "3) Syncing via WinSCP..." -ForegroundColor Cyan
|
||||
$openCommand = if ($useStoredSession) {
|
||||
"open `"$winScpSessionName`""
|
||||
}
|
||||
else {
|
||||
$ftpUser = [Uri]::EscapeDataString([string]$config.FtpUser)
|
||||
$ftpPassword = [Uri]::EscapeDataString($passwordForSession.Replace("`n", "").Replace("`r", ""))
|
||||
$ftpHost = [string]$config.FtpHost
|
||||
"open ftp://$ftpUser`:$ftpPassword@$ftpHost"
|
||||
}
|
||||
|
||||
& $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
|
||||
|
||||
if ($RecycleAppPool) {
|
||||
if ($recycleAppPool) {
|
||||
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 {
|
||||
Invoke-Command @invokeParams
|
||||
} catch {
|
||||
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
|
||||
param($poolName)
|
||||
Import-Module WebAdministration
|
||||
Start-WebAppPool -Name $poolName
|
||||
} -ArgumentList @($appPoolName)
|
||||
}
|
||||
catch {
|
||||
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
|
||||
$sec = ConvertTo-SecureString $Password -AsPlainText -Force
|
||||
$cred = New-Object pscredential($WinRmCredentialUser, $sec)
|
||||
$invokeParams = @{
|
||||
ComputerName = $WinRmComputer
|
||||
Credential = $cred
|
||||
ScriptBlock = {
|
||||
try {
|
||||
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
|
||||
param($sitePath)
|
||||
Set-Location $sitePath
|
||||
if (-not (Get-Command dotnet ef -ErrorAction SilentlyContinue)) {
|
||||
throw "dotnet ef not available on remote host. Install SDK or set `$RunEfMigrations = $false."
|
||||
if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
|
||||
throw "dotnet is not available on remote host."
|
||||
}
|
||||
|
||||
dotnet ef database update --no-build
|
||||
} -ArgumentList @([string]$config.RemoteSitePath)
|
||||
}
|
||||
ArgumentList = @($RemoteSitePath)
|
||||
}
|
||||
if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true }
|
||||
if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth }
|
||||
try {
|
||||
Invoke-Command @invokeParams
|
||||
} catch {
|
||||
catch {
|
||||
Write-Warning "WinRM migrations failed: $($_.Exception.Message)."
|
||||
}
|
||||
}
|
||||
|
||||
14
scripts/deploy-ftp1.ps1
Normal file
14
scripts/deploy-ftp1.ps1
Normal 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
|
||||
209
scripts/generate-api-client.mjs
Normal file
209
scripts/generate-api-client.mjs
Normal file
@@ -0,0 +1,209 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import prettier from "prettier";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const repoRoot = path.resolve(__dirname, "..");
|
||||
const openApiPath = path.join(repoRoot, "openapi", "GameList.json");
|
||||
const outputPath = path.join(repoRoot, "wwwroot", "js", "api-client.generated.js");
|
||||
|
||||
const requiredOperationIds = [
|
||||
"GetAuthOptions",
|
||||
"Register",
|
||||
"Login",
|
||||
"Logout",
|
||||
"GetState",
|
||||
"GetStateEvents",
|
||||
"GetMe",
|
||||
"NextPhase",
|
||||
"PrevPhase",
|
||||
"GetMySuggestions",
|
||||
"CreateSuggestion",
|
||||
"DeleteSuggestion",
|
||||
"UpdateSuggestion",
|
||||
"GetAllSuggestions",
|
||||
"GetMyVotes",
|
||||
"UpsertVote",
|
||||
"SetVotesFinalized",
|
||||
"GetResults",
|
||||
"SetResultsOpen",
|
||||
"GetVoteStatus",
|
||||
"GrantJoker",
|
||||
"SetPlayerPhase",
|
||||
"SetPlayerAdmin",
|
||||
"DeletePlayer",
|
||||
"LinkSuggestions",
|
||||
"UnlinkSuggestions",
|
||||
"Reset",
|
||||
"FactoryReset",
|
||||
];
|
||||
|
||||
if (!fs.existsSync(openApiPath)) {
|
||||
throw new Error(`OpenAPI document not found at ${openApiPath}. Build the .NET solution first.`);
|
||||
}
|
||||
|
||||
const document = JSON.parse(fs.readFileSync(openApiPath, "utf8"));
|
||||
const operations = collectOperations(document);
|
||||
validateRequiredOperations(operations);
|
||||
|
||||
const generated = renderClient(operations);
|
||||
const prettierConfig =
|
||||
(await prettier.resolveConfig(outputPath, { editorconfig: true })) ?? {};
|
||||
const formatted = await prettier.format(generated, {
|
||||
...prettierConfig,
|
||||
filepath: outputPath,
|
||||
});
|
||||
fs.writeFileSync(outputPath, formatted, "utf8");
|
||||
console.log(`Generated ${path.relative(repoRoot, outputPath)} from ${path.relative(repoRoot, openApiPath)}`);
|
||||
|
||||
function collectOperations(openApiDocument) {
|
||||
const methods = ["get", "post", "put", "delete", "patch"];
|
||||
const entries = [];
|
||||
|
||||
for (const [routePath, pathItem] of Object.entries(openApiDocument.paths ?? {})) {
|
||||
for (const method of methods) {
|
||||
const operation = pathItem?.[method];
|
||||
if (!operation?.operationId) continue;
|
||||
if (!routePath.startsWith("/api/")) continue;
|
||||
|
||||
const pathParameters = (operation.parameters ?? [])
|
||||
.filter((p) => p.in === "path")
|
||||
.map((p) => p.name);
|
||||
|
||||
entries.push({
|
||||
operationId: operation.operationId,
|
||||
method: method.toUpperCase(),
|
||||
path: routePath,
|
||||
hasBody: Boolean(operation.requestBody),
|
||||
pathParameters,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
entries.sort((a, b) => a.operationId.localeCompare(b.operationId));
|
||||
return entries;
|
||||
}
|
||||
|
||||
function validateRequiredOperations(operationsList) {
|
||||
const found = new Set(operationsList.map((operation) => operation.operationId));
|
||||
const missing = requiredOperationIds.filter((operationId) => !found.has(operationId));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`OpenAPI document is missing expected operations: ${missing.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderClient(operationsList) {
|
||||
const operationObjectLiteral = operationsList
|
||||
.map((operation) => {
|
||||
const pathParams = `[${operation.pathParameters.map((name) => `"${name}"`).join(", ")}]`;
|
||||
return [
|
||||
` ${operation.operationId}: {`,
|
||||
` method: "${operation.method}",`,
|
||||
` path: "${operation.path}",`,
|
||||
` hasBody: ${operation.hasBody ? "true" : "false"},`,
|
||||
` pathParameters: ${pathParams},`,
|
||||
" },",
|
||||
].join("\n");
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
const clientFunctions = requiredOperationIds
|
||||
.map((operationId) => {
|
||||
const methodName = toCamelCase(operationId);
|
||||
return ` ${methodName}: (options = {}) => requestOperation("${operationId}", options),`;
|
||||
})
|
||||
.join("\n");
|
||||
|
||||
return `// AUTO-GENERATED FILE. DO NOT EDIT.
|
||||
// Source: scripts/generate-api-client.mjs and openapi/GameList.json
|
||||
|
||||
const defaultHeaders = { "Content-Type": "application/json" };
|
||||
|
||||
const rawBase = document.querySelector('meta[name="app-base"]')?.content || "";
|
||||
const basePath = normalizeBase(rawBase);
|
||||
const withBase = (routePath) => \`\${basePath}\${routePath}\`;
|
||||
|
||||
function normalizeBase(value) {
|
||||
if (!value) return "";
|
||||
if (!value.startsWith("/")) return \`/\${value}\`;
|
||||
return value.endsWith("/") ? value.slice(0, -1) : value;
|
||||
}
|
||||
|
||||
function toApiError(res, fallbackMessage = \`\${res.status}\`) {
|
||||
const err = new Error(fallbackMessage);
|
||||
err.status = res.status;
|
||||
return err;
|
||||
}
|
||||
|
||||
function buildPath(template, pathParameters = {}) {
|
||||
return template.replace(/{([^}]+)}/g, (_, key) => {
|
||||
const value = pathParameters[key];
|
||||
if (value === undefined || value === null) {
|
||||
throw new Error(\`Missing path parameter "\${key}" for route \${template}\`);
|
||||
}
|
||||
|
||||
return encodeURIComponent(String(value));
|
||||
});
|
||||
}
|
||||
|
||||
async function parseApiError(res) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
const message = data.error || data.detail || data.title || JSON.stringify(data);
|
||||
return toApiError(res, message);
|
||||
} catch {
|
||||
return toApiError(res);
|
||||
}
|
||||
}
|
||||
|
||||
export const operations = Object.freeze({
|
||||
${operationObjectLiteral}
|
||||
});
|
||||
|
||||
export function resolveOperationPath(operationId, pathParameters = {}) {
|
||||
const operation = operations[operationId];
|
||||
if (!operation) {
|
||||
throw new Error(\`Unknown operationId "\${operationId}"\`);
|
||||
}
|
||||
|
||||
return withBase(buildPath(operation.path, pathParameters));
|
||||
}
|
||||
|
||||
export async function requestOperation(
|
||||
operationId,
|
||||
{ pathParameters = {}, body, headers = {}, raw = false, acceptStatuses = [] } = {}
|
||||
) {
|
||||
const operation = operations[operationId];
|
||||
if (!operation) {
|
||||
throw new Error(\`Unknown operationId "\${operationId}"\`);
|
||||
}
|
||||
|
||||
const response = await fetch(resolveOperationPath(operationId, pathParameters), {
|
||||
method: operation.method,
|
||||
credentials: "same-origin",
|
||||
headers: { ...defaultHeaders, ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
|
||||
const acceptedStatusSet = new Set(acceptStatuses);
|
||||
if (!response.ok && !acceptedStatusSet.has(response.status)) {
|
||||
throw await parseApiError(response);
|
||||
}
|
||||
|
||||
if (raw) return response;
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const apiClient = Object.freeze({
|
||||
${clientFunctions}
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
function toCamelCase(value) {
|
||||
if (!value) return value;
|
||||
return `${value.charAt(0).toLowerCase()}${value.slice(1)}`;
|
||||
}
|
||||
547
wwwroot/app.js
547
wwwroot/app.js
@@ -1,10 +1,14 @@
|
||||
import { api, adminApi } from "./js/api.js";
|
||||
import { t, setLanguage, getLanguage, initI18n, onLanguageChange, faqMarkdown } from "./js/i18n.js";
|
||||
import { state, clearUserState, getSavedUsername, setSavedUsername } from "./js/state.js";
|
||||
import { $, toast } from "./js/dom.js";
|
||||
import {
|
||||
setAuthUI,
|
||||
setAuthMode,
|
||||
t,
|
||||
setLanguage,
|
||||
getLanguage,
|
||||
initI18n,
|
||||
onLanguageChange,
|
||||
faqMarkdown,
|
||||
} from "./js/i18n.js";
|
||||
import { state, clearUserState } from "./js/state.js";
|
||||
import { toast } from "./js/dom.js";
|
||||
import {
|
||||
handleAuthError,
|
||||
renderWelcome,
|
||||
renderPhasePill,
|
||||
@@ -15,54 +19,183 @@ import {
|
||||
syncVoteScores,
|
||||
renderResults,
|
||||
renderPhaseTitles,
|
||||
openNewSuggestionModal,
|
||||
updatePhaseNav,
|
||||
openConfirmModal,
|
||||
openResultsRelockModal,
|
||||
configureUiRuntime,
|
||||
} from "./js/ui.js";
|
||||
import {
|
||||
loadState,
|
||||
import { api } from "./js/api.js";
|
||||
import { loadSuggestData, loadVoteData, refreshPhaseData } from "./js/data.js";
|
||||
import { setupAuthHandlers } from "./js/app-auth-handlers.js";
|
||||
import { setupAdminHandlers } from "./js/app-admin-handlers.js";
|
||||
import { setupVoteNavigationHandlers } from "./js/app-vote-nav-handlers.js";
|
||||
|
||||
const REFRESH_MIN_MS = 3000;
|
||||
const REFRESH_MAX_MS = 20000;
|
||||
const EVENTS_RECONNECT_MIN_MS = 1000;
|
||||
const EVENTS_RECONNECT_MAX_MS = 15000;
|
||||
let refreshInFlight = null;
|
||||
let refreshTimerId = null;
|
||||
let refreshSchedulerStarted = false;
|
||||
let unchangedRefreshCycles = 0;
|
||||
let nextRefreshDelayMs = REFRESH_MIN_MS;
|
||||
let stateEventSource = null;
|
||||
let eventsReconnectTimerId = null;
|
||||
let eventsReconnectDelayMs = EVENTS_RECONNECT_MIN_MS;
|
||||
|
||||
async function runSerializedRefresh() {
|
||||
if (refreshInFlight) return refreshInFlight;
|
||||
refreshInFlight = refreshPhaseData().finally(() => {
|
||||
refreshInFlight = null;
|
||||
});
|
||||
return refreshInFlight;
|
||||
}
|
||||
|
||||
async function refreshWithUiErrorHandling() {
|
||||
try {
|
||||
const changed = await runSerializedRefresh();
|
||||
updateRefreshCadence(changed === true);
|
||||
if (state.isAuthenticated) {
|
||||
ensureStateEventStream();
|
||||
} else {
|
||||
closeStateEventStream();
|
||||
}
|
||||
} catch (err) {
|
||||
// Back off after transient failures to avoid hammering server/dependencies.
|
||||
nextRefreshDelayMs = Math.min(nextRefreshDelayMs * 2, REFRESH_MAX_MS);
|
||||
if (handleAuthError(err, clearUserState)) {
|
||||
closeStateEventStream();
|
||||
return;
|
||||
}
|
||||
|
||||
toast(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function closeStateEventStream() {
|
||||
if (eventsReconnectTimerId !== null) {
|
||||
window.clearTimeout(eventsReconnectTimerId);
|
||||
eventsReconnectTimerId = null;
|
||||
}
|
||||
|
||||
if (stateEventSource) {
|
||||
stateEventSource.close();
|
||||
stateEventSource = null;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleStateEventReconnect() {
|
||||
if (eventsReconnectTimerId !== null || !state.isAuthenticated) return;
|
||||
|
||||
eventsReconnectTimerId = window.setTimeout(() => {
|
||||
eventsReconnectTimerId = null;
|
||||
ensureStateEventStream();
|
||||
}, eventsReconnectDelayMs);
|
||||
|
||||
eventsReconnectDelayMs = Math.min(
|
||||
Math.round(eventsReconnectDelayMs * 1.8),
|
||||
EVENTS_RECONNECT_MAX_MS,
|
||||
);
|
||||
}
|
||||
|
||||
function ensureStateEventStream() {
|
||||
if (!state.isAuthenticated || typeof window.EventSource === "undefined") {
|
||||
closeStateEventStream();
|
||||
return;
|
||||
}
|
||||
|
||||
if (stateEventSource) return;
|
||||
|
||||
stateEventSource = new EventSource(api.stateEventsUrl(), {
|
||||
withCredentials: true,
|
||||
});
|
||||
|
||||
stateEventSource.onopen = () => {
|
||||
eventsReconnectDelayMs = EVENTS_RECONNECT_MIN_MS;
|
||||
};
|
||||
|
||||
stateEventSource.onerror = () => {
|
||||
if (!stateEventSource) return;
|
||||
stateEventSource.close();
|
||||
stateEventSource = null;
|
||||
scheduleStateEventReconnect();
|
||||
};
|
||||
|
||||
stateEventSource.addEventListener("state", () => {
|
||||
unchangedRefreshCycles = 0;
|
||||
nextRefreshDelayMs = baseRefreshDelayForPhase();
|
||||
if (!document.hidden && !state.adminStatusSelectActive) {
|
||||
refreshWithUiErrorHandling();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleNextRefresh() {
|
||||
refreshTimerId = window.setTimeout(async () => {
|
||||
if (!document.hidden && !state.adminStatusSelectActive) {
|
||||
await refreshWithUiErrorHandling();
|
||||
}
|
||||
scheduleNextRefresh();
|
||||
}, nextRefreshDelayMs);
|
||||
}
|
||||
|
||||
function startRefreshScheduler() {
|
||||
if (refreshSchedulerStarted) return;
|
||||
refreshSchedulerStarted = true;
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden && !state.adminStatusSelectActive) {
|
||||
unchangedRefreshCycles = 0;
|
||||
nextRefreshDelayMs = baseRefreshDelayForPhase();
|
||||
refreshWithUiErrorHandling();
|
||||
}
|
||||
});
|
||||
|
||||
if (refreshTimerId !== null) {
|
||||
window.clearTimeout(refreshTimerId);
|
||||
}
|
||||
scheduleNextRefresh();
|
||||
}
|
||||
|
||||
function updateRefreshCadence(changed) {
|
||||
const base = baseRefreshDelayForPhase();
|
||||
if (changed) {
|
||||
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({
|
||||
refreshPhaseData: runSerializedRefresh,
|
||||
loadSuggestData,
|
||||
loadRevealData,
|
||||
loadVoteData,
|
||||
loadResults,
|
||||
refreshPhaseData,
|
||||
} from "./js/data.js";
|
||||
initI18n();
|
||||
handleAuthError: (err) => handleAuthError(err, clearUserState),
|
||||
});
|
||||
|
||||
function setupHandlers() {
|
||||
const toggleAuth = $("auth-toggle");
|
||||
if (toggleAuth) {
|
||||
toggleAuth.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
setAuthMode(state.authMode === "login" ? "register" : "login");
|
||||
});
|
||||
}
|
||||
setAuthMode(state.authMode);
|
||||
|
||||
const hasConsent = () => document.cookie.split(";").some((c) => c.trim().startsWith("cookie_consent=1"));
|
||||
const setConsent = () => { document.cookie = "cookie_consent=1; path=/; max-age=31536000; SameSite=Lax"; };
|
||||
const consentRows = document.querySelectorAll(".consent-row");
|
||||
const toggleConsentRows = () => {
|
||||
const hide = hasConsent();
|
||||
consentRows.forEach((row) => row.classList.toggle("hidden", hide));
|
||||
};
|
||||
toggleConsentRows();
|
||||
["login-consent", "register-consent"].forEach((id) => {
|
||||
const box = $(id);
|
||||
if (box) {
|
||||
box.checked = hasConsent();
|
||||
}
|
||||
});
|
||||
|
||||
const loginUser = $("login-username");
|
||||
if (loginUser) {
|
||||
const markEditing = () => { loginUser.dataset.userEditing = "1"; };
|
||||
["focus", "input", "keydown"].forEach((evt) => loginUser.addEventListener(evt, markEditing));
|
||||
loginUser.addEventListener("blur", () => { delete loginUser.dataset.userEditing; });
|
||||
}
|
||||
|
||||
setupAuthHandlers({ runSerializedRefresh });
|
||||
setupAdminHandlers({ runSerializedRefresh });
|
||||
setupVoteNavigationHandlers({ runSerializedRefresh });
|
||||
setupLanguageSwitchers();
|
||||
document.getElementById("logout")?.addEventListener("click", () => {
|
||||
closeStateEventStream();
|
||||
});
|
||||
|
||||
onLanguageChange(() => {
|
||||
updateLanguageButtons();
|
||||
@@ -83,218 +216,16 @@ function setupHandlers() {
|
||||
updatePhaseNav();
|
||||
});
|
||||
|
||||
const loginForm = $("login-form");
|
||||
if (loginForm) {
|
||||
loginForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const username = $("login-username").value.trim();
|
||||
const password = $("login-password").value;
|
||||
if (username.length > 24) return toast("Username must be 24 characters or fewer.", true);
|
||||
if (!username || !password) return toast(t("auth.needCredentials"), true);
|
||||
if (!hasConsent() && !$("login-consent")?.checked) return toast(t("auth.cookieRequired"), true);
|
||||
try {
|
||||
await api.login({ username, password });
|
||||
setConsent();
|
||||
toggleConsentRows();
|
||||
setSavedUsername(username);
|
||||
state.isAuthenticated = true;
|
||||
setAuthUI(true);
|
||||
await refreshPhaseData();
|
||||
toast(t("toast.loggedIn"));
|
||||
} catch (err) {
|
||||
if (err?.status === 401) return toast(t("auth.invalidCredentials"), true);
|
||||
if (handleAuthError(err, clearUserState)) return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const registerForm = $("register-form");
|
||||
if (registerForm) {
|
||||
registerForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const username = $("register-username").value.trim();
|
||||
const password = $("register-password").value;
|
||||
const displayName = $("register-displayName").value.trim();
|
||||
const adminKey = $("register-adminkey").value.trim();
|
||||
if (!displayName) return toast(t("toast.displayNameRequired") || "Display name is required.", true);
|
||||
if (username.length > 24) return toast("Username must be 24 characters or fewer.", true);
|
||||
if (displayName.length > 16) return toast("Display name must be 16 characters or fewer.", true);
|
||||
if (!username || !password) return toast(t("auth.needCredentials"), true);
|
||||
if (!hasConsent() && !$("register-consent")?.checked) return toast(t("auth.cookieRequired"), true);
|
||||
try {
|
||||
await api.register({ username, password, displayName, adminKey });
|
||||
setConsent();
|
||||
toggleConsentRows();
|
||||
setSavedUsername(username);
|
||||
state.isAuthenticated = true;
|
||||
setAuthUI(true);
|
||||
await refreshPhaseData();
|
||||
toast(t("toast.registered"));
|
||||
} catch (err) {
|
||||
if (handleAuthError(err, clearUserState)) return;
|
||||
toast(err.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const openSuggestBtn = $("open-suggest-modal");
|
||||
if (openSuggestBtn) {
|
||||
openSuggestBtn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
if (openSuggestBtn.disabled) return;
|
||||
if (state.phase !== "Suggest") return;
|
||||
openNewSuggestionModal();
|
||||
});
|
||||
}
|
||||
const openJokerBtn = $("open-joker-modal");
|
||||
if (openJokerBtn) {
|
||||
openJokerBtn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
if (state.phase !== "Vote" || !state.hasJoker) return;
|
||||
openNewSuggestionModal();
|
||||
});
|
||||
}
|
||||
|
||||
bindNavButtons();
|
||||
|
||||
$("reset").addEventListener("click", () => adminAction(adminApi.reset, t("admin.resetDone")));
|
||||
$("factory-reset").addEventListener("click", () => adminAction(adminApi.factoryReset, t("admin.factoryResetDone")));
|
||||
|
||||
const logoutBtn = $("logout");
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
const lastUser = state.me?.username;
|
||||
try {
|
||||
await api.logout();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
clearUserState();
|
||||
state.isAuthenticated = false;
|
||||
setAuthUI(false);
|
||||
if (lastUser) {
|
||||
setSavedUsername(lastUser);
|
||||
const loginUser = $("login-username");
|
||||
if (loginUser) loginUser.value = lastUser;
|
||||
const loginPass = $("login-password");
|
||||
if (loginPass) loginPass.value = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const adminToggle = $("admin-toggle");
|
||||
const adminCard = $("admin-card");
|
||||
const adminClose = $("admin-close");
|
||||
if (adminToggle && adminCard && adminClose) {
|
||||
const togglePanel = (show) => adminCard.classList.toggle("hidden", !show);
|
||||
adminToggle.addEventListener("click", () => togglePanel(adminCard.classList.contains("hidden")));
|
||||
adminClose.addEventListener("click", () => togglePanel(false));
|
||||
}
|
||||
|
||||
document.querySelectorAll(".help-chip").forEach((chip) => {
|
||||
chip.addEventListener("click", () => openFaqModal());
|
||||
});
|
||||
|
||||
const resultsToggle = $("results-open");
|
||||
if (resultsToggle) {
|
||||
resultsToggle.addEventListener("change", async (e) => {
|
||||
const desired = !!e.target.checked;
|
||||
try {
|
||||
const resp = await adminApi.setResultsOpen(desired);
|
||||
const wasResultsOpen = state.resultsOpen;
|
||||
const wasPhase = state.phase;
|
||||
state.resultsOpen = resp.resultsOpen;
|
||||
if (wasResultsOpen && !resp.resultsOpen && wasPhase === "Results") {
|
||||
openResultsRelockModal();
|
||||
}
|
||||
renderPhasePill();
|
||||
toast(t("admin.resultsUpdated"));
|
||||
await refreshPhaseData();
|
||||
} catch (err) {
|
||||
e.target.checked = !desired;
|
||||
toast(err.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const linkApply = $("link-apply");
|
||||
if (linkApply) {
|
||||
linkApply.addEventListener("click", async () => {
|
||||
const source = Number($("link-source")?.value);
|
||||
const target = Number($("link-target")?.value);
|
||||
if (!source || !target || source === target) {
|
||||
return toast(t("admin.linkValidation"), true);
|
||||
}
|
||||
try {
|
||||
await adminApi.linkSuggestions(source, target);
|
||||
toast(t("admin.linkDone"));
|
||||
await refreshPhaseData();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const playerTable = $("admin-player-table");
|
||||
if (playerTable) {
|
||||
playerTable.addEventListener("click", async (e) => {
|
||||
const grantBtn = e.target.closest("[data-grant-joker]");
|
||||
const deleteBtn = e.target.closest("[data-delete-player]");
|
||||
if (grantBtn) {
|
||||
const playerId = grantBtn.dataset.grantJoker;
|
||||
try {
|
||||
await adminApi.grantJoker(playerId);
|
||||
toast(t("admin.jokerGranted"));
|
||||
await refreshPhaseData();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
} else if (deleteBtn) {
|
||||
const playerId = deleteBtn.dataset.deletePlayer;
|
||||
const name = deleteBtn.dataset.name || "";
|
||||
openConfirmModal({
|
||||
title: t("admin.deleteTitle"),
|
||||
body: t("admin.deleteBody", { name }),
|
||||
confirmLabel: t("admin.deleteConfirm"),
|
||||
onConfirm: async (close) => {
|
||||
try {
|
||||
await adminApi.deletePlayer(playerId);
|
||||
toast(t("admin.deleteDone"));
|
||||
close();
|
||||
await refreshPhaseData();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function adminAction(fn, successMessage) {
|
||||
try {
|
||||
await fn();
|
||||
toast(successMessage);
|
||||
await refreshPhaseData();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await initI18n();
|
||||
setupHandlers();
|
||||
try {
|
||||
await refreshPhaseData();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
setInterval(() => {
|
||||
refreshPhaseData().catch((err) => {
|
||||
if (!handleAuthError(err, clearUserState)) toast(err.message, true);
|
||||
});
|
||||
}, 4000);
|
||||
await refreshWithUiErrorHandling();
|
||||
startRefreshScheduler();
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -310,7 +241,9 @@ function updateLanguageButtons() {
|
||||
function setupLanguageSwitchers() {
|
||||
const switches = document.querySelectorAll(".lang-switch");
|
||||
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) => {
|
||||
const btn = wrap.querySelector(".lang-button");
|
||||
@@ -338,103 +271,6 @@ function setupLanguageSwitchers() {
|
||||
updateLanguageButtons();
|
||||
}
|
||||
|
||||
function bindNavButtons() {
|
||||
const makeForward = (id, before) => {
|
||||
const btn = $(id);
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
try {
|
||||
if (before) {
|
||||
const proceed = await before();
|
||||
if (!proceed) return;
|
||||
}
|
||||
const resp = await api.nextPhase();
|
||||
state.prevPhase = state.phase;
|
||||
state.phase = resp.currentPhase;
|
||||
state.resultsOpen = resp.resultsOpen ?? state.resultsOpen;
|
||||
state.votesRendered = false;
|
||||
renderPhasePill();
|
||||
await refreshPhaseData();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const makeBack = (id) => {
|
||||
const btn = $(id);
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
try {
|
||||
const resp = await api.prevPhase();
|
||||
state.prevPhase = state.phase;
|
||||
state.phase = resp.currentPhase;
|
||||
state.resultsOpen = resp.resultsOpen ?? state.resultsOpen;
|
||||
state.votesRendered = false;
|
||||
renderPhasePill();
|
||||
await refreshPhaseData();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
makeForward("nav-suggest-next", async () => {
|
||||
return await new Promise((resolve) => {
|
||||
openConfirmModal({
|
||||
title: t("nav.freezeModalTitle"),
|
||||
body: t("nav.freezeModalBody"),
|
||||
confirmLabel: t("nav.next"),
|
||||
onConfirm: (close) => {
|
||||
close();
|
||||
resolve(true);
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
makeBack("nav-vote-prev");
|
||||
|
||||
const finalizeBtn = $("finalize-votes");
|
||||
if (finalizeBtn) {
|
||||
const finalizeVotes = async (desired) => {
|
||||
await api.finalizeVotes(desired);
|
||||
state.votesFinal = desired;
|
||||
renderPhasePill();
|
||||
renderVotes();
|
||||
toast(desired ? t("vote.finalize") : t("vote.unfinalize"));
|
||||
};
|
||||
|
||||
const missingVotes = () => {
|
||||
const votedIds = new Set((state.myVotes ?? []).map((v) => v.suggestionId));
|
||||
return (state.allSuggestions ?? []).filter((s) => !votedIds.has(s.id));
|
||||
};
|
||||
|
||||
finalizeBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const desired = !state.votesFinal;
|
||||
if (desired) {
|
||||
const missing = missingVotes();
|
||||
if (missing.length > 0) {
|
||||
openConfirmModal({
|
||||
title: t("vote.finalizeMissingTitle"),
|
||||
body: t("vote.finalizeMissingBody", { count: missing.length }),
|
||||
confirmLabel: t("vote.finalizeMissingConfirm"),
|
||||
onConfirm: async (close) => {
|
||||
await finalizeVotes(desired);
|
||||
close();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
await finalizeVotes(desired);
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function markdownToHtml(md) {
|
||||
const lines = md.trim().split(/\r?\n/);
|
||||
const html = [];
|
||||
@@ -442,10 +278,7 @@ function markdownToHtml(md) {
|
||||
let inParagraph = false;
|
||||
|
||||
const escapeHtml = (text) =>
|
||||
text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
const formatInline = (text) =>
|
||||
escapeHtml(text)
|
||||
@@ -537,7 +370,11 @@ function openFaqModal() {
|
||||
|
||||
const close = () => overlay.remove();
|
||||
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);
|
||||
|
||||
@@ -55,3 +55,9 @@
|
||||
border: 1px solid #e3d4bd;
|
||||
background: #fffaf3;
|
||||
}
|
||||
|
||||
.admin-status-select {
|
||||
width: 100%;
|
||||
min-width: 140px;
|
||||
background: #fffaf3;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,17 @@ button.ghost:hover {
|
||||
border-color: #b4d9f3;
|
||||
color: #1a3d64;
|
||||
}
|
||||
button.needs-suggestion,
|
||||
button.needs-suggestion:disabled {
|
||||
background: #e0564f;
|
||||
border-color: #c54740;
|
||||
color: #fffaf3;
|
||||
opacity: 1;
|
||||
}
|
||||
button.needs-suggestion:hover {
|
||||
background: #c9473f;
|
||||
border-color: #a83a35;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: #6c5a42;
|
||||
|
||||
@@ -47,6 +47,16 @@
|
||||
display: block;
|
||||
padding: 0;
|
||||
}
|
||||
.card-visual.has-image {
|
||||
background: #f6b24f;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-visual-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.card-visual.hovering {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
@@ -173,6 +183,10 @@ button .chip {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.nav-hint {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.warning-text {
|
||||
color: #b23b3b;
|
||||
font-weight: 600;
|
||||
@@ -219,6 +233,7 @@ button .chip {
|
||||
/* Slider */
|
||||
input[type="range"].full-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
border-radius: 999px;
|
||||
@@ -264,3 +279,10 @@ input[type="range"].full-slider:disabled::-moz-range-thumb {
|
||||
background: #f1f1f1;
|
||||
border-color: #c1c1c1;
|
||||
}
|
||||
|
||||
.fx-canvas {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,9 @@
|
||||
overflow: auto;
|
||||
max-height: 70vh;
|
||||
}
|
||||
.edit-modal .edit-body .confirm-actions {
|
||||
margin-top: 12px;
|
||||
}
|
||||
.edit-modal .delete-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
222
wwwroot/data/i18n/faq/de.md
Normal file
222
wwwroot/data/i18n/faq/de.md
Normal file
@@ -0,0 +1,222 @@
|
||||
Pick'n'play hilft Gruppen dabei, fair und transparent zu entscheiden, welches Spiel als Nächstes gespielt wird. Spieler können Vorschläge einreichen, diese unabhängig bewerten und durch strukturierte Phasen gehen, die den Prozess organisiert und anonym halten. Es löst das klassische „Was sollen wir spielen?"-Chaos, indem es Gruppenentscheidungen in einen klaren, ausgewogenen und stressfreien Ablauf verwandelt.
|
||||
|
||||
## Konten & Anmeldung
|
||||
|
||||
### Wie erstelle ich ein Konto?
|
||||
|
||||
Registriere dich mit:
|
||||
- Einem **eindeutigen Benutzernamen** (max. 24 Zeichen)
|
||||
- Einem **Passwort**
|
||||
- Einem **Anzeigenamen** (max. 16 Zeichen)
|
||||
|
||||
Dein Anzeigename ist erforderlich ‒ er erscheint neben all deinen Vorschlägen und Bewertungen.
|
||||
|
||||
### Wie werden Passwörter geschützt?
|
||||
|
||||
Passwörter werden niemals im Klartext gespeichert. Pick'n'Play speichert gesalzene, versionierte Passwort-Hashes. Neue und aktualisierte Hashes verwenden Argon2id, während ältere Hash-Versionen nach erfolgreicher Anmeldung oder Admin-Passwort-Bestätigung transparent aktualisiert werden.
|
||||
|
||||
### Brauche ich Admin-Rechte?
|
||||
|
||||
Wenn du einen **Admin-Schlüssel** erhalten hast, gib ihn bei der Registrierung ein. Ist der Schlüssel ungültig, wird die Anfrage abgelehnt. Die Admin-Schlüssel-Registrierung ist nur verfügbar, bis das erste Admin-Konto erstellt wurde. Admin-Rechte können später nicht über die öffentliche Registrierung hinzugefügt werden.
|
||||
Sobald ein Owner-Konto existiert, wird das Admin-Schlüssel-Feld in der Registrierung nicht mehr angezeigt.
|
||||
|
||||
## Phasen im Überblick
|
||||
|
||||
### Persönliche Phasen
|
||||
|
||||
Jeder Spieler durchläuft die Phasen unabhängig voneinander:
|
||||
|
||||
**Vorschlagen → Abstimmen → Ergebnisse**
|
||||
|
||||
Klicke auf **„Weiter"**, um fortzufahren. Admins können sich bei Bedarf auch wieder zurücksetzen.
|
||||
In der **Vorschlagsphase** bleibt **„Weiter"** deaktiviert, bis dein Konto mindestens einen eigenen Spielvorschlag hat.
|
||||
|
||||
### Muss ich die Seite manuell aktualisieren?
|
||||
|
||||
Normalerweise nicht. Pick'n'Play erhält Live-Updates vom Server und nutzt nur dann periodische Prüfungen, wenn der Live-Kanal vorübergehend nicht verfügbar ist.
|
||||
|
||||
## Spiele vorschlagen
|
||||
|
||||
### Wie viele Spiele kann ich vorschlagen?
|
||||
|
||||
Bis zu **5 Vorschläge pro Spieler**.
|
||||
|
||||
### Pflichtfelder und Grenzen
|
||||
|
||||
- **Name** ‒ erforderlich (max. 100 Zeichen)
|
||||
- **Genre** ‒ optional (max. 50 Zeichen)
|
||||
- **Beschreibung** ‒ optional (max. 500 Zeichen)
|
||||
- **Links** ‒ optional (URLs bis zu 2048 Zeichen)
|
||||
|
||||
### Min./Max. Spieleranzahl
|
||||
- Müssen gemeinsam ausgefüllt werden (oder beide leer bleiben)
|
||||
- Werte müssen zwischen **1 und 32** liegen
|
||||
- Minimum muss ≤ Maximum sein
|
||||
|
||||
### Screenshot-Regeln
|
||||
|
||||
Wenn du eine Screenshot-URL angibst, muss sie:
|
||||
- **http oder https** verwenden
|
||||
- Mit einer gültigen Bilddateiendung enden (`png`, `jpg`, `jpeg`, `gif`, `webp`, `avif`)
|
||||
- Direkt erreichbar sein (keine Weiterleitungen)
|
||||
- Innerhalb von ~3 Sekunden laden
|
||||
- Unter **5 MB**groß sein
|
||||
- Nicht auf lokale, private oder reservierte Hosts verweisen
|
||||
|
||||
Screenshots sind optional.
|
||||
|
||||
### Weitere Links
|
||||
|
||||
Spiel-Links und YouTube-Links müssen **http oder https** verwenden. Andere URL-Schemata werden abgelehnt.
|
||||
|
||||
### Vorschlag bearbeiten
|
||||
|
||||
Klicke auf das **Bearbeiten-Symbol (Stift)** auf einer Spielkarte, um Felder jederzeit zu aktualisieren.
|
||||
|
||||
### Vorschlag löschen
|
||||
|
||||
Klicke auf das **Löschen-Symbol (Kreuz)** auf einer Spielkarte, um sie zu entfernen ‒ außer du befindest dich bereits in der Abstimmungsphase (siehe unten).
|
||||
|
||||
### Warum wurde mein Vorschlag blockiert?
|
||||
|
||||
Häufige Gründe:
|
||||
- Fehlender Anzeigename
|
||||
- Das Limit von 5 Vorschlägen wurde erreicht
|
||||
- Name überschreitet das Zeichenlimit
|
||||
- Screenshot-URL ist ungültig, nicht erreichbar oder zu groß
|
||||
- Min./Max.-Spieler fehlen oder sind ungültig
|
||||
- Versuch, in der falschen Phase einen Vorschlag hinzuzufügen
|
||||
|
||||
Überprüfe Fehlermeldungen unten rechts auf dem Bildschirm.
|
||||
|
||||
## Abstimmung
|
||||
|
||||
### Wer darf abstimmen?
|
||||
|
||||
Authentifizierte Spieler während der **Abstimmungsphase**, welche mindestens ein Vorschlag hinzugefügt haben.
|
||||
|
||||
### Wie vergebe ich Punkte?
|
||||
|
||||
Nutze den Schieberegler, um eine ganze Zahl von **0 bis 10** zu vergeben.
|
||||
|
||||
### Bearbeiten während der Abstimmung
|
||||
|
||||
- Die meisten Spieldetails können weiterhin bearbeitet werden
|
||||
- Der **Spielname ist während der Abstimmungsphase gesperrt**
|
||||
- Eigene Vorschläge können nicht mehr gelöscht werden
|
||||
- Admins können Vorschläge bei Bedarf löschen
|
||||
|
||||
### Verknüpfte Duplikate
|
||||
|
||||
Wenn ein Admin doppelte Spiele verknüpft:
|
||||
- Eine Punkteänderung wirkt sich auf alle verknüpften Einträge aus
|
||||
- Punkte werden pro Gruppe gespeichert, nicht pro Einzeleintrag
|
||||
|
||||
### Abstimmung finalisieren
|
||||
|
||||
Mit **„Finalisieren"** werden deine Bewertungen gesperrt. Deaktiviere es, um erneut zu bearbeiten.
|
||||
|
||||
„Finalisieren" ist nur während der Abstimmungsphase verfügbar und wird automatisch zurückgesetzt, wenn:
|
||||
- Ein Joker ein neues Spiel hinzufügt
|
||||
- Ein Admin Spiele verknüpft oder trennt
|
||||
|
||||
### Abstimmen nach Änderungen
|
||||
|
||||
Wenn neue Spiele hinzugefügt oder Verknüpfungen geändert werden:
|
||||
- Betroffene Stimmen werden gelöscht
|
||||
- Deine Abstimmung wird automatisch zurückgesetzt
|
||||
- Das Update-Popup erscheint nur, wenn sich deine bereits sichtbare Abstimmungsliste verändert
|
||||
|
||||
Überprüfe deine Liste und bewerte erneut, bevor du wieder finalisierst.
|
||||
|
||||
## Joker (Späte Ergänzungen)
|
||||
|
||||
### Was ist ein Joker?
|
||||
|
||||
Ein **Joker** ist ein einmaliger zusätzlicher Vorschlags-Slot, der nur während der **Abstimmungsphase** verfügbar ist. Ein Admin muss ihn dir gewähren.
|
||||
|
||||
### So funktioniert es
|
||||
|
||||
Wenn du einen Joker erhältst:
|
||||
- Erscheint ein Button in der oberen Leiste, mit dem du ein weiteres Spiel hinzufügen kannst
|
||||
- Nach der Nutzung wird der Joker sofort verbraucht
|
||||
- Die Finalisierung aller Abstimmungen werden automatisch zurückgesetzt, damit das neue Spiel bewertet werden kann
|
||||
|
||||
Admins können bei Bedarf zusätzliche Joker vergeben.
|
||||
|
||||
## Ergebnisse
|
||||
|
||||
### Wann sind die Ergebnisse sichtbar?
|
||||
|
||||
Die Ergebnisse bleiben verborgen, bis ein Admin sie freigibt. Danach werden alle Spieler automatisch in die **Ergebnisphase** verschoben. Falls nötig, kann ein Admin die Ergebnisse wieder schließen: Konten mit mindestens einem eigenen Vorschlag kehren in die Abstimmungsphase zurück, Konten ohne Vorschläge in die Vorschlagsphase, und alle Abstimmungen werden zur Anpassung zurückgesetzt.
|
||||
|
||||
### Kann ich in der Ergebnisphase etwas bearbeiten?
|
||||
|
||||
Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf an einen Admin.
|
||||
|
||||
### Wie sehe ich, wer für ein Spiel abgestimmt hat?
|
||||
|
||||
Fahre mit der Maus über den Durchschnittswert oder ein Bewertungs-Emoji in der Ergebniszeile, um die alphabetisch sortierte Liste der Abstimmenden zu sehen.
|
||||
|
||||
## Admin-Tools (Für Hosts)
|
||||
|
||||
### Was können Admin-Konten tun?
|
||||
|
||||
- Joker während der Abstimmung vergeben
|
||||
- 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)
|
||||
- Admin-Rechte für Nicht-Owner-Konten in der Spielertabelle vergeben oder entziehen
|
||||
- Doppelte Vorschläge verknüpfen oder trennen
|
||||
- Vorschläge löschen
|
||||
- Abstimmungsstatus einsehen (wer finalisiert hat)
|
||||
- Einen Spieler löschen (inklusive dessen Vorschläge und Stimmen)
|
||||
- Für Konto-Löschung, Zurücksetzen und Werkseinstellung das Admin-Passwort bestätigen
|
||||
- Die Datenbank auf Werkseinstellungen zurücksetzen
|
||||
- Zu vorherigen Phasen zurückkehren
|
||||
|
||||
### Was können Admin-Konten nicht tun?
|
||||
|
||||
- Einzelne Spielerbewertungen einsehen
|
||||
- Owner-Rechte entziehen oder das Owner-Konto löschen
|
||||
|
||||
Die Abstimmung bleibt anonym und fair.
|
||||
|
||||
## Häufige Fehler & Lösungen
|
||||
|
||||
### „Screenshot-URL muss http(s) verwenden und mit einer Bilddateiendung enden."
|
||||
|
||||
Stelle sicher:
|
||||
- Der Link ist direkt (keine HTML-Seite)
|
||||
- Er endet mit einer gültigen Bilddateiendung
|
||||
- Die Datei ist unter 5 MB groß
|
||||
- Es gibt keine Weiterleitungen
|
||||
|
||||
### „Du hast das Limit von 5 Vorschlägen erreicht."
|
||||
|
||||
Warte auf die Abstimmungsphase und bitte bei Bedarf um einen Joker.
|
||||
|
||||
### „Füge mindestens einen Vorschlag hinzu, bevor du in die Abstimmungsphase wechselst."
|
||||
|
||||
Füge mit deinem aktuellen Konto mindestens einen Spielvorschlag hinzu. Erst dann kannst du von der Vorschlagsphase in die Abstimmungsphase wechseln. Diesese Verhalten erschwert die Abgabe von mehreren Stimmen pro Benutzer.
|
||||
Bis dahin zeigt die Navigation in der Vorschlagsphase einen Hinweis statt eines Weiter-Buttons und wechselt direkt nach der ersten erfolgreichen Einreichung.
|
||||
|
||||
### „Ungültiger Admin-Schlüssel."
|
||||
|
||||
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
|
||||
|
||||
- Vorschläge, Stimmen und Phasenstatus werden in einer gemeinsamen Datenbank 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.
|
||||
- Wenn ein Admin dein Spielerkonto löscht, werden auch deine Vorschläge und Stimmen entfernt.
|
||||
226
wwwroot/data/i18n/faq/en.md
Normal file
226
wwwroot/data/i18n/faq/en.md
Normal file
@@ -0,0 +1,226 @@
|
||||
Pick'n'play helps groups fairly and transparently choose what game to play next. Players can suggest options, score them independently, and move through structured phases that keep the process organized and anonymous. It solves the classic “what should we play?” chaos by turning group decision-making into a clear, balanced, and drama-free flow.
|
||||
|
||||
## Accounts & Login
|
||||
|
||||
### How do I create an account?
|
||||
|
||||
Register with:
|
||||
- A **unique username** (max 24 characters)
|
||||
- A **password**
|
||||
- A **display name** (max 16 characters)
|
||||
|
||||
Your display name is required ‒ it appears next to all of your suggestions and scores.
|
||||
|
||||
### How are passwords protected?
|
||||
|
||||
Passwords are never stored in plain text. Pick'n'Play stores salted, versioned password hashes. New and upgraded hashes use Argon2id, while older hash versions are transparently upgraded after successful sign-in or admin-password confirmation.
|
||||
|
||||
### Do I need admin privileges?
|
||||
|
||||
If you've been given an **admin key**, enter it during registration. If the key is invalid, the request is rejected.
|
||||
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
|
||||
|
||||
### Personal phases
|
||||
|
||||
Each player progresses independently through the phases:
|
||||
|
||||
**Suggest → Vote → Results**
|
||||
|
||||
Click **"Next"** to move forward. Admins can move themselves backward if needed.
|
||||
In the **Suggest** phase, **Next** stays disabled until your account has at least one own game suggestion.
|
||||
|
||||
### Do I need to refresh the page manually?
|
||||
|
||||
Usually no. Pick'n'Play receives live server updates and falls back to periodic checks if the live channel is temporarily unavailable.
|
||||
|
||||
## Suggesting Games
|
||||
|
||||
### How many games can I suggest?
|
||||
|
||||
Up to **5 suggestions per player**.
|
||||
|
||||
### Required fields and limits
|
||||
|
||||
- **Name** ‒ required (max 100 characters)
|
||||
- **Genre** ‒ optional (max 50 characters)
|
||||
- **Description** ‒ optional (max 500 characters)
|
||||
- **Links** ‒ optional (URLs up to 2048 characters)
|
||||
|
||||
### Min/Max players
|
||||
|
||||
- Must be filled together (or both left empty)
|
||||
- Values must be between **1 and 32**
|
||||
- Minimum must be ≤ maximum
|
||||
|
||||
### Screenshot rules
|
||||
|
||||
If you include a screenshot URL, it must:
|
||||
- Use **http or https**
|
||||
- End with a valid image extension (`png`, `jpg`, `jpeg`, `gif`, `webp`, `avif`)
|
||||
- Be directly accessible (no redirects)
|
||||
- Load within ~3 seconds
|
||||
- Be under **5 MB**
|
||||
- Not point to local, private, or reserved hosts
|
||||
|
||||
Screenshots are optional.
|
||||
|
||||
### Other links
|
||||
|
||||
Game links and YouTube links must use **http or https**. Other URL schemes are rejected.
|
||||
|
||||
### Editing a suggestion
|
||||
|
||||
Click the **edit (pencil) icon** on a game card to update any field at any time.
|
||||
|
||||
### Deleting a suggestion
|
||||
|
||||
Click the **delete (cross) icon** on a game card to remove it ‒ unless you're already in the Vote phase (see below).
|
||||
|
||||
### Why was my suggestion blocked?
|
||||
|
||||
Common reasons:
|
||||
- Missing display name
|
||||
- Already reached the 5-suggestion limit
|
||||
- Name exceeds character limit
|
||||
- Screenshot URL is invalid, unreachable, or too large
|
||||
- Min/max players missing or invalid
|
||||
- Attempting to add a suggestion in the wrong phase
|
||||
|
||||
Check the bottom-right corner of the screen for error messages.
|
||||
|
||||
## Jokers (Late Additions)
|
||||
|
||||
### What is a joker?
|
||||
|
||||
A **joker** is a one-time extra suggestion slot available only during the **Vote phase**. An admin must grant it to you.
|
||||
|
||||
### How it works
|
||||
|
||||
If you receive a joker:
|
||||
- A button appears in the top bar allowing you to add one more game.
|
||||
- Once used, the joker is consumed immediately.
|
||||
- Your ballot becomes unfinalized.
|
||||
- All players are unfinalized so the new game can be scored.
|
||||
|
||||
Admins may grant additional jokers if necessary.
|
||||
|
||||
## Voting
|
||||
|
||||
### Who can vote?
|
||||
|
||||
Authenticated players during the **Vote phase**, who submitted at least one suggestion.
|
||||
|
||||
### How do I score games?
|
||||
|
||||
Use the slider to assign a whole number from **0 to 10**.
|
||||
|
||||
### Editing during Vote
|
||||
|
||||
- You can still edit most game details.
|
||||
- The **game name becomes locked** during the Vote phase.
|
||||
- You can no longer delete your own suggestions.
|
||||
- Admins may delete suggestions if necessary.
|
||||
|
||||
### Linked duplicates
|
||||
|
||||
If an admin links duplicate games:
|
||||
- Changing the score for one updates all linked entries.
|
||||
- Scores are stored per group, not per individual entry.
|
||||
|
||||
### Finalizing your ballot
|
||||
|
||||
Toggling **"Finalize"** locks your scores. Toggle it off to edit again.
|
||||
|
||||
Finalize is only available during the Vote phase and will automatically reset if:
|
||||
- A joker adds a new game
|
||||
- An admin links or unlinks games
|
||||
|
||||
### Voting after changes
|
||||
|
||||
If new games are added or links are modified:
|
||||
- Affected votes are cleared
|
||||
- You are automatically unfinalized
|
||||
- The update popup appears only when your already-visible Vote list changes
|
||||
|
||||
Review your list and rescore before finalizing again.
|
||||
|
||||
## Results
|
||||
|
||||
### When are results visible?
|
||||
|
||||
Results are hidden until an admin opens them. When opened, all players are automatically moved to the **Results phase**.
|
||||
If needed, an admin can close the Results: players with at least one own suggestion return to the Vote phase, accounts without suggestions return to Suggest, and all ballots are unfinalized for adjustments.
|
||||
|
||||
### Can I edit anything in Results?
|
||||
|
||||
No. Suggestions and votes are read-only. Contact an admin for assistance.
|
||||
|
||||
### How can I see who voted for a game?
|
||||
|
||||
Hover the average score or any score emoji in that result row to see the voter list (sorted alphabetically).
|
||||
|
||||
## Admin Tools (For Hosts)
|
||||
|
||||
### What can admin accounts do?
|
||||
|
||||
- Grant jokers during Vote
|
||||
- Move a voter back to Suggest (stronger than a joker; use sparingly)
|
||||
- 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
|
||||
- Delete suggestions
|
||||
- View vote readiness (who has finalized)
|
||||
- Delete a player (removes their suggestions and votes)
|
||||
- Confirm admin password for account deletion, reset, and factory reset
|
||||
- Reset the database to factory defaults
|
||||
- Move backward to previous phases
|
||||
|
||||
### What can't admin accounts do?
|
||||
|
||||
- View individual player votes
|
||||
- Revoke owner permissions or delete the owner account
|
||||
|
||||
Voting remains anonymous and fair.
|
||||
|
||||
## Common Errors & Fixes
|
||||
|
||||
### "Screenshot URL must be http(s) and end with an image file extension."
|
||||
|
||||
Make sure:
|
||||
- The link is direct (not a page or html content)
|
||||
- It ends with a valid image extension
|
||||
- The file is under 5 MB
|
||||
- There are no redirects
|
||||
|
||||
### "You have reached the 5 suggestion limit."
|
||||
|
||||
Wait for the Vote phase and request a joker if needed.
|
||||
|
||||
### "Add at least one suggestion before entering the Vote phase."
|
||||
|
||||
Add at least one game suggestion with your current account. Only then can you move from Suggest to Vote. This behavior hinders the submission of multiple votes per user.
|
||||
Until then, the Suggest navigation shows a hint instead of a Next button, and switches immediately after your first successful submission.
|
||||
|
||||
### "Invalid admin key."
|
||||
|
||||
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
|
||||
|
||||
- Suggestions, votes, and phase states are stored in a shared database.
|
||||
- Passwords are stored as salted hashes (not plaintext).
|
||||
- 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.
|
||||
352
wwwroot/data/i18n/translations.json
Normal file
352
wwwroot/data/i18n/translations.json
Normal file
@@ -0,0 +1,352 @@
|
||||
{
|
||||
"en": {
|
||||
"lang.label": "Language",
|
||||
"lang.en": "English",
|
||||
"lang.de": "Deutsch",
|
||||
"auth.loginTab": "Log in",
|
||||
"auth.registerTab": "Register",
|
||||
"auth.username": "Username",
|
||||
"auth.password": "Password",
|
||||
"auth.displayName": "Display name (shows to group)",
|
||||
"auth.adminKey": "Admin key (optional)",
|
||||
"auth.loginSubmit": "Log in",
|
||||
"auth.registerSubmit": "Create account",
|
||||
"auth.loginHeading": "Log in",
|
||||
"auth.registerHeading": "Create account",
|
||||
"auth.switchToRegister": "Need an account? Register",
|
||||
"auth.switchToLogin": "Have an account? Log in",
|
||||
"auth.cookieLabel": "I agree to the use of essential cookies.",
|
||||
"auth.cookieRequired": "Please agree to essential cookies to continue.",
|
||||
"auth.logout": "Logout",
|
||||
"auth.welcome": "Welcome, {name}!",
|
||||
"auth.defaultName": "Player",
|
||||
"auth.loading": "Loading…",
|
||||
"auth.needCredentials": "Username and password required",
|
||||
"auth.invalidCredentials": "Invalid username or password",
|
||||
"counts.format": "Players: {players} • Suggestions: {suggestions} • Votes: {votes}",
|
||||
"nav.prev": "Back",
|
||||
"nav.next": "Next",
|
||||
"nav.addSuggestionFirst": "Add a game first",
|
||||
"nav.waitingForResults": "Waiting…",
|
||||
"nav.freezeTitle": "Ready to reveal?",
|
||||
"nav.freezeHint": "Moving forward will freeze your suggestions. The suggested game names become locked and can't be edited or deleted anymore, only the optional extra details stay editable.",
|
||||
"nav.freezeModalTitle": "Freeze suggestions?",
|
||||
"nav.freezeModalBody": "Once you leave Suggest, your games are locked: game names cannot be changed or deleted. Only optional details (description, links, players, artwork) remain editable. Continue?",
|
||||
"nav.voteHint": "Cast votes for every game to unlock results.",
|
||||
"nav.voteFinalized": "✅ You finalized your votes. Sit back and relax while the other players finalize their votes.",
|
||||
"suggest.title": "Suggest games (up to 5)",
|
||||
"suggest.new": "Add new suggestion",
|
||||
"suggest.addButton": "Suggest a game",
|
||||
"suggest.maxReached": "max limit reached",
|
||||
"suggest.jokerAddButton": "🃏 Joker: add another game",
|
||||
"suggest.hint": "Only you can see your suggestions until voting starts.",
|
||||
"form.gameName": "Game name *",
|
||||
"form.genre": "Genre",
|
||||
"form.description": "Description",
|
||||
"form.players": "Players",
|
||||
"form.min": "Min",
|
||||
"form.max": "Max",
|
||||
"form.screenshot": "Screenshot URL",
|
||||
"form.youtube": "YouTube URL",
|
||||
"form.gameUrl": "Game website URL",
|
||||
"form.submit": "Submit",
|
||||
"form.placeholder.description": "Short description",
|
||||
"form.placeholder.gameName": "Game name *",
|
||||
"form.placeholder.genre": "Genre",
|
||||
"form.placeholder.screenshot": "Screenshot URL",
|
||||
"form.placeholder.youtube": "YouTube URL",
|
||||
"form.placeholder.gameUrl": "Game website URL",
|
||||
"form.playersInvalid": "Players must be between 1 and 32, and min cannot exceed max.",
|
||||
"form.screenshotHint": "Use a public direct image link (http/https), max 5 MB. Avoid shortlinks/redirects.",
|
||||
"form.screenshotInvalid": "Screenshot must be a direct http/https image URL (png, jpg, jpeg, gif, webp, avif) under 5 MB and not a redirect/shortlink.",
|
||||
"section.mySuggestions": "Your suggestions",
|
||||
"section.allSuggestions": "All suggestions",
|
||||
"section.allSuggestions.count": "All {count} suggestions",
|
||||
"section.vote": "Vote 0-10",
|
||||
"section.vote.count": "Vote for all {count} games",
|
||||
"section.results": "Results",
|
||||
"card.edit": "Edit",
|
||||
"card.delete": "Delete",
|
||||
"card.players": "Players: {min}–{max}",
|
||||
"card.site": "Site ↗",
|
||||
"card.youtube": "YouTube ↗",
|
||||
"card.openScreenshot": "Open screenshot",
|
||||
"card.linked": "Votes linked",
|
||||
"card.linkedWith": "Linked with: {names}",
|
||||
"vote.saved": "Saved vote",
|
||||
"vote.missing": "Missing",
|
||||
"vote.missingWarn": "You haven’t voted yet.",
|
||||
"vote.missingFinalWarn": "You didn't vote for this game.",
|
||||
"vote.missingFooter": "At least one game is missing a score. Check before finalizing.",
|
||||
"vote.finalize": "Finalize votes",
|
||||
"vote.unfinalize": "Edit votes",
|
||||
"vote.finalHint": "Finalize when you’re done. You can unfinalize to change scores.",
|
||||
"vote.waitAdmin": "Waiting for admin to unlock results.",
|
||||
"vote.finalizeMissingTitle": "Finalize with missing votes?",
|
||||
"vote.finalizeMissingBody": "You still have {count} game(s) without a score. Finalizing will mark you as done while those stay unrated.",
|
||||
"vote.finalizeMissingConfirm": "Finalize anyway",
|
||||
"results.rank": "Rank",
|
||||
"results.game": "Game",
|
||||
"results.author": "Author",
|
||||
"results.average": "Ø",
|
||||
"results.votesList": "All votes",
|
||||
"results.myVote": "Your vote",
|
||||
"results.votersTooltip": "Voted by: {users}",
|
||||
"results.votersTooltipEmpty": "No votes yet",
|
||||
"results.links": "Links",
|
||||
"results.link.site": "Site ↗",
|
||||
"results.link.youtube": "YouTube ↗",
|
||||
"results.relockedTitle": "Results closed",
|
||||
"results.relockedBody": "Results have been locked again. You’re back in the voting phase and your finalized status was cleared. Adjust scores and re-finalize when ready.",
|
||||
"results.relockedConfirm": "Got it",
|
||||
"vote.listUpdatedTitle": "Vote list updated",
|
||||
"vote.listUpdatedBody": "New or linked games: {names}",
|
||||
"vote.listUpdatedConfirm": "OK",
|
||||
"admin.title": "Admin",
|
||||
"admin.tools": "Admin tools",
|
||||
"admin.resultsOpenToggle": "Allow results phase",
|
||||
"admin.resultsOpenEnable": "Enable results phase",
|
||||
"admin.resultsOpenDisable": "Disable results phase",
|
||||
"admin.resultsLocked": "Results locked by admin",
|
||||
"admin.resultsUpdated": "Results availability updated",
|
||||
"admin.reset": "Reset (keep players)",
|
||||
"admin.factoryReset": "Factory reset",
|
||||
"admin.resetConfirmTitle": "Reset round data?",
|
||||
"admin.resetConfirmBody": "This clears suggestions and votes while keeping accounts. Enter your admin password to continue.",
|
||||
"admin.factoryResetConfirmTitle": "Factory reset everything?",
|
||||
"admin.factoryResetConfirmBody": "This removes all players, suggestions, and votes. Enter your admin password to continue.",
|
||||
"admin.confirmPasswordLabel": "Admin password",
|
||||
"admin.confirmPasswordRequired": "Admin password is required.",
|
||||
"admin.resetDone": "Reset complete",
|
||||
"admin.factoryResetDone": "Factory reset complete",
|
||||
"admin.readyForResults": "Ready for results",
|
||||
"admin.waitingForPlayers": "Waiting for players: {names}",
|
||||
"admin.playerName": "Name",
|
||||
"admin.playerUsername": "Username",
|
||||
"admin.playerStatus": "Status",
|
||||
"admin.playerGames": "Games",
|
||||
"admin.playerJoker": "Joker",
|
||||
"admin.playerAdmin": "Admin",
|
||||
"admin.playerDelete": "Delete",
|
||||
"admin.owner": "owner",
|
||||
"admin.grantJokerChip": "Grant",
|
||||
"admin.statusSuggesting": "Suggesting",
|
||||
"admin.statusVoting": "Voting",
|
||||
"admin.statusFinished": "Finished",
|
||||
"admin.statusMoveToSuggest": "Move to Suggest",
|
||||
"admin.statusUpdated": "Player phase updated",
|
||||
"admin.roleUpdated": "Admin role updated",
|
||||
"admin.deleteTitle": "Delete account?",
|
||||
"admin.deleteBody": "Delete player \"{name}\" and all their games and votes? This cannot be undone.",
|
||||
"admin.deleteConfirm": "Delete",
|
||||
"admin.deleteDone": "Player deleted",
|
||||
"admin.jokerGranted": "Joker granted",
|
||||
"admin.linkTitle": "Link games",
|
||||
"admin.linkSource": "Game to link",
|
||||
"admin.linkTarget": "Link to (parent)",
|
||||
"admin.linkAction": "Link & clear votes",
|
||||
"admin.linkSourcePlaceholder": "Select source",
|
||||
"admin.linkTargetPlaceholder": "Select target",
|
||||
"admin.linkValidation": "Choose two different games to link.",
|
||||
"admin.linkDone": "Games linked. Votes cleared.",
|
||||
"admin.unlinkTitle": "Remove links?",
|
||||
"admin.unlinkBody": "Remove all links involving \"{name}\"? This clears votes and unfinalizes voters in this group: {peers}.",
|
||||
"admin.unlinkConfirm": "Remove links",
|
||||
"admin.unlinkDone": "Links removed. Votes cleared.",
|
||||
"admin.unlinkUnknownPeers": "linked games",
|
||||
"toast.unexpected": "Unexpected error",
|
||||
"toast.registered": "Registered",
|
||||
"toast.loggedIn": "Logged in",
|
||||
"toast.suggestionAdded": "Suggestion added",
|
||||
"toast.suggestionDeleted": "Suggestion deleted",
|
||||
"toast.savedChanges": "Saved changes",
|
||||
"toast.nameRequired": "Name required",
|
||||
"toast.displayNameRequired": "Display name is required",
|
||||
"toast.invalidImageUrl": "Screenshot URL must be http(s) and end with an image file.",
|
||||
"modal.editTitle": "Edit game",
|
||||
"modal.addTitle": "Suggest a game",
|
||||
"modal.confirmDeleteTitle": "Are you sure?",
|
||||
"modal.confirmDelete": "Confirm delete",
|
||||
"modal.save": "Save changes",
|
||||
"modal.cancel": "Cancel",
|
||||
"modal.close": "Close",
|
||||
"lightbox.close": "Close",
|
||||
"help.label": "Help",
|
||||
"help.title": "FAQ & tips"
|
||||
},
|
||||
"de": {
|
||||
"lang.label": "Sprache",
|
||||
"lang.en": "Englisch",
|
||||
"lang.de": "Deutsch",
|
||||
"auth.loginTab": "Anmelden",
|
||||
"auth.registerTab": "Registrieren",
|
||||
"auth.username": "Benutzername",
|
||||
"auth.password": "Passwort",
|
||||
"auth.displayName": "Anzeigename (für die Gruppe sichtbar)",
|
||||
"auth.adminKey": "Admin-Schlüssel (optional)",
|
||||
"auth.loginSubmit": "Anmelden",
|
||||
"auth.registerSubmit": "Konto erstellen",
|
||||
"auth.loginHeading": "Anmelden",
|
||||
"auth.registerHeading": "Konto erstellen",
|
||||
"auth.switchToRegister": "Noch kein Konto? Registrieren",
|
||||
"auth.switchToLogin": "Schon ein Konto? Anmelden",
|
||||
"auth.cookieLabel": "Ich stimme der Nutzung erforderlicher Cookies zu.",
|
||||
"auth.cookieRequired": "Bitte stimme den erforderlichen Cookies zu.",
|
||||
"auth.logout": "Abmelden",
|
||||
"auth.welcome": "Willkommen, {name}!",
|
||||
"auth.defaultName": "Spieler",
|
||||
"auth.loading": "Lädt…",
|
||||
"auth.needCredentials": "Benutzername und Passwort erforderlich",
|
||||
"auth.invalidCredentials": "Ungültiger Benutzername oder Passwort",
|
||||
"counts.format": "Spieler: {players} • Vorschläge: {suggestions} • Stimmen: {votes}",
|
||||
"nav.prev": "Zurück",
|
||||
"nav.next": "Weiter",
|
||||
"nav.addSuggestionFirst": "Zuerst ein Spiel vorschlagen",
|
||||
"nav.waitingForResults": "Warten…",
|
||||
"nav.freezeTitle": "Bereit zum Aufdecken?",
|
||||
"nav.freezeHint": "Beim Weitergehen werden deine Vorschläge eingefroren. Die vorgeschlagene Spiele werden gesperrt und können hinterher nicht mehr abgeändert werden; abgesehen von den Zusatzinfos, diese bleiben bearbeitbar.",
|
||||
"nav.freezeModalTitle": "Vorschläge einfrieren?",
|
||||
"nav.freezeModalBody": "Sobald du die Vorschlagsphase verlässt, sind deine Spiele gesperrt: Die Namen von deinen Spielen können nicht mehr geändert oder gelöscht werden. Nur optionale Angaben (Beschreibung, Links, Spielerzahlen, Bilder) bleiben bearbeitbar. Fortfahren?",
|
||||
"nav.voteHint": "Bewerte alle Spiele, um die Ergebnisse freizuschalten.",
|
||||
"nav.voteFinalized": "✅ Du hast deine Abstimmung abgeschlossen. Lehn dich zurück, bis die anderen fertig sind.",
|
||||
"suggest.title": "Schlage Spiele vor (bis zu 5)",
|
||||
"suggest.new": "Neuen Vorschlag hinzufügen",
|
||||
"suggest.addButton": "Spiel vorschlagen",
|
||||
"suggest.maxReached": "Limit erreicht",
|
||||
"suggest.jokerAddButton": "🃏 Joker: Weiteres Spiel hinzufügen",
|
||||
"suggest.hint": "Nur du siehst deine Vorschläge bis zum Start der Abstimmung.",
|
||||
"form.gameName": "Spielname *",
|
||||
"form.genre": "Genre",
|
||||
"form.description": "Beschreibung",
|
||||
"form.players": "Spieler",
|
||||
"form.min": "Min",
|
||||
"form.max": "Max",
|
||||
"form.screenshot": "Screenshot-URL",
|
||||
"form.youtube": "YouTube-URL",
|
||||
"form.gameUrl": "Spiel-Webseite",
|
||||
"form.submit": "Absenden",
|
||||
"form.placeholder.description": "Kurze Beschreibung",
|
||||
"form.placeholder.gameName": "Spielname *",
|
||||
"form.placeholder.genre": "Genre",
|
||||
"form.placeholder.screenshot": "Screenshot-URL",
|
||||
"form.placeholder.youtube": "YouTube-URL",
|
||||
"form.placeholder.gameUrl": "Spiel-Webseite",
|
||||
"form.playersInvalid": "Spielerzahl muss zwischen 1 und 32 liegen, und Min darf Max nicht überschreiten.",
|
||||
"form.screenshotHint": "Nutze einen öffentlichen Bildlink (http/https), max. 5 MB. Keine Kurzlinks/Weiterleitungen.",
|
||||
"form.screenshotInvalid": "Screenshot muss eine direkte http/https-Bild-URL sein (png, jpg, jpeg, gif, webp, avif), unter 5 MB und ohne Weiterleitung/Kurzlink.",
|
||||
"section.mySuggestions": "Deine Vorschläge",
|
||||
"section.allSuggestions": "Alle Vorschläge",
|
||||
"section.allSuggestions.count": "Alle {count} Vorschläge",
|
||||
"section.vote": "Bewerten 0-10",
|
||||
"section.vote.count": "Bewerte alle {count} Spiele",
|
||||
"section.results": "Ergebnisse",
|
||||
"card.edit": "Bearbeiten",
|
||||
"card.delete": "Löschen",
|
||||
"card.players": "Spieler: {min}–{max}",
|
||||
"card.site": "Webseite ↗",
|
||||
"card.youtube": "YouTube ↗",
|
||||
"card.openScreenshot": "Screenshot öffnen",
|
||||
"card.linked": "Verknüpfte Stimmen",
|
||||
"card.linkedWith": "Verknüpft mit: {names}",
|
||||
"vote.saved": "Stimme gespeichert",
|
||||
"vote.missing": "Fehlt",
|
||||
"vote.missingWarn": "Du hast hier noch nicht abgestimmt.",
|
||||
"vote.missingFinalWarn": "Du hast für dieses Spiel nicht abgestimmt.",
|
||||
"vote.missingFooter": "Für mindestens einen Spiel fehlt noch eine Wertung. Prüfe vor dem Abschließen.",
|
||||
"vote.finalize": "Abstimmung abschließen",
|
||||
"vote.unfinalize": "Abstimmung bearbeiten",
|
||||
"vote.finalHint": "Schließe ab, wenn du fertig bist. Zum Ändern wieder öffnen.",
|
||||
"vote.waitAdmin": "Warten, bis der Admin die Ergebnisse freigibt.",
|
||||
"vote.finalizeMissingTitle": "Mit fehlenden Stimmen abschließen?",
|
||||
"vote.finalizeMissingBody": "Für {count} Spiel(e) fehlt noch eine Wertung. Beim Abschließen gilt deine Abstimmung als fertig, obwohl diese Spiele unbewertet bleiben.",
|
||||
"vote.finalizeMissingConfirm": "Trotzdem abschließen",
|
||||
"results.rank": "Rang",
|
||||
"results.game": "Spiel",
|
||||
"results.author": "Autor",
|
||||
"results.average": "Ø",
|
||||
"results.votesList": "Alle Stimmen",
|
||||
"results.myVote": "Deine Stimme",
|
||||
"results.votersTooltip": "Abgestimmt von: {users}",
|
||||
"results.votersTooltipEmpty": "Noch keine Stimmen",
|
||||
"results.links": "Links",
|
||||
"results.link.site": "Webseite ↗",
|
||||
"results.link.youtube": "YouTube ↗",
|
||||
"results.relockedTitle": "Ergebnisse geschlossen",
|
||||
"results.relockedBody": "Die Ergebnisse wurden wieder gesperrt. Du bist zurück in der Bewertungsphase und deine Finalisierung wurde zurückgesetzt. Passe deine Bewertungen an und schließe erneut ab, wenn du bereit bist.",
|
||||
"results.relockedConfirm": "Verstanden",
|
||||
"vote.listUpdatedTitle": "Liste aktualisiert",
|
||||
"vote.listUpdatedBody": "Neue oder verknüpfte Spiele: {names}",
|
||||
"vote.listUpdatedConfirm": "OK",
|
||||
"admin.title": "Admin",
|
||||
"admin.tools": "Admin-Werkzeuge",
|
||||
"admin.resultsOpenToggle": "Ergebnisse freigeben",
|
||||
"admin.resultsOpenEnable": "Ergebnisse freigeben",
|
||||
"admin.resultsOpenDisable": "Ergebnisse sperren",
|
||||
"admin.resultsLocked": "Ergebnisse vom Admin gesperrt",
|
||||
"admin.resultsUpdated": "Ergebnisfreigabe aktualisiert",
|
||||
"admin.reset": "Zurücksetzen (Spieler behalten)",
|
||||
"admin.factoryReset": "Werkseinstellung",
|
||||
"admin.resetConfirmTitle": "Rundendaten zurücksetzen?",
|
||||
"admin.resetConfirmBody": "Dadurch werden Vorschläge und Stimmen gelöscht, die Konten bleiben erhalten. Gib dein Admin-Passwort ein, um fortzufahren.",
|
||||
"admin.factoryResetConfirmTitle": "Alles auf Werkseinstellung setzen?",
|
||||
"admin.factoryResetConfirmBody": "Dadurch werden alle Spieler, Vorschläge und Stimmen gelöscht. Gib dein Admin-Passwort ein, um fortzufahren.",
|
||||
"admin.confirmPasswordLabel": "Admin-Passwort",
|
||||
"admin.confirmPasswordRequired": "Admin-Passwort ist erforderlich.",
|
||||
"admin.resetDone": "Zurücksetzen abgeschlossen",
|
||||
"admin.factoryResetDone": "Werkseinstellung abgeschlossen",
|
||||
"admin.readyForResults": "Bereit für Ergebnisse",
|
||||
"admin.waitingForPlayers": "Warten auf: {names}",
|
||||
"admin.playerName": "Name",
|
||||
"admin.playerUsername": "Benutzername",
|
||||
"admin.playerStatus": "Status",
|
||||
"admin.playerGames": "Spiele",
|
||||
"admin.playerJoker": "Joker",
|
||||
"admin.playerAdmin": "Admin",
|
||||
"admin.playerDelete": "Löschen",
|
||||
"admin.owner": "owner",
|
||||
"admin.grantJokerChip": "Joker",
|
||||
"admin.statusSuggesting": "Vorschlagen",
|
||||
"admin.statusVoting": "Bewerten",
|
||||
"admin.statusFinished": "Fertig",
|
||||
"admin.statusMoveToSuggest": "Zur Vorschlagsphase",
|
||||
"admin.statusUpdated": "Spielerphase aktualisiert",
|
||||
"admin.roleUpdated": "Admin-Rolle aktualisiert",
|
||||
"admin.deleteTitle": "Konto löschen?",
|
||||
"admin.deleteBody": "Spieler \"{name}\" samt Spielen und Stimmen löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"admin.deleteConfirm": "Löschen",
|
||||
"admin.deleteDone": "Spieler gelöscht",
|
||||
"admin.jokerGranted": "Joker vergeben",
|
||||
"admin.linkTitle": "Spiele verknüpfen",
|
||||
"admin.linkSource": "Spiel verknüpfen",
|
||||
"admin.linkTarget": "Verknüpfen mit",
|
||||
"admin.linkAction": "Verknüpfen & Stimmen löschen",
|
||||
"admin.linkSourcePlaceholder": "Quelle wählen",
|
||||
"admin.linkTargetPlaceholder": "Ziel wählen",
|
||||
"admin.linkValidation": "Wähle zwei verschiedene Spiele aus.",
|
||||
"admin.linkDone": "Spiele verknüpft. Stimmen gelöscht.",
|
||||
"admin.unlinkTitle": "Links entfernen?",
|
||||
"admin.unlinkBody": "Alle Links zu \"{name}\" entfernen? Dadurch werden Stimmen gelöscht und Finalisierungen aufgehoben für: {peers}.",
|
||||
"admin.unlinkConfirm": "Links entfernen",
|
||||
"admin.unlinkDone": "Links entfernt. Stimmen gelöscht.",
|
||||
"admin.unlinkUnknownPeers": "verknüpfte Spiele",
|
||||
"toast.unexpected": "Unerwarteter Fehler",
|
||||
"toast.registered": "Registriert",
|
||||
"toast.loggedIn": "Angemeldet",
|
||||
"toast.suggestionAdded": "Vorschlag hinzugefügt",
|
||||
"toast.suggestionDeleted": "Vorschlag gelöscht",
|
||||
"toast.savedChanges": "Änderungen gespeichert",
|
||||
"toast.nameRequired": "Name erforderlich",
|
||||
"toast.displayNameRequired": "Anzeigename ist erforderlich",
|
||||
"toast.invalidImageUrl": "Screenshot-URL muss mit http(s) beginnen und auf eine Bilddatei enden.",
|
||||
"modal.editTitle": "Spiel bearbeiten",
|
||||
"modal.addTitle": "Spiel vorschlagen",
|
||||
"modal.confirmDeleteTitle": "Bist du sicher?",
|
||||
"modal.confirmDelete": "Löschen bestätigen",
|
||||
"modal.save": "Änderungen speichern",
|
||||
"modal.cancel": "Abbrechen",
|
||||
"modal.close": "Schließen",
|
||||
"lightbox.close": "Schließen",
|
||||
"help.label": "Hilfe",
|
||||
"help.title": "FAQ & Tipps"
|
||||
}
|
||||
}
|
||||
@@ -62,7 +62,7 @@
|
||||
<span class="label" data-i18n="auth.displayName">Display name (shows to group)</span>
|
||||
<input id="register-displayName" name="displayName" maxlength="16" required />
|
||||
</label>
|
||||
<label class="stack">
|
||||
<label class="stack" id="register-admin-key-field">
|
||||
<span class="label" data-i18n="auth.adminKey">Admin key (optional)</span>
|
||||
<input id="register-adminkey" name="adminKey" type="password" maxlength="128" />
|
||||
</label>
|
||||
@@ -99,6 +99,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main class="grid">
|
||||
@@ -121,6 +122,7 @@
|
||||
<p data-i18n="nav.freezeHint">Moving forward will freeze your suggestions. Titles become locked; only extra details stay editable.</p>
|
||||
</div>
|
||||
<div class="nav-actions">
|
||||
<span id="nav-suggest-hint" class="muted nav-hint" data-i18n="nav.addSuggestionFirst">Add a game first</span>
|
||||
<button id="nav-suggest-next" class="primary" data-i18n="nav.next">Next</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -171,6 +173,7 @@
|
||||
<th data-i18n="admin.playerStatus">Status</th>
|
||||
<th data-i18n="admin.playerGames">Games</th>
|
||||
<th data-i18n="admin.playerJoker">Joker</th>
|
||||
<th data-i18n="admin.playerAdmin">Admin</th>
|
||||
<th data-i18n="admin.playerDelete">Delete</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -178,10 +181,7 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<label class="stack toggle-row">
|
||||
<input type="checkbox" id="results-open" />
|
||||
<span data-i18n="admin.resultsOpenToggle">Allow results phase</span>
|
||||
</label>
|
||||
<button id="results-open" class="secondary" type="button" data-i18n="admin.resultsOpenEnable">Enable results phase</button>
|
||||
<div class="stack hidden" id="admin-linker">
|
||||
<h4 data-i18n="admin.linkTitle">Link games</h4>
|
||||
<label class="stack">
|
||||
|
||||
179
wwwroot/js/admin-ui.js
Normal file
179
wwwroot/js/admin-ui.js
Normal file
@@ -0,0 +1,179 @@
|
||||
import { t } from "./i18n.js";
|
||||
import { state } from "./state.js";
|
||||
import { $ } from "./dom.js";
|
||||
import { buildLinkOptionLabel, truncate } from "./ui-utils.js";
|
||||
|
||||
function displayPlayerStatus(player) {
|
||||
if (!player) return "";
|
||||
const phase = player.phase;
|
||||
if (phase === "Suggest") return t("admin.statusSuggesting");
|
||||
if (phase === "Vote")
|
||||
return player.finalized
|
||||
? t("admin.statusFinished")
|
||||
: t("admin.statusVoting");
|
||||
if (phase === "Results") return t("admin.statusFinished");
|
||||
return phase;
|
||||
}
|
||||
|
||||
function buildStatusSelect(player) {
|
||||
const canMoveToSuggest = player.phase === "Vote";
|
||||
const select = document.createElement("select");
|
||||
select.className = "chip admin-status-select";
|
||||
select.dataset.setPlayerPhase = player.playerId;
|
||||
select.setAttribute("aria-label", t("admin.playerStatus"));
|
||||
|
||||
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() {
|
||||
if (!state.me?.isAdmin) return;
|
||||
if (state.adminStatusSelectActive) return;
|
||||
const statusBadge = $("admin-ready-status");
|
||||
const table = $("admin-player-table")?.querySelector("tbody");
|
||||
if (!state.adminVoteStatus || !statusBadge || !table) return;
|
||||
|
||||
table.innerHTML = "";
|
||||
state.adminVoteStatus.voters.forEach((v) => {
|
||||
const tr = document.createElement("tr");
|
||||
const gamesTooltip = (v.suggestionTitles || []).join(", ");
|
||||
|
||||
const nameCell = document.createElement("td");
|
||||
nameCell.title = v.name ?? "";
|
||||
nameCell.textContent = truncate(v.name, 28);
|
||||
|
||||
const usernameCell = document.createElement("td");
|
||||
usernameCell.className = "muted small";
|
||||
usernameCell.title = v.username ?? "";
|
||||
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);
|
||||
});
|
||||
|
||||
const waiting = state.adminVoteStatus.waiting;
|
||||
const ready = waiting.length === 0;
|
||||
const waitingDisplay = waiting.map((name) =>
|
||||
name?.length > 24 ? `${name.slice(0, 21)}...` : name,
|
||||
);
|
||||
statusBadge.textContent = ready
|
||||
? t("admin.readyForResults")
|
||||
: t("admin.waitingForPlayers", { names: waitingDisplay.join(", ") });
|
||||
statusBadge.className = ready ? "badge" : "badge warning";
|
||||
}
|
||||
|
||||
export function renderAdminLinker() {
|
||||
const wrap = $("admin-linker");
|
||||
const source = $("link-source");
|
||||
const target = $("link-target");
|
||||
if (!wrap || !source || !target) return;
|
||||
|
||||
const visible = state.me?.isAdmin && state.phase === "Vote";
|
||||
wrap.classList.toggle("hidden", !visible);
|
||||
if (!visible) return;
|
||||
|
||||
const previousSource = source.value;
|
||||
const previousTarget = target.value;
|
||||
const options = (state.allSuggestions ?? [])
|
||||
.slice()
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
const fillSelect = (select, placeholderKey) => {
|
||||
select.innerHTML = "";
|
||||
const placeholder = document.createElement("option");
|
||||
placeholder.value = "";
|
||||
placeholder.textContent = t(placeholderKey);
|
||||
placeholder.disabled = true;
|
||||
placeholder.selected = true;
|
||||
select.appendChild(placeholder);
|
||||
|
||||
options.forEach((s) => {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = s.id;
|
||||
opt.textContent = buildLinkOptionLabel(s);
|
||||
select.appendChild(opt);
|
||||
});
|
||||
};
|
||||
|
||||
fillSelect(source, "admin.linkSourcePlaceholder");
|
||||
fillSelect(target, "admin.linkTargetPlaceholder");
|
||||
|
||||
if (previousSource && options.some((s) => String(s.id) === previousSource))
|
||||
source.value = previousSource;
|
||||
if (previousTarget && options.some((s) => String(s.id) === previousTarget))
|
||||
target.value = previousTarget;
|
||||
|
||||
const preventSameSelection = () => {
|
||||
const sourceVal = source.value;
|
||||
const targetVal = target.value;
|
||||
Array.from(target.options).forEach((opt) => {
|
||||
if (!opt.value) return;
|
||||
opt.disabled = opt.value === sourceVal;
|
||||
});
|
||||
Array.from(source.options).forEach((opt) => {
|
||||
if (!opt.value) return;
|
||||
opt.disabled = opt.value === targetVal;
|
||||
});
|
||||
};
|
||||
|
||||
source.onchange = preventSameSelection;
|
||||
target.onchange = preventSameSelection;
|
||||
preventSameSelection();
|
||||
}
|
||||
303
wwwroot/js/api-client.generated.js
Normal file
303
wwwroot/js/api-client.generated.js
Normal file
@@ -0,0 +1,303 @@
|
||||
// AUTO-GENERATED FILE. DO NOT EDIT.
|
||||
// Source: scripts/generate-api-client.mjs and openapi/GameList.json
|
||||
|
||||
const defaultHeaders = { "Content-Type": "application/json" };
|
||||
|
||||
const rawBase = document.querySelector('meta[name="app-base"]')?.content || "";
|
||||
const basePath = normalizeBase(rawBase);
|
||||
const withBase = (routePath) => `${basePath}${routePath}`;
|
||||
|
||||
function normalizeBase(value) {
|
||||
if (!value) return "";
|
||||
if (!value.startsWith("/")) return `/${value}`;
|
||||
return value.endsWith("/") ? value.slice(0, -1) : value;
|
||||
}
|
||||
|
||||
function toApiError(res, fallbackMessage = `${res.status}`) {
|
||||
const err = new Error(fallbackMessage);
|
||||
err.status = res.status;
|
||||
return err;
|
||||
}
|
||||
|
||||
function buildPath(template, pathParameters = {}) {
|
||||
return template.replace(/{([^}]+)}/g, (_, key) => {
|
||||
const value = pathParameters[key];
|
||||
if (value === undefined || value === null) {
|
||||
throw new Error(
|
||||
`Missing path parameter "${key}" for route ${template}`,
|
||||
);
|
||||
}
|
||||
|
||||
return encodeURIComponent(String(value));
|
||||
});
|
||||
}
|
||||
|
||||
async function parseApiError(res) {
|
||||
try {
|
||||
const data = await res.json();
|
||||
const message =
|
||||
data.error || data.detail || data.title || JSON.stringify(data);
|
||||
return toApiError(res, message);
|
||||
} catch {
|
||||
return toApiError(res);
|
||||
}
|
||||
}
|
||||
|
||||
export const operations = Object.freeze({
|
||||
CreateSuggestion: {
|
||||
method: "POST",
|
||||
path: "/api/suggestions",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
DeletePlayer: {
|
||||
method: "DELETE",
|
||||
path: "/api/admin/players/{playerId}",
|
||||
hasBody: true,
|
||||
pathParameters: ["playerId"],
|
||||
},
|
||||
DeleteSuggestion: {
|
||||
method: "DELETE",
|
||||
path: "/api/suggestions/{id}",
|
||||
hasBody: false,
|
||||
pathParameters: ["id"],
|
||||
},
|
||||
FactoryReset: {
|
||||
method: "POST",
|
||||
path: "/api/admin/factory-reset",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
GetAllSuggestions: {
|
||||
method: "GET",
|
||||
path: "/api/suggestions/all",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
GetAuthOptions: {
|
||||
method: "GET",
|
||||
path: "/api/auth/options",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
GetMe: {
|
||||
method: "GET",
|
||||
path: "/api/me",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
GetMySuggestions: {
|
||||
method: "GET",
|
||||
path: "/api/suggestions/mine",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
GetMyVotes: {
|
||||
method: "GET",
|
||||
path: "/api/votes/mine",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
GetResults: {
|
||||
method: "GET",
|
||||
path: "/api/results",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
GetState: {
|
||||
method: "GET",
|
||||
path: "/api/state",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
GetStateEvents: {
|
||||
method: "GET",
|
||||
path: "/api/events/state",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
GetVoteStatus: {
|
||||
method: "GET",
|
||||
path: "/api/admin/vote-status",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
GrantJoker: {
|
||||
method: "POST",
|
||||
path: "/api/admin/joker",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
LinkSuggestions: {
|
||||
method: "POST",
|
||||
path: "/api/admin/link-suggestions",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
Login: {
|
||||
method: "POST",
|
||||
path: "/api/auth/login",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
Logout: {
|
||||
method: "POST",
|
||||
path: "/api/auth/logout",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
NextPhase: {
|
||||
method: "POST",
|
||||
path: "/api/me/phase/next",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
PrevPhase: {
|
||||
method: "POST",
|
||||
path: "/api/me/phase/prev",
|
||||
hasBody: false,
|
||||
pathParameters: [],
|
||||
},
|
||||
Register: {
|
||||
method: "POST",
|
||||
path: "/api/auth/register",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
Reset: {
|
||||
method: "POST",
|
||||
path: "/api/admin/reset",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
SetPlayerAdmin: {
|
||||
method: "POST",
|
||||
path: "/api/admin/player-admin",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
SetPlayerPhase: {
|
||||
method: "POST",
|
||||
path: "/api/admin/player-phase",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
SetResultsOpen: {
|
||||
method: "POST",
|
||||
path: "/api/admin/results",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
SetVotesFinalized: {
|
||||
method: "POST",
|
||||
path: "/api/votes/finalize",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
UnlinkSuggestions: {
|
||||
method: "POST",
|
||||
path: "/api/admin/unlink-suggestions",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
UpdateSuggestion: {
|
||||
method: "PUT",
|
||||
path: "/api/suggestions/{id}",
|
||||
hasBody: true,
|
||||
pathParameters: ["id"],
|
||||
},
|
||||
UpsertVote: {
|
||||
method: "POST",
|
||||
path: "/api/votes",
|
||||
hasBody: true,
|
||||
pathParameters: [],
|
||||
},
|
||||
});
|
||||
|
||||
export function resolveOperationPath(operationId, pathParameters = {}) {
|
||||
const operation = operations[operationId];
|
||||
if (!operation) {
|
||||
throw new Error(`Unknown operationId "${operationId}"`);
|
||||
}
|
||||
|
||||
return withBase(buildPath(operation.path, pathParameters));
|
||||
}
|
||||
|
||||
export async function requestOperation(
|
||||
operationId,
|
||||
{
|
||||
pathParameters = {},
|
||||
body,
|
||||
headers = {},
|
||||
raw = false,
|
||||
acceptStatuses = [],
|
||||
} = {},
|
||||
) {
|
||||
const operation = operations[operationId];
|
||||
if (!operation) {
|
||||
throw new Error(`Unknown operationId "${operationId}"`);
|
||||
}
|
||||
|
||||
const response = await fetch(
|
||||
resolveOperationPath(operationId, pathParameters),
|
||||
{
|
||||
method: operation.method,
|
||||
credentials: "same-origin",
|
||||
headers: { ...defaultHeaders, ...headers },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
|
||||
const acceptedStatusSet = new Set(acceptStatuses);
|
||||
if (!response.ok && !acceptedStatusSet.has(response.status)) {
|
||||
throw await parseApiError(response);
|
||||
}
|
||||
|
||||
if (raw) return response;
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export const apiClient = Object.freeze({
|
||||
getAuthOptions: (options = {}) =>
|
||||
requestOperation("GetAuthOptions", options),
|
||||
register: (options = {}) => requestOperation("Register", options),
|
||||
login: (options = {}) => requestOperation("Login", options),
|
||||
logout: (options = {}) => requestOperation("Logout", options),
|
||||
getState: (options = {}) => requestOperation("GetState", options),
|
||||
getStateEvents: (options = {}) =>
|
||||
requestOperation("GetStateEvents", options),
|
||||
getMe: (options = {}) => requestOperation("GetMe", options),
|
||||
nextPhase: (options = {}) => requestOperation("NextPhase", options),
|
||||
prevPhase: (options = {}) => requestOperation("PrevPhase", options),
|
||||
getMySuggestions: (options = {}) =>
|
||||
requestOperation("GetMySuggestions", options),
|
||||
createSuggestion: (options = {}) =>
|
||||
requestOperation("CreateSuggestion", options),
|
||||
deleteSuggestion: (options = {}) =>
|
||||
requestOperation("DeleteSuggestion", options),
|
||||
updateSuggestion: (options = {}) =>
|
||||
requestOperation("UpdateSuggestion", options),
|
||||
getAllSuggestions: (options = {}) =>
|
||||
requestOperation("GetAllSuggestions", options),
|
||||
getMyVotes: (options = {}) => requestOperation("GetMyVotes", options),
|
||||
upsertVote: (options = {}) => requestOperation("UpsertVote", options),
|
||||
setVotesFinalized: (options = {}) =>
|
||||
requestOperation("SetVotesFinalized", options),
|
||||
getResults: (options = {}) => requestOperation("GetResults", options),
|
||||
setResultsOpen: (options = {}) =>
|
||||
requestOperation("SetResultsOpen", options),
|
||||
getVoteStatus: (options = {}) => requestOperation("GetVoteStatus", options),
|
||||
grantJoker: (options = {}) => requestOperation("GrantJoker", options),
|
||||
setPlayerPhase: (options = {}) =>
|
||||
requestOperation("SetPlayerPhase", options),
|
||||
setPlayerAdmin: (options = {}) =>
|
||||
requestOperation("SetPlayerAdmin", options),
|
||||
deletePlayer: (options = {}) => requestOperation("DeletePlayer", options),
|
||||
linkSuggestions: (options = {}) =>
|
||||
requestOperation("LinkSuggestions", options),
|
||||
unlinkSuggestions: (options = {}) =>
|
||||
requestOperation("UnlinkSuggestions", options),
|
||||
reset: (options = {}) => requestOperation("Reset", options),
|
||||
factoryReset: (options = {}) => requestOperation("FactoryReset", options),
|
||||
});
|
||||
@@ -1,67 +1,91 @@
|
||||
const defaultHeaders = { "Content-Type": "application/json" };
|
||||
import { apiClient, resolveOperationPath } from "./api-client.generated.js";
|
||||
|
||||
const rawBase = document.querySelector('meta[name="app-base"]')?.content || "";
|
||||
const basePath = normalizeBase(rawBase);
|
||||
const withBase = (path) => `${basePath}${path}`;
|
||||
async function requestState(ifNoneMatch) {
|
||||
const headers = {};
|
||||
if (ifNoneMatch) headers["If-None-Match"] = ifNoneMatch;
|
||||
|
||||
function normalizeBase(value) {
|
||||
if (!value) return "";
|
||||
if (!value.startsWith("/")) return `/${value}`;
|
||||
return value.endsWith("/") ? value.slice(0, -1) : value;
|
||||
}
|
||||
|
||||
async function request(path, { method = "GET", body } = {}) {
|
||||
const res = await fetch(withBase(path), {
|
||||
method,
|
||||
credentials: "same-origin",
|
||||
headers: defaultHeaders,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
const res = await apiClient.getState({
|
||||
headers,
|
||||
raw: true,
|
||||
acceptStatuses: [304],
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let msg = `${res.status}`;
|
||||
try {
|
||||
const data = await res.json();
|
||||
msg = data.error || JSON.stringify(data);
|
||||
} catch { /* ignore */ }
|
||||
const err = new Error(msg);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
if (res.status === 304) {
|
||||
return {
|
||||
notModified: true,
|
||||
etag: res.headers.get("ETag"),
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
return res.status === 204 ? null : res.json();
|
||||
|
||||
return {
|
||||
notModified: false,
|
||||
etag: res.headers.get("ETag"),
|
||||
data: await res.json(),
|
||||
};
|
||||
}
|
||||
|
||||
export const api = {
|
||||
state: () => request("/api/state"),
|
||||
me: () => request("/api/me"),
|
||||
register: (payload) => request("/api/auth/register", { method: "POST", body: payload }),
|
||||
login: (payload) => request("/api/auth/login", { method: "POST", body: payload }),
|
||||
logout: () => request("/api/auth/logout", { method: "POST" }),
|
||||
state: (ifNoneMatch) => requestState(ifNoneMatch),
|
||||
stateEventsUrl: () => resolveOperationPath("GetStateEvents"),
|
||||
me: () => apiClient.getMe(),
|
||||
authOptions: () => apiClient.getAuthOptions(),
|
||||
register: (payload) => apiClient.register({ body: payload }),
|
||||
login: (payload) => apiClient.login({ body: payload }),
|
||||
logout: () => apiClient.logout(),
|
||||
|
||||
mySuggestions: () => request("/api/suggestions/mine"),
|
||||
createSuggestion: (payload) => request("/api/suggestions", { method: "POST", body: payload }),
|
||||
deleteSuggestion: (id) => request(`/api/suggestions/${id}`, { method: "DELETE" }),
|
||||
updateSuggestion: (id, payload) => request(`/api/suggestions/${id}`, { method: "PUT", body: payload }),
|
||||
allSuggestions: () => request("/api/suggestions/all"),
|
||||
mySuggestions: () => apiClient.getMySuggestions(),
|
||||
createSuggestion: (payload) =>
|
||||
apiClient.createSuggestion({ body: payload }),
|
||||
deleteSuggestion: (id) =>
|
||||
apiClient.deleteSuggestion({ pathParameters: { id } }),
|
||||
updateSuggestion: (id, payload) =>
|
||||
apiClient.updateSuggestion({ pathParameters: { id }, body: payload }),
|
||||
allSuggestions: () => apiClient.getAllSuggestions(),
|
||||
|
||||
myVotes: () => request("/api/votes/mine"),
|
||||
vote: (suggestionId, score) => request("/api/votes", { method: "POST", body: { suggestionId, score } }),
|
||||
finalizeVotes: (final) => request("/api/votes/finalize", { method: "POST", body: { final } }),
|
||||
myVotes: () => apiClient.getMyVotes(),
|
||||
vote: (suggestionId, score) =>
|
||||
apiClient.upsertVote({
|
||||
body: { suggestionId, score },
|
||||
}),
|
||||
finalizeVotes: (final) => apiClient.setVotesFinalized({ body: { final } }),
|
||||
|
||||
results: () => request("/api/results"),
|
||||
nextPhase: () => request("/api/me/phase/next", { method: "POST" }),
|
||||
prevPhase: () => request("/api/me/phase/prev", { method: "POST" }),
|
||||
results: () => apiClient.getResults(),
|
||||
nextPhase: () => apiClient.nextPhase(),
|
||||
prevPhase: () => apiClient.prevPhase(),
|
||||
};
|
||||
|
||||
export const adminApi = {
|
||||
setResultsOpen: (resultsOpen) => request("/api/admin/results", { method: "POST", body: { resultsOpen } }),
|
||||
voteStatus: () => request("/api/admin/vote-status"),
|
||||
reset: () => request("/api/admin/reset", { method: "POST" }),
|
||||
factoryReset: () => request("/api/admin/factory-reset", { method: "POST" }),
|
||||
grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }),
|
||||
deletePlayer: (playerId) => request(`/api/admin/players/${playerId}`, { method: "DELETE" }),
|
||||
setResultsOpen: (resultsOpen) =>
|
||||
apiClient.setResultsOpen({
|
||||
body: { resultsOpen },
|
||||
}),
|
||||
voteStatus: () => apiClient.getVoteStatus(),
|
||||
reset: (password) => apiClient.reset({ body: { password } }),
|
||||
factoryReset: (password) =>
|
||||
apiClient.factoryReset({
|
||||
body: { password },
|
||||
}),
|
||||
grantJoker: (playerId) => apiClient.grantJoker({ body: { playerId } }),
|
||||
setPlayerAdmin: (playerId, isAdmin) =>
|
||||
apiClient.setPlayerAdmin({
|
||||
body: { playerId, isAdmin },
|
||||
}),
|
||||
setPlayerPhase: (playerId, phase) =>
|
||||
apiClient.setPlayerPhase({
|
||||
body: { playerId, phase },
|
||||
}),
|
||||
deletePlayer: (playerId, password) =>
|
||||
apiClient.deletePlayer({
|
||||
pathParameters: { playerId },
|
||||
body: { password },
|
||||
}),
|
||||
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
|
||||
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }),
|
||||
apiClient.linkSuggestions({
|
||||
body: { sourceSuggestionId, targetSuggestionId },
|
||||
}),
|
||||
unlinkSuggestions: (suggestionId) =>
|
||||
request("/api/admin/unlink-suggestions", { method: "POST", body: { suggestionId } }),
|
||||
apiClient.unlinkSuggestions({
|
||||
body: { suggestionId },
|
||||
}),
|
||||
};
|
||||
|
||||
228
wwwroot/js/app-admin-handlers.js
Normal file
228
wwwroot/js/app-admin-handlers.js
Normal file
@@ -0,0 +1,228 @@
|
||||
import { adminApi } from "./api.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { state } from "./state.js";
|
||||
import { $, toast } from "./dom.js";
|
||||
import {
|
||||
openConfirmModal,
|
||||
openResultsRelockModal,
|
||||
renderPhasePill,
|
||||
} from "./ui.js";
|
||||
|
||||
function openAdminPasswordModal({ title, body, confirmLabel, onConfirm }) {
|
||||
openConfirmModal({
|
||||
title,
|
||||
body,
|
||||
confirmLabel,
|
||||
confirmClass: "danger",
|
||||
requirePassword: true,
|
||||
passwordLabel: t("admin.confirmPasswordLabel"),
|
||||
onConfirm: async (close, payload) => {
|
||||
const password = (payload?.password || "").trim();
|
||||
if (!password) {
|
||||
toast(t("admin.confirmPasswordRequired"), true);
|
||||
return;
|
||||
}
|
||||
await onConfirm(password, close);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function setupAdminPanelToggle() {
|
||||
const adminToggle = $("admin-toggle");
|
||||
const adminCard = $("admin-card");
|
||||
const adminClose = $("admin-close");
|
||||
if (!adminToggle || !adminCard || !adminClose) return;
|
||||
|
||||
const togglePanel = (show) => adminCard.classList.toggle("hidden", !show);
|
||||
adminToggle.addEventListener("click", () =>
|
||||
togglePanel(adminCard.classList.contains("hidden")),
|
||||
);
|
||||
adminClose.addEventListener("click", () => togglePanel(false));
|
||||
}
|
||||
|
||||
function setupResetButtons(runSerializedRefresh) {
|
||||
$("reset").addEventListener("click", () => {
|
||||
openAdminPasswordModal({
|
||||
title: t("admin.resetConfirmTitle"),
|
||||
body: t("admin.resetConfirmBody"),
|
||||
confirmLabel: t("admin.reset"),
|
||||
onConfirm: async (password, close) => {
|
||||
try {
|
||||
await adminApi.reset(password);
|
||||
toast(t("admin.resetDone"));
|
||||
close();
|
||||
await runSerializedRefresh();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
$("factory-reset").addEventListener("click", () => {
|
||||
openAdminPasswordModal({
|
||||
title: t("admin.factoryResetConfirmTitle"),
|
||||
body: t("admin.factoryResetConfirmBody"),
|
||||
confirmLabel: t("admin.factoryReset"),
|
||||
onConfirm: async (password, close) => {
|
||||
try {
|
||||
await adminApi.factoryReset(password);
|
||||
toast(t("admin.factoryResetDone"));
|
||||
close();
|
||||
await runSerializedRefresh();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function setupResultsToggle(runSerializedRefresh) {
|
||||
const resultsToggle = $("results-open");
|
||||
if (!resultsToggle) return;
|
||||
|
||||
resultsToggle.addEventListener("click", async () => {
|
||||
const desired = !state.resultsOpen;
|
||||
resultsToggle.disabled = true;
|
||||
try {
|
||||
const resp = await adminApi.setResultsOpen(desired);
|
||||
const wasResultsOpen = state.resultsOpen;
|
||||
const wasPhase = state.phase;
|
||||
state.resultsOpen = resp.resultsOpen;
|
||||
if (wasResultsOpen && !resp.resultsOpen && wasPhase === "Results") {
|
||||
openResultsRelockModal();
|
||||
}
|
||||
renderPhasePill();
|
||||
toast(t("admin.resultsUpdated"));
|
||||
await runSerializedRefresh();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
} finally {
|
||||
resultsToggle.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupLinkApply(runSerializedRefresh) {
|
||||
const linkApply = $("link-apply");
|
||||
if (!linkApply) return;
|
||||
|
||||
linkApply.addEventListener("click", async () => {
|
||||
const source = Number($("link-source")?.value);
|
||||
const target = Number($("link-target")?.value);
|
||||
if (!source || !target || source === target) {
|
||||
return toast(t("admin.linkValidation"), true);
|
||||
}
|
||||
try {
|
||||
await adminApi.linkSuggestions(source, target);
|
||||
toast(t("admin.linkDone"));
|
||||
await runSerializedRefresh();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupPlayerTableActions(runSerializedRefresh) {
|
||||
const playerTable = $("admin-player-table");
|
||||
if (!playerTable) return;
|
||||
const phaseSelectSelector = "[data-set-player-phase]";
|
||||
const adminCheckboxSelector = "[data-set-player-admin]";
|
||||
|
||||
playerTable.addEventListener("focusin", (e) => {
|
||||
if (e.target.matches?.(phaseSelectSelector)) {
|
||||
state.adminStatusSelectActive = true;
|
||||
}
|
||||
});
|
||||
|
||||
playerTable.addEventListener("focusout", (e) => {
|
||||
if (!e.target.matches?.(phaseSelectSelector)) return;
|
||||
window.setTimeout(() => {
|
||||
const focused = document.activeElement;
|
||||
state.adminStatusSelectActive =
|
||||
!!focused?.matches?.(phaseSelectSelector);
|
||||
}, 0);
|
||||
});
|
||||
|
||||
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);
|
||||
if (!select) return;
|
||||
const playerId = select.dataset.setPlayerPhase;
|
||||
const phase = select.value;
|
||||
if (!playerId || !phase) return;
|
||||
select.disabled = true;
|
||||
|
||||
try {
|
||||
await adminApi.setPlayerPhase(playerId, phase);
|
||||
toast(t("admin.statusUpdated"));
|
||||
state.adminStatusSelectActive = false;
|
||||
await runSerializedRefresh();
|
||||
} catch (err) {
|
||||
select.value = "";
|
||||
toast(err.message, true);
|
||||
} finally {
|
||||
select.disabled = false;
|
||||
state.adminStatusSelectActive = false;
|
||||
}
|
||||
});
|
||||
|
||||
playerTable.addEventListener("click", async (e) => {
|
||||
const grantBtn = e.target.closest("[data-grant-joker]");
|
||||
const deleteBtn = e.target.closest("[data-delete-player]");
|
||||
if (grantBtn) {
|
||||
const playerId = grantBtn.dataset.grantJoker;
|
||||
try {
|
||||
await adminApi.grantJoker(playerId);
|
||||
toast(t("admin.jokerGranted"));
|
||||
await runSerializedRefresh();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
} else if (deleteBtn) {
|
||||
const playerId = deleteBtn.dataset.deletePlayer;
|
||||
const name = deleteBtn.dataset.name || "";
|
||||
openAdminPasswordModal({
|
||||
title: t("admin.deleteTitle"),
|
||||
body: t("admin.deleteBody", { name }),
|
||||
confirmLabel: t("admin.deleteConfirm"),
|
||||
onConfirm: async (password, close) => {
|
||||
try {
|
||||
await adminApi.deletePlayer(playerId, password);
|
||||
toast(t("admin.deleteDone"));
|
||||
close();
|
||||
await runSerializedRefresh();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function setupAdminHandlers({ runSerializedRefresh }) {
|
||||
setupResetButtons(runSerializedRefresh);
|
||||
setupAdminPanelToggle();
|
||||
setupResultsToggle(runSerializedRefresh);
|
||||
setupLinkApply(runSerializedRefresh);
|
||||
setupPlayerTableActions(runSerializedRefresh);
|
||||
}
|
||||
214
wwwroot/js/app-auth-handlers.js
Normal file
214
wwwroot/js/app-auth-handlers.js
Normal file
@@ -0,0 +1,214 @@
|
||||
import { api } from "./api.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { state, clearUserState, setSavedUsername } from "./state.js";
|
||||
import { $, toast } from "./dom.js";
|
||||
import {
|
||||
handleAuthError,
|
||||
openNewSuggestionModal,
|
||||
setAuthMode,
|
||||
setAuthUI,
|
||||
} from "./ui.js";
|
||||
|
||||
function setupConsentRows() {
|
||||
const hasConsent = () =>
|
||||
document.cookie
|
||||
.split(";")
|
||||
.some((c) => c.trim().startsWith("cookie_consent=1"));
|
||||
const setConsent = () => {
|
||||
document.cookie =
|
||||
"cookie_consent=1; path=/; max-age=31536000; SameSite=Lax";
|
||||
};
|
||||
const consentRows = document.querySelectorAll(".consent-row");
|
||||
const toggleConsentRows = () => {
|
||||
const hide = hasConsent();
|
||||
consentRows.forEach((row) => row.classList.toggle("hidden", hide));
|
||||
};
|
||||
|
||||
toggleConsentRows();
|
||||
["login-consent", "register-consent"].forEach((id) => {
|
||||
const box = $(id);
|
||||
if (box) {
|
||||
box.checked = hasConsent();
|
||||
}
|
||||
});
|
||||
|
||||
return { hasConsent, setConsent, toggleConsentRows };
|
||||
}
|
||||
|
||||
function setupAuthModeToggle() {
|
||||
const toggleAuth = $("auth-toggle");
|
||||
if (toggleAuth) {
|
||||
toggleAuth.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
setAuthMode(state.authMode === "login" ? "register" : "login");
|
||||
});
|
||||
}
|
||||
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() {
|
||||
const loginUser = $("login-username");
|
||||
if (!loginUser) return;
|
||||
|
||||
const markEditing = () => {
|
||||
loginUser.dataset.userEditing = "1";
|
||||
};
|
||||
["focus", "input", "keydown"].forEach((evt) =>
|
||||
loginUser.addEventListener(evt, markEditing),
|
||||
);
|
||||
loginUser.addEventListener("blur", () => {
|
||||
delete loginUser.dataset.userEditing;
|
||||
});
|
||||
}
|
||||
|
||||
function setupLoginFormHandlers({
|
||||
hasConsent,
|
||||
setConsent,
|
||||
toggleConsentRows,
|
||||
runSerializedRefresh,
|
||||
}) {
|
||||
const loginForm = $("login-form");
|
||||
if (!loginForm) return;
|
||||
|
||||
loginForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const username = $("login-username").value.trim();
|
||||
const password = $("login-password").value;
|
||||
if (!username || !password)
|
||||
return toast(t("auth.needCredentials"), true);
|
||||
if (!hasConsent() && !$("login-consent")?.checked)
|
||||
return toast(t("auth.cookieRequired"), true);
|
||||
try {
|
||||
await api.login({ username, password });
|
||||
setConsent();
|
||||
toggleConsentRows();
|
||||
setSavedUsername(username);
|
||||
state.isAuthenticated = true;
|
||||
setAuthUI(true);
|
||||
await runSerializedRefresh();
|
||||
toast(t("toast.loggedIn"));
|
||||
} catch (err) {
|
||||
if (err?.status === 401)
|
||||
return toast(t("auth.invalidCredentials"), true);
|
||||
if (handleAuthError(err, clearUserState)) return;
|
||||
toast(err?.message || t("toast.unexpected"), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupRegisterFormHandlers({
|
||||
hasConsent,
|
||||
setConsent,
|
||||
toggleConsentRows,
|
||||
runSerializedRefresh,
|
||||
}) {
|
||||
const registerForm = $("register-form");
|
||||
if (!registerForm) return;
|
||||
|
||||
registerForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const username = $("register-username").value.trim();
|
||||
const password = $("register-password").value;
|
||||
const displayName = $("register-displayName").value.trim();
|
||||
const adminKey = $("register-adminkey").value.trim();
|
||||
if (!displayName)
|
||||
return toast(
|
||||
t("toast.displayNameRequired") || "Display name is required.",
|
||||
true,
|
||||
);
|
||||
if (!username || !password)
|
||||
return toast(t("auth.needCredentials"), true);
|
||||
if (!hasConsent() && !$("register-consent")?.checked)
|
||||
return toast(t("auth.cookieRequired"), true);
|
||||
try {
|
||||
await api.register({ username, password, displayName, adminKey });
|
||||
await refreshRegistrationOptions();
|
||||
setConsent();
|
||||
toggleConsentRows();
|
||||
setSavedUsername(username);
|
||||
state.isAuthenticated = true;
|
||||
setAuthUI(true);
|
||||
await runSerializedRefresh();
|
||||
toast(t("toast.registered"));
|
||||
} catch (err) {
|
||||
if (handleAuthError(err, clearUserState)) return;
|
||||
toast(err.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function setupLogoutHandler() {
|
||||
const logoutBtn = $("logout");
|
||||
if (!logoutBtn) return;
|
||||
|
||||
logoutBtn.addEventListener("click", async (e) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.logout();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
document.querySelectorAll(".auth-form").forEach((form) => form.reset());
|
||||
setAuthMode("login");
|
||||
setSavedUsername("");
|
||||
clearUserState();
|
||||
state.isAuthenticated = false;
|
||||
setAuthUI(false);
|
||||
await refreshRegistrationOptions();
|
||||
});
|
||||
}
|
||||
|
||||
function setupSuggestionEntryButtons() {
|
||||
const openSuggestBtn = $("open-suggest-modal");
|
||||
if (openSuggestBtn) {
|
||||
openSuggestBtn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
if (openSuggestBtn.disabled) return;
|
||||
if (state.phase !== "Suggest") return;
|
||||
openNewSuggestionModal();
|
||||
});
|
||||
}
|
||||
|
||||
const openJokerBtn = $("open-joker-modal");
|
||||
if (openJokerBtn) {
|
||||
openJokerBtn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
if (state.phase !== "Vote" || !state.hasJoker) return;
|
||||
openNewSuggestionModal();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function setupAuthHandlers({ runSerializedRefresh }) {
|
||||
setupAuthModeToggle();
|
||||
refreshRegistrationOptions();
|
||||
const consent = setupConsentRows();
|
||||
setupLoginUserEditingHint();
|
||||
setupLoginFormHandlers({ ...consent, runSerializedRefresh });
|
||||
setupRegisterFormHandlers({ ...consent, runSerializedRefresh });
|
||||
setupSuggestionEntryButtons();
|
||||
setupLogoutHandler();
|
||||
}
|
||||
113
wwwroot/js/app-vote-nav-handlers.js
Normal file
113
wwwroot/js/app-vote-nav-handlers.js
Normal file
@@ -0,0 +1,113 @@
|
||||
import { api } from "./api.js";
|
||||
import { t } from "./i18n.js";
|
||||
import { state } from "./state.js";
|
||||
import { $, toast } from "./dom.js";
|
||||
import { openConfirmModal, renderPhasePill, renderVotes } from "./ui.js";
|
||||
|
||||
function bindPhaseAdvanceButtons(runSerializedRefresh) {
|
||||
const makeForward = (id, before) => {
|
||||
const btn = $(id);
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
try {
|
||||
if (before) {
|
||||
const proceed = await before();
|
||||
if (!proceed) return;
|
||||
}
|
||||
const resp = await api.nextPhase();
|
||||
state.prevPhase = state.phase;
|
||||
state.phase = resp.currentPhase;
|
||||
state.resultsOpen = resp.resultsOpen ?? state.resultsOpen;
|
||||
state.votesRendered = false;
|
||||
renderPhasePill();
|
||||
await runSerializedRefresh();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const makeBack = (id) => {
|
||||
const btn = $(id);
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
try {
|
||||
const resp = await api.prevPhase();
|
||||
state.prevPhase = state.phase;
|
||||
state.phase = resp.currentPhase;
|
||||
state.resultsOpen = resp.resultsOpen ?? state.resultsOpen;
|
||||
state.votesRendered = false;
|
||||
renderPhasePill();
|
||||
await runSerializedRefresh();
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
makeForward("nav-suggest-next", async () => {
|
||||
return await new Promise((resolve) => {
|
||||
openConfirmModal({
|
||||
title: t("nav.freezeModalTitle"),
|
||||
body: t("nav.freezeModalBody"),
|
||||
confirmLabel: t("nav.next"),
|
||||
onConfirm: (close) => {
|
||||
close();
|
||||
resolve(true);
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
makeBack("nav-vote-prev");
|
||||
}
|
||||
|
||||
function bindVoteFinalizeButton() {
|
||||
const finalizeBtn = $("finalize-votes");
|
||||
if (!finalizeBtn) return;
|
||||
|
||||
const finalizeVotes = async (desired) => {
|
||||
await api.finalizeVotes(desired);
|
||||
state.votesFinal = desired;
|
||||
renderPhasePill();
|
||||
renderVotes();
|
||||
toast(desired ? t("vote.finalize") : t("vote.unfinalize"));
|
||||
};
|
||||
|
||||
const missingVotes = () => {
|
||||
const votedIds = new Set(
|
||||
(state.myVotes ?? []).map((v) => v.suggestionId),
|
||||
);
|
||||
return (state.allSuggestions ?? []).filter((s) => !votedIds.has(s.id));
|
||||
};
|
||||
|
||||
finalizeBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
const desired = !state.votesFinal;
|
||||
if (desired) {
|
||||
const missing = missingVotes();
|
||||
if (missing.length > 0) {
|
||||
openConfirmModal({
|
||||
title: t("vote.finalizeMissingTitle"),
|
||||
body: t("vote.finalizeMissingBody", {
|
||||
count: missing.length,
|
||||
}),
|
||||
confirmLabel: t("vote.finalizeMissingConfirm"),
|
||||
onConfirm: async (close) => {
|
||||
await finalizeVotes(desired);
|
||||
close();
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
await finalizeVotes(desired);
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function setupVoteNavigationHandlers({ runSerializedRefresh }) {
|
||||
bindPhaseAdvanceButtons(runSerializedRefresh);
|
||||
bindVoteFinalizeButton();
|
||||
}
|
||||
72
wwwroot/js/auth-ui.js
Normal file
72
wwwroot/js/auth-ui.js
Normal file
@@ -0,0 +1,72 @@
|
||||
import { t } from "./i18n.js";
|
||||
import { state, getSavedUsername } from "./state.js";
|
||||
import { $, toast } from "./dom.js";
|
||||
|
||||
export function setAuthUI(isAuthed) {
|
||||
const main = document.querySelector("main");
|
||||
const statusBar = document.querySelector(".status-bar");
|
||||
const authCard = $("auth-card");
|
||||
[main, statusBar].forEach((el) =>
|
||||
el?.classList.toggle("hidden", !isAuthed),
|
||||
);
|
||||
if (authCard) authCard.classList.toggle("hidden", isAuthed);
|
||||
const adminToggle = $("admin-toggle");
|
||||
if (adminToggle)
|
||||
adminToggle.classList.toggle("hidden", !isAuthed || !state.me?.isAdmin);
|
||||
if (!isAuthed) {
|
||||
const adminCard = $("admin-card");
|
||||
if (adminCard) adminCard.classList.add("hidden");
|
||||
const loginUser = $("login-username");
|
||||
const cachedUser = getSavedUsername();
|
||||
if (
|
||||
loginUser &&
|
||||
cachedUser &&
|
||||
!loginUser.dataset.userEditing &&
|
||||
!loginUser.value
|
||||
) {
|
||||
loginUser.value = cachedUser;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function setAuthMode(mode) {
|
||||
state.authMode = mode;
|
||||
document.querySelectorAll(".auth-form").forEach((form) => {
|
||||
form.classList.toggle("hidden", form.dataset.mode !== mode);
|
||||
});
|
||||
const title = $("auth-title");
|
||||
const toggleBtn = $("auth-toggle");
|
||||
if (title) {
|
||||
title.textContent =
|
||||
mode === "login"
|
||||
? t("auth.loginHeading")
|
||||
: t("auth.registerHeading");
|
||||
}
|
||||
if (toggleBtn) {
|
||||
toggleBtn.textContent =
|
||||
mode === "login"
|
||||
? t("auth.switchToRegister")
|
||||
: t("auth.switchToLogin");
|
||||
}
|
||||
}
|
||||
|
||||
export function handleAuthError(err, clearUserState) {
|
||||
if (err?.status === 401) {
|
||||
clearUserState();
|
||||
state.isAuthenticated = false;
|
||||
setAuthUI(false);
|
||||
return true;
|
||||
}
|
||||
toast(err?.message || t("toast.unexpected"), true);
|
||||
return false;
|
||||
}
|
||||
|
||||
export function renderWelcome() {
|
||||
const el = $("welcome-text");
|
||||
if (!el) return;
|
||||
const name =
|
||||
state.me?.displayName?.trim() ||
|
||||
state.me?.username ||
|
||||
t("auth.defaultName");
|
||||
el.textContent = t("auth.welcome", { name });
|
||||
}
|
||||
@@ -1,16 +1,44 @@
|
||||
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";
|
||||
|
||||
export async function loadState() {
|
||||
const [me, stateData] = await Promise.all([api.me(), api.state()]);
|
||||
const stateResponse = await api.state(state.stateEtag);
|
||||
if (stateResponse?.etag) state.stateEtag = stateResponse.etag;
|
||||
if (stateResponse?.notModified) return false;
|
||||
|
||||
const stateData = stateResponse.data;
|
||||
state.isAuthenticated = true;
|
||||
state.me = me;
|
||||
state.hasJoker = me.hasJoker ?? false;
|
||||
state.me = {
|
||||
id: stateData.id,
|
||||
username: stateData.username,
|
||||
displayName: stateData.displayName,
|
||||
isAdmin: stateData.isAdmin,
|
||||
isOwner: stateData.isOwner,
|
||||
currentPhase: stateData.currentPhase,
|
||||
votesFinal: stateData.votesFinal,
|
||||
hasJoker: stateData.hasJoker,
|
||||
};
|
||||
state.hasJoker = stateData.hasJoker ?? false;
|
||||
state.prevPhase = state.phase;
|
||||
state.phase = stateData.currentPhase;
|
||||
state.resultsOpen = stateData.resultsOpen;
|
||||
state.votesFinal = stateData.votesFinal ?? me?.votesFinal ?? false;
|
||||
state.votesFinal = stateData.votesFinal ?? false;
|
||||
state.counts = stateData;
|
||||
if (state.prevPhase !== state.phase && state.phase === "Vote") {
|
||||
state.votesRendered = false;
|
||||
@@ -19,22 +47,29 @@ export async function loadState() {
|
||||
renderWelcome();
|
||||
renderPhasePill();
|
||||
renderCounts();
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function loadSuggestData() {
|
||||
if (state.phase !== "Suggest") return;
|
||||
state.mySuggestions = await api.mySuggestions();
|
||||
renderMySuggestions();
|
||||
updatePhaseNav();
|
||||
}
|
||||
|
||||
export async function loadRevealData() {
|
||||
export async function loadSuggestionsData() {
|
||||
if (state.phase === "Vote" || state.phase === "Results") {
|
||||
const prev = state.allSuggestions ?? [];
|
||||
const prevById = Object.fromEntries(prev.map((s) => [s.id, s]));
|
||||
const latest = await api.allSuggestions();
|
||||
const latestSig = signatureSuggestions(latest);
|
||||
const changed = latestSig !== state.allSuggestionsSig;
|
||||
if (changed && state.phase === "Vote" && state.allSuggestionsSig) {
|
||||
if (
|
||||
changed &&
|
||||
state.phase === "Vote" &&
|
||||
state.votesRendered &&
|
||||
state.allSuggestionsSig
|
||||
) {
|
||||
const added = latest
|
||||
.filter((s) => !prevById[s.id])
|
||||
.map((s) => s.name);
|
||||
@@ -80,18 +115,35 @@ export async function loadResults() {
|
||||
}
|
||||
|
||||
export async function refreshPhaseData() {
|
||||
const before = buildRefreshSnapshot();
|
||||
try {
|
||||
const prevPhase = state.phase;
|
||||
const prevResultsOpen = state.resultsOpen;
|
||||
await loadState();
|
||||
await Promise.all([loadSuggestData(), loadRevealData(), loadResults()]);
|
||||
const stateChanged = await loadState();
|
||||
const adminCard = document.getElementById("admin-card");
|
||||
const adminPanelVisible =
|
||||
!!adminCard && !adminCard.classList.contains("hidden");
|
||||
|
||||
if (!stateChanged) {
|
||||
if (state.me?.isAdmin && adminPanelVisible) {
|
||||
state.adminVoteStatus = await adminApi.voteStatus();
|
||||
}
|
||||
updatePhaseNav();
|
||||
return false;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
loadSuggestData(),
|
||||
loadSuggestionsData(),
|
||||
loadResults(),
|
||||
]);
|
||||
if (state.phase === "Vote") {
|
||||
if (!state.votesRendered) await loadVoteData();
|
||||
} else {
|
||||
state.votesRendered = false;
|
||||
await loadVoteData();
|
||||
}
|
||||
if (state.me?.isAdmin) {
|
||||
if (state.me?.isAdmin && adminPanelVisible) {
|
||||
state.adminVoteStatus = await adminApi.voteStatus();
|
||||
}
|
||||
if (
|
||||
@@ -103,12 +155,34 @@ export async function refreshPhaseData() {
|
||||
openResultsRelockModal();
|
||||
}
|
||||
updatePhaseNav();
|
||||
const after = buildRefreshSnapshot();
|
||||
return before !== after;
|
||||
} catch (err) {
|
||||
if (handleAuthError(err, clearUserState)) return;
|
||||
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) {
|
||||
return JSON.stringify(
|
||||
list.map((s) => [
|
||||
@@ -126,9 +200,3 @@ export function signatureSuggestions(list) {
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// expose for UI handlers that call back in
|
||||
window.refreshPhaseData = refreshPhaseData;
|
||||
window.loadSuggestData = loadSuggestData;
|
||||
window.loadVoteData = loadVoteData;
|
||||
window.handleAuthError = (err) => handleAuthError(err, clearUserState);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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) {
|
||||
if (!toastEl) return;
|
||||
|
||||
47
wwwroot/js/effects.js
vendored
47
wwwroot/js/effects.js
vendored
@@ -3,48 +3,15 @@
|
||||
// Screenshot hover ---------------------------------------------------
|
||||
export function setupCardVisualHover(el, url) {
|
||||
if (!el || !url) return;
|
||||
const img = new Image();
|
||||
let naturalW = 0;
|
||||
let naturalH = 0;
|
||||
let loaded = false;
|
||||
img.src = url;
|
||||
img.onload = () => {
|
||||
naturalW = img.naturalWidth;
|
||||
naturalH = img.naturalHeight;
|
||||
loaded = true;
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
el.classList.remove("hovering");
|
||||
el.style.backgroundSize = "";
|
||||
el.style.backgroundPosition = "";
|
||||
el.style.backgroundRepeat = "";
|
||||
};
|
||||
|
||||
el.addEventListener("mouseenter", () => {
|
||||
el.classList.add("hovering");
|
||||
el.style.backgroundSize = "auto";
|
||||
el.style.backgroundRepeat = "no-repeat";
|
||||
el.style.backgroundPosition = "center";
|
||||
});
|
||||
|
||||
el.addEventListener("mousemove", (e) => {
|
||||
if (!loaded) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const overW = naturalW - rect.width;
|
||||
const overH = naturalH - rect.height;
|
||||
if (overW <= 0 && overH <= 0) {
|
||||
el.style.backgroundPosition = "center";
|
||||
return;
|
||||
}
|
||||
const xRatio = (e.clientX - rect.left) / rect.width;
|
||||
const yRatio = (e.clientY - rect.top) / rect.height;
|
||||
const xPercent = overW > 0 ? xRatio * 100 : 50;
|
||||
const yPercent = overH > 0 ? yRatio * 100 : 50;
|
||||
el.style.backgroundPosition = `${xPercent}% ${yPercent}%`;
|
||||
});
|
||||
|
||||
["mouseleave", "blur"].forEach((evt) => el.addEventListener(evt, reset));
|
||||
["mouseleave", "blur"].forEach((evt) =>
|
||||
el.addEventListener(evt, () => {
|
||||
el.classList.remove("hovering");
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Celebration FX -----------------------------------------------------
|
||||
@@ -57,10 +24,6 @@ function ensureFxCanvas() {
|
||||
if (fxCanvas) return;
|
||||
fxCanvas = document.createElement("canvas");
|
||||
fxCanvas.className = "fx-canvas";
|
||||
fxCanvas.style.position = "fixed";
|
||||
fxCanvas.style.inset = "0";
|
||||
fxCanvas.style.pointerEvents = "none";
|
||||
fxCanvas.style.zIndex = "120";
|
||||
fxCanvas.width = window.innerWidth;
|
||||
fxCanvas.height = window.innerHeight;
|
||||
fxCtx = fxCanvas.getContext("2d");
|
||||
|
||||
@@ -1,744 +1,148 @@
|
||||
const translations = {
|
||||
en: {
|
||||
"lang.label": "Language",
|
||||
"lang.en": "English",
|
||||
"lang.de": "Deutsch",
|
||||
|
||||
"auth.loginTab": "Log in",
|
||||
"auth.registerTab": "Register",
|
||||
"auth.username": "Username",
|
||||
"auth.password": "Password",
|
||||
"auth.displayName": "Display name (shows to group)",
|
||||
"auth.adminKey": "Admin key (optional)",
|
||||
"auth.loginSubmit": "Log in",
|
||||
"auth.registerSubmit": "Create account",
|
||||
"auth.loginHeading": "Log in",
|
||||
"auth.registerHeading": "Create account",
|
||||
"auth.switchToRegister": "Need an account? Register",
|
||||
"auth.switchToLogin": "Have an account? Log in",
|
||||
"auth.cookieLabel": "I agree to the use of essential cookies.",
|
||||
"auth.cookieRequired": "Please agree to essential cookies to continue.",
|
||||
"auth.logout": "Logout",
|
||||
"auth.welcome": "Welcome, {name}!",
|
||||
"auth.defaultName": "Player",
|
||||
"auth.loading": "Loading…",
|
||||
"auth.needCredentials": "Username and password required",
|
||||
"auth.invalidCredentials": "Invalid username or password",
|
||||
|
||||
"counts.format": "Players: {players} • Suggestions: {suggestions} • Votes: {votes}",
|
||||
|
||||
"nav.prev": "Back",
|
||||
"nav.next": "Next",
|
||||
"nav.waitingForResults": "Waiting…",
|
||||
"nav.freezeTitle": "Ready to reveal?",
|
||||
"nav.freezeHint": "Moving forward will freeze your suggestions. Game names become locked; only extra details stay editable.",
|
||||
"nav.freezeModalTitle": "Freeze suggestions?",
|
||||
"nav.freezeModalBody": "Once you leave Suggest, your games are locked: game names cannot be changed or deleted. Only optional details (description, links, players, artwork) remain editable. Continue?",
|
||||
"nav.voteHint": "Cast votes for every game to unlock results.",
|
||||
"nav.voteFinalized": "✅ You finalized your votes. Sit back and relax while the other players finalize their votes.",
|
||||
|
||||
"suggest.title": "Suggest games (up to 5)",
|
||||
"suggest.new": "Add new suggestion",
|
||||
"suggest.addButton": "Suggest a game",
|
||||
"suggest.maxReached": "max limit reached",
|
||||
"suggest.jokerAddButton": "🃏 Joker: add another game",
|
||||
"suggest.hint": "Only you can see your suggestions until voting starts.",
|
||||
"form.gameName": "Game name *",
|
||||
"form.genre": "Genre",
|
||||
"form.description": "Description",
|
||||
"form.players": "Players",
|
||||
"form.min": "Min",
|
||||
"form.max": "Max",
|
||||
"form.screenshot": "Screenshot URL",
|
||||
"form.youtube": "YouTube URL",
|
||||
"form.gameUrl": "Game website URL",
|
||||
"form.submit": "Submit",
|
||||
"form.placeholder.description": "Short description",
|
||||
"form.placeholder.gameName": "Game name *",
|
||||
"form.placeholder.genre": "Genre",
|
||||
"form.placeholder.screenshot": "Screenshot URL",
|
||||
"form.placeholder.youtube": "YouTube URL",
|
||||
"form.placeholder.gameUrl": "Game website URL",
|
||||
"form.playersInvalid": "Players must be between 1 and 32, and min cannot exceed max.",
|
||||
"form.screenshotHint": "Use a public direct image link (http/https), max 5 MB. Avoid shortlinks/redirects.",
|
||||
"form.screenshotInvalid": "Screenshot must be a direct http/https image URL (png, jpg, jpeg, gif, webp, avif) under 5 MB and not a redirect/shortlink.",
|
||||
|
||||
"section.mySuggestions": "Your suggestions",
|
||||
"section.allSuggestions": "All suggestions",
|
||||
"section.allSuggestions.count": "All {count} suggestions",
|
||||
"section.vote": "Vote 0-10",
|
||||
"section.vote.count": "Vote for all {count} games",
|
||||
"section.results": "Results",
|
||||
|
||||
"card.edit": "Edit",
|
||||
"card.delete": "Delete",
|
||||
"card.players": "Players: {min}–{max}",
|
||||
"card.site": "Site ↗",
|
||||
"card.youtube": "YouTube ↗",
|
||||
"card.openScreenshot": "Open screenshot",
|
||||
"card.linked": "Votes linked",
|
||||
"card.linkedWith": "Linked with: {names}",
|
||||
|
||||
"vote.saved": "Saved vote",
|
||||
"vote.missing": "Missing",
|
||||
"vote.missingWarn": "You haven’t voted yet.",
|
||||
"vote.missingFinalWarn": "You didn't vote for this game.",
|
||||
"vote.missingFooter": "At least one game is missing a score. Check before finalizing.",
|
||||
"vote.finalize": "Finalize votes",
|
||||
"vote.unfinalize": "Edit votes",
|
||||
"vote.finalHint": "Finalize when you’re done. You can unfinalize to change scores.",
|
||||
"vote.waitAdmin": "Waiting for admin to unlock results.",
|
||||
"vote.finalizeMissingTitle": "Finalize with missing votes?",
|
||||
"vote.finalizeMissingBody": "You still have {count} game(s) without a score. Finalizing will mark you as done while those stay unrated.",
|
||||
"vote.finalizeMissingConfirm": "Finalize anyway",
|
||||
|
||||
"results.rank": "Rank",
|
||||
"results.game": "Game",
|
||||
"results.author": "Author",
|
||||
"results.average": "Ø",
|
||||
"results.votesList": "All votes",
|
||||
"results.myVote": "Your vote",
|
||||
"results.links": "Links",
|
||||
"results.link.site": "Site ↗",
|
||||
"results.link.youtube": "YouTube ↗",
|
||||
"results.relockedTitle": "Results closed",
|
||||
"results.relockedBody": "Results have been locked again. You’re back in the voting phase and your finalized status was cleared. Adjust scores and re-finalize when ready.",
|
||||
"results.relockedConfirm": "Got it",
|
||||
"vote.listUpdatedTitle": "Vote list updated",
|
||||
"vote.listUpdatedBody": "New or linked games: {names}",
|
||||
"vote.listUpdatedConfirm": "OK",
|
||||
|
||||
"admin.title": "Admin",
|
||||
"admin.tools": "Admin tools",
|
||||
"admin.resultsOpenToggle": "Allow results phase",
|
||||
"admin.resultsLocked": "Results locked by admin",
|
||||
"admin.resultsUpdated": "Results availability updated",
|
||||
"admin.reset": "Reset (keep players)",
|
||||
"admin.factoryReset": "Factory reset",
|
||||
"admin.resetDone": "Reset complete",
|
||||
"admin.factoryResetDone": "Factory reset complete",
|
||||
"admin.readyForResults": "Ready for results",
|
||||
"admin.waitingForPlayers": "Waiting for players: {names}",
|
||||
"admin.playerName": "Name",
|
||||
"admin.playerUsername": "Username",
|
||||
"admin.playerStatus": "Status",
|
||||
"admin.playerGames": "Games",
|
||||
"admin.playerJoker": "Joker",
|
||||
"admin.playerDelete": "Delete",
|
||||
"admin.grantJokerChip": "Grant",
|
||||
"admin.statusSuggesting": "Suggesting",
|
||||
"admin.statusVoting": "Voting",
|
||||
"admin.statusFinished": "Finished",
|
||||
"admin.deleteTitle": "Delete account?",
|
||||
"admin.deleteBody": "Delete player \"{name}\" and all their games and votes? This cannot be undone.",
|
||||
"admin.deleteConfirm": "Delete",
|
||||
"admin.deleteDone": "Player deleted",
|
||||
"admin.jokerGranted": "Joker granted",
|
||||
"admin.linkTitle": "Link games",
|
||||
"admin.linkSource": "Game to link",
|
||||
"admin.linkTarget": "Link to (parent)",
|
||||
"admin.linkAction": "Link & clear votes",
|
||||
"admin.linkSourcePlaceholder": "Select source",
|
||||
"admin.linkTargetPlaceholder": "Select target",
|
||||
"admin.linkValidation": "Choose two different games to link.",
|
||||
"admin.linkDone": "Games linked. Votes cleared.",
|
||||
"admin.unlinkTitle": "Remove links?",
|
||||
"admin.unlinkBody": "Remove all links involving \"{name}\"? This clears votes and unfinalizes voters in this group: {peers}.",
|
||||
"admin.unlinkConfirm": "Remove links",
|
||||
"admin.unlinkDone": "Links removed. Votes cleared.",
|
||||
"admin.unlinkUnknownPeers": "linked games",
|
||||
|
||||
"toast.unexpected": "Unexpected error",
|
||||
"toast.registered": "Registered",
|
||||
"toast.loggedIn": "Logged in",
|
||||
"toast.suggestionAdded": "Suggestion added",
|
||||
"toast.suggestionDeleted": "Suggestion deleted",
|
||||
"toast.savedChanges": "Saved changes",
|
||||
"toast.nameRequired": "Name required",
|
||||
"toast.displayNameRequired": "Display name is required",
|
||||
"toast.invalidImageUrl": "Screenshot URL must be http(s) and end with an image file.",
|
||||
|
||||
"modal.editTitle": "Edit game",
|
||||
"modal.addTitle": "Suggest a game",
|
||||
"modal.confirmDeleteTitle": "Are you sure?",
|
||||
"modal.confirmDelete": "Confirm delete",
|
||||
"modal.save": "Save changes",
|
||||
"modal.cancel": "Cancel",
|
||||
"modal.close": "Close",
|
||||
|
||||
"lightbox.close": "Close",
|
||||
"help.label": "Help",
|
||||
"help.title": "FAQ & tips",
|
||||
},
|
||||
de: {
|
||||
"lang.label": "Sprache",
|
||||
"lang.en": "Englisch",
|
||||
"lang.de": "Deutsch",
|
||||
|
||||
"auth.loginTab": "Anmelden",
|
||||
"auth.registerTab": "Registrieren",
|
||||
"auth.username": "Benutzername",
|
||||
"auth.password": "Passwort",
|
||||
"auth.displayName": "Anzeigename (für die Gruppe sichtbar)",
|
||||
"auth.adminKey": "Admin-Schlüssel (optional)",
|
||||
"auth.loginSubmit": "Anmelden",
|
||||
"auth.registerSubmit": "Konto erstellen",
|
||||
"auth.loginHeading": "Anmelden",
|
||||
"auth.registerHeading": "Konto erstellen",
|
||||
"auth.switchToRegister": "Noch kein Konto? Registrieren",
|
||||
"auth.switchToLogin": "Schon ein Konto? Anmelden",
|
||||
"auth.cookieLabel": "Ich stimme der Nutzung erforderlicher Cookies zu.",
|
||||
"auth.cookieRequired": "Bitte stimme den erforderlichen Cookies zu.",
|
||||
"auth.logout": "Abmelden",
|
||||
"auth.welcome": "Willkommen, {name}!",
|
||||
"auth.defaultName": "Spieler",
|
||||
"auth.loading": "Lädt…",
|
||||
"auth.needCredentials": "Benutzername und Passwort erforderlich",
|
||||
"auth.invalidCredentials": "Ungültiger Benutzername oder Passwort",
|
||||
|
||||
"counts.format": "Spieler: {players} • Vorschläge: {suggestions} • Stimmen: {votes}",
|
||||
|
||||
"nav.prev": "Zurück",
|
||||
"nav.next": "Weiter",
|
||||
"nav.waitingForResults": "Warten…",
|
||||
"nav.freezeTitle": "Bereit zum Aufdecken?",
|
||||
"nav.freezeHint": "Beim Weitergehen werden deine Vorschläge eingefroren. Spielnamen werden gesperrt; nur Zusatzinfos bleiben bearbeitbar.",
|
||||
"nav.freezeModalTitle": "Vorschläge einfrieren?",
|
||||
"nav.freezeModalBody": "Sobald du die Vorschlagsphase verlässt, sind deine Spiele gesperrt: Die Namen von deinen Spielen können nicht mehr geändert oder gelöscht werden. Nur optionale Angaben (Beschreibung, Links, Spielerzahlen, Bilder) bleiben bearbeitbar. Fortfahren?",
|
||||
"nav.voteHint": "Bewerte alle Spiele, um die Ergebnisse freizuschalten.",
|
||||
"nav.voteFinalized": "✅ Du hast deine Abstimmung abgeschlossen. Lehn dich zurück, bis die anderen fertig sind.",
|
||||
|
||||
"suggest.title": "Schlage Spiele vor (bis zu 5)",
|
||||
"suggest.new": "Neuen Vorschlag hinzufügen",
|
||||
"suggest.addButton": "Spiel vorschlagen",
|
||||
"suggest.maxReached": "Limit erreicht",
|
||||
"suggest.jokerAddButton": "🃏 Joker: Weiteres Spiel hinzufügen",
|
||||
"suggest.hint": "Nur du siehst deine Vorschläge bis zum Start der Abstimmung.",
|
||||
"form.gameName": "Spielname *",
|
||||
"form.genre": "Genre",
|
||||
"form.description": "Beschreibung",
|
||||
"form.players": "Spieler",
|
||||
"form.min": "Min",
|
||||
"form.max": "Max",
|
||||
"form.screenshot": "Screenshot-URL",
|
||||
"form.youtube": "YouTube-URL",
|
||||
"form.gameUrl": "Spiel-Webseite",
|
||||
"form.submit": "Absenden",
|
||||
"form.placeholder.description": "Kurze Beschreibung",
|
||||
"form.placeholder.gameName": "Spielname *",
|
||||
"form.placeholder.genre": "Genre",
|
||||
"form.placeholder.screenshot": "Screenshot-URL",
|
||||
"form.placeholder.youtube": "YouTube-URL",
|
||||
"form.placeholder.gameUrl": "Spiel-Webseite",
|
||||
"form.playersInvalid": "Spielerzahl muss zwischen 1 und 32 liegen, und Min darf Max nicht überschreiten.",
|
||||
"form.screenshotHint": "Nutze einen öffentlichen Bildlink (http/https), max. 5 MB. Keine Kurzlinks/Weiterleitungen.",
|
||||
"form.screenshotInvalid": "Screenshot muss eine direkte http/https-Bild-URL sein (png, jpg, jpeg, gif, webp, avif), unter 5 MB und ohne Weiterleitung/Kurzlink.",
|
||||
|
||||
"section.mySuggestions": "Deine Vorschläge",
|
||||
"section.allSuggestions": "Alle Vorschläge",
|
||||
"section.allSuggestions.count": "Alle {count} Vorschläge",
|
||||
"section.vote": "Bewerten 0-10",
|
||||
"section.vote.count": "Bewerte alle {count} Spiele",
|
||||
"section.results": "Ergebnisse",
|
||||
|
||||
"card.edit": "Bearbeiten",
|
||||
"card.delete": "Löschen",
|
||||
"card.players": "Spieler: {min}–{max}",
|
||||
"card.site": "Webseite ↗",
|
||||
"card.youtube": "YouTube ↗",
|
||||
"card.openScreenshot": "Screenshot öffnen",
|
||||
"card.linked": "Verknüpfte Stimmen",
|
||||
"card.linkedWith": "Verknüpft mit: {names}",
|
||||
|
||||
"vote.saved": "Stimme gespeichert",
|
||||
"vote.missing": "Fehlt",
|
||||
"vote.missingWarn": "Du hast hier noch nicht abgestimmt.",
|
||||
"vote.missingFinalWarn": "Du hast für dieses Spiel nicht abgestimmt.",
|
||||
"vote.missingFooter": "Für mindestens einen Spiel fehlt noch eine Wertung. Prüfe vor dem Abschließen.",
|
||||
"vote.finalize": "Abstimmung abschließen",
|
||||
"vote.unfinalize": "Abstimmung bearbeiten",
|
||||
"vote.finalHint": "Schließe ab, wenn du fertig bist. Zum Ändern wieder öffnen.",
|
||||
"vote.waitAdmin": "Warten, bis der Admin die Ergebnisse freigibt.",
|
||||
"vote.finalizeMissingTitle": "Mit fehlenden Stimmen abschließen?",
|
||||
"vote.finalizeMissingBody": "Für {count} Spiel(e) fehlt noch eine Wertung. Beim Abschließen gilt deine Abstimmung als fertig, obwohl diese Spiele unbewertet bleiben.",
|
||||
"vote.finalizeMissingConfirm": "Trotzdem abschließen",
|
||||
|
||||
"results.rank": "Rang",
|
||||
"results.game": "Spiel",
|
||||
"results.author": "Autor",
|
||||
"results.average": "Ø",
|
||||
"results.votesList": "Alle Stimmen",
|
||||
"results.myVote": "Deine Stimme",
|
||||
"results.links": "Links",
|
||||
"results.link.site": "Webseite ↗",
|
||||
"results.link.youtube": "YouTube ↗",
|
||||
"results.relockedTitle": "Ergebnisse geschlossen",
|
||||
"results.relockedBody": "Die Ergebnisse wurden wieder gesperrt. Du bist zurück in der Bewertungsphase und deine Finalisierung wurde zurückgesetzt. Passe deine Bewertungen an und schließe erneut ab, wenn du bereit bist.",
|
||||
"results.relockedConfirm": "Verstanden",
|
||||
"vote.listUpdatedTitle": "Liste aktualisiert",
|
||||
"vote.listUpdatedBody": "Neue oder verknüpfte Spiele: {names}",
|
||||
"vote.listUpdatedConfirm": "OK",
|
||||
|
||||
"admin.title": "Admin",
|
||||
"admin.tools": "Admin-Werkzeuge",
|
||||
"admin.resultsOpenToggle": "Ergebnisse freigeben",
|
||||
"admin.resultsLocked": "Ergebnisse vom Admin gesperrt",
|
||||
"admin.resultsUpdated": "Ergebnisfreigabe aktualisiert",
|
||||
"admin.reset": "Zurücksetzen (Spieler behalten)",
|
||||
"admin.factoryReset": "Werkseinstellung",
|
||||
"admin.resetDone": "Zurücksetzen abgeschlossen",
|
||||
"admin.factoryResetDone": "Werkseinstellung abgeschlossen",
|
||||
"admin.readyForResults": "Bereit für Ergebnisse",
|
||||
"admin.waitingForPlayers": "Warten auf: {names}",
|
||||
"admin.playerName": "Name",
|
||||
"admin.playerUsername": "Benutzername",
|
||||
"admin.playerStatus": "Status",
|
||||
"admin.playerGames": "Spiele",
|
||||
"admin.playerJoker": "Joker",
|
||||
"admin.playerDelete": "Löschen",
|
||||
"admin.grantJokerChip": "Joker",
|
||||
"admin.statusSuggesting": "Vorschlagen",
|
||||
"admin.statusVoting": "Bewerten",
|
||||
"admin.statusFinished": "Fertig",
|
||||
"admin.deleteTitle": "Konto löschen?",
|
||||
"admin.deleteBody": "Spieler \"{name}\" samt Spielen und Stimmen löschen? Dies kann nicht rückgängig gemacht werden.",
|
||||
"admin.deleteConfirm": "Löschen",
|
||||
"admin.deleteDone": "Spieler gelöscht",
|
||||
"admin.jokerGranted": "Joker vergeben",
|
||||
"admin.linkTitle": "Spiele verknüpfen",
|
||||
"admin.linkSource": "Spiel verknüpfen",
|
||||
"admin.linkTarget": "Verknüpfen mit",
|
||||
"admin.linkAction": "Verknüpfen & Stimmen löschen",
|
||||
"admin.linkSourcePlaceholder": "Quelle wählen",
|
||||
"admin.linkTargetPlaceholder": "Ziel wählen",
|
||||
"admin.linkValidation": "Wähle zwei verschiedene Spiele aus.",
|
||||
"admin.linkDone": "Spiele verknüpft. Stimmen gelöscht.",
|
||||
"admin.unlinkTitle": "Links entfernen?",
|
||||
"admin.unlinkBody": "Alle Links zu \"{name}\" entfernen? Dadurch werden Stimmen gelöscht und Finalisierungen aufgehoben für: {peers}.",
|
||||
"admin.unlinkConfirm": "Links entfernen",
|
||||
"admin.unlinkDone": "Links entfernt. Stimmen gelöscht.",
|
||||
"admin.unlinkUnknownPeers": "verknüpfte Spiele",
|
||||
|
||||
"toast.unexpected": "Unerwarteter Fehler",
|
||||
"toast.registered": "Registriert",
|
||||
"toast.loggedIn": "Angemeldet",
|
||||
"toast.suggestionAdded": "Vorschlag hinzugefügt",
|
||||
"toast.suggestionDeleted": "Vorschlag gelöscht",
|
||||
"toast.savedChanges": "Änderungen gespeichert",
|
||||
"toast.nameRequired": "Name erforderlich",
|
||||
"toast.displayNameRequired": "Anzeigename ist erforderlich",
|
||||
"toast.invalidImageUrl": "Screenshot-URL muss mit http(s) beginnen und auf eine Bilddatei enden.",
|
||||
|
||||
"modal.editTitle": "Spiel bearbeiten",
|
||||
"modal.addTitle": "Spiel vorschlagen",
|
||||
"modal.confirmDeleteTitle": "Bist du sicher?",
|
||||
"modal.confirmDelete": "Löschen bestätigen",
|
||||
"modal.save": "Änderungen speichern",
|
||||
"modal.cancel": "Abbrechen",
|
||||
"modal.close": "Schließen",
|
||||
|
||||
"lightbox.close": "Schließen",
|
||||
"help.label": "Hilfe",
|
||||
"help.title": "FAQ & Tipps",
|
||||
}
|
||||
};
|
||||
|
||||
const faqMarkdown = {
|
||||
en: `
|
||||
Pick'n'play helps groups fairly and transparently choose what game to play next. Players can suggest options, score them independently, and move through structured phases that keep the process organized and anonymous. It solves the classic “what should we play?” chaos by turning group decision-making into a clear, balanced, and drama-free flow.
|
||||
|
||||
## Accounts & Login
|
||||
|
||||
### How do I create an account?
|
||||
|
||||
Register with:
|
||||
- A **unique username** (max 24 characters)
|
||||
- A **password**
|
||||
- A **display name** (max 16 characters)
|
||||
|
||||
Your display name is required ‒ it appears next to all of your suggestions and scores.
|
||||
|
||||
### 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.
|
||||
Admin access cannot be added later. To become an admin, you must re-register with the correct key.
|
||||
|
||||
## Phases at a Glance
|
||||
|
||||
### Personal phases
|
||||
|
||||
Each player progresses independently through the phases:
|
||||
|
||||
**Suggest → Vote → Results**
|
||||
|
||||
Click **"Next"** to move forward. Admins can move themselves backward if needed.
|
||||
|
||||
## Suggesting Games
|
||||
|
||||
### How many games can I suggest?
|
||||
|
||||
Up to **5 suggestions per player**.
|
||||
|
||||
### Required fields and limits
|
||||
|
||||
- **Name** ‒ required (max 100 characters)
|
||||
- **Genre** ‒ optional (max 50 characters)
|
||||
- **Description** ‒ optional (max 500 characters)
|
||||
- **Links** ‒ optional (URLs up to 2048 characters)
|
||||
|
||||
### Min/Max players
|
||||
|
||||
- Must be filled together (or both left empty)
|
||||
- Values must be between **1 and 32**
|
||||
- Minimum must be ≤ maximum
|
||||
|
||||
### Screenshot rules
|
||||
|
||||
If you include a screenshot URL, it must:
|
||||
- Use **http or https**
|
||||
- End with a valid image extension (\`png\`, \`jpg\`, \`jpeg\`, \`gif\`, \`webp\`, \`avif\`)
|
||||
- Be directly accessible (no redirects)
|
||||
- Load within ~3 seconds
|
||||
- Be under **5 MB**
|
||||
- Not point to local or private hosts
|
||||
|
||||
Screenshots are optional.
|
||||
|
||||
### Other links
|
||||
|
||||
Game links and YouTube links must use **http or https**. Other URL schemes are rejected.
|
||||
|
||||
### Editing a suggestion
|
||||
|
||||
Click the **edit (pencil) icon** on a game card to update any field at any time.
|
||||
|
||||
### Deleting a suggestion
|
||||
|
||||
Click the **delete (cross) icon** on a game card to remove it ‒ unless you're already in the Vote phase (see below).
|
||||
|
||||
### Why was my suggestion blocked?
|
||||
|
||||
Common reasons:
|
||||
- Missing display name
|
||||
- Already reached the 5-suggestion limit
|
||||
- Name exceeds character limit
|
||||
- Screenshot URL is invalid, unreachable, or too large
|
||||
- Min/max players missing or invalid
|
||||
- Attempting to add a suggestion in the wrong phase
|
||||
|
||||
Check the bottom-right corner of the screen for error messages.
|
||||
|
||||
## Jokers (Late Additions)
|
||||
|
||||
### What is a joker?
|
||||
|
||||
A **joker** is a one-time extra suggestion slot available only during the **Vote phase**. An admin must grant it to you.
|
||||
|
||||
### How it works
|
||||
|
||||
If you receive a joker:
|
||||
- A button appears in the top bar allowing you to add one more game.
|
||||
- Once used, the joker is consumed immediately.
|
||||
- Your ballot becomes unfinalized.
|
||||
- All players are unfinalized so the new game can be scored.
|
||||
|
||||
Admins may grant additional jokers if necessary.
|
||||
|
||||
## Voting
|
||||
|
||||
### Who can vote?
|
||||
|
||||
Authenticated players during the **Vote phase**.
|
||||
|
||||
### How do I score games?
|
||||
|
||||
Use the slider to assign a whole number from **0 to 10**.
|
||||
|
||||
### Editing during Vote
|
||||
|
||||
- You can still edit most game details.
|
||||
- The **game name becomes locked** during the Vote phase.
|
||||
- You can no longer delete your own suggestions.
|
||||
- Admins may delete suggestions if necessary.
|
||||
|
||||
### Linked duplicates
|
||||
|
||||
If an admin links duplicate games:
|
||||
- Changing the score for one updates all linked entries.
|
||||
- Scores are stored per group, not per individual entry.
|
||||
|
||||
### Finalizing your ballot
|
||||
|
||||
Toggling **"Finalize"** locks your scores. Toggle it off to edit again.
|
||||
|
||||
Finalize is only available during the Vote phase and will automatically reset if:
|
||||
- A joker adds a new game
|
||||
- An admin links or unlinks games
|
||||
|
||||
### Voting after changes
|
||||
|
||||
If new games are added or links are modified:
|
||||
- Affected votes are cleared
|
||||
- You are automatically unfinalized
|
||||
|
||||
Review your list and rescore before finalizing again.
|
||||
|
||||
## Results
|
||||
|
||||
### When are results visible?
|
||||
|
||||
Results are hidden until an admin opens them. When opened, all players are automatically moved to the **Results phase**.
|
||||
If needed, an admin can close the Results: everyone returns to the Vote phase, and all ballots are unfinalized for adjustments.
|
||||
|
||||
### Can I edit anything in Results?
|
||||
|
||||
No. Suggestions and votes are read-only. Contact an admin for assistance.
|
||||
|
||||
## Admin Tools (For Hosts)
|
||||
|
||||
### What can admin accounts do?
|
||||
|
||||
- Grant jokers during Vote
|
||||
- Link or unlink duplicate suggestions
|
||||
- Delete suggestions
|
||||
- View vote readiness (who has finalized)
|
||||
- Delete a player (removes their suggestions and votes)
|
||||
- Reset the database to factory defaults
|
||||
- Move backward to previous phases
|
||||
|
||||
### What can't admin accounts do?
|
||||
|
||||
- View individual player votes
|
||||
|
||||
Voting remains anonymous and fair.
|
||||
|
||||
## Common Errors & Fixes
|
||||
|
||||
### "Screenshot URL must be http(s) and end with an image file extension."
|
||||
|
||||
Make sure:
|
||||
- The link is direct (not a page or html content)
|
||||
- It ends with a valid image extension
|
||||
- The file is under 5 MB
|
||||
- There are no redirects
|
||||
|
||||
### "You have reached the 5 suggestion limit."
|
||||
|
||||
Wait for the Vote phase and request a joker if needed.
|
||||
|
||||
### "Invalid admin key."
|
||||
|
||||
Register again using the correct key from the host ‒ or leave it blank to create a regular account.
|
||||
|
||||
## Data & Privacy
|
||||
|
||||
- Suggestions, votes, and phase states are stored in a shared **SQLite database**.
|
||||
- Passwords are stored with a SHA256 encryption.
|
||||
- Logging out clears your authentication cookie.
|
||||
- If an admin deletes your player account, your suggestions and votes are removed as well.
|
||||
`,
|
||||
de: `
|
||||
Pick'n'play hilft Gruppen dabei, fair und transparent zu entscheiden, welches Spiel als Nächstes gespielt wird. Spieler können Vorschläge einreichen, diese unabhängig bewerten und durch strukturierte Phasen gehen, die den Prozess organisiert und anonym halten. Es löst das klassische „Was sollen wir spielen?"-Chaos, indem es Gruppenentscheidungen in einen klaren, ausgewogenen und stressfreien Ablauf verwandelt.
|
||||
|
||||
## Konten & Anmeldung
|
||||
|
||||
### Wie erstelle ich ein Konto?
|
||||
|
||||
Registriere dich mit:
|
||||
- Einem **eindeutigen Benutzernamen** (max. 24 Zeichen)
|
||||
- Einem **Passwort**
|
||||
- Einem **Anzeigenamen** (max. 16 Zeichen)
|
||||
|
||||
Dein Anzeigename ist erforderlich ‒ er erscheint neben all deinen Vorschlägen und Bewertungen.
|
||||
|
||||
### 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.
|
||||
|
||||
## Phasen im Überblick
|
||||
|
||||
### Persönliche Phasen
|
||||
|
||||
Jeder Spieler durchläuft die Phasen unabhängig voneinander:
|
||||
|
||||
**Vorschlagen → Abstimmen → Ergebnisse**
|
||||
|
||||
Klicke auf **„Weiter"**, um fortzufahren. Admins können sich bei Bedarf auch wieder zurücksetzen.
|
||||
|
||||
## Spiele vorschlagen
|
||||
|
||||
### Wie viele Spiele kann ich vorschlagen?
|
||||
|
||||
Bis zu **5 Vorschläge pro Spieler**.
|
||||
|
||||
### Pflichtfelder und Grenzen
|
||||
|
||||
- **Name** ‒ erforderlich (max. 100 Zeichen)
|
||||
- **Genre** ‒ optional (max. 50 Zeichen)
|
||||
- **Beschreibung** ‒ optional (max. 500 Zeichen)
|
||||
- **Links** ‒ optional (URLs bis zu 2048 Zeichen)
|
||||
|
||||
### Min./Max. Spieleranzahl
|
||||
- Müssen gemeinsam ausgefüllt werden (oder beide leer bleiben)
|
||||
- Werte müssen zwischen **1 und 32** liegen
|
||||
- Minimum muss ≤ Maximum sein
|
||||
|
||||
### Screenshot-Regeln
|
||||
|
||||
Wenn du eine Screenshot-URL angibst, muss sie:
|
||||
- **http oder https** verwenden
|
||||
- Mit einer gültigen Bilddateiendung enden (\`png\`, \`jpg\`, \`jpeg\`, \`gif\`, \`webp\`, \`avif\`)
|
||||
- Direkt erreichbar sein (keine Weiterleitungen)
|
||||
- Innerhalb von ~3 Sekunden laden
|
||||
- Unter **5 MB**groß sein
|
||||
- Nicht auf lokale oder private Hosts verweisen
|
||||
|
||||
Screenshots sind optional.
|
||||
|
||||
### Weitere Links
|
||||
|
||||
Spiel-Links und YouTube-Links müssen **http oder https** verwenden. Andere URL-Schemata werden abgelehnt.
|
||||
|
||||
### Vorschlag bearbeiten
|
||||
|
||||
Klicke auf das **Bearbeiten-Symbol (Stift)** auf einer Spielkarte, um Felder jederzeit zu aktualisieren.
|
||||
|
||||
### Vorschlag löschen
|
||||
|
||||
Klicke auf das **Löschen-Symbol (Kreuz)** auf einer Spielkarte, um sie zu entfernen ‒ außer du befindest dich bereits in der Abstimmungsphase (siehe unten).
|
||||
|
||||
### Warum wurde mein Vorschlag blockiert?
|
||||
|
||||
Häufige Gründe:
|
||||
- Fehlender Anzeigename
|
||||
- Das Limit von 5 Vorschlägen wurde erreicht
|
||||
- Name überschreitet das Zeichenlimit
|
||||
- Screenshot-URL ist ungültig, nicht erreichbar oder zu groß
|
||||
- Min./Max.-Spieler fehlen oder sind ungültig
|
||||
- Versuch, im falschen Phase einen Vorschlag hinzuzufügen
|
||||
|
||||
Überprüfe Fehlermeldungen unten rechts auf dem Bildschirm.
|
||||
|
||||
## Abstimmung
|
||||
|
||||
### Wer darf abstimmen?
|
||||
|
||||
Authentifizierte Spieler während der **Abstimmungsphase**.
|
||||
|
||||
### Wie vergebe ich Punkte?
|
||||
|
||||
Nutze den Schieberegler, um eine ganze Zahl von **0 bis 10** zu vergeben.
|
||||
|
||||
### Bearbeiten während der Abstimmung
|
||||
|
||||
- Die meisten Spieldetails können weiterhin bearbeitet werden
|
||||
- Der **Spielname ist während der Abstimmungsphase gesperrt**
|
||||
- Eigene Vorschläge können nicht mehr gelöscht werden
|
||||
- Admins können Vorschläge bei Bedarf löschen
|
||||
|
||||
### Verknüpfte Duplikate
|
||||
|
||||
Wenn ein Admin doppelte Spiele verknüpft:
|
||||
- Eine Punkteänderung wirkt sich auf alle verknüpften Einträge aus
|
||||
- Punkte werden pro Gruppe gespeichert, nicht pro Einzeleintrag
|
||||
|
||||
### Abstimmung finalisieren
|
||||
|
||||
Mit **„Finalisieren"** werden deine Bewertungen gesperrt. Deaktiviere es, um erneut zu bearbeiten.
|
||||
|
||||
„Finalisieren" ist nur während der Abstimmungsphase verfügbar und wird automatisch zurückgesetzt, wenn:
|
||||
- Ein Joker ein neues Spiel hinzufügt
|
||||
- Ein Admin Spiele verknüpft oder trennt
|
||||
|
||||
### Abstimmen nach Änderungen
|
||||
|
||||
Wenn neue Spiele hinzugefügt oder Verknüpfungen geändert werden:
|
||||
- Betroffene Stimmen werden gelöscht
|
||||
- Deine Abstimmung wird automatisch zurückgesetzt
|
||||
|
||||
Überprüfe deine Liste und bewerte erneut, bevor du wieder finalisierst.
|
||||
|
||||
## Joker (Späte Ergänzungen)
|
||||
|
||||
### Was ist ein Joker?
|
||||
|
||||
Ein **Joker** ist ein einmaliger zusätzlicher Vorschlags-Slot, der nur während der **Abstimmungsphase** verfügbar ist. Ein Admin muss ihn dir gewähren.
|
||||
|
||||
### So funktioniert es
|
||||
|
||||
Wenn du einen Joker erhältst:
|
||||
- Erscheint ein Button in der oberen Leiste, mit dem du ein weiteres Spiel hinzufügen kannst
|
||||
- Nach der Nutzung wird der Joker sofort verbraucht
|
||||
- Die Finalisierung aller Abstimmungen werden automatisch zurückgesetzt, damit das neue Spiel bewertet werden kann
|
||||
|
||||
Admins können bei Bedarf zusätzliche Joker vergeben.
|
||||
|
||||
## Ergebnisse
|
||||
|
||||
### Wann sind die Ergebnisse sichtbar?
|
||||
|
||||
Die Ergebnisse bleiben verborgen, bis ein Admin sie freigibt. Danach werden alle Spieler automatisch in die **Ergebnisphase** verschoben. Falls nötig, kann ein Admin die Ergebnisse wieder schließen: Alle kehren in die Abstimmungsphase zurück und alle Abstimmungen werden zur Anpassung zurückgesetzt.
|
||||
|
||||
### Kann ich in der Ergebnisphase etwas bearbeiten?
|
||||
|
||||
Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf an einen Admin.
|
||||
|
||||
## Admin-Tools (Für Hosts)
|
||||
|
||||
### Was können Admin-Konten tun?
|
||||
|
||||
- Joker während der Abstimmung vergeben
|
||||
- Doppelte Vorschläge verknüpfen oder trennen
|
||||
- Vorschläge löschen
|
||||
- Abstimmungsstatus einsehen (wer finalisiert hat)
|
||||
- Einen Spieler löschen (inklusive dessen Vorschläge und Stimmen)
|
||||
- Die Datenbank auf Werkseinstellungen zurücksetzen
|
||||
- Zu vorherigen Phasen zurückkehren
|
||||
|
||||
### Was können Admin-Konten nicht tun?
|
||||
|
||||
- Einzelne Spielerbewertungen einsehen
|
||||
|
||||
Die Abstimmung bleibt anonym und fair.
|
||||
|
||||
## Häufige Fehler & Lösungen
|
||||
|
||||
### „Screenshot-URL muss http(s) verwenden und mit einer Bilddateiendung enden."
|
||||
|
||||
Stelle sicher:
|
||||
- Der Link ist direkt (keine HTML-Seite)
|
||||
- Er endet mit einer gültigen Bilddateiendung
|
||||
- Die Datei ist unter 5 MB groß
|
||||
- Es gibt keine Weiterleitungen
|
||||
|
||||
### „Du hast das Limit von 5 Vorschlägen erreicht."
|
||||
|
||||
Warte auf die Abstimmungsphase und bitte bei Bedarf um einen Joker.
|
||||
|
||||
### „Ungültiger Admin-Schlüssel."
|
||||
|
||||
Registriere dich erneut mit dem korrekten Schlüssel vom Host ‒ oder lasse das Feld leer, um ein normales Konto zu erstellen.
|
||||
|
||||
## Daten & Datenschutz
|
||||
|
||||
- Vorschläge, Stimmen und Phasenstatus werden in einer gemeinsamen **SQLite-Datenbank** gespeichert.
|
||||
- Passwörtwer werden mit einer SHA256 Verschlüsselung gespeichert.
|
||||
- Beim Abmelden wird dein Authentifizierungs-Cookie gelöscht.
|
||||
- Wenn ein Admin dein Spielerkonto löscht, werden auch deine Vorschläge und Stimmen entfernt.
|
||||
`,
|
||||
};
|
||||
|
||||
const storageKey = "app_lang";
|
||||
const defaultLang = "en";
|
||||
const translationsAssetUrl = new URL(
|
||||
"../data/i18n/translations.json",
|
||||
import.meta.url,
|
||||
);
|
||||
const faqBaseUrl = new URL("../data/i18n/faq/", import.meta.url);
|
||||
|
||||
let currentLang = defaultLang;
|
||||
const listeners = [];
|
||||
let assetsLoadingPromise = null;
|
||||
let assetsLoaded = false;
|
||||
|
||||
export let translations = {};
|
||||
export let faqMarkdown = {};
|
||||
|
||||
function isRecord(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validateTranslations(raw) {
|
||||
if (!isRecord(raw)) {
|
||||
throw new Error("Invalid i18n translations payload.");
|
||||
}
|
||||
|
||||
const languages = Object.keys(raw);
|
||||
if (languages.length === 0 || !isRecord(raw[defaultLang])) {
|
||||
throw new Error(
|
||||
`Missing default translation language "${defaultLang}".`,
|
||||
);
|
||||
}
|
||||
|
||||
const defaultDict = raw[defaultLang];
|
||||
const defaultKeys = Object.keys(defaultDict);
|
||||
|
||||
languages.forEach((lang) => {
|
||||
const dict = raw[lang];
|
||||
if (!isRecord(dict)) {
|
||||
throw new Error(
|
||||
`Invalid translation dictionary for language "${lang}".`,
|
||||
);
|
||||
}
|
||||
|
||||
Object.entries(dict).forEach(([key, value]) => {
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(
|
||||
`Invalid translation value for "${lang}.${key}".`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const missing = defaultKeys.filter(
|
||||
(key) => typeof dict[key] !== "string",
|
||||
);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(
|
||||
`Missing translation keys for "${lang}": ${missing.join(", ")}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return raw;
|
||||
}
|
||||
|
||||
async function loadJson(url, label) {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load ${label}: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function loadText(url, label) {
|
||||
const response = await fetch(url, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load ${label}: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.text();
|
||||
}
|
||||
|
||||
async function loadFaqMarkdownForLanguages(languages) {
|
||||
if (!Array.isArray(languages) || languages.length === 0) {
|
||||
throw new Error("No i18n languages available for FAQ loading.");
|
||||
}
|
||||
|
||||
const faqByLanguage = {};
|
||||
for (const lang of languages) {
|
||||
const faqUrl = new URL(`${lang}.md`, faqBaseUrl);
|
||||
try {
|
||||
faqByLanguage[lang] = await loadText(faqUrl, `faq.${lang}`);
|
||||
} catch (err) {
|
||||
if (lang === defaultLang) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = faqByLanguage[defaultLang];
|
||||
if (typeof fallback !== "string" || fallback.trim().length === 0) {
|
||||
throw new Error(`Missing default FAQ language "${defaultLang}".`);
|
||||
}
|
||||
|
||||
languages.forEach((lang) => {
|
||||
const value = faqByLanguage[lang];
|
||||
faqByLanguage[lang] =
|
||||
typeof value === "string" && value.trim().length > 0
|
||||
? value
|
||||
: fallback;
|
||||
});
|
||||
|
||||
return faqByLanguage;
|
||||
}
|
||||
|
||||
async function ensureAssetsLoaded() {
|
||||
if (assetsLoaded) return;
|
||||
if (assetsLoadingPromise) return assetsLoadingPromise;
|
||||
|
||||
assetsLoadingPromise = (async () => {
|
||||
const translationsRaw = await loadJson(
|
||||
translationsAssetUrl,
|
||||
"translations",
|
||||
);
|
||||
translations = validateTranslations(translationsRaw);
|
||||
faqMarkdown = await loadFaqMarkdownForLanguages(
|
||||
Object.keys(translations),
|
||||
);
|
||||
assetsLoaded = true;
|
||||
})().finally(() => {
|
||||
assetsLoadingPromise = null;
|
||||
});
|
||||
|
||||
return assetsLoadingPromise;
|
||||
}
|
||||
|
||||
function interpolate(template, params = {}) {
|
||||
return template.replace(/\{(\w+)\}/g, (_, key) => (params[key] ?? `{${key}}`));
|
||||
return template.replace(
|
||||
/\{(\w+)\}/g,
|
||||
(_, key) => params[key] ?? `{${key}}`,
|
||||
);
|
||||
}
|
||||
|
||||
function t(key, params) {
|
||||
const fallback = translations[defaultLang][key] ?? key;
|
||||
const fallback = translations[defaultLang]?.[key] ?? key;
|
||||
const phrase = translations[currentLang]?.[key] ?? fallback;
|
||||
return interpolate(phrase, params);
|
||||
}
|
||||
@@ -784,7 +188,8 @@ function getLanguage() {
|
||||
return currentLang;
|
||||
}
|
||||
|
||||
function initI18n() {
|
||||
async function initI18n() {
|
||||
await ensureAssetsLoaded();
|
||||
currentLang = detectLanguage();
|
||||
document.documentElement.lang = currentLang;
|
||||
applyTranslations();
|
||||
@@ -796,4 +201,11 @@ function onLanguageChange(fn) {
|
||||
listeners.push(fn);
|
||||
}
|
||||
|
||||
export { t, setLanguage, getLanguage, initI18n, applyTranslations, onLanguageChange, translations, faqMarkdown };
|
||||
export {
|
||||
t,
|
||||
setLanguage,
|
||||
getLanguage,
|
||||
initI18n,
|
||||
applyTranslations,
|
||||
onLanguageChange,
|
||||
};
|
||||
|
||||
148
wwwroot/js/modals-ui.js
Normal file
148
wwwroot/js/modals-ui.js
Normal file
@@ -0,0 +1,148 @@
|
||||
import { t } from "./i18n.js";
|
||||
import { toast } from "./dom.js";
|
||||
|
||||
export function openLightbox(url, title) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "lightbox";
|
||||
|
||||
const content = document.createElement("div");
|
||||
content.className = "lightbox-content";
|
||||
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.className = "lightbox-close";
|
||||
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) => {
|
||||
if (
|
||||
e.target.classList.contains("lightbox") ||
|
||||
e.target.classList.contains("lightbox-close")
|
||||
) {
|
||||
overlay.remove();
|
||||
}
|
||||
});
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
export function openConfirmModal({
|
||||
title,
|
||||
body,
|
||||
confirmLabel,
|
||||
cancelLabel = t("modal.cancel"),
|
||||
confirmClass = null,
|
||||
requirePassword = false,
|
||||
passwordLabel = t("auth.password"),
|
||||
onConfirm,
|
||||
}) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "edit-modal";
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "edit-panel";
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "edit-header";
|
||||
|
||||
const heading = document.createElement("h3");
|
||||
heading.textContent = title ?? "";
|
||||
|
||||
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 actions = document.createElement("div");
|
||||
actions.className = "stack horizontal confirm-actions";
|
||||
const confirmBtn = document.createElement("button");
|
||||
if (confirmClass) confirmBtn.className = confirmClass;
|
||||
confirmBtn.textContent = confirmLabel ?? t("modal.confirm");
|
||||
confirmBtn.disabled = requirePassword;
|
||||
actions.append(confirmBtn);
|
||||
if (cancelLabel !== null && cancelLabel !== undefined) {
|
||||
const cancelBtn = document.createElement("button");
|
||||
cancelBtn.className = "ghost";
|
||||
cancelBtn.type = "button";
|
||||
cancelBtn.textContent = cancelLabel;
|
||||
actions.append(cancelBtn);
|
||||
cancelBtn.addEventListener("click", close);
|
||||
}
|
||||
const bodyContainer = bodyWrap;
|
||||
let passwordInput = null;
|
||||
if (requirePassword && bodyContainer) {
|
||||
const field = document.createElement("label");
|
||||
field.className = "stack";
|
||||
const label = document.createElement("span");
|
||||
label.className = "label";
|
||||
label.textContent = passwordLabel;
|
||||
passwordInput = document.createElement("input");
|
||||
passwordInput.type = "password";
|
||||
passwordInput.autocomplete = "current-password";
|
||||
field.append(label, passwordInput);
|
||||
bodyContainer.appendChild(field);
|
||||
passwordInput.addEventListener("input", () => {
|
||||
confirmBtn.disabled = !(passwordInput.value || "").trim();
|
||||
});
|
||||
}
|
||||
bodyContainer?.appendChild(actions);
|
||||
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (
|
||||
e.target.classList.contains("edit-modal") ||
|
||||
e.target.classList.contains("lightbox-close")
|
||||
) {
|
||||
close();
|
||||
}
|
||||
});
|
||||
confirmBtn.addEventListener("click", async () => {
|
||||
try {
|
||||
await onConfirm?.(close, { password: passwordInput?.value ?? "" });
|
||||
} catch (err) {
|
||||
toast(err.message, true);
|
||||
}
|
||||
});
|
||||
|
||||
overlay.appendChild(panel);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
export function openResultsRelockModal() {
|
||||
openConfirmModal({
|
||||
title: t("results.relockedTitle"),
|
||||
body: t("results.relockedBody"),
|
||||
confirmLabel: t("results.relockedConfirm"),
|
||||
cancelLabel: null,
|
||||
onConfirm: (close) => close(),
|
||||
});
|
||||
}
|
||||
|
||||
export function openSuggestionsChangedModal(names) {
|
||||
const uniq = Array.from(new Set(names)).filter(Boolean);
|
||||
if (uniq.length === 0) return;
|
||||
openConfirmModal({
|
||||
title: t("vote.listUpdatedTitle"),
|
||||
body: t("vote.listUpdatedBody", { names: uniq.join(", ") }),
|
||||
confirmLabel: t("vote.listUpdatedConfirm"),
|
||||
cancelLabel: null,
|
||||
onConfirm: (close) => close(),
|
||||
});
|
||||
}
|
||||
147
wwwroot/js/results-ui.js
Normal file
147
wwwroot/js/results-ui.js
Normal file
@@ -0,0 +1,147 @@
|
||||
import { t } from "./i18n.js";
|
||||
import { state } from "./state.js";
|
||||
import { $ } from "./dom.js";
|
||||
import {
|
||||
linkRootId,
|
||||
renderLinkBadge,
|
||||
escapeHtml,
|
||||
safeUrl,
|
||||
} from "./ui-utils.js";
|
||||
import { scoreToEmoji } from "./votes-ui.js";
|
||||
import { openLightbox } from "./modals-ui.js";
|
||||
|
||||
export function renderResults() {
|
||||
const container = $("results-list");
|
||||
if (!container) return;
|
||||
container.innerHTML = "";
|
||||
const table = document.createElement("table");
|
||||
table.className = "results-table";
|
||||
table.innerHTML = `
|
||||
<thead>
|
||||
<tr>
|
||||
<th>${t("results.rank")}</th>
|
||||
<th>${t("results.game")}</th>
|
||||
<th>${t("results.author")}</th>
|
||||
<th>${t("results.average")}</th>
|
||||
<th>${t("results.votesList")}</th>
|
||||
<th>${t("results.myVote")}</th>
|
||||
<th>${t("results.links")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
`;
|
||||
const tbody = table.querySelector("tbody");
|
||||
const rankByRoot = new Map();
|
||||
let nextRank = 1;
|
||||
state.results.forEach((r) => {
|
||||
const root = linkRootId(r);
|
||||
let rank = rankByRoot.get(root);
|
||||
if (!rank) {
|
||||
rank = nextRank++;
|
||||
rankByRoot.set(root, rank);
|
||||
}
|
||||
const medal =
|
||||
rank === 1
|
||||
? "🥇"
|
||||
: rank === 2
|
||||
? "🥈"
|
||||
: rank === 3
|
||||
? "🥉"
|
||||
: `${rank}`;
|
||||
const row = document.createElement("tr");
|
||||
const podiumClass =
|
||||
rank === 1
|
||||
? "podium podium-1"
|
||||
: rank === 2
|
||||
? "podium podium-2"
|
||||
: rank === 3
|
||||
? "podium podium-3"
|
||||
: "";
|
||||
row.className = podiumClass;
|
||||
const safeName = escapeHtml(r.name);
|
||||
const safeAuthor = escapeHtml(r.author ?? "—");
|
||||
const safeShot = safeUrl(r.screenshotUrl);
|
||||
const safeGameUrl = safeUrl(r.gameUrl);
|
||||
const safeYoutubeUrl = safeUrl(r.youtubeUrl);
|
||||
const votersTooltip = buildVotersTooltip(r);
|
||||
const safeVotersTooltip = escapeHtml(votersTooltip);
|
||||
const averageScore =
|
||||
r.average?.toFixed && typeof r.average === "number"
|
||||
? r.average.toFixed(1)
|
||||
: r.average;
|
||||
row.innerHTML = `
|
||||
<td class="rank-cell"><span class="medal">${medal}</span></td>
|
||||
<td class="game-cell">
|
||||
${safeShot ? `<img class="thumb clickable-thumb" src="${safeShot}" alt="${safeName}">` : ""}
|
||||
<div class="game-meta">
|
||||
<div class="title-line">
|
||||
<span class="title-text">${safeName}</span>
|
||||
${renderLinkBadge(r)}
|
||||
</div>
|
||||
${buildResultMeta(r)}
|
||||
</div>
|
||||
</td>
|
||||
<td class="author-cell">${safeAuthor || "—"}</td>
|
||||
<td><span title="${safeVotersTooltip}">${averageScore}</span></td>
|
||||
<td>${formatVotes(r.votes, votersTooltip)}</td>
|
||||
<td>${formatMyVote(r.myVote, votersTooltip)}</td>
|
||||
<td>
|
||||
${safeGameUrl ? `<a class="link compact" href="${safeGameUrl}" target="_blank" rel="noopener">${t("results.link.site")}</a><br>` : ""}
|
||||
${safeYoutubeUrl ? `<a class="link compact" href="${safeYoutubeUrl}" target="_blank" rel="noopener">${t("results.link.youtube")}</a>` : ""}
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
const frame = document.createElement("div");
|
||||
frame.className = "results-frame";
|
||||
frame.appendChild(table);
|
||||
container.appendChild(frame);
|
||||
container.querySelectorAll(".clickable-thumb").forEach((img) => {
|
||||
img.addEventListener("click", () => openLightbox(img.src, img.alt));
|
||||
});
|
||||
}
|
||||
|
||||
function buildResultMeta(r) {
|
||||
const hasPlayers = r.minPlayers || r.maxPlayers;
|
||||
const players = hasPlayers
|
||||
? t("card.players", {
|
||||
min: r.minPlayers ?? "?",
|
||||
max: r.maxPlayers ?? "?",
|
||||
})
|
||||
: null;
|
||||
const bits = [r.genre ? escapeHtml(r.genre) : null, players].filter(
|
||||
Boolean,
|
||||
);
|
||||
if (bits.length === 0) return "";
|
||||
return `<div class="muted small">${bits.join(" • ")}</div>`;
|
||||
}
|
||||
|
||||
function formatVotes(votes, tooltip) {
|
||||
const safeTooltip = escapeHtml(tooltip);
|
||||
if (!Array.isArray(votes) || votes.length === 0) {
|
||||
return `<span class="score-emoji" title="${safeTooltip}">⚠️</span>`;
|
||||
}
|
||||
const sorted = [...votes].sort((a, b) => a - b);
|
||||
return sorted
|
||||
.map(
|
||||
(v) =>
|
||||
`<span class="score-emoji" title="${safeTooltip}">${scoreToEmoji(v)}</span>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function formatMyVote(score, tooltip) {
|
||||
if (score == null || Number.isNaN(score)) return "—";
|
||||
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(", ") });
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export const state = {
|
||||
isAuthenticated: false,
|
||||
ownerExists: false,
|
||||
authMode: "login",
|
||||
me: null,
|
||||
phase: null,
|
||||
@@ -15,9 +16,12 @@ export const state = {
|
||||
results: [],
|
||||
votesRendered: false,
|
||||
adminVoteStatus: null,
|
||||
adminStatusSelectActive: false,
|
||||
stateEtag: null,
|
||||
};
|
||||
|
||||
export function clearUserState() {
|
||||
state.ownerExists = false;
|
||||
state.me = null;
|
||||
state.phase = null;
|
||||
state.prevPhase = null;
|
||||
@@ -27,9 +31,13 @@ export function clearUserState() {
|
||||
state.counts = null;
|
||||
state.mySuggestions = [];
|
||||
state.allSuggestions = [];
|
||||
state.allSuggestionsSig = null;
|
||||
state.myVotes = [];
|
||||
state.results = [];
|
||||
state.votesRendered = false;
|
||||
state.adminVoteStatus = null;
|
||||
state.adminStatusSelectActive = false;
|
||||
state.stateEtag = null;
|
||||
const adminCard = document.getElementById("admin-card");
|
||||
if (adminCard) adminCard.classList.add("hidden");
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user