22 Commits

Author SHA1 Message Date
6e5bbec86e Automate app-base injection during FTP deploy 2026-02-09 18:46:52 +01:00
78dccff90f Cleanup 2026-02-08 22:46:12 +01:00
a6408979ee Merge branch 'chore/review-remediation-2026-02-08' 2026-02-08 22:43:16 +01:00
018fc47d9c Ignore test result coverage artifacts 2026-02-08 22:42:47 +01:00
bc0245c1d4 Fix deploy password prompt and WinRM app pool args 2026-02-08 22:40:29 +01:00
6eea5dcf32 Fix deploy profile data file compatibility 2026-02-08 22:38:08 +01:00
de9123b260 Add local deploy wrappers and ignore private FTP profile 2026-02-08 22:33:09 +01:00
d375b942ff Reduce frontend polling load and clean stale UI hooks 2026-02-08 21:57:47 +01:00
726ba79fdf Enforce explicit test coverage thresholds in CI 2026-02-08 21:52:37 +01:00
368b4877bc Parameterize FTP deployment with environment profiles 2026-02-08 21:50:58 +01:00
d2ab8a676f Harden auth validation against null request fields 2026-02-08 21:48:07 +01:00
acffbc199d Remove startup migration and runtime frontend rewrites 2026-02-08 21:46:26 +01:00
2d2201d0a2 Decouple workflow services from HTTP result types 2026-02-08 21:43:07 +01:00
fe6a9d5da4 Harden owner and suggestion invariants for concurrent writes 2026-02-08 21:37:46 +01:00
569cea161f Add critical architecture and quality review findings 2026-02-08 21:13:32 +01:00
d62ccdcf53 Removed tasks 2026-02-08 21:01:36 +01:00
1bb34c51bf Removed symbol requirement for password, fix formatting. 2026-02-08 20:44:44 +01:00
1c59d68a50 Add owner role and admin management controls 2026-02-08 19:01:58 +01:00
97f1b30b75 Formatting and loca 2026-02-08 18:51:01 +01:00
42e60d2a5a Harden app security controls from audit 2026-02-08 18:40:13 +01:00
a6364b0802 Add categorized webapp security audit tasks 2026-02-08 18:21:34 +01:00
e922afacdf Merge branch 'codex/tasks-md-2026-02-08' 2026-02-08 16:11:23 +01:00
63 changed files with 2936 additions and 836 deletions

View File

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

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

18
API.md
View File

