Compare commits
19 Commits
codex/task
...
chore/revi
| Author | SHA1 | Date | |
|---|---|---|---|
| 018fc47d9c | |||
| bc0245c1d4 | |||
| 6eea5dcf32 | |||
| de9123b260 | |||
| d375b942ff | |||
| 726ba79fdf | |||
| 368b4877bc | |||
| d2ab8a676f | |||
| acffbc199d | |||
| 2d2201d0a2 | |||
| fe6a9d5da4 | |||
| 569cea161f | |||
| d62ccdcf53 | |||
| 1bb34c51bf | |||
| 1c59d68a50 | |||
| 97f1b30b75 | |||
| 42e60d2a5a | |||
| a6364b0802 | |||
| e922afacdf |
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -40,4 +40,7 @@ jobs:
|
||||
run: dotnet build GameList.sln --no-restore -warnaserror
|
||||
|
||||
- name: Test
|
||||
run: dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal
|
||||
run: dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal --collect:"XPlat Code Coverage"
|
||||
|
||||
- name: Enforce coverage thresholds
|
||||
run: pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -11,12 +11,17 @@ node_modules/
|
||||
|
||||
# User secrets / configs
|
||||
appsettings.Development.json
|
||||
scripts/deploy-ftp.profile.psd1
|
||||
*.user
|
||||
*.suo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
|
||||
# Test results / coverage artifacts
|
||||
TestResults/
|
||||
coverage.cobertura.xml
|
||||
|
||||
# SQLite data
|
||||
App_Data/
|
||||
*.db
|
||||
|
||||
18
API.md
18
API.md
@@ -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 caller’s finalized status (blocks further vote edits when true)
|
||||
Vote upsert includes conflict handling for concurrent writes against the unique `(PlayerId, SuggestionId)` index.
|
||||
|
||||
## Results (requires auth + Results phase + resultsOpen)
|
||||
GET /api/results — leaderboard with totals, counts, averages, caller’s vote, media/links, link metadata
|
||||
@@ -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.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
namespace GameList.Contracts;
|
||||
|
||||
public record RegisterRequest(string Username, string Password, string? DisplayName, string? AdminKey);
|
||||
public record RegisterRequest(string? Username, string? Password, string? DisplayName, string? AdminKey);
|
||||
|
||||
public record LoginRequest(string Username, string Password);
|
||||
public record LoginRequest(string? Username, string? Password);
|
||||
|
||||
@@ -6,13 +6,17 @@ public record SuggestionRequest(string Name, string? Genre, string? Description,
|
||||
|
||||
public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers, int? ParentSuggestionId = null, IReadOnlyList<int>? LinkedIds = null, IReadOnlyList<string>? LinkedTitles = null);
|
||||
|
||||
public record SuggestionAllDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers, string? Author, int? ParentSuggestionId, bool IsOwner, IReadOnlyList<int> LinkedIds, IReadOnlyList<string> LinkedTitles);
|
||||
|
||||
public record VoteRequest(int SuggestionId, int Score);
|
||||
|
||||
public record VoteRecordDto(int SuggestionId, int Score);
|
||||
|
||||
public record ResultsOpenRequest(bool ResultsOpen);
|
||||
|
||||
public record VoteFinalizeRequest(bool Final);
|
||||
|
||||
public record VoteStatusDto(Guid PlayerId, string Name, string Username, Phase Phase, bool Finalized, bool HasJoker, int SuggestionCount, IReadOnlyList<string> SuggestionTitles);
|
||||
public record VoteStatusDto(Guid PlayerId, string Name, string Username, Phase Phase, bool Finalized, bool HasJoker, bool IsAdmin, bool IsOwner, int SuggestionCount, IReadOnlyList<string> SuggestionTitles);
|
||||
|
||||
public record LinkSuggestionsRequest(int SourceSuggestionId, int TargetSuggestionId);
|
||||
|
||||
@@ -22,4 +26,6 @@ public record GrantJokerRequest(Guid PlayerId);
|
||||
|
||||
public record SetPlayerPhaseRequest(Guid PlayerId, Phase Phase);
|
||||
|
||||
public record SetPlayerAdminRequest(Guid PlayerId, bool IsAdmin);
|
||||
|
||||
public record AdminPasswordRequest(string Password);
|
||||
|
||||
@@ -4,17 +4,7 @@ namespace GameList.Contracts;
|
||||
|
||||
public record SuggestionCreatedResponse(int Id);
|
||||
|
||||
public record SuggestionUpdatedResponse(
|
||||
int Id,
|
||||
string Name,
|
||||
string? Genre,
|
||||
string? Description,
|
||||
string? ScreenshotUrl,
|
||||
string? YoutubeUrl,
|
||||
string? GameUrl,
|
||||
int? MinPlayers,
|
||||
int? MaxPlayers
|
||||
);
|
||||
public record SuggestionUpdatedResponse(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers);
|
||||
|
||||
public record VoteUpsertResponse(IReadOnlyList<int> SuggestionIds, int Score);
|
||||
|
||||
@@ -26,6 +16,8 @@ public record AdminGrantJokerResponse(Guid Id, bool HasJoker);
|
||||
|
||||
public record AdminSetPlayerPhaseResponse(Guid PlayerId, Phase CurrentPhase, bool VotesFinal);
|
||||
|
||||
public record AdminSetPlayerAdminResponse(Guid PlayerId, bool IsAdmin);
|
||||
|
||||
public record AdminDeletePlayerResponse(Guid DeletedPlayerId);
|
||||
|
||||
public record AdminLinkSuggestionsResponse(int RootId, IReadOnlyList<int> LinkedSuggestionIds, int UnfinalizedPlayers);
|
||||
@@ -36,48 +28,14 @@ public record AdminResetStateResponse(Phase Phase, bool ResultsOpen, DateTimeOff
|
||||
|
||||
public record VoteStatusResponse(IReadOnlyList<VoteStatusDto> Voters, bool Ready, IReadOnlyList<string> Waiting);
|
||||
|
||||
public record ResultItemDto(
|
||||
int Id,
|
||||
string Name,
|
||||
string? Author,
|
||||
int? MinPlayers,
|
||||
int? MaxPlayers,
|
||||
int Total,
|
||||
int Count,
|
||||
double Average,
|
||||
IReadOnlyList<int> Votes,
|
||||
int? MyVote,
|
||||
string? ScreenshotUrl,
|
||||
string? YoutubeUrl,
|
||||
string? GameUrl,
|
||||
string? Description,
|
||||
string? Genre,
|
||||
int? ParentSuggestionId,
|
||||
IReadOnlyList<int> LinkedIds,
|
||||
IReadOnlyList<string> LinkedTitles
|
||||
);
|
||||
public record ResultItemDto(int Id, string Name, string? Author, int? MinPlayers, int? MaxPlayers, int Total, int Count, double Average, IReadOnlyList<int> Votes, 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
251
Data/Migrations/20260208175912_AddOwnerRole.Designer.cs
generated
Normal file
251
Data/Migrations/20260208175912_AddOwnerRole.Designer.cs
generated
Normal file
@@ -0,0 +1,251 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using GameList.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GameList.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260208175912_AddOwnerRole")]
|
||||
partial class AddOwnerRole
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.AppState", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("ResultsOpen")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AppState");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
ResultsOpen = false,
|
||||
UpdatedAt = new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Player", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrentPhase")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("HasJoker")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("IsOwner")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedUsername")
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte[]>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<byte[]>("PasswordSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("VotesFinal")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedUsername")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Players");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GameUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("MaxPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("MinPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParentSuggestionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PlayerId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ScreenshotUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("YoutubeUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentSuggestionId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("Suggestions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Vote", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("PlayerId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SuggestionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SuggestionId");
|
||||
|
||||
b.HasIndex("PlayerId", "SuggestionId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Votes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||
{
|
||||
b.HasOne("GameList.Domain.Suggestion", "ParentSuggestion")
|
||||
.WithMany("LinkedSuggestions")
|
||||
.HasForeignKey("ParentSuggestionId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("GameList.Domain.Player", "Player")
|
||||
.WithMany("Suggestions")
|
||||
.HasForeignKey("PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ParentSuggestion");
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Vote", b =>
|
||||
{
|
||||
b.HasOne("GameList.Domain.Player", "Player")
|
||||
.WithMany("Votes")
|
||||
.HasForeignKey("PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("GameList.Domain.Suggestion", "Suggestion")
|
||||
.WithMany("Votes")
|
||||
.HasForeignKey("SuggestionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Player");
|
||||
|
||||
b.Navigation("Suggestion");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Player", b =>
|
||||
{
|
||||
b.Navigation("Suggestions");
|
||||
|
||||
b.Navigation("Votes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||
{
|
||||
b.Navigation("LinkedSuggestions");
|
||||
|
||||
b.Navigation("Votes");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
42
Data/Migrations/20260208175912_AddOwnerRole.cs
Normal file
42
Data/Migrations/20260208175912_AddOwnerRole.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GameList.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddOwnerRole : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsOwner",
|
||||
table: "Players",
|
||||
type: "INTEGER",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
UPDATE Players
|
||||
SET IsOwner = 1
|
||||
WHERE Id = (
|
||||
SELECT Id
|
||||
FROM Players
|
||||
WHERE IsAdmin = 1
|
||||
ORDER BY CreatedAt, Id
|
||||
LIMIT 1
|
||||
);
|
||||
""");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsOwner",
|
||||
table: "Players");
|
||||
}
|
||||
}
|
||||
}
|
||||
255
Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.Designer.cs
generated
Normal file
255
Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.Designer.cs
generated
Normal file
@@ -0,0 +1,255 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using GameList.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GameList.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260208203323_HardenOwnerAndSuggestionInvariants")]
|
||||
partial class HardenOwnerAndSuggestionInvariants
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.AppState", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("ResultsOpen")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("AppState");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
Id = 1,
|
||||
ResultsOpen = false,
|
||||
UpdatedAt = new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0))
|
||||
});
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Player", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CurrentPhase")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(0);
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(16)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("HasJoker")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("IsAdmin")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<bool>("IsOwner")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.Property<DateTimeOffset?>("LastLoginAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedUsername")
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<byte[]>("PasswordHash")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<byte[]>("PasswordSalt")
|
||||
.IsRequired()
|
||||
.HasColumnType("BLOB");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasMaxLength(24)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("VotesFinal")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER")
|
||||
.HasDefaultValue(false);
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("IsOwner")
|
||||
.IsUnique()
|
||||
.HasFilter("IsOwner = 1");
|
||||
|
||||
b.HasIndex("NormalizedUsername")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Players");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasMaxLength(500)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("GameUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("MaxPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int?>("MinPlayers")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int?>("ParentSuggestionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<Guid>("PlayerId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ScreenshotUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("YoutubeUrl")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ParentSuggestionId");
|
||||
|
||||
b.HasIndex("PlayerId");
|
||||
|
||||
b.ToTable("Suggestions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Vote", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("PlayerId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Score")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("SuggestionId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("SuggestionId");
|
||||
|
||||
b.HasIndex("PlayerId", "SuggestionId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Votes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||
{
|
||||
b.HasOne("GameList.Domain.Suggestion", "ParentSuggestion")
|
||||
.WithMany("LinkedSuggestions")
|
||||
.HasForeignKey("ParentSuggestionId")
|
||||
.OnDelete(DeleteBehavior.SetNull);
|
||||
|
||||
b.HasOne("GameList.Domain.Player", "Player")
|
||||
.WithMany("Suggestions")
|
||||
.HasForeignKey("PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("ParentSuggestion");
|
||||
|
||||
b.Navigation("Player");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Vote", b =>
|
||||
{
|
||||
b.HasOne("GameList.Domain.Player", "Player")
|
||||
.WithMany("Votes")
|
||||
.HasForeignKey("PlayerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("GameList.Domain.Suggestion", "Suggestion")
|
||||
.WithMany("Votes")
|
||||
.HasForeignKey("SuggestionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Player");
|
||||
|
||||
b.Navigation("Suggestion");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Player", b =>
|
||||
{
|
||||
b.Navigation("Suggestions");
|
||||
|
||||
b.Navigation("Votes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("GameList.Domain.Suggestion", b =>
|
||||
{
|
||||
b.Navigation("LinkedSuggestions");
|
||||
|
||||
b.Navigation("Votes");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace GameList.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class HardenOwnerAndSuggestionInvariants : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Players_IsOwner",
|
||||
table: "Players",
|
||||
column: "IsOwner",
|
||||
unique: true,
|
||||
filter: "IsOwner = 1");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
CREATE TRIGGER IF NOT EXISTS TR_Suggestions_MaxFivePerPlayer
|
||||
BEFORE INSERT ON Suggestions
|
||||
WHEN
|
||||
(SELECT COUNT(1) FROM Suggestions WHERE PlayerId = NEW.PlayerId) >= 5
|
||||
AND (
|
||||
COALESCE((SELECT HasJoker FROM Players WHERE Id = NEW.PlayerId), 0) = 0
|
||||
OR COALESCE((SELECT CurrentPhase FROM Players WHERE Id = NEW.PlayerId), 0) != 2
|
||||
)
|
||||
BEGIN
|
||||
SELECT RAISE(ABORT, 'suggestion_limit_exceeded');
|
||||
END;
|
||||
"""
|
||||
);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.Sql("DROP TRIGGER IF EXISTS TR_Suggestions_MaxFivePerPlayer;");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_Players_IsOwner",
|
||||
table: "Players");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ internal static class AuthValidator
|
||||
public const int MaxUsernameLength = 24;
|
||||
public const int MaxDisplayNameLength = 16;
|
||||
public const int MaxAdminKeyLength = 128;
|
||||
public const int MinPasswordLength = 8;
|
||||
public const int MaxPasswordLength = 128;
|
||||
|
||||
public static bool TryValidateRegistration(RegisterRequest request, out ValidatedRegistration validated, out string error)
|
||||
{
|
||||
var username = (request.Username).Trim();
|
||||
var username = (request.Username ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(username) || username.Length > MaxUsernameLength)
|
||||
{
|
||||
validated = default;
|
||||
@@ -25,6 +27,24 @@ internal static class AuthValidator
|
||||
return false;
|
||||
}
|
||||
|
||||
var password = request.Password.Trim();
|
||||
if (password.Length < MinPasswordLength || password.Length > MaxPasswordLength)
|
||||
{
|
||||
validated = default;
|
||||
error = $"Password must be between {MinPasswordLength} and {MaxPasswordLength} characters.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var hasUpper = password.Any(char.IsUpper);
|
||||
var hasLower = password.Any(char.IsLower);
|
||||
var hasDigit = password.Any(char.IsDigit);
|
||||
if (!hasUpper || !hasLower || !hasDigit)
|
||||
{
|
||||
validated = default;
|
||||
error = "Password must include at least one uppercase and one lowercase characters and and digit.";
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((request.DisplayName ?? string.Empty).Trim().Length > MaxDisplayNameLength)
|
||||
{
|
||||
validated = default;
|
||||
@@ -41,14 +61,14 @@ internal static class AuthValidator
|
||||
}
|
||||
|
||||
var adminKey = EndpointHelpers.TrimTo(request.AdminKey, MaxAdminKeyLength);
|
||||
validated = new ValidatedRegistration(username, username.ToLowerInvariant(), displayName, adminKey);
|
||||
validated = new ValidatedRegistration(username, username.ToLowerInvariant(), password, displayName, adminKey);
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryValidateLogin(LoginRequest request, out string username, out string normalizedUsername, out string error)
|
||||
{
|
||||
username = (request.Username).Trim();
|
||||
username = (request.Username ?? string.Empty).Trim();
|
||||
normalizedUsername = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password))
|
||||
@@ -63,15 +83,16 @@ internal static class AuthValidator
|
||||
return false;
|
||||
}
|
||||
|
||||
if (request.Password.Length > MaxPasswordLength)
|
||||
{
|
||||
error = $"Password must be <= {MaxPasswordLength} characters.";
|
||||
return false;
|
||||
}
|
||||
|
||||
normalizedUsername = username.ToLowerInvariant();
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
public readonly record struct ValidatedRegistration(
|
||||
string Username,
|
||||
string NormalizedUsername,
|
||||
string DisplayName,
|
||||
string? AdminKey
|
||||
);
|
||||
public readonly record struct ValidatedRegistration(string Username, string NormalizedUsername, string Password, string DisplayName, string? AdminKey);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Net;
|
||||
using System.Net.Sockets;
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
internal static class EndpointHelpers
|
||||
{
|
||||
public const string SingleOwnerIndexName = "IX_Players_IsOwner";
|
||||
public const string SuggestionLimitTriggerError = "suggestion_limit_exceeded";
|
||||
|
||||
public static async Task<Player?> GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db)
|
||||
{
|
||||
if (ctx.User.Identity?.IsAuthenticated != true)
|
||||
@@ -106,6 +112,36 @@ internal static class EndpointHelpers
|
||||
|
||||
public static IResult UnauthorizedError(string detail = "Unauthorized") => Problem(StatusCodes.Status401Unauthorized, "Unauthorized", detail);
|
||||
|
||||
public static IResult ToHttpResult<T>(this ServiceResult<T> result, Func<T, IResult> onSuccess)
|
||||
{
|
||||
if (result.IsSuccess)
|
||||
return onSuccess(result.Value!);
|
||||
|
||||
return ToHttpError(result.Error!);
|
||||
}
|
||||
|
||||
public static IResult ToHttpResult(this ServiceResult<Unit> result, Func<IResult> onSuccess)
|
||||
{
|
||||
if (result.IsSuccess)
|
||||
return onSuccess();
|
||||
|
||||
return ToHttpError(result.Error!);
|
||||
}
|
||||
|
||||
public static bool IsSqliteConstraintViolation(DbUpdateException ex)
|
||||
{
|
||||
return ex.InnerException is SqliteException sqliteEx
|
||||
&& sqliteEx.SqliteErrorCode == 19;
|
||||
}
|
||||
|
||||
public static bool IsSqliteConstraintViolation(DbUpdateException ex, string containsMessage)
|
||||
{
|
||||
if (!IsSqliteConstraintViolation(ex))
|
||||
return false;
|
||||
|
||||
return ex.InnerException?.Message.Contains(containsMessage, StringComparison.OrdinalIgnoreCase) == true;
|
||||
}
|
||||
|
||||
private static IResult Problem(int statusCode, string title, string detail)
|
||||
{
|
||||
return Results.Problem(
|
||||
@@ -140,6 +176,48 @@ internal static class EndpointHelpers
|
||||
|| path.EndsWith(".avif", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static IResult ToHttpError(ServiceError error)
|
||||
{
|
||||
return error.Code switch
|
||||
{
|
||||
ServiceErrorCode.BadRequest => BadRequestError(error.Detail),
|
||||
ServiceErrorCode.Unauthorized => UnauthorizedError(error.Detail),
|
||||
ServiceErrorCode.NotFound => NotFoundError(error.Detail),
|
||||
ServiceErrorCode.Conflict => ConflictError(error.Detail),
|
||||
_ => Problem(StatusCodes.Status500InternalServerError, "Internal Server Error", "Unhandled service error.")
|
||||
};
|
||||
}
|
||||
|
||||
public static HttpMessageHandler CreateImageValidationHandler()
|
||||
{
|
||||
return new SocketsHttpHandler
|
||||
{
|
||||
AllowAutoRedirect = false,
|
||||
ConnectCallback = async (context, cancellationToken) =>
|
||||
{
|
||||
var addresses = await ResolveSafePublicAddressesAsync(context.DnsEndPoint.Host, cancellationToken);
|
||||
if (addresses.Count == 0)
|
||||
throw new HttpRequestException("No safe public IPs found for host.");
|
||||
|
||||
foreach (var ip in addresses)
|
||||
{
|
||||
var socket = new Socket(ip.AddressFamily, SocketType.Stream, ProtocolType.Tcp);
|
||||
try
|
||||
{
|
||||
await socket.ConnectAsync(new IPEndPoint(ip, context.DnsEndPoint.Port), cancellationToken);
|
||||
return new NetworkStream(socket, ownsSocket: true);
|
||||
}
|
||||
catch
|
||||
{
|
||||
socket.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
throw new HttpRequestException("Unable to connect to validated public IP for host.");
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
public static async Task<bool> IsReachableImageAsync(string? url, IHttpClientFactory httpFactory, HttpMessageHandler? handler = null, CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
@@ -148,13 +226,21 @@ internal static class EndpointHelpers
|
||||
return false;
|
||||
if (uri.Scheme is not ("http" or "https"))
|
||||
return false;
|
||||
if (!await IsSafePublicHostAsync(uri, ct))
|
||||
if (handler is null)
|
||||
{
|
||||
if (!await IsSafePublicHostAsync(uri, ct))
|
||||
return false;
|
||||
}
|
||||
else if (IPAddress.TryParse(uri.Host, out var literal) && IsBlockedAddress(literal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(TimeSpan.FromSeconds(3));
|
||||
|
||||
var client = handler is null ? httpFactory.CreateClient("imageValidation") : new HttpClient(handler, disposeHandler: false);
|
||||
using var fallbackClient = handler is null ? null : new HttpClient(handler, disposeHandler: false);
|
||||
var client = fallbackClient ?? httpFactory.CreateClient("imageValidation");
|
||||
|
||||
try
|
||||
{
|
||||
@@ -234,24 +320,8 @@ internal static class EndpointHelpers
|
||||
{
|
||||
try
|
||||
{
|
||||
var host = uri.Host;
|
||||
if (Uri.CheckHostName(host) == UriHostNameType.Dns || Uri.CheckHostName(host) == UriHostNameType.IPv4 || Uri.CheckHostName(host) == UriHostNameType.IPv6)
|
||||
{
|
||||
var addresses = await System.Net.Dns.GetHostAddressesAsync(host, ct);
|
||||
foreach (var ip in addresses)
|
||||
{
|
||||
if (System.Net.IPAddress.IsLoopback(ip))
|
||||
return false;
|
||||
if (IsPrivate(ip))
|
||||
return false;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
var addresses = await ResolveSafePublicAddressesAsync(uri.Host, ct);
|
||||
return addresses.Count > 0;
|
||||
}
|
||||
catch
|
||||
{
|
||||
@@ -259,26 +329,90 @@ internal static class EndpointHelpers
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsPrivate(System.Net.IPAddress ip)
|
||||
private static async Task<IReadOnlyList<IPAddress>> ResolveSafePublicAddressesAsync(string host, CancellationToken ct)
|
||||
{
|
||||
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork)
|
||||
if (!IsSupportedHostType(host))
|
||||
return [];
|
||||
|
||||
IPAddress[] resolved;
|
||||
if (IPAddress.TryParse(host, out var literal))
|
||||
{
|
||||
var bytes = ip.GetAddressBytes();
|
||||
return bytes[0] switch
|
||||
{
|
||||
10 => true,
|
||||
172 when bytes[1] >= 16 && bytes[1] <= 31 => true,
|
||||
192 when bytes[1] == 168 => true,
|
||||
127 => true,
|
||||
_ => false
|
||||
};
|
||||
resolved = [literal];
|
||||
}
|
||||
else
|
||||
{
|
||||
resolved = await Dns.GetHostAddressesAsync(host, ct);
|
||||
}
|
||||
|
||||
if (ip.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6)
|
||||
var safe = new List<IPAddress>(resolved.Length);
|
||||
foreach (var ip in resolved)
|
||||
{
|
||||
return ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal || ip.IsIPv6Multicast || System.Net.IPAddress.IsLoopback(ip);
|
||||
if (!IsBlockedAddress(ip))
|
||||
safe.Add(ip);
|
||||
}
|
||||
|
||||
return safe.Distinct().ToArray();
|
||||
}
|
||||
|
||||
private static bool IsSupportedHostType(string host)
|
||||
{
|
||||
var type = Uri.CheckHostName(host);
|
||||
return type is UriHostNameType.Dns or UriHostNameType.IPv4 or UriHostNameType.IPv6;
|
||||
}
|
||||
|
||||
private static bool IsBlockedAddress(IPAddress ip)
|
||||
{
|
||||
if (IPAddress.IsLoopback(ip))
|
||||
return true;
|
||||
|
||||
if (ip.IsIPv4MappedToIPv6)
|
||||
return IsBlockedAddress(ip.MapToIPv4());
|
||||
|
||||
if (ip.AddressFamily == AddressFamily.InterNetwork)
|
||||
return IsBlockedIpv4(ip);
|
||||
|
||||
if (ip.AddressFamily == AddressFamily.InterNetworkV6)
|
||||
return IsBlockedIpv6(ip);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IsBlockedIpv4(IPAddress ip)
|
||||
{
|
||||
var b = ip.GetAddressBytes();
|
||||
return b[0] switch
|
||||
{
|
||||
0 => true, // "This network"
|
||||
10 => true, // private
|
||||
100 when b[1] >= 64 && b[1] <= 127 => true, // CGNAT
|
||||
127 => true, // loopback
|
||||
169 when b[1] == 254 => true, // link local
|
||||
172 when b[1] >= 16 && b[1] <= 31 => true, // private
|
||||
192 when b[1] == 0 && b[2] == 0 => true, // IETF protocol assignments
|
||||
192 when b[1] == 0 && b[2] == 2 => true, // documentation
|
||||
192 when b[1] == 88 && b[2] == 99 => true, // 6to4 relay anycast
|
||||
192 when b[1] == 168 => true, // private
|
||||
198 when b[1] is 18 or 19 => true, // benchmarking
|
||||
198 when b[1] == 51 && b[2] == 100 => true, // documentation
|
||||
203 when b[1] == 0 && b[2] == 113 => true, // documentation
|
||||
>= 224 => true, // multicast/reserved/broadcast
|
||||
_ => false
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsBlockedIpv6(IPAddress ip)
|
||||
{
|
||||
if (ip.Equals(IPAddress.IPv6None))
|
||||
return true;
|
||||
if (ip.IsIPv6Multicast || ip.IsIPv6LinkLocal || ip.IsIPv6SiteLocal)
|
||||
return true;
|
||||
|
||||
var bytes = ip.GetAddressBytes();
|
||||
if ((bytes[0] & 0xFE) == 0xFC) // fc00::/7 unique local
|
||||
return true;
|
||||
if (bytes[0] == 0x20 && bytes[1] == 0x01 && bytes[2] == 0x0D && bytes[3] == 0xB8) // 2001:db8::/32 docs
|
||||
return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
36
Endpoints/ServiceResult.cs
Normal file
36
Endpoints/ServiceResult.cs
Normal file
@@ -0,0 +1,36 @@
|
||||
using GameList.Domain;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
internal enum ServiceErrorCode
|
||||
{
|
||||
BadRequest,
|
||||
Unauthorized,
|
||||
NotFound,
|
||||
Conflict
|
||||
}
|
||||
|
||||
internal sealed record ServiceError(ServiceErrorCode Code, string Detail)
|
||||
{
|
||||
public static ServiceError BadRequest(string detail) => new(ServiceErrorCode.BadRequest, detail);
|
||||
|
||||
public static ServiceError Unauthorized(string detail = "Unauthorized") => new(ServiceErrorCode.Unauthorized, detail);
|
||||
|
||||
public static ServiceError NotFound(string detail) => new(ServiceErrorCode.NotFound, detail);
|
||||
|
||||
public static ServiceError Conflict(string detail) => new(ServiceErrorCode.Conflict, detail);
|
||||
|
||||
public static ServiceError PhaseMismatch(Phase required, Phase current) =>
|
||||
BadRequest($"This endpoint is available in the {required} phase. Your current phase is {current}.");
|
||||
}
|
||||
|
||||
internal readonly record struct Unit;
|
||||
|
||||
internal readonly record struct ServiceResult<T>(T? Value, ServiceError? Error)
|
||||
{
|
||||
public bool IsSuccess => Error is null;
|
||||
|
||||
public static ServiceResult<T> Success(T value) => new(value, null);
|
||||
|
||||
public static ServiceResult<T> Failure(ServiceError error) => new(default, error);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
internal static class SuggestionValidator
|
||||
{
|
||||
public static async Task<string?> ValidateAsync(SuggestionInput input, IHttpClientFactory httpFactory)
|
||||
private static readonly ConcurrentDictionary<string, (bool Reachable, DateTimeOffset ExpiresAt)> ImageReachabilityCache = new(StringComparer.OrdinalIgnoreCase);
|
||||
private static readonly TimeSpan ReachableCacheTtl = TimeSpan.FromMinutes(15);
|
||||
private static readonly TimeSpan UnreachableCacheTtl = TimeSpan.FromMinutes(2);
|
||||
|
||||
public static async Task<string?> ValidateAsync(SuggestionInput input, IHttpClientFactory httpFactory, bool shouldValidateImageReachability = true)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(input.Name) || input.Name.Length > 100)
|
||||
return "Name is required and must be <= 100 characters.";
|
||||
@@ -10,7 +16,7 @@ internal static class SuggestionValidator
|
||||
if (!EndpointHelpers.IsValidImageUrl(input.ScreenshotUrl))
|
||||
return "Screenshot URL must be http(s) and end with an image file extension.";
|
||||
|
||||
if (!await EndpointHelpers.IsReachableImageAsync(input.ScreenshotUrl, httpFactory))
|
||||
if (shouldValidateImageReachability && !await IsReachableImageCachedAsync(input.ScreenshotUrl, httpFactory))
|
||||
return "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB).";
|
||||
|
||||
if (!EndpointHelpers.IsValidHttpUrl(input.GameUrl))
|
||||
@@ -22,6 +28,21 @@ internal static class SuggestionValidator
|
||||
return ValidatePlayers(input.MinPlayers, input.MaxPlayers);
|
||||
}
|
||||
|
||||
private static async Task<bool> IsReachableImageCachedAsync(string? url, IHttpClientFactory httpFactory)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return true;
|
||||
|
||||
var normalized = url.Trim();
|
||||
if (ImageReachabilityCache.TryGetValue(normalized, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow)
|
||||
return cached.Reachable;
|
||||
|
||||
var reachable = await EndpointHelpers.IsReachableImageAsync(normalized, httpFactory);
|
||||
var ttl = reachable ? ReachableCacheTtl : UnreachableCacheTtl;
|
||||
ImageReachabilityCache[normalized] = (reachable, DateTimeOffset.UtcNow.Add(ttl));
|
||||
return reachable;
|
||||
}
|
||||
|
||||
private static string? ValidatePlayers(int? minPlayers, int? maxPlayers)
|
||||
{
|
||||
if (minPlayers is null && maxPlayers is null)
|
||||
|
||||
@@ -7,7 +7,7 @@ namespace GameList.Endpoints;
|
||||
|
||||
internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFactory httpFactory)
|
||||
{
|
||||
public async Task<IResult> GetMineAsync(Guid playerId)
|
||||
public async Task<ServiceResult<IReadOnlyList<SuggestionDto>>> GetMineAsync(Guid playerId)
|
||||
{
|
||||
var mine = await db.Suggestions
|
||||
.AsNoTracking()
|
||||
@@ -29,18 +29,19 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var ordered = mine
|
||||
IReadOnlyList<SuggestionDto> ordered = mine
|
||||
.OrderBy(s => s.CreatedAt)
|
||||
.Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId));
|
||||
.Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId))
|
||||
.ToList();
|
||||
|
||||
return Results.Ok(ordered);
|
||||
return ServiceResult<IReadOnlyList<SuggestionDto>>.Success(ordered);
|
||||
}
|
||||
|
||||
public async Task<IResult> CreateAsync(Guid playerId, SuggestionInput input)
|
||||
public async Task<ServiceResult<SuggestionCreatedResponse>> CreateAsync(Guid playerId, SuggestionInput input)
|
||||
{
|
||||
var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory);
|
||||
if (validationError is not null)
|
||||
return EndpointHelpers.BadRequestError(validationError);
|
||||
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest(validationError));
|
||||
|
||||
var playerState = await db.Players
|
||||
.AsNoTracking()
|
||||
@@ -55,14 +56,14 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
var usingJoker = phase == Phase.Vote && playerState.HasJoker;
|
||||
if (phase != Phase.Suggest && !usingJoker)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(playerState.DisplayName))
|
||||
return EndpointHelpers.BadRequestError("Set a display name before submitting suggestions.");
|
||||
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest("Set a display name before submitting suggestions."));
|
||||
|
||||
var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == playerId);
|
||||
var existingCount = await db.Suggestions.AsNoTracking().CountAsync(s => s.PlayerId == playerId);
|
||||
if (!usingJoker && existingCount >= 5)
|
||||
return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit.");
|
||||
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest("You have reached the 5 suggestion limit."));
|
||||
|
||||
var suggestion = new Suggestion
|
||||
{
|
||||
@@ -81,21 +82,29 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
|
||||
db.Suggestions.Add(suggestion);
|
||||
|
||||
if (usingJoker)
|
||||
try
|
||||
{
|
||||
await db.Players
|
||||
.Where(p => p.Id == playerId)
|
||||
.ExecuteUpdateAsync(p => p.SetProperty(x => x.HasJoker, false));
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
if (usingJoker)
|
||||
{
|
||||
await db.Players
|
||||
.Where(p => p.Id == playerId)
|
||||
.ExecuteUpdateAsync(p => p.SetProperty(x => x.HasJoker, false));
|
||||
await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false));
|
||||
}
|
||||
|
||||
await tx.CommitAsync();
|
||||
}
|
||||
catch (DbUpdateException ex) when (EndpointHelpers.IsSqliteConstraintViolation(ex, EndpointHelpers.SuggestionLimitTriggerError))
|
||||
{
|
||||
return ServiceResult<SuggestionCreatedResponse>.Failure(ServiceError.BadRequest("You have reached the 5 suggestion limit."));
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
|
||||
return Results.Created($"/api/suggestions/{suggestion.Id}", new SuggestionCreatedResponse(suggestion.Id));
|
||||
return ServiceResult<SuggestionCreatedResponse>.Success(new SuggestionCreatedResponse(suggestion.Id));
|
||||
}
|
||||
|
||||
public async Task<IResult> DeleteAsync(Guid playerId, int suggestionId)
|
||||
public async Task<ServiceResult<Unit>> DeleteAsync(Guid playerId, int suggestionId)
|
||||
{
|
||||
var actor = await db.Players
|
||||
.AsNoTracking()
|
||||
@@ -111,14 +120,14 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
{
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
if (phase != Phase.Suggest)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
return ServiceResult<Unit>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
|
||||
}
|
||||
|
||||
var suggestion = isAdmin
|
||||
? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId)
|
||||
: await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId && s.PlayerId == playerId);
|
||||
if (suggestion == null)
|
||||
return EndpointHelpers.NotFoundError("Suggestion not found.");
|
||||
return ServiceResult<Unit>.Failure(ServiceError.NotFound("Suggestion not found."));
|
||||
|
||||
await using var tx = await db.Database.BeginTransactionAsync();
|
||||
|
||||
@@ -131,15 +140,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
db.Suggestions.Remove(suggestion);
|
||||
await db.SaveChangesAsync();
|
||||
await tx.CommitAsync();
|
||||
return Results.NoContent();
|
||||
return ServiceResult<Unit>.Success(default);
|
||||
}
|
||||
|
||||
public async Task<IResult> UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input)
|
||||
public async Task<ServiceResult<SuggestionUpdatedResponse>> UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input)
|
||||
{
|
||||
var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory);
|
||||
if (validationError is not null)
|
||||
return EndpointHelpers.BadRequestError(validationError);
|
||||
|
||||
var actor = await db.Players
|
||||
.AsNoTracking()
|
||||
.Where(p => p.Id == playerId)
|
||||
@@ -151,17 +156,22 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
|
||||
var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId);
|
||||
if (suggestion == null)
|
||||
return EndpointHelpers.NotFoundError("Suggestion not found.");
|
||||
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.NotFound("Suggestion not found."));
|
||||
|
||||
var shouldValidateScreenshot = ShouldValidateScreenshotReachability(input.ScreenshotUrl, suggestion.ScreenshotUrl);
|
||||
var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory, shouldValidateScreenshot);
|
||||
if (validationError is not null)
|
||||
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.BadRequest(validationError));
|
||||
|
||||
var isAdmin = actor.IsAdmin;
|
||||
if (!isAdmin)
|
||||
{
|
||||
if (suggestion.PlayerId != playerId)
|
||||
return EndpointHelpers.UnauthorizedError();
|
||||
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.Unauthorized());
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
if (phase == Phase.Results)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
|
||||
|
||||
if (phase == Phase.Suggest)
|
||||
{
|
||||
@@ -169,7 +179,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
}
|
||||
else if (phase != Phase.Vote)
|
||||
{
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase);
|
||||
return ServiceResult<SuggestionUpdatedResponse>.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase));
|
||||
}
|
||||
|
||||
ApplyEditableFields(suggestion, input);
|
||||
@@ -182,7 +192,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
return Results.Ok(new SuggestionUpdatedResponse(
|
||||
return ServiceResult<SuggestionUpdatedResponse>.Success(new SuggestionUpdatedResponse(
|
||||
suggestion.Id,
|
||||
suggestion.Name,
|
||||
suggestion.Genre,
|
||||
@@ -195,11 +205,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
));
|
||||
}
|
||||
|
||||
public async Task<IResult> GetAllAsync(Guid playerId)
|
||||
public async Task<ServiceResult<IReadOnlyList<SuggestionAllDto>>> GetAllAsync(Guid playerId)
|
||||
{
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
if (phase < Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
return ServiceResult<IReadOnlyList<SuggestionAllDto>>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||
|
||||
var all = await db.Suggestions
|
||||
.AsNoTracking()
|
||||
@@ -225,12 +235,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
var nameLookup = all.ToDictionary(s => s.Id, s => s.Name);
|
||||
|
||||
var ordered = all.OrderBy(s => s.CreatedAt).Select(s =>
|
||||
IReadOnlyList<SuggestionAllDto> ordered = all.OrderBy(s => s.CreatedAt).Select(s =>
|
||||
{
|
||||
var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex).Where(id => id != s.Id).ToList();
|
||||
|
||||
return new
|
||||
{
|
||||
return new SuggestionAllDto(
|
||||
s.Id,
|
||||
s.Name,
|
||||
s.Genre,
|
||||
@@ -243,12 +252,12 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
s.Author,
|
||||
s.ParentSuggestionId,
|
||||
s.IsOwner,
|
||||
LinkedIds = linkedIds,
|
||||
LinkedTitles = linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList()
|
||||
};
|
||||
});
|
||||
linkedIds,
|
||||
linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList()
|
||||
);
|
||||
}).ToList();
|
||||
|
||||
return Results.Ok(ordered);
|
||||
return ServiceResult<IReadOnlyList<SuggestionAllDto>>.Success(ordered);
|
||||
}
|
||||
|
||||
private static void ApplyEditableFields(Suggestion suggestion, SuggestionInput input)
|
||||
@@ -261,4 +270,10 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact
|
||||
suggestion.MinPlayers = input.MinPlayers;
|
||||
suggestion.MaxPlayers = input.MaxPlayers;
|
||||
}
|
||||
|
||||
private static bool ShouldValidateScreenshotReachability(string? requestedScreenshotUrl, string? existingScreenshotUrl)
|
||||
{
|
||||
var normalizedRequested = EndpointHelpers.TrimTo(requestedScreenshotUrl, 2048);
|
||||
return !string.Equals(normalizedRequested, existingScreenshotUrl, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,34 +2,31 @@ using GameList.Contracts;
|
||||
using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.ChangeTracking;
|
||||
|
||||
namespace GameList.Endpoints;
|
||||
|
||||
internal sealed class VoteWorkflowService(AppDbContext db)
|
||||
{
|
||||
public async Task<IResult> GetMineAsync(Guid playerId)
|
||||
public async Task<ServiceResult<IReadOnlyList<VoteRecordDto>>> GetMineAsync(Guid playerId)
|
||||
{
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
return ServiceResult<IReadOnlyList<VoteRecordDto>>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||
|
||||
var votes = await db.Votes
|
||||
IReadOnlyList<VoteRecordDto> votes = await db.Votes
|
||||
.AsNoTracking()
|
||||
.Where(v => v.PlayerId == playerId)
|
||||
.Select(v => new
|
||||
{
|
||||
v.SuggestionId,
|
||||
v.Score
|
||||
})
|
||||
.Select(v => new VoteRecordDto(v.SuggestionId, v.Score))
|
||||
.ToListAsync();
|
||||
|
||||
return Results.Ok(votes);
|
||||
return ServiceResult<IReadOnlyList<VoteRecordDto>>.Success(votes);
|
||||
}
|
||||
|
||||
public async Task<IResult> UpsertAsync(Guid playerId, int suggestionId, int score)
|
||||
public async Task<ServiceResult<VoteUpsertResponse>> UpsertAsync(Guid playerId, int suggestionId, int score)
|
||||
{
|
||||
if (score is < 0 or > 10)
|
||||
return EndpointHelpers.BadRequestError("Score must be between 0 and 10.");
|
||||
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Score must be between 0 and 10."));
|
||||
|
||||
var playerState = await db.Players
|
||||
.AsNoTracking()
|
||||
@@ -42,14 +39,14 @@ internal sealed class VoteWorkflowService(AppDbContext db)
|
||||
.FirstAsync();
|
||||
|
||||
if (playerState.VotesFinal)
|
||||
return EndpointHelpers.BadRequestError("Votes are finalized. Unfinalize before changing scores.");
|
||||
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Votes are finalized. Unfinalize before changing scores."));
|
||||
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(playerState.DisplayName))
|
||||
return EndpointHelpers.BadRequestError("Set a display name before voting.");
|
||||
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Set a display name before voting."));
|
||||
|
||||
var linkMap = await db.Suggestions
|
||||
.AsNoTracking()
|
||||
@@ -61,7 +58,7 @@ internal sealed class VoteWorkflowService(AppDbContext db)
|
||||
.ToListAsync();
|
||||
var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId)));
|
||||
if (!rootIndex.ContainsKey(suggestionId))
|
||||
return EndpointHelpers.BadRequestError("Suggestion not found.");
|
||||
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.BadRequest("Suggestion not found."));
|
||||
|
||||
var linkedIds = EndpointHelpers.LinkedIdsFor(suggestionId, rootIndex);
|
||||
if (linkedIds.Count == 0)
|
||||
@@ -71,38 +68,67 @@ internal sealed class VoteWorkflowService(AppDbContext db)
|
||||
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
|
||||
.ToListAsync();
|
||||
|
||||
foreach (var linkedSuggestionId in linkedIds)
|
||||
for (var attempt = 0; attempt < 2; attempt++)
|
||||
{
|
||||
var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId);
|
||||
if (vote == null)
|
||||
foreach (var linkedSuggestionId in linkedIds)
|
||||
{
|
||||
db.Votes.Add(new Vote
|
||||
var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId);
|
||||
if (vote == null)
|
||||
{
|
||||
PlayerId = playerId,
|
||||
SuggestionId = linkedSuggestionId,
|
||||
Score = score
|
||||
});
|
||||
db.Votes.Add(new Vote
|
||||
{
|
||||
PlayerId = playerId,
|
||||
SuggestionId = linkedSuggestionId,
|
||||
Score = score
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
vote.Score = score;
|
||||
}
|
||||
}
|
||||
else
|
||||
|
||||
try
|
||||
{
|
||||
vote.Score = score;
|
||||
await db.SaveChangesAsync();
|
||||
return ServiceResult<VoteUpsertResponse>.Success(new VoteUpsertResponse(linkedIds, score));
|
||||
}
|
||||
catch (DbUpdateException ex) when (attempt == 0 && EndpointHelpers.IsSqliteConstraintViolation(ex))
|
||||
{
|
||||
DetachAddedVotes(db.ChangeTracker.Entries<Vote>());
|
||||
|
||||
await db.Votes
|
||||
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
|
||||
.ExecuteUpdateAsync(v => v.SetProperty(x => x.Score, score));
|
||||
|
||||
existingVotes = await db.Votes
|
||||
.Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId))
|
||||
.ToListAsync();
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new VoteUpsertResponse(linkedIds, score));
|
||||
return ServiceResult<VoteUpsertResponse>.Failure(ServiceError.Conflict("Vote update conflict. Please retry."));
|
||||
}
|
||||
|
||||
public async Task<IResult> SetFinalizeAsync(Guid playerId, bool final)
|
||||
public async Task<ServiceResult<VoteFinalizeResponse>> SetFinalizeAsync(Guid playerId, bool final)
|
||||
{
|
||||
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
|
||||
if (phase != Phase.Vote)
|
||||
return EndpointHelpers.PhaseMismatch(Phase.Vote, phase);
|
||||
return ServiceResult<VoteFinalizeResponse>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase));
|
||||
|
||||
var player = await db.Players.FirstAsync(p => p.Id == playerId);
|
||||
|
||||
player.VotesFinal = final;
|
||||
await db.SaveChangesAsync();
|
||||
return Results.Ok(new VoteFinalizeResponse(player.VotesFinal));
|
||||
return ServiceResult<VoteFinalizeResponse>.Success(new VoteFinalizeResponse(player.VotesFinal));
|
||||
}
|
||||
|
||||
private static void DetachAddedVotes(IEnumerable<EntityEntry<Vote>> voteEntries)
|
||||
{
|
||||
foreach (var entry in voteEntries)
|
||||
{
|
||||
if (entry.State == EntityState.Added)
|
||||
entry.State = EntityState.Detached;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,79 @@ public class AdminTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_can_grant_and_revoke_admin_for_non_owner_accounts()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var owner = factory.CreateClientWithCookies();
|
||||
await owner.RegisterAsync("owner", admin: true);
|
||||
var player = factory.CreateClientWithCookies();
|
||||
await player.RegisterAsync("player");
|
||||
var playerId = await player.GetProfileIdAsync();
|
||||
|
||||
var grant = await owner.PostAsJsonAsync("/api/admin/player-admin", new
|
||||
{
|
||||
playerId,
|
||||
isAdmin = true
|
||||
});
|
||||
grant.EnsureSuccessStatusCode();
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var promoted = await db.Players.AsNoTracking().SingleAsync(p => p.Id == playerId);
|
||||
Assert.True(promoted.IsAdmin);
|
||||
Assert.False(promoted.IsOwner);
|
||||
});
|
||||
|
||||
var revoke = await owner.PostAsJsonAsync("/api/admin/player-admin", new
|
||||
{
|
||||
playerId,
|
||||
isAdmin = false
|
||||
});
|
||||
revoke.EnsureSuccessStatusCode();
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var demoted = await db.Players.AsNoTracking().SingleAsync(p => p.Id == playerId);
|
||||
Assert.False(demoted.IsAdmin);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_admin_role_cannot_be_changed_or_deleted()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var owner = factory.CreateClientWithCookies();
|
||||
await owner.RegisterAsync("owner", admin: true);
|
||||
var ownerId = await owner.GetProfileIdAsync();
|
||||
|
||||
var toggleOwner = await owner.PostAsJsonAsync("/api/admin/player-admin", new
|
||||
{
|
||||
playerId = ownerId,
|
||||
isAdmin = false
|
||||
});
|
||||
Assert.Equal(HttpStatusCode.BadRequest, toggleOwner.StatusCode);
|
||||
|
||||
var deleteOwner = await owner.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{ownerId}") { Content = JsonContent.Create(new { password = AdminPassword }) });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, deleteOwner.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Set_player_admin_returns_not_found_for_unknown_player()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var owner = factory.CreateClientWithCookies();
|
||||
await owner.RegisterAsync("owner", admin: true);
|
||||
|
||||
var response = await owner.PostAsJsonAsync("/api/admin/player-admin", new
|
||||
{
|
||||
playerId = Guid.NewGuid(),
|
||||
isAdmin = true
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_player_phase_requires_vote_phase_and_suggest_target()
|
||||
{
|
||||
@@ -136,10 +209,7 @@ public class AdminTests
|
||||
Score = 8
|
||||
});
|
||||
|
||||
var resp = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{await player.GetProfileIdAsync()}")
|
||||
{
|
||||
Content = JsonContent.Create(new { password = AdminPassword })
|
||||
});
|
||||
var resp = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{await player.GetProfileIdAsync()}") { Content = JsonContent.Create(new { password = AdminPassword }) });
|
||||
resp.EnsureSuccessStatusCode();
|
||||
|
||||
await factory.WithDbContextAsync(db =>
|
||||
@@ -554,10 +624,7 @@ public class AdminTests
|
||||
|
||||
var playerId = await factory.WithDbContextAsync(async db => await db.Players.Where(p => p.Username == "target").Select(p => p.Id).SingleAsync());
|
||||
|
||||
var deleteWrongPassword = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{playerId}")
|
||||
{
|
||||
Content = JsonContent.Create(new { password = "wrong" })
|
||||
});
|
||||
var deleteWrongPassword = await admin.SendAsync(new HttpRequestMessage(HttpMethod.Delete, $"/api/admin/players/{playerId}") { Content = JsonContent.Create(new { password = "wrong" }) });
|
||||
Assert.Equal(HttpStatusCode.BadRequest, deleteWrongPassword.StatusCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GameList.Data;
|
||||
using GameList.Domain;
|
||||
using GameList.Infrastructure;
|
||||
using GameList.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -62,6 +64,24 @@ public class AuthTests
|
||||
Assert.Equal(HttpStatusCode.BadRequest, displayResp.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Register_rejects_weak_passwords()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
|
||||
var weak = await client.PostAsJsonAsync("/api/auth/register", new
|
||||
{
|
||||
Username = "weakpw",
|
||||
Password = "alllowercase1!",
|
||||
DisplayName = "weak"
|
||||
});
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, weak.StatusCode);
|
||||
var json = await weak.Content.ReadFromJsonAsync<JsonElement>();
|
||||
Assert.Equal("Password must include at least one uppercase and one lowercase characters and and digit.", json.GetProperty("error").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Login_sets_last_login_and_fills_missing_display_name()
|
||||
{
|
||||
@@ -99,6 +119,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 +212,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 +272,32 @@ public class AuthTests
|
||||
resp.EnsureSuccessStatusCode();
|
||||
Assert.True(resp.Headers.TryGetValues("Set-Cookie", out var cookies) && cookies.Any(c => c.Contains("player")));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Owner_uniqueness_is_enforced_by_database_constraint()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var ownerClient = factory.CreateClientWithCookies();
|
||||
await ownerClient.RegisterAsync("owner1", admin: true);
|
||||
|
||||
var thrown = await Assert.ThrowsAsync<DbUpdateException>(() => factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
var (hash, salt) = PasswordHasher.HashPassword("Pass123!");
|
||||
db.Players.Add(new Player
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Username = "owner2",
|
||||
NormalizedUsername = "owner2",
|
||||
PasswordHash = hash,
|
||||
PasswordSalt = salt,
|
||||
DisplayName = "Owner2",
|
||||
IsOwner = true,
|
||||
IsAdmin = true
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}));
|
||||
|
||||
Assert.Contains("Players.IsOwner", thrown.InnerException?.Message ?? thrown.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.TestHost;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using System.Text.Json;
|
||||
using System.Net.Http.Json;
|
||||
@@ -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)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using GameList.Domain;
|
||||
using GameList.Tests.Support;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
@@ -347,6 +348,45 @@ public class SuggestionTests
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Update_does_not_revalidate_unchanged_screenshot_url()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("reval");
|
||||
|
||||
var create = await client.PostAsJsonAsync("/api/suggestions", new
|
||||
{
|
||||
Name = "Reachable once",
|
||||
Genre = (string?)null,
|
||||
Description = (string?)null,
|
||||
ScreenshotUrl = "http://example.com/shot.png",
|
||||
YoutubeUrl = (string?)null,
|
||||
GameUrl = (string?)null,
|
||||
MinPlayers = (int?)null,
|
||||
MaxPlayers = (int?)null
|
||||
});
|
||||
create.EnsureSuccessStatusCode();
|
||||
var createdPayload = await create.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var suggestionId = createdPayload.GetProperty("id").GetInt32();
|
||||
|
||||
factory.HttpHandler.SetResponder(_ => new HttpResponseMessage(HttpStatusCode.BadRequest));
|
||||
|
||||
var update = await client.PutAsJsonAsync($"/api/suggestions/{suggestionId}", new
|
||||
{
|
||||
Name = "Reachable once",
|
||||
Genre = "Updated",
|
||||
Description = (string?)null,
|
||||
ScreenshotUrl = "http://example.com/shot.png",
|
||||
YoutubeUrl = (string?)null,
|
||||
GameUrl = (string?)null,
|
||||
MinPlayers = (int?)null,
|
||||
MaxPlayers = (int?)null
|
||||
});
|
||||
|
||||
update.EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Get_all_requires_vote_phase()
|
||||
{
|
||||
@@ -626,4 +666,41 @@ public class SuggestionTests
|
||||
Assert.False(db.Votes.Any(v => v.SuggestionId == id));
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Suggestion_limit_is_enforced_by_database_trigger_without_joker()
|
||||
{
|
||||
await using var factory = new TestWebApplicationFactory();
|
||||
var client = factory.CreateClientWithCookies();
|
||||
await client.RegisterAsync("dbcap");
|
||||
|
||||
var playerId = await factory.WithDbContextAsync(async db => await db.Players.Select(p => p.Id).SingleAsync());
|
||||
|
||||
await factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
for (var i = 0; i < 5; i++)
|
||||
{
|
||||
db.Suggestions.Add(new Suggestion
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Name = $"Seed {i}"
|
||||
});
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
});
|
||||
|
||||
var thrown = await Assert.ThrowsAsync<DbUpdateException>(() => factory.WithDbContextAsync(async db =>
|
||||
{
|
||||
db.Suggestions.Add(new Suggestion
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Name = "Blocked by trigger"
|
||||
});
|
||||
|
||||
await db.SaveChangesAsync();
|
||||
}));
|
||||
|
||||
Assert.Contains("suggestion_limit_exceeded", thrown.InnerException?.Message ?? thrown.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ internal class TestWebApplicationFactory : WebApplicationFactory<Program>
|
||||
services.Remove(descriptor);
|
||||
}
|
||||
|
||||
_connection = new SqliteConnection("Data Source=:memory:;Cache=Shared");
|
||||
_connection = new SqliteConnection($"Data Source=file:tests-{Guid.NewGuid():N}?mode=memory&cache=shared");
|
||||
_connection.Open();
|
||||
|
||||
services.AddDbContext<AppDbContext>(options => { options.UseSqlite(_connection); });
|
||||
@@ -44,7 +44,6 @@ internal class TestWebApplicationFactory : WebApplicationFactory<Program>
|
||||
|
||||
using var scope = host.Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||
db.Database.EnsureCreated();
|
||||
db.Database.Migrate();
|
||||
|
||||
return host;
|
||||
|
||||
13
IIS.md
13
IIS.md
@@ -8,6 +8,7 @@
|
||||
|
||||
## Publish
|
||||
- From repo root: `dotnet publish -c Release -o publish`
|
||||
- Before first start (and after every new migration): run `dotnet ef database update` from repo root against the target environment.
|
||||
- Copy `publish/` contents to site directory (keep `App_Data` writable by the app pool user).
|
||||
- Set environment variables in web.config or IIS config:
|
||||
- `ASPNETCORE_ENVIRONMENT=Production`
|
||||
@@ -16,10 +17,22 @@
|
||||
- Configure trusted reverse proxies/networks for forwarded headers (do not trust all sources):
|
||||
- `ForwardedHeaders__KnownProxies__0=10.0.0.10`
|
||||
- `ForwardedHeaders__KnownNetworks__0=10.0.0.0/24`
|
||||
- Configure allowed hostnames explicitly (do not use wildcard in production):
|
||||
- `AllowedHosts=picknplay.example.com;www.picknplay.example.com`
|
||||
- Optional: enable stdout logging in `web.config` during troubleshooting only; disable afterward.
|
||||
- Data protection keys are persisted to `App_Data/keys`; ensure this folder is deployed and writable so auth cookies stay valid across app pool recycles.
|
||||
- Frontend base path: set `<meta name="app-base" content="/picknplay">` in `wwwroot/index.html` for production so API calls include the subpath (keep blank for local/root).
|
||||
- Deployment script: copy `scripts/deploy-ftp.profile.sample.psd1` to `scripts/deploy-ftp.profile.psd1`, fill environment values, then run `pwsh ./scripts/deploy-ftp.ps1 -ProfilePath ./scripts/deploy-ftp.profile.psd1`.
|
||||
- Shortcut command: run `pwsh ./deploy.ps1` from repo root to deploy with the local profile directly.
|
||||
- Prefer `WinScpSessionName` in the deploy profile to avoid embedding FTP credentials in scripted URLs.
|
||||
|
||||
## Permissions
|
||||
- Grant modify rights to the app pool identity on `App_Data` (DB file + wal).
|
||||
- Ensure firewall/HTTPS bindings match `applicationUrl` configured in IIS.
|
||||
|
||||
## Security Checklist
|
||||
- Verify HTTPS binding/certificate is active before exposing the site publicly.
|
||||
- Confirm `Strict-Transport-Security` is present in production responses.
|
||||
- Confirm baseline headers are present (`Content-Security-Policy`, `X-Content-Type-Options`, `X-Frame-Options`, `Referrer-Policy`).
|
||||
- Confirm `AllowedHosts` contains only your actual IIS hostnames.
|
||||
- Confirm trusted proxy lists are explicit and minimal.
|
||||
|
||||
60
Infrastructure/AuthAttemptMonitor.cs
Normal file
60
Infrastructure/AuthAttemptMonitor.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace GameList.Infrastructure;
|
||||
|
||||
public sealed class AuthAttemptMonitor(ILogger<AuthAttemptMonitor> logger)
|
||||
{
|
||||
private static readonly TimeSpan FailureWindow = TimeSpan.FromMinutes(10);
|
||||
private const int AlertThreshold = 5;
|
||||
private static readonly Action<ILogger, string, string, string, string, int, Exception?> LogAuthFailure = LoggerMessage.Define<string, string, string, string, int>(LogLevel.Warning, new EventId(2001, nameof(LogAuthFailure)), "Auth failure scope={Scope} actor={Actor} ip={Ip} reason={Reason} failuresInWindow={Count}");
|
||||
private static readonly Action<ILogger, string, string, string, int, double, Exception?> LogSecurityAlert = LoggerMessage.Define<string, string, string, int, double>(LogLevel.Error, new EventId(2002, nameof(LogSecurityAlert)), "Security alert: repeated auth failures scope={Scope} actor={Actor} ip={Ip} failuresInWindow={Count} windowMinutes={WindowMinutes}");
|
||||
private static readonly Action<ILogger, string, string, string, Exception?> LogRateLimited = LoggerMessage.Define<string, string, string>(LogLevel.Warning, new EventId(2003, nameof(LogRateLimited)), "Rate limit rejection path={Path} ip={Ip} userId={UserId}");
|
||||
private static readonly Action<ILogger, string, string, DateTimeOffset, Exception?> LogSessionExpired = LoggerMessage.Define<string, string, DateTimeOffset>(LogLevel.Warning, new EventId(2004, nameof(LogSessionExpired)), "Session expired by absolute lifetime path={Path} ip={Ip} startedAt={StartedAt:o}");
|
||||
|
||||
private readonly ConcurrentDictionary<string, AttemptState> _failures = new(StringComparer.Ordinal);
|
||||
|
||||
public void RecordFailure(HttpContext context, string scope, string actor, string reason)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var key = BuildKey(context, scope, actor);
|
||||
|
||||
var state = _failures.AddOrUpdate(key, _ => new AttemptState(1, now, now), (_, previous) => previous.LastFailureAt + FailureWindow < now
|
||||
? new AttemptState(1, now, now)
|
||||
: previous with
|
||||
{
|
||||
Count = previous.Count + 1,
|
||||
LastFailureAt = now
|
||||
});
|
||||
|
||||
LogAuthFailure(logger, scope, actor, GetRemoteIp(context), reason, state.Count, null);
|
||||
|
||||
if (state.Count >= AlertThreshold && state.Count % AlertThreshold == 0)
|
||||
{
|
||||
LogSecurityAlert(logger, scope, actor, GetRemoteIp(context), state.Count, FailureWindow.TotalMinutes, null);
|
||||
}
|
||||
}
|
||||
|
||||
public void RecordSuccess(HttpContext context, string scope, string actor)
|
||||
{
|
||||
_failures.TryRemove(BuildKey(context, scope, actor), out _);
|
||||
}
|
||||
|
||||
public void RecordRateLimited(HttpContext context)
|
||||
{
|
||||
LogRateLimited(logger, context.Request.Path.Value ?? string.Empty, GetRemoteIp(context), context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)?.Value ?? "anonymous", null);
|
||||
}
|
||||
|
||||
public void RecordSessionExpired(HttpContext context, DateTimeOffset startedAt)
|
||||
{
|
||||
LogSessionExpired(logger, context.Request.Path.Value ?? string.Empty, GetRemoteIp(context), startedAt, null);
|
||||
}
|
||||
|
||||
private static string BuildKey(HttpContext context, string scope, string actor)
|
||||
{
|
||||
return $"{scope}|{actor}|{GetRemoteIp(context)}";
|
||||
}
|
||||
|
||||
private static string GetRemoteIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString() ?? "unknown-ip";
|
||||
|
||||
private readonly record struct AttemptState(int Count, DateTimeOffset FirstFailureAt, DateTimeOffset LastFailureAt);
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
171
Program.cs
171
Program.cs
@@ -1,13 +1,18 @@
|
||||
using GameList.Data;
|
||||
using GameList.Endpoints;
|
||||
using GameList.Infrastructure;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.RateLimiting;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.HttpOverrides;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Net;
|
||||
using System.Security.Claims;
|
||||
using System.Globalization;
|
||||
using System.Threading.RateLimiting;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
@@ -40,11 +45,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;
|
||||
|
||||
20
README.md
20
README.md
@@ -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,25 @@ 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`.
|
||||
|
||||
## Operations
|
||||
|
||||
@@ -53,4 +60,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
167
REVIEW.md
@@ -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).
|
||||
|
||||
25
TESTS.md
25
TESTS.md
@@ -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 caller’s suggestions ordered by CreatedAt.
|
||||
- POST /: success with valid data; enforces ≤5 per player; trims optional fields; requires display name; rejects bad image URL/ext, unreachable image (mocked), invalid game/youtube URLs, invalid player counts, missing name/too long.
|
||||
- DB trigger also enforces suggestion cap for non-joker inserts, protecting against concurrent over-limit writes.
|
||||
- Joker path: when phase=Vote and HasJoker=true allows creation, consumes joker, resets VotesFinal for all players.
|
||||
- Phase gating: non-admin cannot create/update/delete outside Suggest (except joker create); admin bypasses phase checks for update/delete.
|
||||
- PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; validation mirrors create.
|
||||
- PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; screenshot reachability check is skipped when screenshot URL is unchanged.
|
||||
- DELETE /{id}: player deletes own in Suggest; admin any time; also breaks child links and deletes related votes.
|
||||
- GET /all: accessible from Vote+, orders by CreatedAt, includes link metadata, enforces phase mismatch before Vote.
|
||||
|
||||
@@ -58,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 0–10; rejects when VotesFinal=true; enforces display name requirement and phase gating.
|
||||
- Linked votes: when suggestions are linked, a single post updates all linked IDs; invalid suggestionId returns 400; linking root detection works for nested links.
|
||||
- Concurrent vote upserts are handled with retry logic around unique-key conflicts to avoid server errors.
|
||||
- Finalize: POST /finalize toggles VotesFinal flag; allowed only in Vote.
|
||||
|
||||
### 5) Results
|
||||
@@ -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.
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"AllowedHosts": "localhost;127.0.0.1;[::1]",
|
||||
"BasePath": "",
|
||||
"ConnectionStrings": {
|
||||
"Default": "Data Source=App_Data/gamelist.db"
|
||||
|
||||
17
deploy.ps1
Normal file
17
deploy.ps1
Normal file
@@ -0,0 +1,17 @@
|
||||
param(
|
||||
[string]$Password,
|
||||
[switch]$SkipRecycle,
|
||||
[switch]$SkipMigrations
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$scriptPath = Join-Path $PSScriptRoot "scripts/deploy-ftp1.ps1"
|
||||
$profilePath = Join-Path $PSScriptRoot "scripts/deploy-ftp.profile.psd1"
|
||||
|
||||
& $scriptPath `
|
||||
-ProfilePath $profilePath `
|
||||
-Password $Password `
|
||||
-SkipRecycle:$SkipRecycle `
|
||||
-SkipMigrations:$SkipMigrations
|
||||
@@ -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",
|
||||
|
||||
43
scripts/check-coverage.ps1
Normal file
43
scripts/check-coverage.ps1
Normal file
@@ -0,0 +1,43 @@
|
||||
param(
|
||||
[double]$MinLineRate = 0.90,
|
||||
[double]$MinBranchRate = 0.70,
|
||||
[string]$ResultsRoot = "GameList.Tests/TestResults"
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
if (-not (Test-Path $ResultsRoot)) {
|
||||
throw "Coverage results folder not found: $ResultsRoot"
|
||||
}
|
||||
|
||||
$coverageFile = Get-ChildItem -Path $ResultsRoot -Recurse -Filter "coverage.cobertura.xml" |
|
||||
Sort-Object LastWriteTimeUtc -Descending |
|
||||
Select-Object -First 1
|
||||
|
||||
if ($null -eq $coverageFile) {
|
||||
throw "No coverage.cobertura.xml found under $ResultsRoot"
|
||||
}
|
||||
|
||||
[xml]$xml = Get-Content -Path $coverageFile.FullName
|
||||
$coverage = $xml.coverage
|
||||
|
||||
if ($null -eq $coverage) {
|
||||
throw "Coverage XML is missing root coverage node: $($coverageFile.FullName)"
|
||||
}
|
||||
|
||||
[double]$lineRate = [double]$coverage.'line-rate'
|
||||
[double]$branchRate = [double]$coverage.'branch-rate'
|
||||
|
||||
$linePercent = [Math]::Round($lineRate * 100, 2)
|
||||
$branchPercent = [Math]::Round($branchRate * 100, 2)
|
||||
$minLinePercent = [Math]::Round($MinLineRate * 100, 2)
|
||||
$minBranchPercent = [Math]::Round($MinBranchRate * 100, 2)
|
||||
|
||||
Write-Host "Coverage source: $($coverageFile.FullName)"
|
||||
Write-Host ("Line coverage: {0}% (required >= {1}%)" -f $linePercent, $minLinePercent)
|
||||
Write-Host ("Branch coverage: {0}% (required >= {1}%)" -f $branchPercent, $minBranchPercent)
|
||||
|
||||
if ($lineRate -lt $MinLineRate -or $branchRate -lt $MinBranchRate) {
|
||||
throw "Coverage thresholds failed."
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
31
scripts/deploy-ftp.profile.sample.psd1
Normal file
31
scripts/deploy-ftp.profile.sample.psd1
Normal file
@@ -0,0 +1,31 @@
|
||||
@{
|
||||
# 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"
|
||||
|
||||
# Preferred: use a named WinSCP stored session (no credential string in script)
|
||||
WinScpSessionName = "picknplay-prod"
|
||||
|
||||
# Optional FTP URL fallback if no stored session is configured
|
||||
# FtpHost = "example.com"
|
||||
# FtpUser = "deploy-user"
|
||||
|
||||
# Optional IIS recycle and WinRM controls
|
||||
RecycleAppPool = $true
|
||||
AppPoolName = "picknplay-app-pool"
|
||||
WinRmComputer = "example.com"
|
||||
WinRmCredentialUser = "Administrator"
|
||||
UseWinRmHttps = $true
|
||||
WinRmAuth = "Basic"
|
||||
|
||||
# Optional remote migration
|
||||
RunEfMigrations = $false
|
||||
RemoteSitePath = "C:\Inetpub\vhosts\example.com\httpdocs\picknplay"
|
||||
}
|
||||
@@ -1,157 +1,251 @@
|
||||
# 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 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) {
|
||||
if ($recycleAppPool) {
|
||||
Require-ConfigValue $config "AppPoolName"
|
||||
$appPoolName = [string]$config.AppPoolName
|
||||
Write-Host "2) Stopping IIS app pool via WinRM..." -ForegroundColor Cyan
|
||||
$sec = ConvertTo-SecureString $Password -AsPlainText -Force
|
||||
$cred = New-Object pscredential($WinRmCredentialUser, $sec)
|
||||
$invokeParams = @{
|
||||
ComputerName = $WinRmComputer
|
||||
Credential = $cred
|
||||
ScriptBlock = {
|
||||
Import-Module WebAdministration
|
||||
Stop-WebAppPool -Name $using:AppPoolName -ErrorAction SilentlyContinue
|
||||
Get-Process GameList -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
Get-Process dotnet -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*picknplay*" } | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true }
|
||||
if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth }
|
||||
try {
|
||||
Invoke-Command @invokeParams
|
||||
} catch {
|
||||
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
|
||||
param($poolName)
|
||||
Import-Module WebAdministration
|
||||
Stop-WebAppPool -Name $poolName -ErrorAction SilentlyContinue
|
||||
Get-Process GameList -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
Get-Process dotnet -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*picknplay*" } | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
} -ArgumentList @($appPoolName)
|
||||
}
|
||||
catch {
|
||||
Write-Warning "WinRM stop failed: $($_.Exception.Message)."
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host "3) Syncing via WinSCP (FTP mirror with delete)..." -ForegroundColor Cyan
|
||||
$tempScript = New-TemporaryFile
|
||||
@"
|
||||
option batch continue
|
||||
option confirm off
|
||||
open ftp://$($FtpUser):$($Password.Replace('`n','').Replace('`r',''))@$FtpHost
|
||||
lcd $PublishDir
|
||||
cd $RemoteDir
|
||||
synchronize remote . -delete -filemask="|web.config;App_Data/;logs/;GameList.Tests/"
|
||||
exit
|
||||
"@ | Set-Content -Path $tempScript -Encoding UTF8
|
||||
Write-Host "3) Syncing via WinSCP..." -ForegroundColor Cyan
|
||||
$openCommand = if ($useStoredSession) {
|
||||
"open `"$winScpSessionName`""
|
||||
}
|
||||
else {
|
||||
$ftpUser = [Uri]::EscapeDataString([string]$config.FtpUser)
|
||||
$ftpPassword = [Uri]::EscapeDataString($passwordForSession.Replace("`n", "").Replace("`r", ""))
|
||||
$ftpHost = [string]$config.FtpHost
|
||||
"open ftp://$ftpUser`:$ftpPassword@$ftpHost"
|
||||
}
|
||||
|
||||
& $WinScpPath "/ini=nul" "/script=$tempScript"
|
||||
$tempScript = New-TemporaryFile
|
||||
@(
|
||||
"option batch continue"
|
||||
"option confirm off"
|
||||
$openCommand
|
||||
"lcd `"$publishDir`""
|
||||
"cd $([string]$config.RemoteDir)"
|
||||
"synchronize remote . -delete -filemask=`"|web.config;App_Data/;logs/;GameList.Tests/`""
|
||||
"exit"
|
||||
) | Set-Content -Path $tempScript -Encoding UTF8
|
||||
|
||||
& $winScpPath "/ini=nul" "/script=$tempScript"
|
||||
Remove-Item $tempScript -ErrorAction SilentlyContinue
|
||||
|
||||
if ($RecycleAppPool) {
|
||||
if ($recycleAppPool) {
|
||||
Write-Host "4) Starting IIS app pool via WinRM..." -ForegroundColor Cyan
|
||||
$sec = ConvertTo-SecureString $Password -AsPlainText -Force
|
||||
$cred = New-Object pscredential($WinRmCredentialUser, $sec)
|
||||
$invokeParams = @{
|
||||
ComputerName = $WinRmComputer
|
||||
Credential = $cred
|
||||
ScriptBlock = {
|
||||
Import-Module WebAdministration
|
||||
Start-WebAppPool -Name $using:AppPoolName
|
||||
}
|
||||
}
|
||||
if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true }
|
||||
if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth }
|
||||
try {
|
||||
Invoke-Command @invokeParams
|
||||
} catch {
|
||||
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
|
||||
param($poolName)
|
||||
Import-Module WebAdministration
|
||||
Start-WebAppPool -Name $poolName
|
||||
} -ArgumentList @($appPoolName)
|
||||
}
|
||||
catch {
|
||||
Write-Warning "WinRM start failed: $($_.Exception.Message)."
|
||||
}
|
||||
}
|
||||
|
||||
if ($RunEfMigrations) {
|
||||
if ($runEfMigrations) {
|
||||
Require-ConfigValue $config "RemoteSitePath"
|
||||
Write-Host "5) Running EF Core migrations on remote site..." -ForegroundColor Cyan
|
||||
$sec = ConvertTo-SecureString $Password -AsPlainText -Force
|
||||
$cred = New-Object pscredential($WinRmCredentialUser, $sec)
|
||||
$invokeParams = @{
|
||||
ComputerName = $WinRmComputer
|
||||
Credential = $cred
|
||||
ScriptBlock = {
|
||||
try {
|
||||
Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock {
|
||||
param($sitePath)
|
||||
Set-Location $sitePath
|
||||
if (-not (Get-Command dotnet ef -ErrorAction SilentlyContinue)) {
|
||||
throw "dotnet ef not available on remote host. Install SDK or set `$RunEfMigrations = $false."
|
||||
if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) {
|
||||
throw "dotnet is not available on remote host."
|
||||
}
|
||||
|
||||
dotnet ef database update --no-build
|
||||
}
|
||||
ArgumentList = @($RemoteSitePath)
|
||||
} -ArgumentList @([string]$config.RemoteSitePath)
|
||||
}
|
||||
if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true }
|
||||
if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth }
|
||||
try {
|
||||
Invoke-Command @invokeParams
|
||||
} catch {
|
||||
catch {
|
||||
Write-Warning "WinRM migrations failed: $($_.Exception.Message)."
|
||||
}
|
||||
}
|
||||
|
||||
14
scripts/deploy-ftp1.ps1
Normal file
14
scripts/deploy-ftp1.ps1
Normal file
@@ -0,0 +1,14 @@
|
||||
param(
|
||||
[string]$ProfilePath = (Join-Path $PSScriptRoot "deploy-ftp.profile.psd1"),
|
||||
[string]$Password,
|
||||
[switch]$SkipRecycle,
|
||||
[switch]$SkipMigrations
|
||||
)
|
||||
|
||||
$scriptPath = Join-Path $PSScriptRoot "deploy-ftp.ps1"
|
||||
|
||||
& $scriptPath `
|
||||
-ProfilePath $ProfilePath `
|
||||
-Password $Password `
|
||||
-SkipRecycle:$SkipRecycle `
|
||||
-SkipMigrations:$SkipMigrations
|
||||
418
wwwroot/app.js
418
wwwroot/app.js
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
const escapeHtml = (text) =>
|
||||
text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
|
||||
const formatInline = (text) =>
|
||||
escapeHtml(text)
|
||||
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
const formatInline = (text) =>
|
||||
escapeHtml(text)
|
||||
.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>")
|
||||
.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
|
||||
const closeParagraph = () => {
|
||||
if (inParagraph) {
|
||||
html.push("</p>");
|
||||
inParagraph = false;
|
||||
}
|
||||
};
|
||||
const closeParagraph = () => {
|
||||
if (inParagraph) {
|
||||
html.push("</p>");
|
||||
inParagraph = false;
|
||||
}
|
||||
};
|
||||
|
||||
const closeList = () => {
|
||||
if (inList) {
|
||||
html.push("</ul>");
|
||||
inList = false;
|
||||
}
|
||||
};
|
||||
const closeList = () => {
|
||||
if (inList) {
|
||||
html.push("</ul>");
|
||||
inList = false;
|
||||
}
|
||||
};
|
||||
|
||||
lines.forEach((rawLine) => {
|
||||
const line = rawLine.trimEnd();
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
closeParagraph();
|
||||
closeList();
|
||||
return;
|
||||
}
|
||||
lines.forEach((rawLine) => {
|
||||
const line = rawLine.trimEnd();
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
closeParagraph();
|
||||
closeList();
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^-{5,}$/.test(trimmed)) {
|
||||
closeParagraph();
|
||||
closeList();
|
||||
html.push('<hr class="faq-divider" />');
|
||||
return;
|
||||
}
|
||||
if (/^-{5,}$/.test(trimmed)) {
|
||||
closeParagraph();
|
||||
closeList();
|
||||
html.push('<hr class="faq-divider" />');
|
||||
return;
|
||||
}
|
||||
|
||||
const heading = trimmed.match(/^(#{1,3})\s+(.*)$/);
|
||||
if (heading) {
|
||||
closeParagraph();
|
||||
closeList();
|
||||
const level = heading[1].length;
|
||||
const tag = level === 1 ? "h2" : level === 2 ? "h3" : "h4";
|
||||
html.push(`<${tag}>${formatInline(heading[2].trim())}</${tag}>`);
|
||||
return;
|
||||
}
|
||||
const heading = trimmed.match(/^(#{1,3})\s+(.*)$/);
|
||||
if (heading) {
|
||||
closeParagraph();
|
||||
closeList();
|
||||
const level = heading[1].length;
|
||||
const tag = level === 1 ? "h2" : level === 2 ? "h3" : "h4";
|
||||
html.push(`<${tag}>${formatInline(heading[2].trim())}</${tag}>`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (/^[*-]\s+/.test(trimmed)) {
|
||||
closeParagraph();
|
||||
if (!inList) {
|
||||
html.push("<ul>");
|
||||
inList = true;
|
||||
}
|
||||
const text = trimmed.replace(/^[*-]\s+/, "");
|
||||
html.push(`<li>${formatInline(text)}</li>`);
|
||||
return;
|
||||
}
|
||||
if (/^[*-]\s+/.test(trimmed)) {
|
||||
closeParagraph();
|
||||
if (!inList) {
|
||||
html.push("<ul>");
|
||||
inList = true;
|
||||
}
|
||||
const text = trimmed.replace(/^[*-]\s+/, "");
|
||||
html.push(`<li>${formatInline(text)}</li>`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!inParagraph) {
|
||||
html.push("<p>");
|
||||
inParagraph = true;
|
||||
}
|
||||
html.push(formatInline(trimmed));
|
||||
});
|
||||
if (!inParagraph) {
|
||||
html.push("<p>");
|
||||
inParagraph = true;
|
||||
}
|
||||
html.push(formatInline(trimmed));
|
||||
});
|
||||
|
||||
closeParagraph();
|
||||
closeList();
|
||||
return html.join("\n");
|
||||
closeParagraph();
|
||||
closeList();
|
||||
return html.join("\n");
|
||||
}
|
||||
|
||||
function openFaqModal() {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "edit-modal";
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "edit-panel faq-panel";
|
||||
panel.innerHTML = `
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "edit-modal";
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "edit-panel faq-panel";
|
||||
panel.innerHTML = `
|
||||
<div class="edit-header">
|
||||
<h3>${t("help.title")}</h3>
|
||||
<button class="lightbox-close" aria-label="${t("modal.close")}">x</button>
|
||||
@@ -250,16 +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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
<span class="label" data-i18n="auth.displayName">Display name (shows to group)</span>
|
||||
<input id="register-displayName" name="displayName" maxlength="16" required />
|
||||
</label>
|
||||
<label class="stack">
|
||||
<label class="stack" id="register-admin-key-field">
|
||||
<span class="label" data-i18n="auth.adminKey">Admin key (optional)</span>
|
||||
<input id="register-adminkey" name="adminKey" type="password" maxlength="128" />
|
||||
</label>
|
||||
@@ -99,6 +99,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<main class="grid">
|
||||
@@ -172,6 +173,7 @@
|
||||
<th data-i18n="admin.playerStatus">Status</th>
|
||||
<th data-i18n="admin.playerGames">Games</th>
|
||||
<th data-i18n="admin.playerJoker">Joker</th>
|
||||
<th data-i18n="admin.playerAdmin">Admin</th>
|
||||
<th data-i18n="admin.playerDelete">Delete</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { t } from "./i18n.js";
|
||||
import { state } from "./state.js";
|
||||
import { $ } from "./dom.js";
|
||||
import { buildLinkOptionLabel, escapeHtml, truncate } from "./ui-utils.js";
|
||||
import { buildLinkOptionLabel, truncate } from "./ui-utils.js";
|
||||
|
||||
function displayPlayerStatus(player) {
|
||||
if (!player) return "";
|
||||
@@ -16,14 +16,24 @@ function displayPlayerStatus(player) {
|
||||
}
|
||||
|
||||
function buildStatusSelect(player) {
|
||||
const statusText = displayPlayerStatus(player);
|
||||
const canMoveToSuggest = player.phase === "Vote";
|
||||
return `
|
||||
<select class="chip admin-status-select" data-set-player-phase="${player.playerId}" aria-label="${t("admin.playerStatus")}">
|
||||
<option value="" selected>${statusText}</option>
|
||||
<option value="Suggest" ${canMoveToSuggest ? "" : "disabled"}>${t("admin.statusMoveToSuggest")}</option>
|
||||
</select>
|
||||
`;
|
||||
const select = document.createElement("select");
|
||||
select.className = "chip admin-status-select";
|
||||
select.dataset.setPlayerPhase = player.playerId;
|
||||
select.setAttribute("aria-label", t("admin.playerStatus"));
|
||||
|
||||
const current = document.createElement("option");
|
||||
current.value = "";
|
||||
current.selected = true;
|
||||
current.textContent = displayPlayerStatus(player);
|
||||
|
||||
const suggest = document.createElement("option");
|
||||
suggest.value = "Suggest";
|
||||
suggest.disabled = !canMoveToSuggest;
|
||||
suggest.textContent = t("admin.statusMoveToSuggest");
|
||||
|
||||
select.append(current, suggest);
|
||||
return select;
|
||||
}
|
||||
|
||||
export function renderAdminVoteStatus() {
|
||||
@@ -36,17 +46,65 @@ export function renderAdminVoteStatus() {
|
||||
table.innerHTML = "";
|
||||
state.adminVoteStatus.voters.forEach((v) => {
|
||||
const tr = document.createElement("tr");
|
||||
const gamesTooltip = escapeHtml((v.suggestionTitles || []).join(", "));
|
||||
const nameText = escapeHtml(truncate(v.name, 28));
|
||||
const userText = escapeHtml(truncate(v.username, 24));
|
||||
tr.innerHTML = `
|
||||
<td title="${escapeHtml(v.name)}">${nameText}</td>
|
||||
<td class="muted small" title="${escapeHtml(v.username)}">${userText}</td>
|
||||
<td>${buildStatusSelect(v)}</td>
|
||||
<td title="${gamesTooltip}">${v.suggestionCount ?? 0}</td>
|
||||
<td><button class="chip" data-grant-joker="${v.playerId}" type="button">${v.hasJoker ? "🎟" : t("admin.grantJokerChip")}</button></td>
|
||||
<td><button class="chip danger-chip" data-delete-player="${v.playerId}" data-name="${v.name}" type="button">✕</button></td>
|
||||
`;
|
||||
const gamesTooltip = (v.suggestionTitles || []).join(", ");
|
||||
|
||||
const nameCell = document.createElement("td");
|
||||
nameCell.title = v.name ?? "";
|
||||
nameCell.textContent = truncate(v.name, 28);
|
||||
|
||||
const usernameCell = document.createElement("td");
|
||||
usernameCell.className = "muted small";
|
||||
usernameCell.title = v.username ?? "";
|
||||
usernameCell.textContent = truncate(v.username, 24);
|
||||
|
||||
const statusCell = document.createElement("td");
|
||||
statusCell.appendChild(buildStatusSelect(v));
|
||||
|
||||
const countCell = document.createElement("td");
|
||||
countCell.title = gamesTooltip;
|
||||
countCell.textContent = String(v.suggestionCount ?? 0);
|
||||
|
||||
const jokerCell = document.createElement("td");
|
||||
const jokerButton = document.createElement("button");
|
||||
jokerButton.className = "chip";
|
||||
jokerButton.dataset.grantJoker = v.playerId;
|
||||
jokerButton.type = "button";
|
||||
jokerButton.textContent = v.hasJoker ? "🎟" : t("admin.grantJokerChip");
|
||||
jokerCell.appendChild(jokerButton);
|
||||
|
||||
const adminCell = document.createElement("td");
|
||||
if (v.isOwner) {
|
||||
const ownerLabel = document.createElement("span");
|
||||
ownerLabel.className = "muted small";
|
||||
ownerLabel.textContent = t("admin.owner");
|
||||
adminCell.appendChild(ownerLabel);
|
||||
} else {
|
||||
const adminCheckbox = document.createElement("input");
|
||||
adminCheckbox.type = "checkbox";
|
||||
adminCheckbox.dataset.setPlayerAdmin = v.playerId;
|
||||
adminCheckbox.checked = !!v.isAdmin;
|
||||
adminCheckbox.setAttribute("aria-label", t("admin.playerAdmin"));
|
||||
adminCell.appendChild(adminCheckbox);
|
||||
}
|
||||
|
||||
const deleteCell = document.createElement("td");
|
||||
const deleteButton = document.createElement("button");
|
||||
deleteButton.className = "chip danger-chip";
|
||||
deleteButton.dataset.deletePlayer = v.playerId;
|
||||
deleteButton.dataset.name = v.name ?? "";
|
||||
deleteButton.type = "button";
|
||||
deleteButton.textContent = "✕";
|
||||
deleteCell.appendChild(deleteButton);
|
||||
|
||||
tr.append(
|
||||
nameCell,
|
||||
usernameCell,
|
||||
statusCell,
|
||||
countCell,
|
||||
jokerCell,
|
||||
adminCell,
|
||||
deleteCell,
|
||||
);
|
||||
table.appendChild(tr);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 },
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -127,6 +127,7 @@ function setupPlayerTableActions(runSerializedRefresh) {
|
||||
const playerTable = $("admin-player-table");
|
||||
if (!playerTable) return;
|
||||
const phaseSelectSelector = "[data-set-player-phase]";
|
||||
const adminCheckboxSelector = "[data-set-player-admin]";
|
||||
|
||||
playerTable.addEventListener("focusin", (e) => {
|
||||
if (e.target.matches?.(phaseSelectSelector)) {
|
||||
@@ -144,6 +145,25 @@ function setupPlayerTableActions(runSerializedRefresh) {
|
||||
});
|
||||
|
||||
playerTable.addEventListener("change", async (e) => {
|
||||
const adminCheckbox = e.target.closest(adminCheckboxSelector);
|
||||
if (adminCheckbox) {
|
||||
const playerId = adminCheckbox.dataset.setPlayerAdmin;
|
||||
if (!playerId) return;
|
||||
const previous = !adminCheckbox.checked;
|
||||
adminCheckbox.disabled = true;
|
||||
try {
|
||||
await adminApi.setPlayerAdmin(playerId, adminCheckbox.checked);
|
||||
toast(t("admin.roleUpdated"));
|
||||
await runSerializedRefresh();
|
||||
} catch (err) {
|
||||
adminCheckbox.checked = previous;
|
||||
toast(err.message, true);
|
||||
} finally {
|
||||
adminCheckbox.disabled = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const select = e.target.closest(phaseSelectSelector);
|
||||
if (!select) return;
|
||||
const playerId = select.dataset.setPlayerPhase;
|
||||
|
||||
@@ -46,6 +46,29 @@ function setupAuthModeToggle() {
|
||||
setAuthMode(state.authMode);
|
||||
}
|
||||
|
||||
function applyRegistrationOptions(ownerExists) {
|
||||
state.ownerExists = !!ownerExists;
|
||||
const adminKeyField = $("register-admin-key-field");
|
||||
const adminKeyInput = $("register-adminkey");
|
||||
if (!adminKeyField || !adminKeyInput) return;
|
||||
|
||||
const hideAdminKeyInput = state.ownerExists;
|
||||
adminKeyField.classList.toggle("hidden", hideAdminKeyInput);
|
||||
adminKeyInput.disabled = hideAdminKeyInput;
|
||||
if (hideAdminKeyInput) {
|
||||
adminKeyInput.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshRegistrationOptions() {
|
||||
try {
|
||||
const options = await api.authOptions();
|
||||
applyRegistrationOptions(options?.ownerExists);
|
||||
} catch {
|
||||
applyRegistrationOptions(false);
|
||||
}
|
||||
}
|
||||
|
||||
function setupLoginUserEditingHint() {
|
||||
const loginUser = $("login-username");
|
||||
if (!loginUser) return;
|
||||
@@ -91,6 +114,7 @@ function setupLoginFormHandlers({
|
||||
if (err?.status === 401)
|
||||
return toast(t("auth.invalidCredentials"), true);
|
||||
if (handleAuthError(err, clearUserState)) return;
|
||||
toast(err?.message || t("toast.unexpected"), true);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -121,6 +145,7 @@ function setupRegisterFormHandlers({
|
||||
return toast(t("auth.cookieRequired"), true);
|
||||
try {
|
||||
await api.register({ username, password, displayName, adminKey });
|
||||
await refreshRegistrationOptions();
|
||||
setConsent();
|
||||
toggleConsentRows();
|
||||
setSavedUsername(username);
|
||||
@@ -152,6 +177,7 @@ function setupLogoutHandler() {
|
||||
clearUserState();
|
||||
state.isAuthenticated = false;
|
||||
setAuthUI(false);
|
||||
await refreshRegistrationOptions();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -178,6 +204,7 @@ function setupSuggestionEntryButtons() {
|
||||
|
||||
export function setupAuthHandlers({ runSerializedRefresh }) {
|
||||
setupAuthModeToggle();
|
||||
refreshRegistrationOptions();
|
||||
const consent = setupConsentRows();
|
||||
setupLoginUserEditingHint();
|
||||
setupLoginFormHandlers({ ...consent, runSerializedRefresh });
|
||||
|
||||
@@ -1,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) => [
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -261,15 +261,6 @@ export function updatePhaseNav() {
|
||||
}
|
||||
}
|
||||
|
||||
const voteNext = $("nav-vote-next");
|
||||
if (voteNext) {
|
||||
const locked = !state.resultsOpen && !isAdmin;
|
||||
voteNext.disabled = locked;
|
||||
voteNext.textContent = locked
|
||||
? t("nav.waitingForResults")
|
||||
: t("nav.next");
|
||||
}
|
||||
|
||||
const adminResultsToggle = $("results-open");
|
||||
if (adminResultsToggle) {
|
||||
adminResultsToggle.textContent = state.resultsOpen
|
||||
|
||||
Reference in New Issue
Block a user