Compare commits
32 Commits
codex/task
...
ae85c369ba
| 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 |
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -40,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
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -11,12 +11,17 @@ 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
|
||||
|
||||
14
AGENTS.md
14
AGENTS.md
@@ -1,6 +1,6 @@
|
||||
# Agent Guide — Pick'n'Play
|
||||
|
||||
Also see the other related technical documentation: API.md, IIS.md, SPEC.md, TESTS.md and README.md.
|
||||
Also see the other related technical documentation: API.md, IIS.md, SPEC.md, TESTS.md, REVIEW.md and README.md.
|
||||
Also see the user-facing documentation: per-language md files in wwwroot/data/i18n/faq
|
||||
|
||||
## Rules
|
||||
@@ -12,14 +12,6 @@ Also see the user-facing documentation: per-language md files in wwwroot/data/i1
|
||||
- After every iteration, run "scripts/ci-local.ps1" and ensure that nothing broke.
|
||||
- After every iteration, update all related documentation according to the change, and evaluate if a FAQ entry would help the users, serving as public documentation for this project.
|
||||
- After every iteration, do a git commit with a brief summary of the changes as a commit message.
|
||||
- Keep changes small and commit often. If one iteration encompasses many smaller tasks, create a git branch and do the commits there. Let me review the branch before merging it back to master.
|
||||
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated.), never revert them and never restore the old state. Assume that those changes happened with intent.
|
||||
- Keep changes small and commit often. If one iteration encompasses many smaller tasks with more than one commit, create a git branch and do the commits there. Let me review the branch before merging it back to master.
|
||||
- If you find unexpected changes in the code (deletions, changes, diff results that were not communicated), never revert them and never restore the old state. Assume that those changes happened with intent.
|
||||
- After changing the database, run "dotnet ef database update". If this is blocked by a running dotnet process, feel free to kill the process and retry the operations once.
|
||||
|
||||
## Tech constraints:
|
||||
- .NET 10
|
||||
- ASP.NET Core Minimal API
|
||||
- Static HTML/CSS/JS (no Razor Pages, no Blazor, no HTMX)
|
||||
- SQLite via EF Core
|
||||
- Username+Password identity (account stored in database)
|
||||
- Runs on IIS (Windows Server)
|
||||
|
||||
28
API.md
28
API.md
@@ -1,16 +1,24 @@
|
||||
# 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 requires at least one own suggestion; Vote→Results is gated by resultsOpen)
|
||||
@@ -22,22 +30,34 @@ POST /api/suggestions — create (name required ≤100; max 5 per player; valida
|
||||
PUT /api/suggestions/{id} — update (non-admin: own suggestion; title locked after Suggest)
|
||||
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 — `{ 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,13 +6,17 @@ public record SuggestionRequest(string Name, string? Genre, string? Description,
|
||||
|
||||
public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers, int? ParentSuggestionId = null, IReadOnlyList<int>? LinkedIds = null, IReadOnlyList<string>? LinkedTitles = null);
|
||||
|
||||
public record 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);
|
||||
|
||||
@@ -22,4 +26,6 @@ public record GrantJokerRequest(Guid PlayerId);
|
||||
|
||||
public record SetPlayerPhaseRequest(Guid PlayerId, Phase Phase);
|
||||
|
||||
public record SetPlayerAdminRequest(Guid PlayerId, bool IsAdmin);
|
||||
|
||||
public record AdminPasswordRequest(string Password);
|
||||
|
||||
@@ -4,17 +4,7 @@ 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 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);
|
||||
|
||||
@@ -26,6 +16,8 @@ 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);
|
||||
@@ -36,48 +28,14 @@ public record AdminResetStateResponse(Phase Phase, bool ResultsOpen, DateTimeOff
|
||||
|
||||
public record VoteStatusResponse(IReadOnlyList<VoteStatusDto> Voters, bool Ready, IReadOnlyList<string> Waiting);
|
||||
|
||||
public record ResultItemDto(
|
||||
int Id,
|
||||
string Name,
|
||||
string? Author,
|
||||
int? MinPlayers,
|
||||
int? MaxPlayers,
|
||||
int Total,
|
||||
int Count,
|
||||
double Average,
|
||||
IReadOnlyList<int> Votes,
|
||||
int? MyVote,
|
||||
string? ScreenshotUrl,
|
||||
string? YoutubeUrl,
|
||||
string? GameUrl,
|
||||
string? Description,
|
||||
string? Genre,
|
||||
int? ParentSuggestionId,
|
||||
IReadOnlyList<int> LinkedIds,
|
||||
IReadOnlyList<string> LinkedTitles
|
||||
);
|
||||
public record ResultItemDto(int Id, string Name, string? Author, int? MinPlayers, int? MaxPlayers, int Total, int Count, double Average, IReadOnlyList<int> Votes, IReadOnlyList<string> VoterNames, int? MyVote, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, string? Description, string? Genre, int? ParentSuggestionId, IReadOnlyList<int> LinkedIds, IReadOnlyList<string> LinkedTitles);
|
||||
|
||||
public record AuthSessionResponse(Guid Id, string Username, string? DisplayName, bool IsAdmin);
|
||||
|
||||
public record StateSummaryResponse(
|
||||
Phase CurrentPhase,
|
||||
bool VotesFinal,
|
||||
bool HasJoker,
|
||||
bool ResultsOpen,
|
||||
DateTimeOffset UpdatedAt,
|
||||
int Players,
|
||||
int Suggestions,
|
||||
int Votes
|
||||
);
|
||||
public record AuthOptionsResponse(bool OwnerExists);
|
||||
|
||||
public record MeResponse(
|
||||
Guid Id,
|
||||
string Username,
|
||||
string? DisplayName,
|
||||
bool IsAdmin,
|
||||
Phase CurrentPhase,
|
||||
bool VotesFinal,
|
||||
bool HasJoker
|
||||
);
|
||||
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();
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -9,15 +9,36 @@ 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, AdminWorkflowService service) => await service.SetResultsOpenAsync(request.ResultsOpen));
|
||||
admin.MapPost("/results", async ([FromBody] ResultsOpenRequest request, AdminWorkflowService service) =>
|
||||
{
|
||||
var result = await service.SetResultsOpenAsync(request.ResultsOpen);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("SetResultsOpen");
|
||||
|
||||
admin.MapGet("/vote-status", async (AdminWorkflowService service) => await service.GetVoteStatusAsync());
|
||||
admin.MapGet("/vote-status", async (AdminWorkflowService service) =>
|
||||
{
|
||||
var result = await service.GetVoteStatusAsync();
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("GetVoteStatus");
|
||||
|
||||
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) => await service.GrantJokerAsync(request.PlayerId));
|
||||
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) =>
|
||||
{
|
||||
var result = await service.GrantJokerAsync(request.PlayerId);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("GrantJoker");
|
||||
|
||||
admin.MapPost("/player-phase", async ([FromBody] SetPlayerPhaseRequest request, AdminWorkflowService service) => await service.SetPlayerPhaseAsync(request.PlayerId, request.Phase));
|
||||
admin.MapPost("/player-phase", async ([FromBody] SetPlayerPhaseRequest request, AdminWorkflowService service) =>
|
||||
{
|
||||
var result = await service.SetPlayerPhaseAsync(request.PlayerId, request.Phase);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("SetPlayerPhase");
|
||||
admin.MapPost("/player-admin", async ([FromBody] SetPlayerAdminRequest request, AdminWorkflowService service) =>
|
||||
{
|
||||
var result = await service.SetPlayerAdminAsync(request.PlayerId, request.IsAdmin);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("SetPlayerAdmin");
|
||||
|
||||
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, [FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||
{
|
||||
@@ -25,8 +46,9 @@ public static class AdminEndpoints
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
return await service.DeletePlayerAsync(playerId, player.Id, request.Password);
|
||||
});
|
||||
var result = await service.DeletePlayerAsync(playerId, player.Id, request.Password, ctx);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("DeletePlayer");
|
||||
|
||||
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||
{
|
||||
@@ -34,8 +56,9 @@ public static class AdminEndpoints
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
return await service.LinkSuggestionsAsync(player.Id, request.SourceSuggestionId, request.TargetSuggestionId);
|
||||
});
|
||||
var result = await service.LinkSuggestionsAsync(player.Id, request.SourceSuggestionId, request.TargetSuggestionId);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("LinkSuggestions");
|
||||
|
||||
admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||
{
|
||||
@@ -43,8 +66,9 @@ public static class AdminEndpoints
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
return await service.UnlinkSuggestionsAsync(player.Id, request.SuggestionId);
|
||||
});
|
||||
var result = await service.UnlinkSuggestionsAsync(player.Id, request.SuggestionId);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("UnlinkSuggestions");
|
||||
|
||||
admin.MapPost("/reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||
{
|
||||
@@ -52,8 +76,9 @@ public static class AdminEndpoints
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
return await service.ResetAsync(player.Id, request.Password);
|
||||
});
|
||||
var result = await service.ResetAsync(player.Id, request.Password, ctx);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("Reset");
|
||||
|
||||
admin.MapPost("/factory-reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
|
||||
{
|
||||
@@ -61,8 +86,9 @@ public static class AdminEndpoints
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
return await service.FactoryResetAsync(player.Id, request.Password);
|
||||
});
|
||||
var result = await service.FactoryResetAsync(player.Id, request.Password, ctx);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("FactoryReset");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace GameList.Endpoints;
|
||||
|
||||
internal sealed class AdminWorkflowService(AppDbContext db)
|
||||
{
|
||||
public async Task<IResult> SetResultsOpenAsync(bool resultsOpen)
|
||||
public async Task<ServiceResult<AdminResultsStateResponse>> SetResultsOpenAsync(bool resultsOpen)
|
||||
{
|
||||
var state = await db.AppState.SingleAsync();
|
||||
state.ResultsOpen = resultsOpen;
|
||||
@@ -22,80 +22,88 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
||||
}
|
||||
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.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 Results.Ok(new AdminResultsStateResponse(currentState.ResultsOpen, currentState.UpdatedAt));
|
||||
return ServiceResult<AdminResultsStateResponse>.Success(new AdminResultsStateResponse(currentState.ResultsOpen, currentState.UpdatedAt));
|
||||
}
|
||||
|
||||
public async Task<IResult> GetVoteStatusAsync()
|
||||
public async Task<ServiceResult<VoteStatusResponse>> GetVoteStatusAsync()
|
||||
{
|
||||
var voters = await db.Players
|
||||
.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 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 Results.Ok(new VoteStatusResponse(voters, ready, waiting));
|
||||
return ServiceResult<VoteStatusResponse>.Success(new VoteStatusResponse(voters, ready, waiting));
|
||||
}
|
||||
|
||||
public async Task<IResult> GrantJokerAsync(Guid playerId)
|
||||
public async Task<ServiceResult<AdminGrantJokerResponse>> GrantJokerAsync(Guid playerId)
|
||||
{
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||
if (player is null)
|
||||
return EndpointHelpers.NotFoundError("Player not found.");
|
||||
return ServiceResult<AdminGrantJokerResponse>.Failure(ServiceError.NotFound("Player not found."));
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.BadRequestError("Player must be in the Vote phase to receive a joker.");
|
||||
return ServiceResult<AdminGrantJokerResponse>.Failure(ServiceError.BadRequest("Player must be in the Vote phase to receive a joker."));
|
||||
|
||||
player.HasJoker = true;
|
||||
player.VotesFinal = false;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new AdminGrantJokerResponse(player.Id, player.HasJoker));
|
||||
return ServiceResult<AdminGrantJokerResponse>.Success(new AdminGrantJokerResponse(player.Id, player.HasJoker));
|
||||
}
|
||||
|
||||
public async Task<IResult> SetPlayerPhaseAsync(Guid playerId, Phase phase)
|
||||
public async Task<ServiceResult<AdminSetPlayerPhaseResponse>> SetPlayerPhaseAsync(Guid playerId, Phase phase)
|
||||
{
|
||||
if (phase != Phase.Suggest)
|
||||
return EndpointHelpers.BadRequestError("Only transition to Suggest is supported.");
|
||||
return ServiceResult<AdminSetPlayerPhaseResponse>.Failure(ServiceError.BadRequest("Only transition to Suggest is supported."));
|
||||
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||
if (player is null)
|
||||
return EndpointHelpers.NotFoundError("Player not found.");
|
||||
return ServiceResult<AdminSetPlayerPhaseResponse>.Failure(ServiceError.NotFound("Player not found."));
|
||||
|
||||
var currentPhase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id);
|
||||
if (currentPhase != Phase.Vote)
|
||||
return EndpointHelpers.BadRequestError("Player must currently be in the Vote phase.");
|
||||
return ServiceResult<AdminSetPlayerPhaseResponse>.Failure(ServiceError.BadRequest("Player must currently be in the Vote phase."));
|
||||
|
||||
player.CurrentPhase = Phase.Suggest;
|
||||
player.VotesFinal = false;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal));
|
||||
return ServiceResult<AdminSetPlayerPhaseResponse>.Success(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal));
|
||||
}
|
||||
|
||||
public async Task<IResult> DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password)
|
||||
public async Task<ServiceResult<AdminSetPlayerAdminResponse>> SetPlayerAdminAsync(Guid playerId, bool isAdmin)
|
||||
{
|
||||
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password);
|
||||
var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId);
|
||||
if (player is null)
|
||||
return ServiceResult<AdminSetPlayerAdminResponse>.Failure(ServiceError.NotFound("Player not found."));
|
||||
|
||||
if (player.IsOwner)
|
||||
return ServiceResult<AdminSetPlayerAdminResponse>.Failure(ServiceError.BadRequest("Owner permissions cannot be changed."));
|
||||
|
||||
player.IsAdmin = isAdmin;
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return ServiceResult<AdminSetPlayerAdminResponse>.Success(new AdminSetPlayerAdminResponse(player.Id, player.IsAdmin));
|
||||
}
|
||||
|
||||
public async Task<ServiceResult<AdminDeletePlayerResponse>> DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password, HttpContext ctx)
|
||||
{
|
||||
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
|
||||
if (passwordError is not null)
|
||||
return passwordError;
|
||||
return ServiceResult<AdminDeletePlayerResponse>.Failure(passwordError);
|
||||
|
||||
var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId);
|
||||
if (player is null)
|
||||
return EndpointHelpers.NotFoundError("Player not found.");
|
||||
return ServiceResult<AdminDeletePlayerResponse>.Failure(ServiceError.NotFound("Player not found."));
|
||||
if (player.IsOwner)
|
||||
return ServiceResult<AdminDeletePlayerResponse>.Failure(ServiceError.BadRequest("Owner account cannot be deleted."));
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
@@ -104,9 +112,7 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
||||
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.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();
|
||||
}
|
||||
@@ -115,30 +121,30 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
||||
await db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
return Results.Ok(new AdminDeletePlayerResponse(playerId));
|
||||
return ServiceResult<AdminDeletePlayerResponse>.Success(new AdminDeletePlayerResponse(playerId));
|
||||
}
|
||||
|
||||
public async Task<IResult> LinkSuggestionsAsync(Guid adminPlayerId, int sourceSuggestionId, int targetSuggestionId)
|
||||
public async Task<ServiceResult<AdminLinkSuggestionsResponse>> LinkSuggestionsAsync(Guid adminPlayerId, int sourceSuggestionId, int targetSuggestionId)
|
||||
{
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||
|
||||
if (sourceSuggestionId == targetSuggestionId)
|
||||
return EndpointHelpers.BadRequestError("Pick two different games to link.");
|
||||
return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.BadRequest("Pick two different games to link."));
|
||||
|
||||
var suggestions = await db.Suggestions.ToListAsync();
|
||||
var source = suggestions.FirstOrDefault(s => s.Id == sourceSuggestionId);
|
||||
var target = suggestions.FirstOrDefault(s => s.Id == targetSuggestionId);
|
||||
if (source is null || target is null)
|
||||
return EndpointHelpers.NotFoundError("Suggestion not found.");
|
||||
return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.NotFound("Suggestion not found."));
|
||||
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
if (!rootIndex.TryGetValue(source.Id, out var sourceRoot) || !rootIndex.TryGetValue(target.Id, out var targetRoot))
|
||||
return EndpointHelpers.NotFoundError("Suggestion not found.");
|
||||
return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.NotFound("Suggestion not found."));
|
||||
|
||||
if (sourceRoot == targetRoot)
|
||||
return EndpointHelpers.BadRequestError("These games are already linked.");
|
||||
return ServiceResult<AdminLinkSuggestionsResponse>.Failure(ServiceError.BadRequest("These games are already linked."));
|
||||
|
||||
var affectedRootIds = new HashSet<int>
|
||||
{
|
||||
@@ -170,23 +176,23 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
||||
|
||||
await tx.CommitAsync();
|
||||
|
||||
return Results.Ok(new AdminLinkSuggestionsResponse(targetRoot, affectedIds, await db.Players.CountAsync()));
|
||||
return ServiceResult<AdminLinkSuggestionsResponse>.Success(new AdminLinkSuggestionsResponse(targetRoot, affectedIds, await db.Players.CountAsync()));
|
||||
}
|
||||
|
||||
public async Task<IResult> UnlinkSuggestionsAsync(Guid adminPlayerId, int suggestionId)
|
||||
public async Task<ServiceResult<AdminUnlinkSuggestionsResponse>> UnlinkSuggestionsAsync(Guid adminPlayerId, int suggestionId)
|
||||
{
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
return ServiceResult<AdminUnlinkSuggestionsResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||
|
||||
var suggestions = await db.Suggestions.ToListAsync();
|
||||
var target = suggestions.FirstOrDefault(s => s.Id == suggestionId);
|
||||
if (target is null)
|
||||
return Results.Ok(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0));
|
||||
return ServiceResult<AdminUnlinkSuggestionsResponse>.Success(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0));
|
||||
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
if (!rootIndex.TryGetValue(target.Id, out var rootId))
|
||||
return Results.Ok(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0));
|
||||
return ServiceResult<AdminUnlinkSuggestionsResponse>.Success(new AdminUnlinkSuggestionsResponse(Array.Empty<int>(), 0));
|
||||
|
||||
var groupIds = rootIndex.Where(kv => kv.Value == rootId).Select(kv => kv.Key).ToList();
|
||||
|
||||
@@ -205,14 +211,14 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
||||
|
||||
await tx.CommitAsync();
|
||||
|
||||
return Results.Ok(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync()));
|
||||
return ServiceResult<AdminUnlinkSuggestionsResponse>.Success(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync()));
|
||||
}
|
||||
|
||||
public async Task<IResult> ResetAsync(Guid adminPlayerId, string password)
|
||||
public async Task<ServiceResult<AdminResetStateResponse>> ResetAsync(Guid adminPlayerId, string password, HttpContext ctx)
|
||||
{
|
||||
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password);
|
||||
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
|
||||
if (passwordError is not null)
|
||||
return passwordError;
|
||||
return ServiceResult<AdminResetStateResponse>.Failure(passwordError);
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
@@ -226,14 +232,14 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
||||
await db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt));
|
||||
return ServiceResult<AdminResetStateResponse>.Success(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt));
|
||||
}
|
||||
|
||||
public async Task<IResult> FactoryResetAsync(Guid adminPlayerId, string password)
|
||||
public async Task<ServiceResult<AdminResetStateResponse>> FactoryResetAsync(Guid adminPlayerId, string password, HttpContext ctx)
|
||||
{
|
||||
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password);
|
||||
var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx);
|
||||
if (passwordError is not null)
|
||||
return passwordError;
|
||||
return ServiceResult<AdminResetStateResponse>.Failure(passwordError);
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
@@ -248,20 +254,36 @@ internal sealed class AdminWorkflowService(AppDbContext db)
|
||||
|
||||
await tx.CommitAsync();
|
||||
|
||||
return Results.Ok(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt));
|
||||
return ServiceResult<AdminResetStateResponse>.Success(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt));
|
||||
}
|
||||
|
||||
private async Task<IResult?> ValidateAdminPasswordAsync(Guid adminPlayerId, string password)
|
||||
private async Task<ServiceError?> ValidateAdminPasswordAsync(Guid adminPlayerId, string password, HttpContext ctx)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(password))
|
||||
return EndpointHelpers.BadRequestError("Admin password is required.");
|
||||
return ServiceError.BadRequest("Admin password is required.");
|
||||
|
||||
var admin = await db.Players.AsNoTracking().FirstOrDefaultAsync(p => p.Id == adminPlayerId && p.IsAdmin);
|
||||
var admin = await db.Players.FirstOrDefaultAsync(p => p.Id == adminPlayerId && p.IsAdmin);
|
||||
if (admin is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
return ServiceError.Unauthorized();
|
||||
|
||||
return PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt)
|
||||
? null
|
||||
: EndpointHelpers.BadRequestError("Invalid admin password.");
|
||||
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))
|
||||
{
|
||||
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 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)
|
||||
{
|
||||
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,14 +60,31 @@ 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);
|
||||
await db.SaveChangesAsync();
|
||||
try
|
||||
{
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
catch (DbUpdateException ex) when (isOwner && EndpointHelpers.IsSqliteConstraintViolation(ex, EndpointHelpers.SingleOwnerIndexName))
|
||||
{
|
||||
authAttemptMonitor.RecordFailure(ctx, "auth-register-admin", validated.NormalizedUsername, "bootstrap-admin-race");
|
||||
return EndpointHelpers.BadRequestError("Admin registration via admin key is disabled once an owner account exists.");
|
||||
}
|
||||
catch (DbUpdateException ex) when (EndpointHelpers.IsSqliteConstraintViolation(ex, "IX_Players_NormalizedUsername"))
|
||||
{
|
||||
return EndpointHelpers.ConflictError("Username already taken.");
|
||||
}
|
||||
|
||||
if (isAdmin)
|
||||
authAttemptMonitor.RecordSuccess(ctx, "auth-register-admin", validated.NormalizedUsername);
|
||||
|
||||
await PlayerIdentityExtensions.SignInPlayerAsync(ctx, player);
|
||||
|
||||
@@ -57,16 +94,31 @@ public static class AuthEndpoints
|
||||
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))
|
||||
{
|
||||
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))
|
||||
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))
|
||||
{
|
||||
@@ -76,6 +128,7 @@ 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 AuthSessionResponse(
|
||||
@@ -84,12 +137,14 @@ public static class AuthEndpoints
|
||||
player.DisplayName,
|
||||
player.IsAdmin
|
||||
));
|
||||
});
|
||||
}).WithName("Login");
|
||||
|
||||
group.MapPost("/logout", async (HttpContext ctx) =>
|
||||
{
|
||||
await PlayerIdentityExtensions.SignOutPlayerAsync(ctx);
|
||||
return Results.NoContent();
|
||||
});
|
||||
}).WithName("Logout");
|
||||
}
|
||||
|
||||
private static string NormalizeActor(string? username) => string.IsNullOrWhiteSpace(username) ? "(missing)" : username.Trim();
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ 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)
|
||||
{
|
||||
var username = (request.Username).Trim();
|
||||
var username = (request.Username ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(username) || username.Length > MaxUsernameLength)
|
||||
{
|
||||
validated = default;
|
||||
@@ -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,14 +61,14 @@ 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;
|
||||
}
|
||||
|
||||
public static bool TryValidateLogin(LoginRequest request, out string username, out string normalizedUsername, out string error)
|
||||
{
|
||||
username = (request.Username).Trim();
|
||||
username = (request.Username ?? string.Empty).Trim();
|
||||
normalizedUsername = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password))
|
||||
@@ -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)
|
||||
@@ -106,6 +112,36 @@ internal static class EndpointHelpers
|
||||
|
||||
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(
|
||||
@@ -140,6 +176,48 @@ internal static class EndpointHelpers
|
||||
|| 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)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
@@ -148,13 +226,21 @@ internal static class EndpointHelpers
|
||||
return false;
|
||||
if (uri.Scheme is not ("http" or "https"))
|
||||
return false;
|
||||
if (!await IsSafePublicHostAsync(uri, ct))
|
||||
if (handler is null)
|
||||
{
|
||||
if (!await IsSafePublicHostAsync(uri, ct))
|
||||
return false;
|
||||
}
|
||||
else if (IPAddress.TryParse(uri.Host, out var literal) && IsBlockedAddress(literal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
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
|
||||
{
|
||||
@@ -234,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
|
||||
{
|
||||
@@ -259,26 +329,90 @@ internal static class EndpointHelpers
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPrivate(System.Net.IPAddress ip)
|
||||
private static async Task<IReadOnlyList<IPAddress>> ResolveSafePublicAddressesAsync(string host, CancellationToken ct)
|
||||
{
|
||||
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
if (!IsSupportedHostType(host))
|
||||
return [];
|
||||
|
||||
IPAddress[] resolved;
|
||||
if (IPAddress.TryParse(host, out var literal))
|
||||
{
|
||||
var bytes = ip.GetAddressBytes();
|
||||
return bytes[0] switch
|
||||
{
|
||||
10 => true,
|
||||
172 when bytes[1] >= 16 && bytes[1] <= 31 => true,
|
||||
192 when bytes[1] == 168 => true,
|
||||
127 => true,
|
||||
_ => false
|
||||
};
|
||||
resolved = [literal];
|
||||
}
|
||||
else
|
||||
{
|
||||
resolved = await Dns.GetHostAddressesAsync(host, ct);
|
||||
}
|
||||
|
||||
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
|
||||
var safe = new List<IPAddress>(resolved.Length);
|
||||
foreach (var ip in resolved)
|
||||
{
|
||||
return ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal || ip.IsIPv6Multicast || System.Net.IPAddress.IsLoopback(ip);
|
||||
if (!IsBlockedAddress(ip))
|
||||
safe.Add(ip);
|
||||
}
|
||||
|
||||
return safe.Distinct().ToArray();
|
||||
}
|
||||
|
||||
private static bool IsSupportedHostType(string host)
|
||||
{
|
||||
var type = Uri.CheckHostName(host);
|
||||
return type is UriHostNameType.Dns or UriHostNameType.IPv4 or UriHostNameType.IPv6;
|
||||
}
|
||||
|
||||
private static bool IsBlockedAddress(IPAddress ip)
|
||||
{
|
||||
if (IPAddress.IsLoopback(ip))
|
||||
return true;
|
||||
|
||||
if (ip.IsIPv4MappedToIPv6)
|
||||
return IsBlockedAddress(ip.MapToIPv4());
|
||||
|
||||
if (ip.AddressFamily == AddressFamily.InterNetwork)
|
||||
return IsBlockedIpv4(ip);
|
||||
|
||||
if (ip.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
return IsBlockedIpv6(ip);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsBlockedIpv4(IPAddress ip)
|
||||
{
|
||||
var b = ip.GetAddressBytes();
|
||||
return b[0] switch
|
||||
{
|
||||
0 => true, // "This network"
|
||||
10 => true, // private
|
||||
100 when b[1] >= 64 && b[1] <= 127 => true, // CGNAT
|
||||
127 => true, // loopback
|
||||
169 when b[1] == 254 => true, // link local
|
||||
172 when b[1] >= 16 && b[1] <= 31 => true, // private
|
||||
192 when b[1] == 0 && b[2] == 0 => true, // IETF protocol assignments
|
||||
192 when b[1] == 0 && b[2] == 2 => true, // documentation
|
||||
192 when b[1] == 88 && b[2] == 99 => true, // 6to4 relay anycast
|
||||
192 when b[1] == 168 => true, // private
|
||||
198 when b[1] is 18 or 19 => true, // benchmarking
|
||||
198 when b[1] == 51 && b[2] == 100 => true, // documentation
|
||||
203 when b[1] == 0 && b[2] == 113 => true, // documentation
|
||||
>= 224 => true, // multicast/reserved/broadcast
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsBlockedIpv6(IPAddress ip)
|
||||
{
|
||||
if (ip.Equals(IPAddress.IPv6None))
|
||||
return true;
|
||||
if (ip.IsIPv6Multicast || ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal)
|
||||
return true;
|
||||
|
||||
var bytes = ip.GetAddressBytes();
|
||||
if ((bytes[0] & 0xFE) == 0xFC) // fc00::/7 unique local
|
||||
return true;
|
||||
if (bytes[0] == 0x20 && bytes[1] == 0x01 && bytes[2] == 0x0D && bytes[3] == 0xB8) // 2001:db8::/32 docs
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ public static class ResultsEndpoints
|
||||
public static void MapResultsEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/results")
|
||||
.WithTags("Results")
|
||||
.RequireAuthorization()
|
||||
.AddEndpointFilter(new PhaseRequirementFilter(Phase.Results));
|
||||
|
||||
@@ -18,8 +19,9 @@ public static class ResultsEndpoints
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
return await service.GetResultsAsync(player.Id);
|
||||
});
|
||||
var result = await service.GetResultsAsync(player.Id);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("GetResults");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,15 +7,15 @@ namespace GameList.Endpoints;
|
||||
|
||||
internal sealed class ResultsWorkflowService(AppDbContext db)
|
||||
{
|
||||
public async Task<IResult> GetResultsAsync(Guid playerId)
|
||||
public async Task<ServiceResult<IReadOnlyList<ResultItemDto>>> GetResultsAsync(Guid playerId)
|
||||
{
|
||||
var appState = await db.AppState.AsNoTracking().SingleAsync();
|
||||
if (!appState.ResultsOpen)
|
||||
return EndpointHelpers.BadRequestError("Results are locked until the admin enables them.");
|
||||
return ServiceResult<IReadOnlyList<ResultItemDto>>.Failure(ServiceError.BadRequest("Results are locked until the admin enables them."));
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
if (phase != Phase.Results)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Results, phase);
|
||||
return ServiceResult<IReadOnlyList<ResultItemDto>>.Failure(ServiceError.PhaseMismatch(Phase.Results, phase));
|
||||
|
||||
var results = await db
|
||||
.Suggestions.AsNoTracking()
|
||||
@@ -32,6 +32,9 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
|
||||
s.Votes.Count,
|
||||
Average = s.Votes.Count == 0 ? 0 : s.Votes.Average(v => v.Score),
|
||||
Votes = s.Votes.Select(v => v.Score).ToList(),
|
||||
VoterNames = s.Votes
|
||||
.Select(v => v.Player!.DisplayName ?? v.Player!.Username)
|
||||
.ToList(),
|
||||
MyVote = s.Votes
|
||||
.Where(v => v.PlayerId == playerId)
|
||||
.Select(v => (int?)v.Score)
|
||||
@@ -49,7 +52,7 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
|
||||
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 =>
|
||||
IReadOnlyList<ResultItemDto> shaped = results.Select(r =>
|
||||
{
|
||||
var linkedIds = EndpointHelpers.LinkedIdsFor(r.Id, rootIndex)
|
||||
.Where(id => id != r.Id)
|
||||
@@ -59,6 +62,11 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
|
||||
.Where(nameLookup.ContainsKey)
|
||||
.Select(id => nameLookup[id])
|
||||
.ToList();
|
||||
var voterNames = r.VoterNames
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(name => name, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
return new ResultItemDto(
|
||||
r.Id,
|
||||
@@ -70,6 +78,7 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
|
||||
r.Count,
|
||||
r.Average,
|
||||
r.Votes,
|
||||
voterNames,
|
||||
r.MyVote,
|
||||
r.ScreenshotUrl,
|
||||
r.YoutubeUrl,
|
||||
@@ -80,8 +89,8 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
|
||||
linkedIds,
|
||||
linkedTitles
|
||||
);
|
||||
});
|
||||
}).ToList();
|
||||
|
||||
return Results.Ok(shaped);
|
||||
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,4 +1,5 @@
|
||||
using GameList.Data;
|
||||
using GameList.Infrastructure;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
@@ -6,16 +7,73 @@ public static class StateEndpoints
|
||||
{
|
||||
public static void MapStateEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api").RequireAuthorization();
|
||||
var group = app.MapGroup("/api").WithTags("State").RequireAuthorization();
|
||||
|
||||
group.MapGet("/state", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
|
||||
group.MapGet("/state", async (HttpContext ctx, AppDbContext db, StateWorkflowService service, StateChangeNotifier notifier) =>
|
||||
{
|
||||
ctx.Response.Headers.CacheControl = "private, no-cache";
|
||||
if (notifier.MatchesCurrentEtag(ctx.Request.Headers.IfNoneMatch))
|
||||
{
|
||||
ctx.Response.Headers.ETag = notifier.CurrentEtag;
|
||||
return Results.StatusCode(StatusCodes.Status304NotModified);
|
||||
}
|
||||
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
var result = await service.GetStateAsync(player);
|
||||
return result.ToHttpResult(payload =>
|
||||
{
|
||||
ctx.Response.Headers.ETag = notifier.CurrentEtag;
|
||||
return Results.Ok(payload);
|
||||
});
|
||||
}).WithName("GetState");
|
||||
|
||||
group.MapGet("/events/state", async (HttpContext ctx, AppDbContext db, StateChangeNotifier notifier) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
return await service.GetStateAsync(player);
|
||||
});
|
||||
ctx.Response.ContentType = "text/event-stream";
|
||||
ctx.Response.Headers.CacheControl = "no-cache";
|
||||
ctx.Response.Headers["X-Accel-Buffering"] = "no";
|
||||
|
||||
var observedVersion = notifier.CurrentVersion;
|
||||
await WriteStateEventAsync(ctx, "ready", observedVersion, ctx.RequestAborted);
|
||||
|
||||
while (!ctx.RequestAborted.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
var changeTask = notifier.WaitForChangeAsync(observedVersion, ctx.RequestAborted);
|
||||
var heartbeatTask = Task.Delay(TimeSpan.FromSeconds(20), ctx.RequestAborted);
|
||||
var completed = await Task.WhenAny(changeTask, heartbeatTask);
|
||||
|
||||
if (completed == changeTask)
|
||||
{
|
||||
observedVersion = await changeTask;
|
||||
await WriteStateEventAsync(ctx, "state", observedVersion, ctx.RequestAborted);
|
||||
}
|
||||
else
|
||||
{
|
||||
await ctx.Response.WriteAsync(": ping\n\n", ctx.RequestAborted);
|
||||
await ctx.Response.Body.FlushAsync(ctx.RequestAborted);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return Results.Empty;
|
||||
}).WithName("GetStateEvents");
|
||||
|
||||
group.MapGet("/me", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
|
||||
{
|
||||
@@ -23,8 +81,9 @@ public static class StateEndpoints
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
return await service.GetMeAsync(player);
|
||||
});
|
||||
var result = await service.GetMeAsync(player);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("GetMe");
|
||||
|
||||
group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
|
||||
{
|
||||
@@ -32,8 +91,9 @@ public static class StateEndpoints
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
return await service.NextPhaseAsync(player);
|
||||
});
|
||||
var result = await service.NextPhaseAsync(player);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("NextPhase");
|
||||
|
||||
group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
|
||||
{
|
||||
@@ -41,8 +101,16 @@ public static class StateEndpoints
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
return await service.PrevPhaseAsync(player);
|
||||
});
|
||||
var result = await service.PrevPhaseAsync(player);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("PrevPhase");
|
||||
|
||||
}
|
||||
|
||||
private static async Task WriteStateEventAsync(HttpContext ctx, string eventName, long version, CancellationToken cancellationToken)
|
||||
{
|
||||
await ctx.Response.WriteAsync($"event: {eventName}\n", cancellationToken);
|
||||
await ctx.Response.WriteAsync($"data: {version}\n\n", cancellationToken);
|
||||
await ctx.Response.Body.FlushAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,39 +7,47 @@ namespace GameList.Endpoints;
|
||||
|
||||
internal sealed class StateWorkflowService(AppDbContext db)
|
||||
{
|
||||
public async Task<IResult> GetStateAsync(Player player)
|
||||
public async Task<ServiceResult<StateSummaryResponse>> GetStateAsync(Player player)
|
||||
{
|
||||
var state = await db.AppState.AsNoTracking().SingleAsync();
|
||||
var state = await db.AppState
|
||||
.AsNoTracking()
|
||||
.Select(s => new
|
||||
{
|
||||
s.ResultsOpen,
|
||||
s.UpdatedAt,
|
||||
Players = db.Players.Count(),
|
||||
Suggestions = db.Suggestions.Count(),
|
||||
Votes = db.Votes.Count()
|
||||
})
|
||||
.SingleAsync();
|
||||
|
||||
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
|
||||
var summary = new StateSummaryResponse(
|
||||
player.Id,
|
||||
player.Username,
|
||||
player.DisplayName,
|
||||
player.IsAdmin,
|
||||
player.IsOwner,
|
||||
phase,
|
||||
player.VotesFinal,
|
||||
player.HasJoker,
|
||||
state.ResultsOpen,
|
||||
state.UpdatedAt,
|
||||
await db.Players.CountAsync(),
|
||||
await db.Suggestions.CountAsync(),
|
||||
await db.Votes.CountAsync()
|
||||
state.Players,
|
||||
state.Suggestions,
|
||||
state.Votes
|
||||
);
|
||||
return Results.Ok(summary);
|
||||
return ServiceResult<StateSummaryResponse>.Success(summary);
|
||||
}
|
||||
|
||||
public async Task<IResult> GetMeAsync(Player player)
|
||||
public async Task<ServiceResult<MeResponse>> GetMeAsync(Player player)
|
||||
{
|
||||
var state = await db.AppState.AsNoTracking().SingleAsync();
|
||||
var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
|
||||
return Results.Ok(new MeResponse(
|
||||
player.Id,
|
||||
player.Username,
|
||||
player.DisplayName,
|
||||
player.IsAdmin,
|
||||
phase,
|
||||
player.VotesFinal,
|
||||
player.HasJoker
|
||||
));
|
||||
return ServiceResult<MeResponse>.Success(new MeResponse(player.Id, player.Username, player.DisplayName, player.IsAdmin, player.IsOwner, phase, player.VotesFinal, player.HasJoker));
|
||||
}
|
||||
|
||||
public async Task<IResult> NextPhaseAsync(Player player)
|
||||
public async Task<ServiceResult<PhaseTransitionResponse>> NextPhaseAsync(Player player)
|
||||
{
|
||||
var appState = await db.AppState.SingleAsync();
|
||||
var shouldSave = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
|
||||
@@ -52,16 +60,16 @@ internal sealed class StateWorkflowService(AppDbContext db)
|
||||
{
|
||||
var hasSuggestions = await db.Suggestions.AnyAsync(s => s.PlayerId == player.Id);
|
||||
if (!hasSuggestions)
|
||||
return EndpointHelpers.BadRequestError("Add at least one suggestion before entering the Vote phase.");
|
||||
return ServiceResult<PhaseTransitionResponse>.Failure(ServiceError.BadRequest("Add at least one suggestion before entering the Vote phase."));
|
||||
}
|
||||
|
||||
if (next == Phase.Results && !appState.ResultsOpen)
|
||||
return EndpointHelpers.BadRequestError("Results are locked until the admin enables them.");
|
||||
return ServiceResult<PhaseTransitionResponse>.Failure(ServiceError.BadRequest("Results are locked until the admin enables them."));
|
||||
|
||||
player.CurrentPhase = next;
|
||||
player.VotesFinal = false; // moving forward clears any prior finalize
|
||||
shouldSave = true;
|
||||
return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
|
||||
return ServiceResult<PhaseTransitionResponse>.Success(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -70,10 +78,10 @@ internal sealed class StateWorkflowService(AppDbContext db)
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<IResult> PrevPhaseAsync(Player player)
|
||||
public async Task<ServiceResult<PhaseTransitionResponse>> PrevPhaseAsync(Player player)
|
||||
{
|
||||
if (!player.IsAdmin)
|
||||
return EndpointHelpers.BadRequestError("Only admins can move backward.");
|
||||
return ServiceResult<PhaseTransitionResponse>.Failure(ServiceError.BadRequest("Only admins can move backward."));
|
||||
|
||||
var appState = await db.AppState.SingleAsync();
|
||||
_ = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen);
|
||||
@@ -81,7 +89,7 @@ internal sealed class StateWorkflowService(AppDbContext db)
|
||||
player.CurrentPhase = PrevPhase(player.CurrentPhase);
|
||||
player.VotesFinal = false;
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
|
||||
return ServiceResult<PhaseTransitionResponse>.Success(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen));
|
||||
}
|
||||
|
||||
private static Phase NextPhase(Phase current) => current switch
|
||||
|
||||
@@ -9,7 +9,7 @@ public static class SuggestEndpoints
|
||||
{
|
||||
public static void MapSuggestEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/suggestions").RequireAuthorization();
|
||||
var group = app.MapGroup("/api/suggestions").WithTags("Suggestions").RequireAuthorization();
|
||||
|
||||
group.MapGet("/mine", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
|
||||
{
|
||||
@@ -17,8 +17,9 @@ public static class SuggestEndpoints
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
return await service.GetMineAsync(player.Id);
|
||||
});
|
||||
var result = await service.GetMineAsync(player.Id);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("GetMySuggestions");
|
||||
|
||||
group.MapPost("/", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
|
||||
{
|
||||
@@ -26,7 +27,7 @@ public static class SuggestEndpoints
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
return await service.CreateAsync(
|
||||
var result = await service.CreateAsync(
|
||||
player.Id,
|
||||
new SuggestionInput(
|
||||
request.Name,
|
||||
@@ -39,7 +40,9 @@ public static class SuggestEndpoints
|
||||
request.MaxPlayers
|
||||
)
|
||||
);
|
||||
}).AddEndpointFilter(new PhaseOrJokerFilter());
|
||||
|
||||
return result.ToHttpResult(payload => Results.Created($"/api/suggestions/{payload.Id}", payload));
|
||||
}).AddEndpointFilter(new PhaseOrJokerFilter()).WithName("CreateSuggestion");
|
||||
|
||||
group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
|
||||
{
|
||||
@@ -47,8 +50,9 @@ public static class SuggestEndpoints
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
return await service.DeleteAsync(player.Id, id);
|
||||
});
|
||||
var result = await service.DeleteAsync(player.Id, id);
|
||||
return result.ToHttpResult(Results.NoContent);
|
||||
}).WithName("DeleteSuggestion");
|
||||
|
||||
group.MapPut("/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
|
||||
{
|
||||
@@ -56,7 +60,7 @@ public static class SuggestEndpoints
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
return await service.UpdateAsync(
|
||||
var result = await service.UpdateAsync(
|
||||
player.Id,
|
||||
id,
|
||||
new SuggestionInput(
|
||||
@@ -70,7 +74,9 @@ public static class SuggestEndpoints
|
||||
request.MaxPlayers
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("UpdateSuggestion");
|
||||
|
||||
group.MapGet("/all", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
|
||||
{
|
||||
@@ -78,8 +84,9 @@ public static class SuggestEndpoints
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
return await service.GetAllAsync(player.Id);
|
||||
});
|
||||
var result = await service.GetAllAsync(player.Id);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("GetAllSuggestions");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
internal static class SuggestionValidator
|
||||
{
|
||||
public static async Task<string?> ValidateAsync(SuggestionInput input, IHttpClientFactory httpFactory)
|
||||
private static readonly ConcurrentDictionary<string, (bool Reachable, DateTimeOffset ExpiresAt)> ImageReachabilityCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly TimeSpan ReachableCacheTtl = TimeSpan.FromMinutes(15);
|
||||
private static readonly TimeSpan UnreachableCacheTtl = TimeSpan.FromMinutes(2);
|
||||
|
||||
public static async Task<string?> ValidateAsync(SuggestionInput input, IHttpClientFactory httpFactory, bool shouldValidateImageReachability = true)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Name) || input.Name.Length > 100)
|
||||
return "Name is required and must be <= 100 characters.";
|
||||
@@ -10,7 +16,7 @@ internal static class SuggestionValidator
|
||||
if (!EndpointHelpers.IsValidImageUrl(input.ScreenshotUrl))
|
||||
return "Screenshot URL must be http(s) and end with an image file extension.";
|
||||
|
||||
if (!await EndpointHelpers.IsReachableImageAsync(input.ScreenshotUrl, httpFactory))
|
||||
if (shouldValidateImageReachability && !await IsReachableImageCachedAsync(input.ScreenshotUrl, httpFactory))
|
||||
return "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB).";
|
||||
|
||||
if (!EndpointHelpers.IsValidHttpUrl(input.GameUrl))
|
||||
@@ -22,6 +28,21 @@ internal static class SuggestionValidator
|
||||
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)
|
||||
{
|
||||
if (minPlayers is null && maxPlayers is null)
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace GameList.Endpoints;
|
||||
|
||||
internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFactory httpFactory)
|
||||
{
|
||||
public async Task<IResult> GetMineAsync(Guid playerId)
|
||||
public async Task<ServiceResult<IReadOnlyList<SuggestionDto>>> GetMineAsync(Guid playerId)
|
||||
{
|
||||
var mine = await db.Suggestions
|
||||
.AsNoTracking()
|
||||
@@ -29,18 +29,19 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var ordered = mine
|
||||
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));
|
||||
.Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId))
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(ordered);
|
||||
return ServiceResult<IReadOnlyList<SuggestionDto>>.Success(ordered);
|
||||
}
|
||||
|
||||
public async Task<IResult> CreateAsync(Guid playerId, SuggestionInput input)
|
||||
public async Task<ServiceResult<SuggestionCreatedResponse>> CreateAsync(Guid playerId, SuggestionInput input)
|
||||
{
|
||||
var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory);
|
||||
if (validationError is not null)
|
||||
return EndpointHelpers.BadRequestError(validationError);
|
||||
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest(validationError));
|
||||
|
||||
var playerState = await db.Players
|
||||
.AsNoTracking()
|
||||
@@ -55,14 +56,14 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
var usingJoker = phase == Phase.Vote && playerState.HasJoker;
|
||||
if (phase != Phase.Suggest && !usingJoker)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(playerState.DisplayName))
|
||||
return EndpointHelpers.BadRequestError("Set a display name before submitting suggestions.");
|
||||
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest("Set a display name before submitting suggestions."));
|
||||
|
||||
var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == playerId);
|
||||
var existingCount = await db.Suggestions.AsNoTracking().CountAsync(s => s.PlayerId == playerId);
|
||||
if (!usingJoker && existingCount >= 5)
|
||||
return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit.");
|
||||
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest("You have reached the 5 suggestion limit."));
|
||||
|
||||
var suggestion = new Suggestion
|
||||
{
|
||||
@@ -81,21 +82,29 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
|
||||
db.Suggestions.Add(suggestion);
|
||||
|
||||
if (usingJoker)
|
||||
try
|
||||
{
|
||||
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 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."));
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
return Results.Created($"/api/suggestions/{suggestion.Id}", new SuggestionCreatedResponse(suggestion.Id));
|
||||
return ServiceResult<SuggestionCreatedResponse>.Success(new SuggestionCreatedResponse(suggestion.Id));
|
||||
}
|
||||
|
||||
public async Task<IResult> DeleteAsync(Guid playerId, int suggestionId)
|
||||
public async Task<ServiceResult<Unit>> DeleteAsync(Guid playerId, int suggestionId)
|
||||
{
|
||||
var actor = await db.Players
|
||||
.AsNoTracking()
|
||||
@@ -111,14 +120,14 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
{
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
if (phase != Phase.Suggest)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
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 EndpointHelpers.NotFoundError("Suggestion not found.");
|
||||
return ServiceResult<Unit>.Failure(ServiceError.NotFound("Suggestion not found."));
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
@@ -131,15 +140,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
db.Suggestions.Remove(suggestion);
|
||||
await db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
return Results.NoContent();
|
||||
return ServiceResult<Unit>.Success(default);
|
||||
}
|
||||
|
||||
public async Task<IResult> UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input)
|
||||
public async Task<ServiceResult<SuggestionUpdatedResponse>> UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input)
|
||||
{
|
||||
var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory);
|
||||
if (validationError is not null)
|
||||
return EndpointHelpers.BadRequestError(validationError);
|
||||
|
||||
var actor = await db.Players
|
||||
.AsNoTracking()
|
||||
.Where(p => p.Id == playerId)
|
||||
@@ -151,17 +156,22 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
|
||||
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId);
|
||||
if (suggestion == null)
|
||||
return EndpointHelpers.NotFoundError("Suggestion not found.");
|
||||
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.NotFound("Suggestion not found."));
|
||||
|
||||
var shouldValidateScreenshot = ShouldValidateScreenshotReachability(input.ScreenshotUrl, suggestion.ScreenshotUrl);
|
||||
var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory, shouldValidateScreenshot);
|
||||
if (validationError is not null)
|
||||
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.BadRequest(validationError));
|
||||
|
||||
var isAdmin = actor.IsAdmin;
|
||||
if (!isAdmin)
|
||||
{
|
||||
if (suggestion.PlayerId != playerId)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.Unauthorized());
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
if (phase == Phase.Results)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
|
||||
|
||||
if (phase == Phase.Suggest)
|
||||
{
|
||||
@@ -169,7 +179,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
}
|
||||
else if (phase != Phase.Vote)
|
||||
{
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
|
||||
}
|
||||
|
||||
ApplyEditableFields(suggestion, input);
|
||||
@@ -182,7 +192,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new SuggestionUpdatedResponse(
|
||||
return ServiceResult<SuggestionUpdatedResponse>.Success(new SuggestionUpdatedResponse(
|
||||
suggestion.Id,
|
||||
suggestion.Name,
|
||||
suggestion.Genre,
|
||||
@@ -195,11 +205,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
));
|
||||
}
|
||||
|
||||
public async Task<IResult> GetAllAsync(Guid playerId)
|
||||
public async Task<ServiceResult<IReadOnlyList<SuggestionAllDto>>> GetAllAsync(Guid playerId)
|
||||
{
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
if (phase < Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
return ServiceResult<IReadOnlyList<SuggestionAllDto>>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||
|
||||
var all = await db.Suggestions
|
||||
.AsNoTracking()
|
||||
@@ -225,12 +235,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
var nameLookup = all.ToDictionary(s => s.Id, s => s.Name);
|
||||
|
||||
var ordered = all.OrderBy(s => s.CreatedAt).Select(s =>
|
||||
IReadOnlyList<SuggestionAllDto> ordered = all.OrderBy(s => s.CreatedAt).Select(s =>
|
||||
{
|
||||
var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex).Where(id => id != s.Id).ToList();
|
||||
|
||||
return new
|
||||
{
|
||||
return new SuggestionAllDto(
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.Genre,
|
||||
@@ -243,12 +252,12 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
s.Author,
|
||||
s.ParentSuggestionId,
|
||||
s.IsOwner,
|
||||
LinkedIds = linkedIds,
|
||||
LinkedTitles = linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList()
|
||||
};
|
||||
});
|
||||
linkedIds,
|
||||
linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList()
|
||||
);
|
||||
}).ToList();
|
||||
|
||||
return Results.Ok(ordered);
|
||||
return ServiceResult<IReadOnlyList<SuggestionAllDto>>.Success(ordered);
|
||||
}
|
||||
|
||||
private static void ApplyEditableFields(Suggestion suggestion, SuggestionInput input)
|
||||
@@ -261,4 +270,10 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ public static class VoteEndpoints
|
||||
{
|
||||
public static void MapVoteEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/api/votes").RequireAuthorization().AddEndpointFilter(new PhaseRequirementFilter(Phase.Vote));
|
||||
var group = app.MapGroup("/api/votes").WithTags("Votes").RequireAuthorization().AddEndpointFilter(new PhaseRequirementFilter(Phase.Vote));
|
||||
|
||||
group.MapGet("/mine", async (HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
|
||||
{
|
||||
@@ -17,16 +17,19 @@ public static class VoteEndpoints
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
return await service.GetMineAsync(player.Id);
|
||||
});
|
||||
var result = await service.GetMineAsync(player.Id);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("GetMyVotes");
|
||||
|
||||
group.MapPost("/", async (VoteRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
|
||||
{
|
||||
var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db);
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
return await service.UpsertAsync(player.Id, request.SuggestionId, request.Score);
|
||||
});
|
||||
|
||||
var result = await service.UpsertAsync(player.Id, request.SuggestionId, request.Score);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("UpsertVote");
|
||||
|
||||
group.MapPost("/finalize", async (VoteFinalizeRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
|
||||
{
|
||||
@@ -34,8 +37,9 @@ public static class VoteEndpoints
|
||||
if (player is null)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
|
||||
return await service.SetFinalizeAsync(player.Id, request.Final);
|
||||
});
|
||||
var result = await service.SetFinalizeAsync(player.Id, request.Final);
|
||||
return result.ToHttpResult(Results.Ok);
|
||||
}).WithName("SetVotesFinalized");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,34 +2,31 @@ 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<IResult> GetMineAsync(Guid playerId)
|
||||
public async Task<ServiceResult<IReadOnlyList<VoteRecordDto>>> GetMineAsync(Guid playerId)
|
||||
{
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
return ServiceResult<IReadOnlyList<VoteRecordDto>>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||
|
||||
var votes = await db.Votes
|
||||
IReadOnlyList<VoteRecordDto> votes = await db.Votes
|
||||
.AsNoTracking()
|
||||
.Where(v => v.PlayerId == playerId)
|
||||
.Select(v => new
|
||||
{
|
||||
v.SuggestionId,
|
||||
v.Score
|
||||
})
|
||||
.Select(v => new VoteRecordDto(v.SuggestionId, v.Score))
|
||||
.ToListAsync();
|
||||
|
||||
return Results.Ok(votes);
|
||||
return ServiceResult<IReadOnlyList<VoteRecordDto>>.Success(votes);
|
||||
}
|
||||
|
||||
public async Task<IResult> UpsertAsync(Guid playerId, int suggestionId, int score)
|
||||
public async Task<ServiceResult<VoteUpsertResponse>> UpsertAsync(Guid playerId, int suggestionId, int score)
|
||||
{
|
||||
if (score is < 0 or > 10)
|
||||
return EndpointHelpers.BadRequestError("Score must be between 0 and 10.");
|
||||
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Score must be between 0 and 10."));
|
||||
|
||||
var playerState = await db.Players
|
||||
.AsNoTracking()
|
||||
@@ -42,14 +39,14 @@ internal sealed class VoteWorkflowService(AppDbContext db)
|
||||
.FirstAsync();
|
||||
|
||||
if (playerState.VotesFinal)
|
||||
return EndpointHelpers.BadRequestError("Votes are finalized. Unfinalize before changing scores.");
|
||||
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Votes are finalized. Unfinalize before changing scores."));
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(playerState.DisplayName))
|
||||
return EndpointHelpers.BadRequestError("Set a display name before voting.");
|
||||
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Set a display name before voting."));
|
||||
|
||||
var linkMap = await db.Suggestions
|
||||
.AsNoTracking()
|
||||
@@ -61,7 +58,7 @@ internal sealed class VoteWorkflowService(AppDbContext db)
|
||||
.ToListAsync();
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
if (!rootIndex.ContainsKey(suggestionId))
|
||||
return EndpointHelpers.BadRequestError("Suggestion not found.");
|
||||
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Suggestion not found."));
|
||||
|
||||
var linkedIds = EndpointHelpers.LinkedIdsFor(suggestionId, rootIndex);
|
||||
if (linkedIds.Count == 0)
|
||||
@@ -71,38 +68,67 @@ internal sealed class VoteWorkflowService(AppDbContext db)
|
||||
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var linkedSuggestionId in linkedIds)
|
||||
for (var attempt = 0; attempt < 2; attempt++)
|
||||
{
|
||||
var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId);
|
||||
if (vote == null)
|
||||
foreach (var linkedSuggestionId in linkedIds)
|
||||
{
|
||||
db.Votes.Add(new Vote
|
||||
var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId);
|
||||
if (vote == null)
|
||||
{
|
||||
PlayerId = playerId,
|
||||
SuggestionId = linkedSuggestionId,
|
||||
Score = score
|
||||
});
|
||||
db.Votes.Add(new Vote
|
||||
{
|
||||
PlayerId = playerId,
|
||||
SuggestionId = linkedSuggestionId,
|
||||
Score = score
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
vote.Score = score;
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
try
|
||||
{
|
||||
vote.Score = score;
|
||||
await db.SaveChangesAsync();
|
||||
return ServiceResult<VoteUpsertResponse>.Success(new VoteUpsertResponse(linkedIds, score));
|
||||
}
|
||||
catch (DbUpdateException ex) when (attempt == 0 && EndpointHelpers.IsSqliteConstraintViolation(ex))
|
||||
{
|
||||
DetachAddedVotes(db.ChangeTracker.Entries<Vote>());
|
||||
|
||||
await db.Votes
|
||||
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
|
||||
.ExecuteUpdateAsync(v => v.SetProperty(x => x.Score, score));
|
||||
|
||||
existingVotes = await db.Votes
|
||||
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new VoteUpsertResponse(linkedIds, score));
|
||||
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.Conflict("Vote update conflict. Please retry."));
|
||||
}
|
||||
|
||||
public async Task<IResult> SetFinalizeAsync(Guid playerId, bool final)
|
||||
public async Task<ServiceResult<VoteFinalizeResponse>> SetFinalizeAsync(Guid playerId, bool final)
|
||||
{
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
return ServiceResult<VoteFinalizeResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||
|
||||
var player = await db.Players.FirstAsync(p => p.Id == playerId);
|
||||
|
||||
player.VotesFinal = final;
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new VoteFinalizeResponse(player.VotesFinal));
|
||||
return ServiceResult<VoteFinalizeResponse>.Success(new VoteFinalizeResponse(player.VotesFinal));
|
||||
}
|
||||
|
||||
private static void DetachAddedVotes(IEnumerable<EntityEntry<Vote>> voteEntries)
|
||||
{
|
||||
foreach (var entry in voteEntries)
|
||||
{
|
||||
if (entry.State == EntityState.Added)
|
||||
entry.State = EntityState.Detached;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,79 @@ public class AdminTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_can_grant_and_revoke_admin_for_non_owner_accounts()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var owner = factory.CreateClientWithCookies();
|
||||
await owner.RegisterAsync("owner", admin: true);
|
||||
var player = factory.CreateClientWithCookies();
|
||||
await player.RegisterAsync("player");
|
||||
var playerId = await player.GetProfileIdAsync();
|
||||
|
||||
var grant = await owner.PostAsJsonAsync("/api/admin/player-admin", new
|
||||
{
|
||||
playerId,
|
||||
isAdmin = true
|
||||
});
|
||||
grant.EnsureSuccessStatusCode();
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var promoted = await db.Players.AsNoTracking().SingleAsync(p => p.Id == playerId);
|
||||
Assert.True(promoted.IsAdmin);
|
||||
Assert.False(promoted.IsOwner);
|
||||
});
|
||||
|
||||
var revoke = await owner.PostAsJsonAsync("/api/admin/player-admin", new
|
||||
{
|
||||
playerId,
|
||||
isAdmin = false
|
||||
});
|
||||
revoke.EnsureSuccessStatusCode();
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var demoted = await db.Players.AsNoTracking().SingleAsync(p => p.Id == playerId);
|
||||
Assert.False(demoted.IsAdmin);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_admin_role_cannot_be_changed_or_deleted()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var owner = factory.CreateClientWithCookies();
|
||||
await owner.RegisterAsync("owner", admin: true);
|
||||
var ownerId = await owner.GetProfileIdAsync();
|
||||
|
||||
var toggleOwner = await owner.PostAsJsonAsync("/api/admin/player-admin", new
|
||||
{
|
||||
playerId = ownerId,
|
||||
isAdmin = false
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, toggleOwner.StatusCode);
|
||||
|
||||
var deleteOwner = await owner.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{ownerId}") { Content = JsonContent.Create(new { password = AdminPassword }) });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, deleteOwner.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Set_player_admin_returns_not_found_for_unknown_player()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var owner = factory.CreateClientWithCookies();
|
||||
await owner.RegisterAsync("owner", admin: true);
|
||||
|
||||
var response = await owner.PostAsJsonAsync("/api/admin/player-admin", new
|
||||
{
|
||||
playerId = Guid.NewGuid(),
|
||||
isAdmin = true
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_player_phase_requires_vote_phase_and_suggest_target()
|
||||
{
|
||||
@@ -136,10 +209,7 @@ public class AdminTests
|
||||
Score = 8
|
||||
});
|
||||
|
||||
var resp = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{await player.GetProfileIdAsync()}")
|
||||
{
|
||||
Content = JsonContent.Create(new { password = AdminPassword })
|
||||
});
|
||||
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 =>
|
||||
@@ -554,10 +624,7 @@ public class AdminTests
|
||||
|
||||
var playerId = await factory.WithDbContextAsync(async db => await db.Players.Where(p => p.Username == "target").Select(p => p.Id).SingleAsync());
|
||||
|
||||
var deleteWrongPassword = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{playerId}")
|
||||
{
|
||||
Content = JsonContent.Create(new { password = "wrong" })
|
||||
});
|
||||
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()
|
||||
{
|
||||
@@ -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()
|
||||
{
|
||||
@@ -189,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,6 +95,51 @@ public class ResultsTests
|
||||
Assert.Equal("High", results[0].GetProperty("name").GetString());
|
||||
Assert.Equal(9, (int)results[0].GetProperty("average").GetDouble());
|
||||
Assert.Equal(1, results[0].GetProperty("count").GetInt32());
|
||||
Assert.Equal("player-name", results[0].GetProperty("voterNames")[0].GetString());
|
||||
Assert.Equal(0, results[1].GetProperty("average").GetDouble());
|
||||
Assert.Equal(0, results[1].GetProperty("voterNames").GetArrayLength());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Results_payload_contains_alphabetically_sorted_voter_names()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var admin = factory.CreateClientWithCookies();
|
||||
await admin.RegisterAsync("admin", admin: true);
|
||||
|
||||
var author = factory.CreateClientWithCookies();
|
||||
await author.RegisterAsync("author");
|
||||
var targetSuggestionId = await author.CreateSuggestionAsync("Target");
|
||||
|
||||
var zeta = factory.CreateClientWithCookies();
|
||||
await zeta.RegisterAsync("zeta");
|
||||
await zeta.AdvanceToVoteAsync("zeta-seed");
|
||||
await zeta.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = targetSuggestionId,
|
||||
Score = 7
|
||||
});
|
||||
|
||||
var alpha = factory.CreateClientWithCookies();
|
||||
await alpha.RegisterAsync("alpha");
|
||||
await alpha.AdvanceToVoteAsync("alpha-seed");
|
||||
await alpha.PostAsJsonAsync("/api/votes", new
|
||||
{
|
||||
SuggestionId = targetSuggestionId,
|
||||
Score = 8
|
||||
});
|
||||
|
||||
await admin.PostAsJsonAsync("/api/admin/results", new { resultsOpen = true });
|
||||
|
||||
var results = await alpha.GetFromJsonAsync<List<JsonElement>>("/api/results");
|
||||
Assert.NotNull(results);
|
||||
var target = results.Single(r => r.GetProperty("name").GetString() == "Target");
|
||||
|
||||
var voterNames = target
|
||||
.GetProperty("voterNames")
|
||||
.EnumerateArray()
|
||||
.Select(n => n.GetString())
|
||||
.ToList();
|
||||
Assert.Equal(new[] { "alpha-name", "zeta-name" }, voterNames);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,11 @@ public class StateTests
|
||||
|
||||
var state = await client.GetFromJsonAsync<JsonElement>("/api/state");
|
||||
|
||||
Assert.True(Guid.TryParse(state.GetProperty("id").GetString(), out _));
|
||||
Assert.Equal("payload", state.GetProperty("username").GetString());
|
||||
Assert.Equal("payload-name", state.GetProperty("displayName").GetString());
|
||||
Assert.False(state.GetProperty("isAdmin").GetBoolean());
|
||||
Assert.False(state.GetProperty("isOwner").GetBoolean());
|
||||
Assert.Equal(nameof(Phase.Suggest), state.GetProperty("currentPhase").GetString());
|
||||
Assert.False(state.GetProperty("votesFinal").GetBoolean());
|
||||
Assert.True(state.GetProperty("hasJoker").GetBoolean());
|
||||
@@ -335,6 +340,118 @@ public class StateTests
|
||||
|
||||
Assert.Equal(Phase.Results, phase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task State_endpoint_supports_conditional_get_with_etag()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("etag");
|
||||
|
||||
var first = await client.GetAsync("/api/state");
|
||||
first.EnsureSuccessStatusCode();
|
||||
var firstEtag = first.Headers.ETag?.ToString();
|
||||
Assert.False(string.IsNullOrWhiteSpace(firstEtag));
|
||||
|
||||
var conditional = new HttpRequestMessage(HttpMethod.Get, "/api/state");
|
||||
conditional.Headers.TryAddWithoutValidation("If-None-Match", firstEtag);
|
||||
var notModified = await client.SendAsync(conditional);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode);
|
||||
Assert.Equal(firstEtag, notModified.Headers.ETag?.ToString());
|
||||
|
||||
await client.CreateSuggestionAsync("etag-changed");
|
||||
|
||||
var stale = new HttpRequestMessage(HttpMethod.Get, "/api/state");
|
||||
stale.Headers.TryAddWithoutValidation("If-None-Match", firstEtag);
|
||||
var changed = await client.SendAsync(stale);
|
||||
|
||||
changed.EnsureSuccessStatusCode();
|
||||
Assert.NotEqual(firstEtag, changed.Headers.ETag?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task State_events_endpoint_emits_state_change_after_mutation()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var watcher = factory.CreateClientWithCookies();
|
||||
await watcher.RegisterAsync("watcher");
|
||||
|
||||
using var streamResponse = await watcher.GetAsync("/api/events/state", HttpCompletionOption.ResponseHeadersRead);
|
||||
streamResponse.EnsureSuccessStatusCode();
|
||||
Assert.Equal("text/event-stream", streamResponse.Content.Headers.ContentType?.MediaType);
|
||||
|
||||
await using var stream = await streamResponse.Content.ReadAsStreamAsync();
|
||||
using var reader = new StreamReader(stream);
|
||||
|
||||
var readyVersion = await ReadSseEventVersionAsync(reader, "ready", TimeSpan.FromSeconds(2));
|
||||
|
||||
var mutator = factory.CreateClientWithCookies();
|
||||
var register = await mutator.RegisterAsync("mutator");
|
||||
register.EnsureSuccessStatusCode();
|
||||
|
||||
var changedVersion = await ReadSseEventVersionAsync(reader, "state", TimeSpan.FromSeconds(3));
|
||||
Assert.True(changedVersion > readyVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_does_not_invalidate_state_etag()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("quietetag");
|
||||
|
||||
var first = await client.GetAsync("/api/state");
|
||||
first.EnsureSuccessStatusCode();
|
||||
var firstEtag = first.Headers.ETag?.ToString();
|
||||
Assert.False(string.IsNullOrWhiteSpace(firstEtag));
|
||||
|
||||
var loginClient = factory.CreateClientWithCookies();
|
||||
var login = await loginClient.LoginAsync("quietetag", "Pass123!");
|
||||
login.EnsureSuccessStatusCode();
|
||||
|
||||
var conditional = new HttpRequestMessage(HttpMethod.Get, "/api/state");
|
||||
conditional.Headers.TryAddWithoutValidation("If-None-Match", firstEtag);
|
||||
var notModified = await client.SendAsync(conditional);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotModified, notModified.StatusCode);
|
||||
Assert.Equal(firstEtag, notModified.Headers.ETag?.ToString());
|
||||
}
|
||||
|
||||
private static async Task<long> ReadSseEventVersionAsync(StreamReader reader, string expectedEventName, TimeSpan timeout)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeout);
|
||||
var eventName = string.Empty;
|
||||
|
||||
while (true)
|
||||
{
|
||||
var line = await reader.ReadLineAsync(cts.Token);
|
||||
if (line is null)
|
||||
throw new Xunit.Sdk.XunitException("SSE stream closed unexpectedly.");
|
||||
|
||||
if (line.Length == 0)
|
||||
{
|
||||
eventName = string.Empty;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.StartsWith("event: ", StringComparison.Ordinal))
|
||||
{
|
||||
eventName = line["event: ".Length..].Trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!line.StartsWith("data: ", StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
if (!string.Equals(eventName, expectedEventName, StringComparison.Ordinal))
|
||||
continue;
|
||||
|
||||
var payload = line["data: ".Length..].Trim();
|
||||
if (long.TryParse(payload, out var version))
|
||||
return version;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
@@ -626,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
176
Program.cs
176
Program.cs
@@ -1,13 +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);
|
||||
@@ -40,11 +45,59 @@ 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 =>
|
||||
{
|
||||
@@ -53,9 +106,11 @@ 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
|
||||
{
|
||||
OnSigningIn = EnsureSessionStartAsync,
|
||||
OnValidatePrincipal = ValidateSessionLifetimeAsync,
|
||||
OnRedirectToLogin = ctx => WriteUnauthorizedChallengeAsync(ctx.HttpContext),
|
||||
OnRedirectToAccessDenied = ctx => WriteUnauthorizedChallengeAsync(ctx.HttpContext)
|
||||
};
|
||||
@@ -66,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();
|
||||
@@ -99,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
|
||||
@@ -153,42 +271,4 @@ static Task WriteUnauthorizedChallengeAsync(HttpContext context)
|
||||
return context.Response.WriteAsJsonAsync(problem);
|
||||
}
|
||||
|
||||
static void UpdateIndexMetaBase(IWebHostEnvironment env, string basePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
var indexPath = Path.Combine(env.WebRootPath, "index.html");
|
||||
if (!File.Exists(indexPath))
|
||||
return;
|
||||
|
||||
var text = File.ReadAllText(indexPath);
|
||||
var marker = "name=\"app-base\"";
|
||||
var contentKey = "content=\"";
|
||||
var markerIndex = text.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
|
||||
if (markerIndex < 0)
|
||||
return;
|
||||
|
||||
var contentIndex = text.IndexOf(contentKey, markerIndex, StringComparison.OrdinalIgnoreCase);
|
||||
if (contentIndex < 0)
|
||||
return;
|
||||
|
||||
var valueStart = contentIndex + contentKey.Length;
|
||||
var valueEnd = text.IndexOf('"', valueStart);
|
||||
if (valueEnd < 0)
|
||||
return;
|
||||
|
||||
var current = text[valueStart..valueEnd];
|
||||
var normalized = basePath.EndsWith('/') ? basePath.TrimEnd('/') : basePath;
|
||||
if (current == normalized)
|
||||
return;
|
||||
|
||||
var updated = text[..valueStart] + normalized + text[valueEnd..];
|
||||
File.WriteAllText(indexPath, updated);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If we can't rewrite, continue; frontend can still be set manually.
|
||||
}
|
||||
}
|
||||
|
||||
public partial class Program;
|
||||
|
||||
35
README.md
35
README.md
@@ -6,16 +6,19 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
|
||||
|
||||
1. Restore and build:
|
||||
`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`
|
||||
@@ -24,24 +27,40 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
|
||||
|
||||
- Authentication: username/password with HttpOnly `player` cookie.
|
||||
- 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`
|
||||
@@ -51,6 +70,8 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
|
||||
GitHub Actions workflow: `.github/workflows/ci.yml`
|
||||
|
||||
- Restores dependencies
|
||||
- Runs frontend lint and format checks
|
||||
- Builds with warnings treated as errors
|
||||
- Runs `GameList.Tests`
|
||||
- Generates frontend API client from OpenAPI contract
|
||||
- Runs frontend lint and format checks
|
||||
- Runs `GameList.Tests` with coverage collection
|
||||
- Enforces minimum coverage thresholds (line 90%, branch 70%)
|
||||
|
||||
169
REVIEW.md
169
REVIEW.md
@@ -1,24 +1,165 @@
|
||||
# Maintainability Review - Pick'n'Play
|
||||
# Review - Pick'n'Play (State-of-the-Art Assessment)
|
||||
|
||||
## A) Current focus
|
||||
Date: 2026-02-18
|
||||
|
||||
This document tracks only active work. Completed work is intentionally omitted and can be reviewed in git history.
|
||||
## Scope
|
||||
|
||||
Active maintainability risks (priority order):
|
||||
- Evaluated backend (`Program.cs`, `Endpoints/*`, `Infrastructure/*`, `Data/*`), frontend (`wwwroot/*`), and CI/deployment scripts.
|
||||
- Focused on risks in maintainability, extensibility, scalability, and security.
|
||||
|
||||
- None at the moment.
|
||||
## Executive summary
|
||||
|
||||
## B) Active task list
|
||||
The app is solid for a small private group, but it still has several architectural bottlenecks compared to current best practice for long-term product growth. The largest risks are read amplification from client polling, hard-coded workflow/permission modeling, and security hardening gaps (CSRF posture and crypto modernization).
|
||||
|
||||
- None.
|
||||
## Findings
|
||||
|
||||
## C) Suggested execution order
|
||||
### 1) High - Scalability - Single-node SQLite bottleneck
|
||||
|
||||
1. Add new items when fresh risks are identified.
|
||||
Evidence:
|
||||
- SQLite is the primary DB (`Program.cs:42`).
|
||||
|
||||
## D) Guardrails
|
||||
Risk:
|
||||
- SQLite is excellent for small single-node deployments, but write concurrency and horizontal scale are limited for larger or bursty usage.
|
||||
|
||||
- Keep endpoint handlers transport-focused and move business rules into services/validators.
|
||||
- Keep reads side-effect free and isolate all persistence changes to explicit command paths.
|
||||
- Maintain one source of truth per validation rule (backend authoritative, frontend UX hints only).
|
||||
- Prefer typed DTOs over anonymous response shapes for non-trivial API payloads.
|
||||
Alternative:
|
||||
- Keep SQLite for local/dev and migrate production to PostgreSQL/SQL Server with provider-specific migrations and connection pooling.
|
||||
|
||||
### 2) High - Scalability - Polling causes read amplification
|
||||
|
||||
Evidence:
|
||||
- Frontend polling runs continuously with 3s-20s cadence (`wwwroot/app.js:30`, `wwwroot/app.js:58`).
|
||||
- Each refresh can hit multiple endpoints (`wwwroot/js/data.js:21`, `wwwroot/js/data.js:109`).
|
||||
- `/api/state` also executes multiple aggregate counts each time (`Endpoints/StateWorkflowService.cs:14`).
|
||||
|
||||
Risk:
|
||||
- As concurrent users increase, backend read load grows quickly and mostly serves unchanged data.
|
||||
|
||||
Alternative:
|
||||
- Move to event-driven updates (SSE/WebSocket) plus conditional GET (`ETag`/`If-None-Match`) and/or a consolidated bootstrap endpoint.
|
||||
|
||||
### 3) High - Security - CSRF protection is implicit, not explicit
|
||||
|
||||
Evidence:
|
||||
- Cookie authentication is used for API auth (`Program.cs:100`, `Program.cs:104`).
|
||||
- Many state-changing endpoints rely on cookie auth (`Endpoints/SuggestEndpoints.cs:24`, `Endpoints/VoteEndpoints.cs:24`, `Endpoints/AdminEndpoints.cs:14`).
|
||||
- No antiforgery middleware/tokens are configured in startup.
|
||||
|
||||
Risk:
|
||||
- `SameSite=Strict` helps (`Program.cs:104`) but is not a full long-term CSRF strategy across browser/proxy edge cases.
|
||||
|
||||
Alternative:
|
||||
- Add explicit anti-forgery tokens for mutating requests (or move to bearer tokens for API calls) and verify origin headers server-side.
|
||||
|
||||
### 4) High - Extensibility - Workflow is hard-coded across backend and frontend
|
||||
|
||||
Evidence:
|
||||
- Phase behavior is encoded via enum and many explicit checks/switches (`Domain/Phase.cs:3`, `Endpoints/StateWorkflowService.cs:70`, `Endpoints/EndpointHelpers.cs:97`).
|
||||
- Frontend also hard-codes phase logic in many files (`wwwroot/app.js:99`, `wwwroot/js/data.js:47`, `wwwroot/js/votes-ui.js:167`).
|
||||
|
||||
Risk:
|
||||
- Adding a new phase or changing transitions requires touching many scattered branches, increasing regression risk.
|
||||
|
||||
Alternative:
|
||||
- Introduce a shared workflow/state-machine model (transition table) and consume it in both backend and frontend.
|
||||
|
||||
### 5) High - Extensibility - Role model is fixed to booleans
|
||||
|
||||
Evidence:
|
||||
- Role/state flags are booleans on player (`Domain/Player.cs:22`, `Domain/Player.cs:23`).
|
||||
- Admin checks are tightly coupled to that model (`Infrastructure/AdminOnlyFilter.cs:13`, `Endpoints/AdminWorkflowService.cs:93`).
|
||||
|
||||
Risk:
|
||||
- Future needs (moderator, read-only admin, per-feature permissions) require schema and logic rewrites instead of additive changes.
|
||||
|
||||
Alternative:
|
||||
- Move to role/permission tables (or claims-based capability model) and policy-based authorization.
|
||||
|
||||
### 6) Medium - Maintainability - Frontend is string-template heavy with global mutable state
|
||||
|
||||
Evidence:
|
||||
- Single global state object (`wwwroot/js/state.js:1`).
|
||||
- Heavy `innerHTML` rendering across modules (`wwwroot/js/suggestions-ui.js:115`, `wwwroot/js/votes-ui.js:35`, `wwwroot/js/results-ui.js:72`).
|
||||
|
||||
Risk:
|
||||
- Harder refactoring, weaker static guarantees, and easy XSS regressions when new contributors add templates.
|
||||
|
||||
Alternative:
|
||||
- Incrementally move to TypeScript + componentized rendering (or at minimum typed JSDoc + stricter lint rules + centralized safe render helpers).
|
||||
|
||||
### 7) Medium - Scalability/Security - In-memory dictionaries are unbounded
|
||||
|
||||
Evidence:
|
||||
- Auth attempt monitor stores failures in unbounded `ConcurrentDictionary` (`Infrastructure/AuthAttemptMonitor.cs:14`).
|
||||
- Image reachability cache is a static dictionary without size limits (`Endpoints/SuggestionValidator.cs:7`, `Endpoints/SuggestionValidator.cs:42`).
|
||||
|
||||
Risk:
|
||||
- High-cardinality traffic can grow memory and become a denial-of-service vector.
|
||||
|
||||
Alternative:
|
||||
- Replace with bounded `MemoryCache` (size limits + eviction) or distributed cache (Redis) with TTL and cardinality controls.
|
||||
|
||||
### 8) Medium - Scalability - Linking/results workflows load full sets into memory
|
||||
|
||||
Evidence:
|
||||
- Link/unlink loads all suggestions then computes roots in memory (`Endpoints/AdminWorkflowService.cs:136`, `Endpoints/AdminWorkflowService.cs:188`).
|
||||
- Results projection includes full vote lists per suggestion (`Endpoints/ResultsWorkflowService.cs:23`, `Endpoints/ResultsWorkflowService.cs:34`, `Endpoints/ResultsWorkflowService.cs:50`).
|
||||
|
||||
Risk:
|
||||
- Memory and query cost rise non-linearly with larger datasets.
|
||||
|
||||
Alternative:
|
||||
- Introduce persisted link-group IDs and push aggregation to SQL; add pagination/windowing for large result sets.
|
||||
|
||||
### 9) Medium - Security - Crypto is good but not state-of-the-art
|
||||
|
||||
Evidence:
|
||||
- Password hashing uses PBKDF2-SHA256 (`Infrastructure/PasswordHasher.cs:33`) with fixed iteration count (`Infrastructure/PasswordHasher.cs:10`).
|
||||
|
||||
Risk:
|
||||
- PBKDF2 remains acceptable, but modern guidance favors memory-hard KDFs (Argon2id/scrypt) against GPU/ASIC attacks.
|
||||
|
||||
Alternative:
|
||||
- Add versioned password hashes and migrate to Argon2id on login/re-hash.
|
||||
|
||||
### 10) Medium - Security - CSP remains permissive for inline style and mixed image origins
|
||||
|
||||
Evidence:
|
||||
- CSP allows `style-src 'unsafe-inline'` and `img-src ... https: http:` (`Program.cs:138`).
|
||||
|
||||
Risk:
|
||||
- Wider policy surface than needed, especially for long-term hardening.
|
||||
|
||||
Alternative:
|
||||
- Remove inline style dependence (e.g., CSS classes or nonce/hash), and restrict image sources to `https` and/or a media proxy allowlist.
|
||||
|
||||
### 11) Medium - Maintainability/Extensibility - API contract sync is manual
|
||||
|
||||
Evidence:
|
||||
- Frontend endpoints are hard-coded in JS (`wwwroot/js/api.js:37`, `wwwroot/js/api.js:70`).
|
||||
- Contract is also maintained manually in markdown (`API.md`).
|
||||
|
||||
Risk:
|
||||
- Contract drift between backend DTOs and frontend consumers over time.
|
||||
|
||||
Alternative:
|
||||
- Generate OpenAPI from endpoints and produce typed client code for frontend consumption.
|
||||
|
||||
### 12) Medium - Scalability/Resilience - External image validation is synchronous on write path
|
||||
|
||||
Evidence:
|
||||
- Suggestion validation calls network reachability checks (`Endpoints/SuggestionValidator.cs:40`).
|
||||
- Validation does outbound DNS/connect/HEAD/GET in request path (`Endpoints/EndpointHelpers.cs:196`, `Endpoints/EndpointHelpers.cs:240`, `Endpoints/EndpointHelpers.cs:270`).
|
||||
|
||||
Risk:
|
||||
- User write latency is coupled to third-party host responsiveness.
|
||||
|
||||
Alternative:
|
||||
- Accept URL quickly, then validate asynchronously (background job + status flag), optionally with trusted media proxying.
|
||||
|
||||
## Recommended remediation order
|
||||
|
||||
1. Reduce read amplification: event-driven updates + state endpoint optimization.
|
||||
2. Harden security baseline: explicit CSRF, CSP tightening, and password-hash migration plan.
|
||||
3. Decouple growth hotspots: workflow state machine + role/permission model.
|
||||
4. Improve operational scale: replace unbounded in-memory structures and large in-memory link/result operations.
|
||||
5. Introduce contract tooling: OpenAPI + generated frontend client to reduce drift.
|
||||
|
||||
2
SPEC.md
2
SPEC.md
@@ -35,8 +35,10 @@ Help a small Discord group (4–8 players) pick a co-op game via phased flow:
|
||||
- Visible only after admin enables results; players auto-advance when opened
|
||||
- Admin controls results availability with a single toggle button whose label reflects enabled/disabled state
|
||||
- Leaderboard sorted by average score; shows totals, counts, player’s own vote, and links/media
|
||||
- Average score and score emojis expose the same tooltip showing the game's voters in alphabetical order
|
||||
- When results are closed again, only accounts with at least one suggestion return to Vote; accounts without suggestions return to Suggest
|
||||
|
||||
## Non-functional
|
||||
- Desktop + mobile friendly
|
||||
- Runs on IIS; SQLite via EF Core
|
||||
- Browser security baseline: strict CSP (no inline styles, no insecure image origins) and same-origin protection for authenticated mutating API requests
|
||||
|
||||
319
TECH.md
Normal file
319
TECH.md
Normal file
@@ -0,0 +1,319 @@
|
||||
# TECH - Kickoff Blueprint from Pick'n'Play
|
||||
|
||||
This document extracts the implementation patterns, conventions, and guardrails from the current codebase so a new webapp can start with proven structure and avoid known pitfalls.
|
||||
|
||||
Scope covered:
|
||||
- Backend (`Program.cs`, `Endpoints/*`, `Infrastructure/*`, `Data/*`, `Domain/*`, `Contracts/*`)
|
||||
- Frontend (`wwwroot/*`)
|
||||
- Tooling and CI (`scripts/*`, `.github/workflows/ci.yml`, npm tooling)
|
||||
- Tests (`GameList.Tests/*`)
|
||||
- Review carryover (`REVIEW.md` unresolved topics)
|
||||
|
||||
## 1) Stack and baseline choices
|
||||
|
||||
- ASP.NET Core Minimal API on .NET 10.
|
||||
- EF Core + SQLite in current project (single-node deployment).
|
||||
- Cookie authentication (`HttpOnly`, `SameSite=Strict`, secure in production).
|
||||
- Static frontend (HTML/CSS/JS modules), no frontend framework.
|
||||
- OpenAPI generated from backend and consumed by generated JS client.
|
||||
- xUnit integration-heavy test suite with in-memory SQLite and coverage gates.
|
||||
|
||||
## 2) Architecture patterns to keep
|
||||
|
||||
### 2.1 API shape and layering
|
||||
|
||||
- Route mapping in thin endpoint modules (`MapXEndpoints` per feature area).
|
||||
- Domain logic in workflow services (`*WorkflowService`) instead of endpoint lambdas.
|
||||
- Service responses normalized via `ServiceResult<T>` + `ServiceError`, then mapped to HTTP at the edge.
|
||||
- Consistent `ProblemDetails` payloads with `error` extension for machine-usable errors.
|
||||
- Endpoint-level concerns handled by endpoint filters (`AdminOnlyFilter`, `PhaseRequirementFilter`, `PhaseOrJokerFilter`).
|
||||
|
||||
Keep this split:
|
||||
- Endpoint adapters: auth, deserialization, HTTP mapping only.
|
||||
- Workflow services: validation, query/update rules, transactions.
|
||||
- Helpers: shared utility and security-sensitive routines.
|
||||
|
||||
### 2.2 Middleware pipeline discipline
|
||||
|
||||
- Security and behavior depend on middleware ordering; keep explicit ordering.
|
||||
- Important current order:
|
||||
1. Forwarded headers
|
||||
2. Rate limiter
|
||||
3. HSTS + HTTPS redirect (prod)
|
||||
4. Security headers writer
|
||||
5. Base path
|
||||
6. Global exception handling
|
||||
7. Authentication
|
||||
8. Ensure player still exists
|
||||
9. CSRF origin/referer checks
|
||||
10. Authorization
|
||||
11. State change notifier middleware
|
||||
12. Static files
|
||||
13. Endpoint mapping
|
||||
|
||||
### 2.3 State synchronization
|
||||
|
||||
- Event-driven invalidation with SSE (`/api/events/state`) plus heartbeats.
|
||||
- Conditional reads for state (`ETag` + `If-None-Match`) to return `304`.
|
||||
- In-process notifier (`StateChangeNotifier`) with monotonic version and etag stamp.
|
||||
- Mutation middleware (`StateChangeNotificationMiddleware`) emits invalidation only for successful mutating API calls.
|
||||
|
||||
This pattern is a strong baseline for low to medium scale and should be the default in the new app.
|
||||
|
||||
### 2.4 Security baseline
|
||||
|
||||
- Cookie auth with short/medium session sliding expiration plus absolute lifetime cap.
|
||||
- Explicit same-origin CSRF checks for authenticated mutating API calls.
|
||||
- Rate limiting on auth-sensitive and admin-sensitive surfaces with custom `429` payload.
|
||||
- Security headers on all responses (`CSP`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`).
|
||||
- Forwarded headers restricted to configured trusted proxies/networks only.
|
||||
- Owner/admin protection rules enforced in business logic and DB constraints.
|
||||
- Destructive admin operations require password re-confirmation.
|
||||
- Password hashing is versioned and supports transparent upgrade on successful auth.
|
||||
- Current hash defaults to Argon2id, with legacy compatibility retained.
|
||||
|
||||
### 2.5 Data and invariants
|
||||
|
||||
- Strong DB model with explicit constraints:
|
||||
- Unique normalized username
|
||||
- Unique owner partial index
|
||||
- Unique vote key `(PlayerId, SuggestionId)`
|
||||
- Seeded singleton app state row
|
||||
- DB-level guardrails (trigger) complement app-level checks:
|
||||
- Suggestion limit enforced in DB (`suggestion_limit_exceeded`) to survive concurrent writes.
|
||||
- EF patterns:
|
||||
- `AsNoTracking()` for read-only queries
|
||||
- `ExecuteUpdateAsync` / `ExecuteDeleteAsync` for efficient bulk operations
|
||||
- Explicit transactions for multi-step destructive/admin operations
|
||||
- Conflict handling around unique constraints
|
||||
|
||||
### 2.6 Workflow and permission model
|
||||
|
||||
- Phases (`Suggest`, `Vote`, `Results`) drive endpoint access and UX behavior.
|
||||
- Effective phase can be derived from persisted phase + global `resultsOpen`.
|
||||
- Reconciliation helper functions centralize phase alignment rules.
|
||||
- Admin abilities are intentionally constrained per operation (e.g., only specific transitions allowed).
|
||||
|
||||
Keep centralization of workflow rules. Avoid spreading phase/permission checks inline.
|
||||
|
||||
### 2.7 Frontend architecture
|
||||
|
||||
- ES module split by concern:
|
||||
- API wrapper
|
||||
- Data loaders
|
||||
- UI composition
|
||||
- Feature-specific renderers/handlers
|
||||
- Shared utils and runtime dependency injection
|
||||
- Single runtime state object with deliberate clear/reset logic.
|
||||
- Refresh scheduler:
|
||||
- Serialized refreshes (no overlap)
|
||||
- Adaptive polling backoff
|
||||
- SSE-triggered immediate refresh for state mutations
|
||||
- Visibility-aware refresh suppression
|
||||
- API client is generated from OpenAPI operation ids, not handwritten endpoints.
|
||||
- Internationalization:
|
||||
- translation file validation at startup
|
||||
- language-specific FAQ markdown loading with fallback to default language
|
||||
|
||||
### 2.8 Frontend safety and rendering hygiene
|
||||
|
||||
- Safe rendering helpers:
|
||||
- `escapeHtml` for template interpolation
|
||||
- `safeUrl` for links/media
|
||||
- Sensitive modal content set via `textContent`, not interpolated HTML.
|
||||
- Trusted output patterns covered by regression tests.
|
||||
|
||||
Maintain this as a non-negotiable standard for any user-supplied content path.
|
||||
|
||||
### 2.9 Testing strategy patterns
|
||||
|
||||
- Full-stack integration tests via `WebApplicationFactory`.
|
||||
- Real migrations applied to in-memory SQLite during test host startup.
|
||||
- HTTP side effects mocked deterministically (`StubHttpMessageHandler` and `IHttpClientFactory` replacement).
|
||||
- Coverage-focused tests for:
|
||||
- auth/security rules
|
||||
- middleware behavior
|
||||
- filter behavior
|
||||
- link/vote/result edge cases
|
||||
- OpenAPI operation id stability
|
||||
- CI-local parity script (`scripts/ci-local.ps1`) mirrors pipeline flow.
|
||||
|
||||
### 2.10 Tooling and contract discipline
|
||||
|
||||
- OpenAPI generated during build (`openapi/GameList.json`).
|
||||
- JS client generated from OpenAPI with required operation-id checks.
|
||||
- Separate lint + format + tests + coverage threshold checks.
|
||||
- Build configured with warnings as errors in CI/local script.
|
||||
|
||||
## 3) Concrete feature set currently implemented
|
||||
|
||||
Use this as a reusable "starter scope menu" for the new app:
|
||||
|
||||
- Auth:
|
||||
- register/login/logout
|
||||
- owner bootstrap via admin key
|
||||
- immutable display name post-registration
|
||||
- auth options endpoint for registration UX
|
||||
- Identity/session:
|
||||
- cookie claim identity with admin claim
|
||||
- stale/deleted-account cookie invalidation
|
||||
- absolute session lifetime enforcement
|
||||
- State:
|
||||
- `/api/state`, `/api/me`
|
||||
- phase next/prev
|
||||
- SSE state invalidation
|
||||
- etag conditional state reads
|
||||
- Suggestions:
|
||||
- create/update/delete/mine/all
|
||||
- phase gating + admin override behavior
|
||||
- suggestion cap + joker path
|
||||
- link metadata exposure
|
||||
- screenshot URL and reachability validation
|
||||
- Votes:
|
||||
- vote upsert
|
||||
- finalize/unfinalize
|
||||
- linked suggestion vote fan-out
|
||||
- conflict handling on concurrent insert/update
|
||||
- Results:
|
||||
- gated by phase + admin-open flag
|
||||
- ordered leaderboard with aggregates and voter metadata
|
||||
- per-user own vote context
|
||||
- Admin:
|
||||
- results open/close toggle with phase realignment
|
||||
- vote status panel
|
||||
- joker grant
|
||||
- player phase correction
|
||||
- admin role grant/revoke (owner protected)
|
||||
- player delete with cascades and password confirmation
|
||||
- link/unlink suggestions with vote reset and unfinalize behavior
|
||||
- reset/factory reset with password confirmation
|
||||
|
||||
## 4) REVIEW.md unresolved topics -> new-project design defaults
|
||||
|
||||
This section translates outstanding review risks into early decisions for the new app.
|
||||
|
||||
### 4.1 Data store scalability
|
||||
|
||||
Review concern:
|
||||
- SQLite bottlenecks under higher write concurrency and multi-node scaling.
|
||||
|
||||
New-project default:
|
||||
- Start with PostgreSQL (or SQL Server) for production profile.
|
||||
- Keep provider abstraction and provider-specific migration strategy from day one.
|
||||
- Keep SQLite only as local dev/test convenience if needed.
|
||||
|
||||
### 4.2 Workflow extensibility
|
||||
|
||||
Review concern:
|
||||
- Workflow transitions are hard-coded in many places.
|
||||
|
||||
New-project default:
|
||||
- Define transitions in a single state-machine table/model.
|
||||
- Drive backend authorization and frontend navigation from same transition metadata.
|
||||
- Add tests that validate the transition table itself.
|
||||
|
||||
### 4.3 Authorization model growth
|
||||
|
||||
Review concern:
|
||||
- Role booleans (`IsAdmin`, `IsOwner`) limit future permission expansion.
|
||||
|
||||
New-project default:
|
||||
- Use role/permission tables or claims-based capabilities.
|
||||
- Keep owner as a protected capability, not a special-case boolean spread across code.
|
||||
- Use policy-based authorization with explicit capability names.
|
||||
|
||||
### 4.4 Frontend maintainability
|
||||
|
||||
Review concern:
|
||||
- String-template-heavy UI + global mutable state can become fragile.
|
||||
|
||||
New-project default:
|
||||
- Move to TypeScript (or strict JSDoc typing) early.
|
||||
- Keep module boundaries by feature.
|
||||
- Keep explicit escaping/safe-url guards and DOM `textContent` standards.
|
||||
- If not using framework, introduce a small typed view-model layer.
|
||||
|
||||
### 4.5 In-memory cache bounds
|
||||
|
||||
Review concern:
|
||||
- Unbounded dictionaries may grow under high-cardinality traffic.
|
||||
|
||||
New-project default:
|
||||
- Replace unbounded maps with bounded `MemoryCache` (size + TTL + eviction).
|
||||
- For distributed deployments, use Redis with cardinality and TTL controls.
|
||||
|
||||
### 4.6 Linking/results query scaling
|
||||
|
||||
Review concern:
|
||||
- Link and result workflows currently pull full sets into memory.
|
||||
|
||||
New-project default:
|
||||
- Persist link-group ids and compute aggregates in SQL.
|
||||
- Add pagination/windowing for large result sets.
|
||||
- Benchmark query plans on realistic volumes.
|
||||
|
||||
### 4.7 External URL validation latency
|
||||
|
||||
Review concern:
|
||||
- Reachability validation happens synchronously on write path.
|
||||
|
||||
New-project default:
|
||||
- Accept user URL quickly, validate asynchronously (job queue/background worker), store validation status.
|
||||
- Optionally proxy or prefetch media through controlled media service.
|
||||
|
||||
## 5) Topics from REVIEW that are already fixed and should be carried forward
|
||||
|
||||
- Polling amplification has been reduced through SSE + etag conditional reads.
|
||||
- CSRF protection is explicit (same-origin validation for authenticated mutating requests).
|
||||
- Password hashing is versioned and modernized (Argon2id current, transparent upgrades).
|
||||
- CSP is tightened compared to prior permissive baseline (no inline style allowance, no insecure image origins).
|
||||
- API drift risk reduced with OpenAPI generation + generated frontend client.
|
||||
|
||||
These are not optional add-ons; they should be baseline in the new app.
|
||||
|
||||
## 6) New-project starter checklist
|
||||
|
||||
- Bootstrap:
|
||||
- choose production DB provider first
|
||||
- define transition/state-machine model before endpoint coding
|
||||
- define permission/capability model before admin features
|
||||
- Security:
|
||||
- cookie or token strategy finalized with CSRF model
|
||||
- rate limiting partitions and thresholds defined
|
||||
- strict CSP and security headers in first commit
|
||||
- versioned password hashing with migration strategy
|
||||
- trusted proxy/host settings explicit
|
||||
- Contract:
|
||||
- OpenAPI generation enabled in build
|
||||
- generated client wired into frontend
|
||||
- operation-id stability tested
|
||||
- Data integrity:
|
||||
- enforce critical invariants both app-side and DB-side
|
||||
- transaction boundaries for multi-entity admin actions
|
||||
- Frontend:
|
||||
- module boundaries and state refresh model defined
|
||||
- escaping/url-safe helpers mandatory
|
||||
- i18n structure and fallback behavior in place
|
||||
- Testing:
|
||||
- integration test host with real migrations
|
||||
- deterministic stubs for network dependencies
|
||||
- coverage gate enforced in local + CI scripts
|
||||
|
||||
## 7) Keep/avoid quick reference
|
||||
|
||||
Keep:
|
||||
- Thin endpoints + workflow services.
|
||||
- Shared service result abstraction.
|
||||
- Explicit middleware order.
|
||||
- SSE + ETag state sync.
|
||||
- Generated API client from OpenAPI.
|
||||
- DB-enforced invariants.
|
||||
- Regression tests for security-sensitive UI rendering.
|
||||
|
||||
Avoid:
|
||||
- Hard-coded workflow transitions scattered in backend/frontend.
|
||||
- Boolean-only role model for long-term products.
|
||||
- Unbounded in-memory caches.
|
||||
- Synchronous external network checks on hot write paths.
|
||||
- Manual API contract duplication between docs/frontend/backend.
|
||||
|
||||
33
TESTS.md
33
TESTS.md
@@ -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,10 +66,11 @@ 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
|
||||
@@ -69,7 +78,9 @@ stateDiagram-v2
|
||||
- GET /admin/vote-status returns list ordered by display/username with suggestion counts, finalized flag, joker flag; ready/waiting derived correctly.
|
||||
- POST /admin/joker grants joker only when target in Vote; resets VotesFinal for target.
|
||||
- POST /admin/player-phase allows Vote->Suggest transitions only; rejects other targets/current phases; clears target VotesFinal.
|
||||
- POST /admin/player-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: requires valid admin password; wipes suggestions/votes, resets phases to Suggest, clears votesFinal/hasJoker, closes results, updates timestamp.
|
||||
@@ -78,13 +89,23 @@ stateDiagram-v2
|
||||
### 7) Infrastructure/Helpers
|
||||
- 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.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"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
|
||||
867
openapi/GameList.json
Normal file
867
openapi/GameList.json
Normal file
@@ -0,0 +1,867 @@
|
||||
{
|
||||
"openapi": "3.1.1",
|
||||
"info": {
|
||||
"title": "GameList | v1",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"paths": {
|
||||
"/health": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"GameList"
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/options": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"operationId": "GetAuthOptions",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/register": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"operationId": "Register",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RegisterRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/login": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"operationId": "Login",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LoginRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/logout": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Auth"
|
||||
],
|
||||
"operationId": "Logout",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/state": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"State"
|
||||
],
|
||||
"operationId": "GetState",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/events/state": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"State"
|
||||
],
|
||||
"operationId": "GetStateEvents",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/me": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"State"
|
||||
],
|
||||
"operationId": "GetMe",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/me/phase/next": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"State"
|
||||
],
|
||||
"operationId": "NextPhase",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/me/phase/prev": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"State"
|
||||
],
|
||||
"operationId": "PrevPhase",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/suggestions/mine": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Suggestions"
|
||||
],
|
||||
"operationId": "GetMySuggestions",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/suggestions": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Suggestions"
|
||||
],
|
||||
"operationId": "CreateSuggestion",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SuggestionRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/suggestions/{id}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Suggestions"
|
||||
],
|
||||
"operationId": "DeleteSuggestion",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"tags": [
|
||||
"Suggestions"
|
||||
],
|
||||
"operationId": "UpdateSuggestion",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": "integer",
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SuggestionRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/suggestions/all": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Suggestions"
|
||||
],
|
||||
"operationId": "GetAllSuggestions",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/votes/mine": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Votes"
|
||||
],
|
||||
"operationId": "GetMyVotes",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/votes": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Votes"
|
||||
],
|
||||
"operationId": "UpsertVote",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/VoteRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/votes/finalize": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Votes"
|
||||
],
|
||||
"operationId": "SetVotesFinalized",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/VoteFinalizeRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/results": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Results"
|
||||
],
|
||||
"operationId": "GetResults",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/results": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "SetResultsOpen",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ResultsOpenRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/vote-status": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "GetVoteStatus",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/joker": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "GrantJoker",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/GrantJokerRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/player-phase": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "SetPlayerPhase",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SetPlayerPhaseRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/player-admin": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "SetPlayerAdmin",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SetPlayerAdminRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/players/{playerId}": {
|
||||
"delete": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "DeletePlayer",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "playerId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AdminPasswordRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/link-suggestions": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "LinkSuggestions",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/LinkSuggestionsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/unlink-suggestions": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "UnlinkSuggestions",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/UnlinkSuggestionsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/reset": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "Reset",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AdminPasswordRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/admin/factory-reset": {
|
||||
"post": {
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"operationId": "FactoryReset",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AdminPasswordRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"AdminPasswordRequest": {
|
||||
"required": [
|
||||
"password"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"password": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"GrantJokerRequest": {
|
||||
"required": [
|
||||
"playerId"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"playerId": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
}
|
||||
}
|
||||
},
|
||||
"LinkSuggestionsRequest": {
|
||||
"required": [
|
||||
"sourceSuggestionId",
|
||||
"targetSuggestionId"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sourceSuggestionId": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"targetSuggestionId": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"LoginRequest": {
|
||||
"required": [
|
||||
"username",
|
||||
"password"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"password": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"Phase": {
|
||||
"enum": [
|
||||
"Suggest",
|
||||
"Vote",
|
||||
"Results"
|
||||
]
|
||||
},
|
||||
"RegisterRequest": {
|
||||
"required": [
|
||||
"username",
|
||||
"password",
|
||||
"displayName",
|
||||
"adminKey"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"username": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"password": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"displayName": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"adminKey": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"ResultsOpenRequest": {
|
||||
"required": [
|
||||
"resultsOpen"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"resultsOpen": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SetPlayerAdminRequest": {
|
||||
"required": [
|
||||
"playerId",
|
||||
"isAdmin"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"playerId": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"isAdmin": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SetPlayerPhaseRequest": {
|
||||
"required": [
|
||||
"playerId",
|
||||
"phase"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"playerId": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"phase": {
|
||||
"$ref": "#/components/schemas/Phase"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SuggestionRequest": {
|
||||
"required": [
|
||||
"name",
|
||||
"genre",
|
||||
"description",
|
||||
"screenshotUrl",
|
||||
"youtubeUrl",
|
||||
"gameUrl",
|
||||
"minPlayers",
|
||||
"maxPlayers"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"genre": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"description": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"screenshotUrl": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"youtubeUrl": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"gameUrl": {
|
||||
"type": [
|
||||
"null",
|
||||
"string"
|
||||
]
|
||||
},
|
||||
"minPlayers": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"null",
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"maxPlayers": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"null",
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"UnlinkSuggestionsRequest": {
|
||||
"required": [
|
||||
"suggestionId"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suggestionId": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
},
|
||||
"VoteFinalizeRequest": {
|
||||
"required": [
|
||||
"final"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"final": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"VoteRequest": {
|
||||
"required": [
|
||||
"suggestionId",
|
||||
"score"
|
||||
],
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"suggestionId": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
},
|
||||
"score": {
|
||||
"pattern": "^-?(?:0|[1-9]\\d*)$",
|
||||
"type": [
|
||||
"integer",
|
||||
"string"
|
||||
],
|
||||
"format": "int32"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"tags": [
|
||||
{
|
||||
"name": "GameList"
|
||||
},
|
||||
{
|
||||
"name": "Auth"
|
||||
},
|
||||
{
|
||||
"name": "State"
|
||||
},
|
||||
{
|
||||
"name": "Suggestions"
|
||||
},
|
||||
{
|
||||
"name": "Votes"
|
||||
},
|
||||
{
|
||||
"name": "Results"
|
||||
},
|
||||
{
|
||||
"name": "Admin"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -3,9 +3,10 @@
|
||||
"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/i18n.js\" \"wwwroot/js/{admin-ui,app-admin-handlers,app-auth-handlers,app-vote-nav-handlers,auth-ui,modals-ui,results-ui,suggestions-ui,ui-runtime,ui-utils,ui,votes-ui}.js\"",
|
||||
"format:check": "prettier --check \"eslint.config.js\" \"wwwroot/js/i18n.js\" \"wwwroot/js/{admin-ui,app-admin-handlers,app-auth-handlers,app-vote-nav-handlers,auth-ui,modals-ui,results-ui,suggestions-ui,ui-runtime,ui-utils,ui,votes-ui}.js\""
|
||||
"format": "prettier --write \"eslint.config.js\" \"wwwroot/**/*.js\"",
|
||||
"format:check": "prettier --check \"eslint.config.js\" \"wwwroot/**/*.js\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "9.21.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."
|
||||
}
|
||||
@@ -31,14 +31,6 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Lint frontend" -Action {
|
||||
npm run lint
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Check frontend formatting" -Action {
|
||||
npm run format:check
|
||||
}
|
||||
|
||||
if (-not $SkipDotnetRestore) {
|
||||
Invoke-Step -Name "Restore .NET solution" -Action {
|
||||
dotnet restore GameList.sln
|
||||
@@ -51,15 +43,31 @@ try {
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Generate frontend API client from OpenAPI" -Action {
|
||||
npm run generate:api-client
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Lint frontend" -Action {
|
||||
npm run lint
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Check frontend formatting" -Action {
|
||||
npm run format:check
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Run tests" -Action {
|
||||
if ($SkipBuild) {
|
||||
dotnet test GameList.Tests/GameList.Tests.csproj --verbosity normal
|
||||
dotnet test GameList.Tests/GameList.Tests.csproj --verbosity normal --collect:"XPlat Code Coverage" --settings GameList.Tests/coverlet.runsettings
|
||||
}
|
||||
else {
|
||||
dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal
|
||||
dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal --collect:"XPlat Code Coverage" --settings GameList.Tests/coverlet.runsettings
|
||||
}
|
||||
}
|
||||
|
||||
Invoke-Step -Name "Enforce coverage thresholds" -Action {
|
||||
pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70
|
||||
}
|
||||
|
||||
Write-Host "CI checks passed."
|
||||
}
|
||||
finally {
|
||||
|
||||
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 = {
|
||||
Import-Module WebAdministration
|
||||
Stop-WebAppPool -Name $using:AppPoolName -ErrorAction SilentlyContinue
|
||||
Get-Process GameList -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
Get-Process dotnet -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*picknplay*" } | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true }
|
||||
if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth }
|
||||
try {
|
||||
Invoke-Command @invokeParams
|
||||
} catch {
|
||||
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
|
||||
param($poolName)
|
||||
Import-Module WebAdministration
|
||||
Stop-WebAppPool -Name $poolName -ErrorAction SilentlyContinue
|
||||
Get-Process GameList -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
Get-Process dotnet -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*picknplay*" } | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
} -ArgumentList @($appPoolName)
|
||||
}
|
||||
catch {
|
||||
Write-Warning "WinRM stop failed: $($_.Exception.Message)."
|
||||
}
|
||||
}
|
||||
|
||||
Write-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 = @($RemoteSitePath)
|
||||
} -ArgumentList @([string]$config.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)}`;
|
||||
}
|
||||
495
wwwroot/app.js
495
wwwroot/app.js
@@ -1,246 +1,359 @@
|
||||
import { t, setLanguage, getLanguage, initI18n, onLanguageChange, faqMarkdown } from "./js/i18n.js";
|
||||
import {
|
||||
t,
|
||||
setLanguage,
|
||||
getLanguage,
|
||||
initI18n,
|
||||
onLanguageChange,
|
||||
faqMarkdown,
|
||||
} from "./js/i18n.js";
|
||||
import { state, clearUserState } from "./js/state.js";
|
||||
import { toast } from "./js/dom.js";
|
||||
import {
|
||||
handleAuthError,
|
||||
renderWelcome,
|
||||
renderPhasePill,
|
||||
renderCounts,
|
||||
renderMySuggestions,
|
||||
renderAllSuggestions,
|
||||
renderVotes,
|
||||
syncVoteScores,
|
||||
renderResults,
|
||||
renderPhaseTitles,
|
||||
updatePhaseNav,
|
||||
configureUiRuntime,
|
||||
handleAuthError,
|
||||
renderWelcome,
|
||||
renderPhasePill,
|
||||
renderCounts,
|
||||
renderMySuggestions,
|
||||
renderAllSuggestions,
|
||||
renderVotes,
|
||||
syncVoteScores,
|
||||
renderResults,
|
||||
renderPhaseTitles,
|
||||
updatePhaseNav,
|
||||
configureUiRuntime,
|
||||
} from "./js/ui.js";
|
||||
import {
|
||||
loadSuggestData,
|
||||
loadVoteData,
|
||||
refreshPhaseData,
|
||||
} from "./js/data.js";
|
||||
import { api } from "./js/api.js";
|
||||
import { loadSuggestData, loadVoteData, refreshPhaseData } from "./js/data.js";
|
||||
import { setupAuthHandlers } from "./js/app-auth-handlers.js";
|
||||
import { setupAdminHandlers } from "./js/app-admin-handlers.js";
|
||||
import { setupVoteNavigationHandlers } from "./js/app-vote-nav-handlers.js";
|
||||
|
||||
const REFRESH_INTERVAL_MS = 4000;
|
||||
const REFRESH_MIN_MS = 3000;
|
||||
const REFRESH_MAX_MS = 20000;
|
||||
const EVENTS_RECONNECT_MIN_MS = 1000;
|
||||
const EVENTS_RECONNECT_MAX_MS = 15000;
|
||||
let refreshInFlight = null;
|
||||
let 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;
|
||||
if (refreshInFlight) return refreshInFlight;
|
||||
refreshInFlight = refreshPhaseData().finally(() => {
|
||||
refreshInFlight = null;
|
||||
});
|
||||
return refreshInFlight;
|
||||
}
|
||||
|
||||
async function refreshWithUiErrorHandling() {
|
||||
try {
|
||||
await runSerializedRefresh();
|
||||
} catch (err) {
|
||||
if (!handleAuthError(err, clearUserState)) toast(err.message, true);
|
||||
}
|
||||
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();
|
||||
}, REFRESH_INTERVAL_MS);
|
||||
refreshTimerId = window.setTimeout(async () => {
|
||||
if (!document.hidden && !state.adminStatusSelectActive) {
|
||||
await refreshWithUiErrorHandling();
|
||||
}
|
||||
scheduleNextRefresh();
|
||||
}, nextRefreshDelayMs);
|
||||
}
|
||||
|
||||
function startRefreshScheduler() {
|
||||
if (refreshSchedulerStarted) return;
|
||||
refreshSchedulerStarted = true;
|
||||
if (refreshSchedulerStarted) return;
|
||||
refreshSchedulerStarted = true;
|
||||
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden && !state.adminStatusSelectActive) {
|
||||
refreshWithUiErrorHandling();
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (!document.hidden && !state.adminStatusSelectActive) {
|
||||
unchangedRefreshCycles = 0;
|
||||
nextRefreshDelayMs = baseRefreshDelayForPhase();
|
||||
refreshWithUiErrorHandling();
|
||||
}
|
||||
});
|
||||
|
||||
if (refreshTimerId !== null) {
|
||||
window.clearTimeout(refreshTimerId);
|
||||
}
|
||||
});
|
||||
scheduleNextRefresh();
|
||||
}
|
||||
|
||||
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,
|
||||
loadVoteData,
|
||||
handleAuthError: (err) => handleAuthError(err, clearUserState),
|
||||
refreshPhaseData: runSerializedRefresh,
|
||||
loadSuggestData,
|
||||
loadVoteData,
|
||||
handleAuthError: (err) => handleAuthError(err, clearUserState),
|
||||
});
|
||||
|
||||
function setupHandlers() {
|
||||
setupAuthHandlers({ runSerializedRefresh });
|
||||
setupAdminHandlers({ runSerializedRefresh });
|
||||
setupVoteNavigationHandlers({ runSerializedRefresh });
|
||||
setupLanguageSwitchers();
|
||||
setupAuthHandlers({ runSerializedRefresh });
|
||||
setupAdminHandlers({ runSerializedRefresh });
|
||||
setupVoteNavigationHandlers({ runSerializedRefresh });
|
||||
setupLanguageSwitchers();
|
||||
document.getElementById("logout")?.addEventListener("click", () => {
|
||||
closeStateEventStream();
|
||||
});
|
||||
|
||||
onLanguageChange(() => {
|
||||
updateLanguageButtons();
|
||||
renderWelcome();
|
||||
renderPhasePill();
|
||||
renderCounts();
|
||||
renderPhaseTitles();
|
||||
renderMySuggestions();
|
||||
renderAllSuggestions();
|
||||
if (state.phase === "Vote") {
|
||||
renderVotes();
|
||||
state.votesRendered = true;
|
||||
syncVoteScores();
|
||||
}
|
||||
if (state.phase === "Results") {
|
||||
renderResults();
|
||||
}
|
||||
updatePhaseNav();
|
||||
});
|
||||
onLanguageChange(() => {
|
||||
updateLanguageButtons();
|
||||
renderWelcome();
|
||||
renderPhasePill();
|
||||
renderCounts();
|
||||
renderPhaseTitles();
|
||||
renderMySuggestions();
|
||||
renderAllSuggestions();
|
||||
if (state.phase === "Vote") {
|
||||
renderVotes();
|
||||
state.votesRendered = true;
|
||||
syncVoteScores();
|
||||
}
|
||||
if (state.phase === "Results") {
|
||||
renderResults();
|
||||
}
|
||||
updatePhaseNav();
|
||||
});
|
||||
|
||||
document.querySelectorAll(".help-chip").forEach((chip) => {
|
||||
chip.addEventListener("click", () => openFaqModal());
|
||||
});
|
||||
document.querySelectorAll(".help-chip").forEach((chip) => {
|
||||
chip.addEventListener("click", () => openFaqModal());
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await initI18n();
|
||||
setupHandlers();
|
||||
await refreshWithUiErrorHandling();
|
||||
startRefreshScheduler();
|
||||
await initI18n();
|
||||
setupHandlers();
|
||||
await refreshWithUiErrorHandling();
|
||||
startRefreshScheduler();
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
function updateLanguageButtons() {
|
||||
document.querySelectorAll(".lang-button").forEach((btn) => {
|
||||
btn.textContent = "🌐";
|
||||
btn.title = t("lang.label");
|
||||
btn.setAttribute("aria-label", t("lang.label"));
|
||||
});
|
||||
document.querySelectorAll(".lang-button").forEach((btn) => {
|
||||
btn.textContent = "🌐";
|
||||
btn.title = t("lang.label");
|
||||
btn.setAttribute("aria-label", t("lang.label"));
|
||||
});
|
||||
}
|
||||
|
||||
function setupLanguageSwitchers() {
|
||||
const switches = document.querySelectorAll(".lang-switch");
|
||||
const closeAll = () =>
|
||||
switches.forEach((wrap) => wrap.querySelector(".lang-menu")?.classList.add("hidden"));
|
||||
const switches = document.querySelectorAll(".lang-switch");
|
||||
const closeAll = () =>
|
||||
switches.forEach((wrap) =>
|
||||
wrap.querySelector(".lang-menu")?.classList.add("hidden"),
|
||||
);
|
||||
|
||||
switches.forEach((wrap) => {
|
||||
const btn = wrap.querySelector(".lang-button");
|
||||
const menu = wrap.querySelector(".lang-menu");
|
||||
if (!btn || !menu) return;
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const isHidden = menu.classList.contains("hidden");
|
||||
closeAll();
|
||||
if (isHidden) menu.classList.remove("hidden");
|
||||
switches.forEach((wrap) => {
|
||||
const btn = wrap.querySelector(".lang-button");
|
||||
const menu = wrap.querySelector(".lang-menu");
|
||||
if (!btn || !menu) return;
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
const isHidden = menu.classList.contains("hidden");
|
||||
closeAll();
|
||||
if (isHidden) menu.classList.remove("hidden");
|
||||
});
|
||||
menu.querySelectorAll("[data-lang]").forEach((item) =>
|
||||
item.addEventListener("click", () => {
|
||||
const lang = item.dataset.lang;
|
||||
if (lang) setLanguage(lang);
|
||||
closeAll();
|
||||
}),
|
||||
);
|
||||
});
|
||||
menu.querySelectorAll("[data-lang]").forEach((item) =>
|
||||
item.addEventListener("click", () => {
|
||||
const lang = item.dataset.lang;
|
||||
if (lang) setLanguage(lang);
|
||||
closeAll();
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!e.target.closest(".lang-switch")) closeAll();
|
||||
});
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!e.target.closest(".lang-switch")) closeAll();
|
||||
});
|
||||
|
||||
updateLanguageButtons();
|
||||
updateLanguageButtons();
|
||||
}
|
||||
|
||||
function markdownToHtml(md) {
|
||||
const lines = md.trim().split(/\r?\n/);
|
||||
const html = [];
|
||||
let inList = false;
|
||||
let inParagraph = false;
|
||||
const lines = md.trim().split(/\r?\n/);
|
||||
const html = [];
|
||||
let inList = false;
|
||||
let inParagraph = false;
|
||||
|
||||
const escapeHtml = (text) =>
|
||||
text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
const escapeHtml = (text) =>
|
||||
text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
const formatInline = (text) =>
|
||||
escapeHtml(text)
|
||||
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
const formatInline = (text) =>
|
||||
escapeHtml(text)
|
||||
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
|
||||
const closeParagraph = () => {
|
||||
if (inParagraph) {
|
||||
html.push("</p>");
|
||||
inParagraph = false;
|
||||
}
|
||||
};
|
||||
const closeParagraph = () => {
|
||||
if (inParagraph) {
|
||||
html.push("</p>");
|
||||
inParagraph = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeList = () => {
|
||||
if (inList) {
|
||||
html.push("</ul>");
|
||||
inList = false;
|
||||
}
|
||||
};
|
||||
const closeList = () => {
|
||||
if (inList) {
|
||||
html.push("</ul>");
|
||||
inList = false;
|
||||
}
|
||||
};
|
||||
|
||||
lines.forEach((rawLine) => {
|
||||
const line = rawLine.trimEnd();
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
closeParagraph();
|
||||
closeList();
|
||||
return;
|
||||
}
|
||||
lines.forEach((rawLine) => {
|
||||
const line = rawLine.trimEnd();
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
closeParagraph();
|
||||
closeList();
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^-{5,}$/.test(trimmed)) {
|
||||
closeParagraph();
|
||||
closeList();
|
||||
html.push('<hr class="faq-divider" />');
|
||||
return;
|
||||
}
|
||||
if (/^-{5,}$/.test(trimmed)) {
|
||||
closeParagraph();
|
||||
closeList();
|
||||
html.push('<hr class="faq-divider" />');
|
||||
return;
|
||||
}
|
||||
|
||||
const heading = trimmed.match(/^(#{1,3})\s+(.*)$/);
|
||||
if (heading) {
|
||||
closeParagraph();
|
||||
closeList();
|
||||
const level = heading[1].length;
|
||||
const tag = level === 1 ? "h2" : level === 2 ? "h3" : "h4";
|
||||
html.push(`<${tag}>${formatInline(heading[2].trim())}</${tag}>`);
|
||||
return;
|
||||
}
|
||||
const heading = trimmed.match(/^(#{1,3})\s+(.*)$/);
|
||||
if (heading) {
|
||||
closeParagraph();
|
||||
closeList();
|
||||
const level = heading[1].length;
|
||||
const tag = level === 1 ? "h2" : level === 2 ? "h3" : "h4";
|
||||
html.push(`<${tag}>${formatInline(heading[2].trim())}</${tag}>`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^[*-]\s+/.test(trimmed)) {
|
||||
closeParagraph();
|
||||
if (!inList) {
|
||||
html.push("<ul>");
|
||||
inList = true;
|
||||
}
|
||||
const text = trimmed.replace(/^[*-]\s+/, "");
|
||||
html.push(`<li>${formatInline(text)}</li>`);
|
||||
return;
|
||||
}
|
||||
if (/^[*-]\s+/.test(trimmed)) {
|
||||
closeParagraph();
|
||||
if (!inList) {
|
||||
html.push("<ul>");
|
||||
inList = true;
|
||||
}
|
||||
const text = trimmed.replace(/^[*-]\s+/, "");
|
||||
html.push(`<li>${formatInline(text)}</li>`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!inParagraph) {
|
||||
html.push("<p>");
|
||||
inParagraph = true;
|
||||
}
|
||||
html.push(formatInline(trimmed));
|
||||
});
|
||||
if (!inParagraph) {
|
||||
html.push("<p>");
|
||||
inParagraph = true;
|
||||
}
|
||||
html.push(formatInline(trimmed));
|
||||
});
|
||||
|
||||
closeParagraph();
|
||||
closeList();
|
||||
return html.join("\n");
|
||||
closeParagraph();
|
||||
closeList();
|
||||
return html.join("\n");
|
||||
}
|
||||
|
||||
function openFaqModal() {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "edit-modal";
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "edit-panel faq-panel";
|
||||
panel.innerHTML = `
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "edit-modal";
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "edit-panel faq-panel";
|
||||
panel.innerHTML = `
|
||||
<div class="edit-header">
|
||||
<h3>${t("help.title")}</h3>
|
||||
<button class="lightbox-close" aria-label="${t("modal.close")}">x</button>
|
||||
@@ -250,16 +363,20 @@ function openFaqModal() {
|
||||
</div>
|
||||
`;
|
||||
|
||||
const list = panel.querySelector(".faq-list");
|
||||
const lang = getLanguage();
|
||||
const md = faqMarkdown[lang] ?? faqMarkdown.en;
|
||||
list.innerHTML = markdownToHtml(md);
|
||||
const list = panel.querySelector(".faq-list");
|
||||
const lang = getLanguage();
|
||||
const md = faqMarkdown[lang] ?? faqMarkdown.en;
|
||||
list.innerHTML = markdownToHtml(md);
|
||||
|
||||
const close = () => overlay.remove();
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (e.target.classList.contains("edit-modal") || e.target.classList.contains("lightbox-close")) close();
|
||||
});
|
||||
const close = () => overlay.remove();
|
||||
overlay.addEventListener("click", (e) => {
|
||||
if (
|
||||
e.target.classList.contains("edit-modal") ||
|
||||
e.target.classList.contains("lightbox-close")
|
||||
)
|
||||
close();
|
||||
});
|
||||
|
||||
overlay.appendChild(panel);
|
||||
document.body.appendChild(overlay);
|
||||
overlay.appendChild(panel);
|
||||
document.body.appendChild(overlay);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,16 @@
|
||||
display: block;
|
||||
padding: 0;
|
||||
}
|
||||
.card-visual.has-image {
|
||||
background: #f6b24f;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card-visual-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.card-visual.hovering {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
@@ -269,3 +279,10 @@ input[type="range"].full-slider:disabled::-moz-range-thumb {
|
||||
background: #f1f1f1;
|
||||
border-color: #c1c1c1;
|
||||
}
|
||||
|
||||
.fx-canvas {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 120;
|
||||
}
|
||||
|
||||
@@ -11,9 +11,14 @@ Registriere dich mit:
|
||||
|
||||
Dein Anzeigename ist erforderlich ‒ er erscheint neben all deinen Vorschlägen und Bewertungen.
|
||||
|
||||
### Wie werden Passwörter geschützt?
|
||||
|
||||
Passwörter werden niemals im Klartext gespeichert. Pick'n'Play speichert gesalzene, versionierte Passwort-Hashes. Neue und aktualisierte Hashes verwenden Argon2id, während ältere Hash-Versionen nach erfolgreicher Anmeldung oder Admin-Passwort-Bestätigung transparent aktualisiert werden.
|
||||
|
||||
### Brauche ich Admin-Rechte?
|
||||
|
||||
Wenn du einen **Admin-Schlüssel** erhalten hast, gib ihn bei der Registrierung ein. Ist der Schlüssel ungültig, wird die Anfrage abgelehnt. Admin-Rechte können später nicht hinzugefügt werden. Um Admin zu werden, musst du dich mit dem korrekten Schlüssel neu registrieren.
|
||||
Wenn du einen **Admin-Schlüssel** erhalten hast, gib ihn bei der Registrierung ein. Ist der Schlüssel ungültig, wird die Anfrage abgelehnt. Die Admin-Schlüssel-Registrierung ist nur verfügbar, bis das erste Admin-Konto erstellt wurde. Admin-Rechte können später nicht über die öffentliche Registrierung hinzugefügt werden.
|
||||
Sobald ein Owner-Konto existiert, wird das Admin-Schlüssel-Feld in der Registrierung nicht mehr angezeigt.
|
||||
|
||||
## Phasen im Überblick
|
||||
|
||||
@@ -26,6 +31,10 @@ Jeder Spieler durchläuft die Phasen unabhängig voneinander:
|
||||
Klicke auf **„Weiter"**, um fortzufahren. Admins können sich bei Bedarf auch wieder zurücksetzen.
|
||||
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?
|
||||
@@ -52,7 +61,7 @@ Wenn du eine Screenshot-URL angibst, muss sie:
|
||||
- Direkt erreichbar sein (keine Weiterleitungen)
|
||||
- Innerhalb von ~3 Sekunden laden
|
||||
- Unter **5 MB**groß sein
|
||||
- Nicht auf lokale oder private Hosts verweisen
|
||||
- Nicht auf lokale, private oder reservierte Hosts verweisen
|
||||
|
||||
Screenshots sind optional.
|
||||
|
||||
@@ -145,6 +154,10 @@ Die Ergebnisse bleiben verborgen, bis ein Admin sie freigibt. Danach werden alle
|
||||
|
||||
Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf an einen Admin.
|
||||
|
||||
### 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?
|
||||
@@ -152,6 +165,7 @@ Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf
|
||||
- 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)
|
||||
@@ -163,6 +177,7 @@ Nein. Vorschläge und Bewertungen sind schreibgeschützt. Wende dich bei Bedarf
|
||||
### 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.
|
||||
|
||||
@@ -189,9 +204,19 @@ Bis dahin zeigt die Navigation in der Vorschlagsphase einen Hinweis statt eines
|
||||
|
||||
Registriere dich erneut mit dem korrekten Schlüssel vom Host ‒ oder lasse das Feld leer, um ein normales Konto zu erstellen.
|
||||
|
||||
### „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 **SQLite-Datenbank** gespeichert.
|
||||
- Passwörtwer werden mit einer SHA256 Verschlüsselung gespeichert.
|
||||
- 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.
|
||||
|
||||
@@ -11,10 +11,15 @@ Register with:
|
||||
|
||||
Your display name is required ‒ it appears next to all of your suggestions and scores.
|
||||
|
||||
### How are passwords protected?
|
||||
|
||||
Passwords are never stored in plain text. Pick'n'Play stores salted, versioned password hashes. New and upgraded hashes use Argon2id, while older hash versions are transparently upgraded after successful sign-in or admin-password confirmation.
|
||||
|
||||
### Do I need admin privileges?
|
||||
|
||||
If you've been given an **admin key**, enter it during registration. If the key is invalid, the request is rejected.
|
||||
Admin access cannot be added later. To become an admin, you must re-register with the correct key.
|
||||
Admin-key bootstrap is only available until the first admin account exists. Admin access cannot be added later. To become an admin afterward, an existing admin must create/manage access outside the public registration flow.
|
||||
Once an owner account exists, the registration form no longer shows the admin-key field.
|
||||
|
||||
## Phases at a Glance
|
||||
|
||||
@@ -27,6 +32,10 @@ Each player progresses independently through the phases:
|
||||
Click **"Next"** to move forward. Admins can move themselves backward if needed.
|
||||
In the **Suggest** phase, **Next** stays disabled until your account has at least one own game suggestion.
|
||||
|
||||
### Do I need to refresh the page manually?
|
||||
|
||||
Usually no. Pick'n'Play receives live server updates and falls back to periodic checks if the live channel is temporarily unavailable.
|
||||
|
||||
## Suggesting Games
|
||||
|
||||
### How many games can I suggest?
|
||||
@@ -54,7 +63,7 @@ If you include a screenshot URL, it must:
|
||||
- Be directly accessible (no redirects)
|
||||
- Load within ~3 seconds
|
||||
- Be under **5 MB**
|
||||
- Not point to local or private hosts
|
||||
- Not point to local, private, or reserved hosts
|
||||
|
||||
Screenshots are optional.
|
||||
|
||||
@@ -149,6 +158,10 @@ If needed, an admin can close the Results: players with at least one own suggest
|
||||
|
||||
No. Suggestions and votes are read-only. Contact an admin for assistance.
|
||||
|
||||
### 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?
|
||||
@@ -156,6 +169,7 @@ No. Suggestions and votes are read-only. Contact an admin for assistance.
|
||||
- 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)
|
||||
@@ -167,6 +181,7 @@ No. Suggestions and votes are read-only. Contact an admin for assistance.
|
||||
### What can't admin accounts do?
|
||||
|
||||
- View individual player votes
|
||||
- Revoke owner permissions or delete the owner account
|
||||
|
||||
Voting remains anonymous and fair.
|
||||
|
||||
@@ -193,9 +208,19 @@ Until then, the Suggest navigation shows a hint instead of a Next button, and sw
|
||||
|
||||
Register again using the correct key from the host ‒ or leave it blank to create a regular account.
|
||||
|
||||
### "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 **SQLite database**.
|
||||
- Passwords are stored with a SHA256 encryption.
|
||||
- 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.
|
||||
|
||||
@@ -91,6 +91,8 @@
|
||||
"results.average": "Ø",
|
||||
"results.votesList": "All votes",
|
||||
"results.myVote": "Your vote",
|
||||
"results.votersTooltip": "Voted by: {users}",
|
||||
"results.votersTooltipEmpty": "No votes yet",
|
||||
"results.links": "Links",
|
||||
"results.link.site": "Site ↗",
|
||||
"results.link.youtube": "YouTube ↗",
|
||||
@@ -124,13 +126,16 @@
|
||||
"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",
|
||||
@@ -261,6 +266,8 @@
|
||||
"results.average": "Ø",
|
||||
"results.votesList": "Alle Stimmen",
|
||||
"results.myVote": "Deine Stimme",
|
||||
"results.votersTooltip": "Abgestimmt von: {users}",
|
||||
"results.votersTooltipEmpty": "Noch keine Stimmen",
|
||||
"results.links": "Links",
|
||||
"results.link.site": "Webseite ↗",
|
||||
"results.link.youtube": "YouTube ↗",
|
||||
@@ -294,13 +301,16 @@
|
||||
"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",
|
||||
|
||||
@@ -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">
|
||||
@@ -172,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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t } from "./i18n.js";
|
||||
import { state } from "./state.js";
|
||||
import { $ } from "./dom.js";
|
||||
import { buildLinkOptionLabel, escapeHtml, truncate } from "./ui-utils.js";
|
||||
import { buildLinkOptionLabel, truncate } from "./ui-utils.js";
|
||||
|
||||
function displayPlayerStatus(player) {
|
||||
if (!player) return "";
|
||||
@@ -16,14 +16,24 @@ function displayPlayerStatus(player) {
|
||||
}
|
||||
|
||||
function buildStatusSelect(player) {
|
||||
const statusText = displayPlayerStatus(player);
|
||||
const canMoveToSuggest = player.phase === "Vote";
|
||||
return `
|
||||
<select class="chip admin-status-select" data-set-player-phase="${player.playerId}" aria-label="${t("admin.playerStatus")}">
|
||||
<option value="" selected>${statusText}</option>
|
||||
<option value="Suggest" ${canMoveToSuggest ? "" : "disabled"}>${t("admin.statusMoveToSuggest")}</option>
|
||||
</select>
|
||||
`;
|
||||
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() {
|
||||
@@ -36,17 +46,65 @@ export function renderAdminVoteStatus() {
|
||||
table.innerHTML = "";
|
||||
state.adminVoteStatus.voters.forEach((v) => {
|
||||
const tr = document.createElement("tr");
|
||||
const gamesTooltip = escapeHtml((v.suggestionTitles || []).join(", "));
|
||||
const nameText = escapeHtml(truncate(v.name, 28));
|
||||
const userText = escapeHtml(truncate(v.username, 24));
|
||||
tr.innerHTML = `
|
||||
<td title="${escapeHtml(v.name)}">${nameText}</td>
|
||||
<td class="muted small" title="${escapeHtml(v.username)}">${userText}</td>
|
||||
<td>${buildStatusSelect(v)}</td>
|
||||
<td title="${gamesTooltip}">${v.suggestionCount ?? 0}</td>
|
||||
<td><button class="chip" data-grant-joker="${v.playerId}" type="button">${v.hasJoker ? "🎟" : t("admin.grantJokerChip")}</button></td>
|
||||
<td><button class="chip danger-chip" data-delete-player="${v.playerId}" data-name="${v.name}" type="button">✕</button></td>
|
||||
`;
|
||||
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);
|
||||
});
|
||||
|
||||
|
||||
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,75 +1,91 @@
|
||||
const defaultHeaders = { "Content-Type": "application/json" };
|
||||
import { apiClient, resolveOperationPath } from "./api-client.generated.js";
|
||||
|
||||
const rawBase = document.querySelector('meta[name="app-base"]')?.content || "";
|
||||
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;
|
||||
}
|
||||
const res = await apiClient.getState({
|
||||
headers,
|
||||
raw: true,
|
||||
acceptStatuses: [304],
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
if (res.status === 304) {
|
||||
return {
|
||||
notModified: true,
|
||||
etag: res.headers.get("ETag"),
|
||||
data: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let msg = `${res.status}`;
|
||||
try {
|
||||
const data = await res.json();
|
||||
msg = data.error || data.detail || data.title || JSON.stringify(data);
|
||||
} catch { /* ignore */ }
|
||||
const err = new Error(msg);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
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: (password) =>
|
||||
request("/api/admin/reset", { method: "POST", body: { password } }),
|
||||
factoryReset: (password) =>
|
||||
request("/api/admin/factory-reset", { method: "POST", body: { password } }),
|
||||
grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }),
|
||||
setPlayerPhase: (playerId, phase) =>
|
||||
request("/api/admin/player-phase", { method: "POST", body: { playerId, phase } }),
|
||||
deletePlayer: (playerId, password) =>
|
||||
request(`/api/admin/players/${playerId}`, {
|
||||
method: "DELETE",
|
||||
body: { password },
|
||||
}),
|
||||
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
|
||||
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }),
|
||||
unlinkSuggestions: (suggestionId) =>
|
||||
request("/api/admin/unlink-suggestions", { method: "POST", body: { suggestionId } }),
|
||||
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) =>
|
||||
apiClient.linkSuggestions({
|
||||
body: { sourceSuggestionId, targetSuggestionId },
|
||||
}),
|
||||
unlinkSuggestions: (suggestionId) =>
|
||||
apiClient.unlinkSuggestions({
|
||||
body: { suggestionId },
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -127,6 +127,7 @@ 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)) {
|
||||
@@ -144,6 +145,25 @@ function setupPlayerTableActions(runSerializedRefresh) {
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
@@ -46,6 +46,29 @@ function setupAuthModeToggle() {
|
||||
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;
|
||||
@@ -91,6 +114,7 @@ function setupLoginFormHandlers({
|
||||
if (err?.status === 401)
|
||||
return toast(t("auth.invalidCredentials"), true);
|
||||
if (handleAuthError(err, clearUserState)) return;
|
||||
toast(err?.message || t("toast.unexpected"), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -121,6 +145,7 @@ function setupRegisterFormHandlers({
|
||||
return toast(t("auth.cookieRequired"), true);
|
||||
try {
|
||||
await api.register({ username, password, displayName, adminKey });
|
||||
await refreshRegistrationOptions();
|
||||
setConsent();
|
||||
toggleConsentRows();
|
||||
setSavedUsername(username);
|
||||
@@ -152,6 +177,7 @@ function setupLogoutHandler() {
|
||||
clearUserState();
|
||||
state.isAuthenticated = false;
|
||||
setAuthUI(false);
|
||||
await refreshRegistrationOptions();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -178,6 +204,7 @@ function setupSuggestionEntryButtons() {
|
||||
|
||||
export function setupAuthHandlers({ runSerializedRefresh }) {
|
||||
setupAuthModeToggle();
|
||||
refreshRegistrationOptions();
|
||||
const consent = setupConsentRows();
|
||||
setupLoginUserEditingHint();
|
||||
setupLoginFormHandlers({ ...consent, runSerializedRefresh });
|
||||
|
||||
@@ -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,6 +47,7 @@ export async function loadState() {
|
||||
renderWelcome();
|
||||
renderPhasePill();
|
||||
renderCounts();
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function loadSuggestData() {
|
||||
@@ -86,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(), loadSuggestionsData(), 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 (
|
||||
@@ -109,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) => [
|
||||
|
||||
@@ -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,18 +1,28 @@
|
||||
import { t } from "./i18n.js";
|
||||
import { toast } from "./dom.js";
|
||||
import { escapeHtml } from "./ui-utils.js";
|
||||
|
||||
export function openLightbox(url, title) {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "lightbox";
|
||||
const safeTitle = escapeHtml(title || "");
|
||||
overlay.innerHTML = `
|
||||
<div class="lightbox-content">
|
||||
<button class="lightbox-close" aria-label="${t("lightbox.close")}">✕</button>
|
||||
<img src="${url}" alt="${safeTitle}" />
|
||||
<p>${safeTitle}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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") ||
|
||||
@@ -38,15 +48,28 @@ export function openConfirmModal({
|
||||
overlay.className = "edit-modal";
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "edit-panel";
|
||||
panel.innerHTML = `
|
||||
<div class="edit-header">
|
||||
<h3>${title}</h3>
|
||||
<button class="lightbox-close" aria-label="${t("modal.close")}">x</button>
|
||||
</div>
|
||||
<div class="edit-body">
|
||||
<p>${body}</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
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";
|
||||
@@ -63,7 +86,7 @@ export function openConfirmModal({
|
||||
actions.append(cancelBtn);
|
||||
cancelBtn.addEventListener("click", close);
|
||||
}
|
||||
const bodyContainer = panel.querySelector(".edit-body");
|
||||
const bodyContainer = bodyWrap;
|
||||
let passwordInput = null;
|
||||
if (requirePassword && bodyContainer) {
|
||||
const field = document.createElement("label");
|
||||
|
||||
@@ -63,6 +63,12 @@ export function renderResults() {
|
||||
const safeShot = safeUrl(r.screenshotUrl);
|
||||
const safeGameUrl = safeUrl(r.gameUrl);
|
||||
const safeYoutubeUrl = safeUrl(r.youtubeUrl);
|
||||
const votersTooltip = buildVotersTooltip(r);
|
||||
const safeVotersTooltip = escapeHtml(votersTooltip);
|
||||
const averageScore =
|
||||
r.average?.toFixed && typeof r.average === "number"
|
||||
? r.average.toFixed(1)
|
||||
: r.average;
|
||||
row.innerHTML = `
|
||||
<td class="rank-cell"><span class="medal">${medal}</span></td>
|
||||
<td class="game-cell">
|
||||
@@ -76,9 +82,9 @@ export function renderResults() {
|
||||
</div>
|
||||
</td>
|
||||
<td class="author-cell">${safeAuthor || "—"}</td>
|
||||
<td>${r.average?.toFixed ? r.average.toFixed(1) : r.average}</td>
|
||||
<td>${formatVotes(r.votes)}</td>
|
||||
<td>${formatMyVote(r.myVote)}</td>
|
||||
<td><span title="${safeVotersTooltip}">${averageScore}</span></td>
|
||||
<td>${formatVotes(r.votes, votersTooltip)}</td>
|
||||
<td>${formatMyVote(r.myVote, votersTooltip)}</td>
|
||||
<td>
|
||||
${safeGameUrl ? `<a class="link compact" href="${safeGameUrl}" target="_blank" rel="noopener">${t("results.link.site")}</a><br>` : ""}
|
||||
${safeYoutubeUrl ? `<a class="link compact" href="${safeYoutubeUrl}" target="_blank" rel="noopener">${t("results.link.youtube")}</a>` : ""}
|
||||
@@ -110,13 +116,32 @@ function buildResultMeta(r) {
|
||||
return `<div class="muted small">${bits.join(" • ")}</div>`;
|
||||
}
|
||||
|
||||
function formatVotes(votes) {
|
||||
if (!Array.isArray(votes) || votes.length === 0) return "⚠️";
|
||||
function formatVotes(votes, tooltip) {
|
||||
const safeTooltip = escapeHtml(tooltip);
|
||||
if (!Array.isArray(votes) || votes.length === 0) {
|
||||
return `<span class="score-emoji" title="${safeTooltip}">⚠️</span>`;
|
||||
}
|
||||
const sorted = [...votes].sort((a, b) => a - b);
|
||||
return sorted.map((v) => scoreToEmoji(v)).join("");
|
||||
return sorted
|
||||
.map(
|
||||
(v) =>
|
||||
`<span class="score-emoji" title="${safeTooltip}">${scoreToEmoji(v)}</span>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function formatMyVote(score) {
|
||||
function formatMyVote(score, tooltip) {
|
||||
if (score == null || Number.isNaN(score)) return "—";
|
||||
return `${score} ${scoreToEmoji(score)}`;
|
||||
const safeTooltip = escapeHtml(tooltip);
|
||||
return `${score} <span class="score-emoji" title="${safeTooltip}">${scoreToEmoji(score)}</span>`;
|
||||
}
|
||||
|
||||
function buildVotersTooltip(result) {
|
||||
const voterNames = Array.isArray(result?.voterNames)
|
||||
? result.voterNames.filter(
|
||||
(name) => typeof name === "string" && name.trim().length > 0,
|
||||
)
|
||||
: [];
|
||||
if (voterNames.length === 0) return t("results.votersTooltipEmpty");
|
||||
return t("results.votersTooltip", { users: voterNames.join(", ") });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
export const state = {
|
||||
isAuthenticated: false,
|
||||
ownerExists: false,
|
||||
authMode: "login",
|
||||
me: null,
|
||||
phase: null,
|
||||
@@ -16,9 +17,11 @@ export const state = {
|
||||
votesRendered: false,
|
||||
adminVoteStatus: null,
|
||||
adminStatusSelectActive: false,
|
||||
stateEtag: null,
|
||||
};
|
||||
|
||||
export function clearUserState() {
|
||||
state.ownerExists = false;
|
||||
state.me = null;
|
||||
state.phase = null;
|
||||
state.prevPhase = null;
|
||||
@@ -32,7 +35,9 @@ export function clearUserState() {
|
||||
state.myVotes = [];
|
||||
state.results = [];
|
||||
state.votesRendered = false;
|
||||
state.adminVoteStatus = null;
|
||||
state.adminStatusSelectActive = false;
|
||||
state.stateEtag = null;
|
||||
const adminCard = document.getElementById("admin-card");
|
||||
if (adminCard) adminCard.classList.add("hidden");
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { setupCardVisualHover, triggerCelebration } from "./effects.js";
|
||||
import { renderAdminLinker } from "./admin-ui.js";
|
||||
import { getUiRuntime } from "./ui-runtime.js";
|
||||
import {
|
||||
cssEscapeUrl,
|
||||
escapeHtml,
|
||||
isLinked,
|
||||
linkedPeerTitles,
|
||||
@@ -49,16 +48,6 @@ export function renderMySuggestions() {
|
||||
|
||||
export function renderAllSuggestions() {
|
||||
renderAdminLinker();
|
||||
const list = $("all-suggestions");
|
||||
if (!list) return;
|
||||
list.innerHTML = "";
|
||||
const allowEdit = true;
|
||||
const allowDelete = !!state.me?.isAdmin;
|
||||
sortByName(state.allSuggestions).forEach((s) =>
|
||||
list.appendChild(
|
||||
buildCard(s, { showAuthor: true, allowEdit, allowDelete }),
|
||||
),
|
||||
);
|
||||
renderPhaseTitles();
|
||||
}
|
||||
|
||||
@@ -105,7 +94,7 @@ export function buildCard(
|
||||
: "";
|
||||
const visual =
|
||||
hasImage && safeShot
|
||||
? `<button class="card-visual" data-img="${safeShot}" aria-label="${t("card.openScreenshot")}" style="background-image:url('${cssEscapeUrl(safeShot)}')"></button>`
|
||||
? `<button class="card-visual has-image" data-img="${escapeHtml(safeShot)}" aria-label="${t("card.openScreenshot")}"><img class="card-visual-image" src="${escapeHtml(safeShot)}" alt="" loading="lazy" decoding="async" /></button>`
|
||||
: `<div class="card-visual"></div>`;
|
||||
const hasPlayers = s.minPlayers || s.maxPlayers;
|
||||
const players = hasPlayers
|
||||
|
||||
@@ -261,15 +261,6 @@ export function updatePhaseNav() {
|
||||
}
|
||||
}
|
||||
|
||||
const voteNext = $("nav-vote-next");
|
||||
if (voteNext) {
|
||||
const locked = !state.resultsOpen && !isAdmin;
|
||||
voteNext.disabled = locked;
|
||||
voteNext.textContent = locked
|
||||
? t("nav.waitingForResults")
|
||||
: t("nav.next");
|
||||
}
|
||||
|
||||
const adminResultsToggle = $("results-open");
|
||||
if (adminResultsToggle) {
|
||||
adminResultsToggle.textContent = state.resultsOpen
|
||||
|
||||
Reference in New Issue
Block a user