@@ -1,16 +1,21 @@
# 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.
## 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/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,11 +27,13 @@ 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 callers finalized status (blocks further vote edits when true)
Vote upsert includes conflict handling for concurrent writes against the unique `(PlayerId, SuggestionId)` index.
## Results (requires auth + Results phase + resultsOpen)
GET /api/results — leaderboard with totals, counts, averages, callers vote, media/links, link metadata
@@ -36,8 +43,15 @@ POST /api/admin/results — `{ resultsOpen: bool }` locks/unlocks results and al
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
- Security headers are set on all responses (`CSP`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`, `Permissions-Policy`).
- In production, HTTPS redirection and HSTS are enabled.
- Screenshot URL validation rejects private/reserved address ranges and pins outbound connections to validated public IPs.

View File

@@ -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);

View File

@@ -6,13 +6,17 @@ public record SuggestionRequest(string Name, string? Genre, string? Description,
public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers, int? ParentSuggestionId = null, IReadOnlyList<int>? LinkedIds = null, IReadOnlyList<string>? LinkedTitles = null);
public record 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);

View File

@@ -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, 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(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);

View File

@@ -22,6 +22,8 @@ public class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(op
builder.Property(p => p.PasswordHash).IsRequired();
builder.Property(p => p.PasswordSalt).IsRequired();
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);

View File

@@ -0,0 +1,251 @@
// <auto-generated />
using System;
using GameList.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace GameList.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260208175912_AddOwnerRole")]
partial class AddOwnerRole
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("GameList.Domain.AppState", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("ResultsOpen")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AppState");
b.HasData(
new
{
Id = 1,
ResultsOpen = false,
UpdatedAt = new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))
});
});
modelBuilder.Entity("GameList.Domain.Player", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrentPhase")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<string>("DisplayName")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<bool>("HasJoker")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<bool>("IsAdmin")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<bool>("IsOwner")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("TEXT");
b.Property<string>("NormalizedUsername")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("TEXT");
b.Property<byte[]>("PasswordHash")
.IsRequired()
.HasColumnType("BLOB");
b.Property<byte[]>("PasswordSalt")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("TEXT");
b.Property<bool>("VotesFinal")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.HasKey("Id");
b.HasIndex("NormalizedUsername")
.IsUnique();
b.ToTable("Players");
});
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("GameUrl")
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("Genre")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int?>("MaxPlayers")
.HasColumnType("INTEGER");
b.Property<int?>("MinPlayers")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int?>("ParentSuggestionId")
.HasColumnType("INTEGER");
b.Property<Guid>("PlayerId")
.HasColumnType("TEXT");
b.Property<string>("ScreenshotUrl")
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("YoutubeUrl")
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ParentSuggestionId");
b.HasIndex("PlayerId");
b.ToTable("Suggestions");
});
modelBuilder.Entity("GameList.Domain.Vote", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("PlayerId")
.HasColumnType("TEXT");
b.Property<int>("Score")
.HasColumnType("INTEGER");
b.Property<int>("SuggestionId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SuggestionId");
b.HasIndex("PlayerId", "SuggestionId")
.IsUnique();
b.ToTable("Votes");
});
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
{
b.HasOne("GameList.Domain.Suggestion", "ParentSuggestion")
.WithMany("LinkedSuggestions")
.HasForeignKey("ParentSuggestionId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("GameList.Domain.Player", "Player")
.WithMany("Suggestions")
.HasForeignKey("PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentSuggestion");
b.Navigation("Player");
});
modelBuilder.Entity("GameList.Domain.Vote", b =>
{
b.HasOne("GameList.Domain.Player", "Player")
.WithMany("Votes")
.HasForeignKey("PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("GameList.Domain.Suggestion", "Suggestion")
.WithMany("Votes")
.HasForeignKey("SuggestionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Player");
b.Navigation("Suggestion");
});
modelBuilder.Entity("GameList.Domain.Player", b =>
{
b.Navigation("Suggestions");
b.Navigation("Votes");
});
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
{
b.Navigation("LinkedSuggestions");
b.Navigation("Votes");
});
#pragma warning restore 612, 618
}
}
}

View File

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

View File

@@ -0,0 +1,255 @@
// <auto-generated />
using System;
using GameList.Data;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace GameList.Data.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260208203323_HardenOwnerAndSuggestionInvariants")]
partial class HardenOwnerAndSuggestionInvariants
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
modelBuilder.Entity("GameList.Domain.AppState", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<bool>("ResultsOpen")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("AppState");
b.HasData(
new
{
Id = 1,
ResultsOpen = false,
UpdatedAt = new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))
});
});
modelBuilder.Entity("GameList.Domain.Player", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<int>("CurrentPhase")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(0);
b.Property<string>("DisplayName")
.HasMaxLength(16)
.HasColumnType("TEXT");
b.Property<bool>("HasJoker")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<bool>("IsAdmin")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<bool>("IsOwner")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.Property<DateTimeOffset?>("LastLoginAt")
.HasColumnType("TEXT");
b.Property<string>("NormalizedUsername")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("TEXT");
b.Property<byte[]>("PasswordHash")
.IsRequired()
.HasColumnType("BLOB");
b.Property<byte[]>("PasswordSalt")
.IsRequired()
.HasColumnType("BLOB");
b.Property<string>("Username")
.IsRequired()
.HasMaxLength(24)
.HasColumnType("TEXT");
b.Property<bool>("VotesFinal")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER")
.HasDefaultValue(false);
b.HasKey("Id");
b.HasIndex("IsOwner")
.IsUnique()
.HasFilter("IsOwner = 1");
b.HasIndex("NormalizedUsername")
.IsUnique();
b.ToTable("Players");
});
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasMaxLength(500)
.HasColumnType("TEXT");
b.Property<string>("GameUrl")
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("Genre")
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<int?>("MaxPlayers")
.HasColumnType("INTEGER");
b.Property<int?>("MinPlayers")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<int?>("ParentSuggestionId")
.HasColumnType("INTEGER");
b.Property<Guid>("PlayerId")
.HasColumnType("TEXT");
b.Property<string>("ScreenshotUrl")
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.Property<string>("YoutubeUrl")
.HasMaxLength(2048)
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("ParentSuggestionId");
b.HasIndex("PlayerId");
b.ToTable("Suggestions");
});
modelBuilder.Entity("GameList.Domain.Vote", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<Guid>("PlayerId")
.HasColumnType("TEXT");
b.Property<int>("Score")
.HasColumnType("INTEGER");
b.Property<int>("SuggestionId")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SuggestionId");
b.HasIndex("PlayerId", "SuggestionId")
.IsUnique();
b.ToTable("Votes");
});
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
{
b.HasOne("GameList.Domain.Suggestion", "ParentSuggestion")
.WithMany("LinkedSuggestions")
.HasForeignKey("ParentSuggestionId")
.OnDelete(DeleteBehavior.SetNull);
b.HasOne("GameList.Domain.Player", "Player")
.WithMany("Suggestions")
.HasForeignKey("PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("ParentSuggestion");
b.Navigation("Player");
});
modelBuilder.Entity("GameList.Domain.Vote", b =>
{
b.HasOne("GameList.Domain.Player", "Player")
.WithMany("Votes")
.HasForeignKey("PlayerId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("GameList.Domain.Suggestion", "Suggestion")
.WithMany("Votes")
.HasForeignKey("SuggestionId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Player");
b.Navigation("Suggestion");
});
modelBuilder.Entity("GameList.Domain.Player", b =>
{
b.Navigation("Suggestions");
b.Navigation("Votes");
});
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
{
b.Navigation("LinkedSuggestions");
b.Navigation("Votes");
});
#pragma warning restore 612, 618
}
}
}

View File

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

View File

@@ -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");
@@ -98,6 +103,10 @@ namespace GameList.Data.Migrations
b.HasKey("Id");
b.HasIndex("IsOwner")
.IsUnique()
.HasFilter("IsOwner = 1");
b.HasIndex("NormalizedUsername")
.IsUnique();

View File

@@ -20,6 +20,7 @@ public class Player
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; }

View File

@@ -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").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);
});
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);
});
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);
});
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);
});
admin.MapPost("/player-admin", async ([FromBody] SetPlayerAdminRequest request, AdminWorkflowService service) =>
{
var result = await service.SetPlayerAdminAsync(request.PlayerId, request.IsAdmin);
return result.ToHttpResult(Results.Ok);
});
admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, [FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
{
@@ -25,7 +46,8 @@ 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);
});
admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
@@ -34,7 +56,8 @@ 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);
});
admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
@@ -43,7 +66,8 @@ 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);
});
admin.MapPost("/reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
@@ -52,7 +76,8 @@ 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);
});
admin.MapPost("/factory-reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
@@ -61,7 +86,8 @@ 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);
});
}
}

View File

@@ -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,27 @@ 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);
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);
if (!verified)
{
monitor.RecordFailure(ctx, "admin-password", admin.NormalizedUsername, "invalid-password");
return ServiceError.BadRequest("Invalid admin password.");
}
monitor.RecordSuccess(ctx, "admin-password", admin.NormalizedUsername);
return null;
}
}

View File

@@ -11,27 +11,47 @@ public static class AuthEndpoints
{
public static void MapAuthEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/api/auth");
var group = app.MapGroup("/api/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));
});
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
{
@@ -42,12 +62,28 @@ public static class AuthEndpoints
PasswordSalt = salt,
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);
@@ -59,14 +95,20 @@ public static class AuthEndpoints
));
});
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))
{
authAttemptMonitor.RecordFailure(ctx, "auth-login", normalizedUsername, "invalid-credentials");
return EndpointHelpers.UnauthorizedError("Invalid username or password.");
}
if (string.IsNullOrWhiteSpace(player.DisplayName))
{
@@ -76,6 +118,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(
@@ -92,4 +135,6 @@ public static class AuthEndpoints
return Results.NoContent();
});
}
private static string NormalizeActor(string? username) => string.IsNullOrWhiteSpace(username) ? "(missing)" : username.Trim();
}

View File

@@ -7,10 +7,12 @@ internal static class AuthValidator
public const int MaxUsernameLength = 24;
public const int 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);
}

View File

@@ -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;
}

View File

@@ -18,7 +18,8 @@ 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);
});
}
}

View File

@@ -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()
@@ -49,7 +49,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)
@@ -80,8 +80,8 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
linkedIds,
linkedTitles
);
});
}).ToList();
return Results.Ok(shaped);
return ServiceResult<IReadOnlyList<ResultItemDto>>.Success(shaped);
}
}

View File

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

View File

@@ -14,7 +14,8 @@ public static class StateEndpoints
if (player is null)
return EndpointHelpers.UnauthorizedError();
return await service.GetStateAsync(player);
var result = await service.GetStateAsync(player);
return result.ToHttpResult(Results.Ok);
});
group.MapGet("/me", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
@@ -23,7 +24,8 @@ 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);
});
group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
@@ -32,7 +34,8 @@ 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);
});
group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) =>
@@ -41,7 +44,8 @@ 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);
});
}

View File

@@ -7,39 +7,22 @@ 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 phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen);
var summary = new StateSummaryResponse(
phase,
player.VotesFinal,
player.HasJoker,
state.ResultsOpen,
state.UpdatedAt,
await db.Players.CountAsync(),
await db.Suggestions.CountAsync(),
await db.Votes.CountAsync()
);
return Results.Ok(summary);
var summary = new StateSummaryResponse(phase, player.VotesFinal, player.HasJoker, state.ResultsOpen, state.UpdatedAt, await db.Players.CountAsync(), await db.Suggestions.CountAsync(), await db.Votes.CountAsync());
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 +35,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 +53,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 +64,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

View File

@@ -17,7 +17,8 @@ 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);
});
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,6 +40,8 @@ public static class SuggestEndpoints
request.MaxPlayers
)
);
return result.ToHttpResult(payload => Results.Created($"/api/suggestions/{payload.Id}", payload));
}).AddEndpointFilter(new PhaseOrJokerFilter());
group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
@@ -47,7 +50,8 @@ 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);
});
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,6 +74,8 @@ public static class SuggestEndpoints
request.MaxPlayers
)
);
return result.ToHttpResult(Results.Ok);
});
group.MapGet("/all", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) =>
@@ -78,7 +84,8 @@ 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);
});
}
}

View File

@@ -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)

View File

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

View File

@@ -17,7 +17,8 @@ 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);
});
group.MapPost("/", async (VoteRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
@@ -25,7 +26,9 @@ public static class VoteEndpoints
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);
});
group.MapPost("/finalize", async (VoteFinalizeRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) =>
@@ -34,7 +37,8 @@ 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);
});
}
}

View File

@@ -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;
}
}
}

View File

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

View File

@@ -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;
@@ -62,6 +63,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()
{
@@ -99,6 +118,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 +211,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 +271,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);
}
}

View File

@@ -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;
@@ -28,34 +27,10 @@ public class HelperTests
}
[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);
}
[Fact]
public void UpdateIndexMetaBase_no_marker_no_change()
{
var webRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
Directory.CreateDirectory(webRoot);
var index = Path.Combine(webRoot, "index.html");
File.WriteAllText(index, "<html></html>");
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"]);
Assert.Equal("<html></html>", File.ReadAllText(index));
var hasRewriteMethod = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).Any(m => m.Name.Contains("UpdateIndexMetaBase", StringComparison.Ordinal));
Assert.False(hasRewriteMethod);
}
[Fact]
@@ -142,6 +117,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 +252,76 @@ 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());
Assert.Contains("default-src 'self'", response.Headers.GetValues("Content-Security-Policy").Single());
}
[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)

View File

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

View File

@@ -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;

15
IIS.md
View File

@@ -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.

View File

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

View File

@@ -7,7 +7,7 @@ public static class PasswordHasher
{
private const int SaltSize = 16;
private const int KeySize = 32;
private const int Iterations = 100_000;
private const int Iterations = 210_000;
public static (byte[] Hash, byte[] Salt) HashPassword(string password)
{

View File

@@ -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,57 @@ builder.Services.AddScoped<VoteWorkflowService>();
builder.Services.AddScoped<AdminWorkflowService>();
builder.Services.AddScoped<ResultsWorkflowService>();
builder.Services.AddScoped<StateWorkflowService>();
builder.Services.AddSingleton<AuthAttemptMonitor>();
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 +104,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,12 +119,33 @@ 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' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: https: http:; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'";
return Task.CompletedTask;
});
await next();
});
var basePath = builder.Configuration["BasePath"];
if (!string.IsNullOrWhiteSpace(basePath))
{
app.UsePathBase(basePath);
UpdateIndexMetaBase(app.Environment, basePath);
}
app.UseGlobalExceptionLogging();
@@ -79,13 +153,6 @@ app.UseAuthentication();
app.UseMiddleware<EnsurePlayerExistsMiddleware>();
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.UseDefaultFiles();
app.UseStaticFiles();
@@ -99,6 +166,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 +266,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;

View File

@@ -6,11 +6,13 @@ 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
@@ -24,20 +26,26 @@ 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`.
- 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.
## 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
@@ -53,4 +61,5 @@ GitHub Actions workflow: `.github/workflows/ci.yml`
- Restores dependencies
- Runs frontend lint and format checks
- Builds with warnings treated as errors
- Runs `GameList.Tests`
- Runs `GameList.Tests` with coverage collection
- Enforces minimum coverage thresholds (line 90%, branch 70%)

167
REVIEW.md
View File

@@ -1,24 +1,163 @@
# Maintainability Review - Pick'n'Play
# Review - Pick'n'Play
## A) Current focus
Date: 2026-02-08
This document tracks only active work. Completed work is intentionally omitted and can be reviewed in git history.
## Scope and baseline
Active maintainability risks (priority order):
- Reviewed backend, frontend, data model, tests, CI, deployment scripts, and documentation.
- Ran local CI checks via `scripts/ci-local.ps1 -SkipNpmInstall` (pass: lint, format, build, tests).
- Ran coverage collection (`dotnet test --collect:"XPlat Code Coverage"`).
- Latest collected coverage: line-rate `92.75%`, branch-rate `74.88%` (`GameList.Tests/TestResults/*/coverage.cobertura.xml:2`).
- None at the moment.
## Executive summary
## B) Active task list
The app is in a solid functional state for a small group and has good safety basics (auth, rate limits, tests, security headers).
However, several design and reliability issues will slow down extension work and make this repo hard to use as a reusable foundation for new apps unless addressed first.
- None.
## Findings (highest priority first)
## C) Suggested execution order
### P0 - Core invariants are not concurrency-safe
1. Add new items when fresh risks are identified.
Evidence:
- Owner bootstrap is check-then-insert (`Endpoints/AuthEndpoints.cs:45`, `Endpoints/AuthEndpoints.cs:70`, `Endpoints/AuthEndpoints.cs:71`) without a DB-level uniqueness guard for owner role (`Data/AppDbContext.cs:25`).
- Suggestion cap (<=5) is count-then-insert (`Endpoints/SuggestionWorkflowService.cs:63`, `Endpoints/SuggestionWorkflowService.cs:64`, `Endpoints/SuggestionWorkflowService.cs:82`, `Endpoints/SuggestionWorkflowService.cs:92`).
- Vote upsert is read-then-insert/update (`Endpoints/VoteWorkflowService.cs:70`, `Endpoints/VoteWorkflowService.cs:79`, `Endpoints/VoteWorkflowService.cs:92`) and can race into unique-key conflicts.
## D) Guardrails
Impact:
- Under concurrent requests, business rules can be violated (multiple owners, over-limit suggestions) or requests can fail with server errors.
- 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.
Recommendation:
- Enforce invariants in the DB and handle race paths explicitly (transaction isolation, constrained indexes, retry/409 handling on expected conflicts).
### P0 - Business layer is tightly coupled to HTTP transport
Evidence:
- Workflow services return `Task<IResult>` directly across the board (`Endpoints/SuggestionWorkflowService.cs:10`, `Endpoints/VoteWorkflowService.cs:10`, `Endpoints/ResultsWorkflowService.cs:10`, `Endpoints/StateWorkflowService.cs:10`, `Endpoints/AdminWorkflowService.cs:11`).
Impact:
- Domain logic cannot be reused cleanly by other apps/services without carrying ASP.NET HTTP result types everywhere.
- Harder to unit test business logic in isolation.
Recommendation:
- Split into application services (domain result objects/errors) + thin endpoint adapters (HTTP mapping only).
### P1 - Startup/runtime side effects are risky for production scale-out
Evidence:
- Automatic schema migration on app startup (`Program.cs:161`).
- Runtime mutation of static frontend file for base path (`Program.cs:149`, `Program.cs:277`), with silent catch (`Program.cs:313`).
Impact:
- Multi-instance startup races and operational fragility.
- Read-only deployments or immutable artifacts can fail subtly.
Recommendation:
- Move migrations to explicit deployment step; remove runtime file rewrite and make base path purely configuration-driven at build/deploy time.
### P1 - Null-safety gaps in auth input validation can produce 500s
Evidence:
- Direct `.Trim()` on potentially null request values (`Endpoints/AuthValidator.cs:15`, `Endpoints/AuthValidator.cs:71`, `Endpoints/AuthEndpoints.cs:26`, `Endpoints/AuthEndpoints.cs:90`).
Impact:
- Malformed JSON payloads can bypass intended 400 responses and trigger 500 errors.
Recommendation:
- Treat inbound fields as nullable at boundaries, normalize safely, and fail closed with typed validation errors.
### P1 - Deployment automation is environment-specific and hard-coded
Evidence:
- Script contains fixed host/user/path/tooling details (`scripts/deploy-ftp.ps1:2`, `scripts/deploy-ftp.ps1:3`, `scripts/deploy-ftp.ps1:11`, `scripts/deploy-ftp.ps1:15`).
- Uses direct FTP credential in command string (`scripts/deploy-ftp.ps1:102`).
Impact:
- Not portable, hard to onboard new maintainers/environments, and higher operational/security risk.
Recommendation:
- Externalize all environment values into secure config; provide a generic deploy profile template per environment.
### P1 - Coverage policy and reality are misaligned
Evidence:
- Documentation claims target of 100% line/branch (`TESTS.md:96`).
- Actual measured coverage is lower (`GameList.Tests/TestResults/*/coverage.cobertura.xml:2`).
- CI/local scripts run tests but do not enforce coverage thresholds (`.github/workflows/ci.yml:43`, `scripts/ci-local.ps1:56`, `scripts/ci-local.ps1:59`).
Impact:
- False confidence in quality gates and unclear definition of done.
Recommendation:
- Decide real threshold policy, enforce it in CI, and keep docs aligned with measured truth.
### P2 - Frontend refresh strategy is chatty and DB-heavy
Evidence:
- Global polling every 4s (`wwwroot/app.js:27`, `wwwroot/app.js:49`).
- Each cycle can trigger multiple API reads (`wwwroot/js/data.js:6`, `wwwroot/js/data.js:93`, `wwwroot/js/data.js:101`).
Impact:
- Scales poorly with player count/sessions; unnecessary backend load.
Recommendation:
- Move to event-driven or adaptive refresh (backoff, ETag/delta, push where needed).
### P2 - Frontend DOM drift and invalid markup
Evidence:
- Missing closing `</div>` for status bar around section boundary (`wwwroot/index.html:83`, `wwwroot/index.html:102`).
- JS references elements not present in HTML: `all-suggestions`, `nav-vote-next` (`wwwroot/js/suggestions-ui.js:52`, `wwwroot/js/votes-ui.js:264`).
Impact:
- Browser autocorrection masks real bugs, increases regression risk, and confuses future contributors.
Recommendation:
- Reconcile HTML/JS contracts and remove dead UI paths.
### P2 - Login flow can fail silently for non-auth errors
Evidence:
- In login catch block, non-401/non-auth-handler errors are swallowed without user feedback (`wwwroot/js/app-auth-handlers.js:113`, `wwwroot/js/app-auth-handlers.js:116`).
Impact:
- Poor UX and harder support/debugging.
Recommendation:
- Add explicit fallback toast/logging for unexpected errors in login flow.
### P2 - Formatting checks do not cover the whole frontend codebase
Evidence:
- ESLint scans all JS (`package.json:6`), but Prettier scripts only include a subset of files (`package.json:7`, `package.json:8`).
Impact:
- Style drift and inconsistent diffs across untouched files.
Recommendation:
- Align formatting scope with lint scope.
### P2 - Suggestion validation does synchronous external network checks on write paths
Evidence:
- Suggestion validation calls image reachability check (`Endpoints/SuggestionValidator.cs:13`).
- Reachability check performs outbound call with timeout per request (`Endpoints/EndpointHelpers.cs:175`, `Endpoints/EndpointHelpers.cs:194`, `Endpoints/EndpointHelpers.cs:197`).
Impact:
- Slower writes and external dependency coupling even for basic edit operations.
Recommendation:
- Make this validation less blocking (cache results, only revalidate when URL changes, or async verification workflow).
## Positive foundations worth keeping
- Security baseline is present: cookie hardening + rate limiting + security headers (`Program.cs:100`, `Program.cs:104`, `Program.cs:121`, `Program.cs:137`).
- Global exception handling and health endpoint exist (`Infrastructure/PlayerIdentityExtensions.cs:40`, `Infrastructure/PlayerIdentityExtensions.cs:68`).
- Broad integration test suite exists and is currently green (104 tests).
## Suggested initial remediation order
1. Concurrency/invariant hardening for owner bootstrap, suggestion limits, and vote upsert.
2. Service boundary refactor (`IResult` decoupling) to make logic reusable for future apps.
3. Startup/deployment hardening (migration strategy + remove runtime file rewrite + parameterized deploy script).
4. Coverage policy enforcement and documentation correction.
5. Frontend cleanup pass (invalid markup, dead selectors, polling strategy, error handling).

View File

@@ -32,7 +32,11 @@ 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.
- Logout clears cookie.
- EnsurePlayerExistsMiddleware: signed cookie for deleted player returns 401 and clears auth.
@@ -48,9 +52,10 @@ stateDiagram-v2
### 3) Suggestions
- GET /mine returns only callers suggestions ordered by CreatedAt.
- POST /: success with valid data; enforces ≤5 per player; trims optional fields; requires display name; rejects bad image URL/ext, unreachable image (mocked), invalid game/youtube URLs, invalid player counts, missing name/too long.
- 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,6 +63,7 @@ stateDiagram-v2
- GET /mine: only in Vote, returns player votes; unauthorized/phase mismatch handled.
- POST /: creates or updates vote; rejects score outside 010; rejects when VotesFinal=true; enforces display name requirement and phase gating.
- 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
@@ -69,7 +75,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 +86,20 @@ 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.
- 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.
- 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.
- 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.

View File

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

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

View File

@@ -4,8 +4,8 @@
"type": "module",
"scripts": {
"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",

View File

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

View File

@@ -53,13 +53,17 @@ try {
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"
}
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"
}
}
Invoke-Step -Name "Enforce coverage thresholds" -Action {
pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70
}
Write-Host "CI checks passed."
}
finally {

View File

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

View File

@@ -1,157 +1,335 @@
# 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
)
$indexPath = Join-Path $PublishDir "index.html"
if (-not (Test-Path $indexPath)) {
throw "Publish output is missing index.html at '$indexPath'."
}
$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
View File

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

View File

@@ -1,246 +1,282 @@
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 { 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;
let refreshInFlight = null;
let refreshTimerId = null;
let refreshSchedulerStarted = false;
let unchangedRefreshCycles = 0;
let nextRefreshDelayMs = REFRESH_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);
} catch (err) {
// Back off after transient failures to avoid hammering server/dependencies.
nextRefreshDelayMs = Math.min(nextRefreshDelayMs * 2, REFRESH_MAX_MS);
if (!handleAuthError(err, clearUserState)) toast(err.message, true);
}
}
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();
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const escapeHtml = (text) =>
text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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 +286,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);
}

View File

@@ -13,7 +13,8 @@ Dein Anzeigename ist erforderlich er erscheint neben all deinen Vorschlägen
### 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
@@ -52,7 +53,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.
@@ -152,6 +153,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 +165,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 +192,14 @@ 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.
## 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.

View File

@@ -14,7 +14,8 @@ Your display name is required it appears next to all of your suggestions and
### 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
@@ -54,7 +55,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.
@@ -156,6 +157,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 +169,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 +196,14 @@ 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.
## 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.

View File

@@ -124,13 +124,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",
@@ -294,13 +297,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",

View File

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

View File

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

View File

@@ -5,71 +5,107 @@ const basePath = normalizeBase(rawBase);
const withBase = (path) => `${basePath}${path}`;
function normalizeBase(value) {
if (!value) return "";
if (!value.startsWith("/")) return `/${value}`;
return value.endsWith("/") ? value.slice(0, -1) : value;
if (!value) return "";
if (!value.startsWith("/")) return `/${value}`;
return value.endsWith("/") ? value.slice(0, -1) : value;
}
async function request(path, { method = "GET", body } = {}) {
const res = await fetch(withBase(path), {
method,
credentials: "same-origin",
headers: defaultHeaders,
body: body ? JSON.stringify(body) : undefined,
});
const res = await fetch(withBase(path), {
method,
credentials: "same-origin",
headers: defaultHeaders,
body: body ? JSON.stringify(body) : undefined,
});
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();
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();
}
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: () => request("/api/state"),
me: () => request("/api/me"),
authOptions: () => request("/api/auth/options"),
register: (payload) =>
request("/api/auth/register", { method: "POST", body: payload }),
login: (payload) =>
request("/api/auth/login", { method: "POST", body: payload }),
logout: () => request("/api/auth/logout", { method: "POST" }),
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: () => 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"),
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: () => 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 } }),
results: () => request("/api/results"),
nextPhase: () => request("/api/me/phase/next", { method: "POST" }),
prevPhase: () => request("/api/me/phase/prev", { method: "POST" }),
results: () => request("/api/results"),
nextPhase: () => request("/api/me/phase/next", { method: "POST" }),
prevPhase: () => request("/api/me/phase/prev", { method: "POST" }),
};
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) =>
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 } }),
setPlayerAdmin: (playerId, isAdmin) =>
request("/api/admin/player-admin", {
method: "POST",
body: { playerId, isAdmin },
}),
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 },
}),
};

View File

@@ -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;

View File

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

View File

@@ -1,5 +1,20 @@
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() {
@@ -86,18 +101,26 @@ 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()]);
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) {
const adminCard = document.getElementById("admin-card");
const adminPanelVisible =
!!adminCard && !adminCard.classList.contains("hidden");
if (state.me?.isAdmin && adminPanelVisible) {
state.adminVoteStatus = await adminApi.voteStatus();
}
if (
@@ -109,12 +132,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) => [

View File

@@ -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;

View File

@@ -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");

View File

@@ -1,5 +1,6 @@
export const state = {
isAuthenticated: false,
ownerExists: false,
authMode: "login",
me: null,
phase: null,
@@ -19,6 +20,7 @@ export const state = {
};
export function clearUserState() {
state.ownerExists = false;
state.me = null;
state.phase = null;
state.prevPhase = null;

View File

@@ -49,16 +49,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();
}

View File

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