Merge branch 'chore/review-remediation-2026-02-08'

This commit is contained in:
2026-02-08 22:43:16 +01:00
47 changed files with 1534 additions and 644 deletions

View File

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

5
.gitignore vendored
View File

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

3
API.md
View File

@@ -11,6 +11,7 @@ POST /api/auth/logout
Display names are set during registration and are immutable afterward. Display names are set during registration and are immutable afterward.
Passwords must be 8-128 chars and contain uppercase, lowercase and number. 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`. The first account created with a valid `adminKey` becomes both `IsAdmin=true` and `IsOwner=true`.
Owner bootstrap is also enforced by a database uniqueness constraint (`IsOwner=true` can only exist once), so concurrent owner registration races fail safely with `400`.
## State (requires auth) ## State (requires auth)
GET /api/state — returns currentPhase (for caller), votesFinal, resultsOpen, updatedAt, counts (players/suggestions/votes) GET /api/state — returns currentPhase (for caller), votesFinal, resultsOpen, updatedAt, counts (players/suggestions/votes)
@@ -26,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) PUT /api/suggestions/{id} — update (non-admin: own suggestion; title locked after Suggest)
DELETE /api/suggestions/{id} — delete (non-admin only in Suggest; admin any time) DELETE /api/suggestions/{id} — delete (non-admin only in Suggest; admin any time)
GET /api/suggestions/all — all suggestions (from Vote onward), includes author, link metadata GET /api/suggestions/all — all suggestions (from Vote onward), includes author, link metadata
Suggestion limit is enforced in both app logic and DB trigger; concurrent writes that exceed limit return `400`.
## Votes (requires auth + Vote phase) ## Votes (requires auth + Vote phase)
GET /api/votes/mine GET /api/votes/mine
POST /api/votes — upsert vote; if suggestion is in a linked group, applies the same score to all linked siblings POST /api/votes — upsert vote; if suggestion is in a linked group, applies the same score to all linked siblings
POST /api/votes/finalize — `{ final: bool }` toggles callers finalized status (blocks further vote edits when true) POST /api/votes/finalize — `{ final: bool }` toggles callers finalized status (blocks further vote edits when true)
Vote upsert includes conflict handling for concurrent writes against the unique `(PlayerId, SuggestionId)` index.
## Results (requires auth + Results phase + resultsOpen) ## Results (requires auth + Results phase + resultsOpen)
GET /api/results — leaderboard with totals, counts, averages, callers vote, media/links, link metadata GET /api/results — leaderboard with totals, counts, averages, callers vote, media/links, link metadata

View File

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

View File

@@ -6,8 +6,12 @@ public record SuggestionRequest(string Name, string? Genre, string? Description,
public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers, int? ParentSuggestionId = null, IReadOnlyList<int>? LinkedIds = null, IReadOnlyList<string>? LinkedTitles = null); public record SuggestionDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers, int? ParentSuggestionId = null, IReadOnlyList<int>? LinkedIds = null, IReadOnlyList<string>? LinkedTitles = null);
public record SuggestionAllDto(int Id, string Name, string? Genre, string? Description, string? ScreenshotUrl, string? YoutubeUrl, string? GameUrl, int? MinPlayers, int? MaxPlayers, string? Author, int? ParentSuggestionId, bool IsOwner, IReadOnlyList<int> LinkedIds, IReadOnlyList<string> LinkedTitles);
public record VoteRequest(int SuggestionId, int Score); public record VoteRequest(int SuggestionId, int Score);
public record VoteRecordDto(int SuggestionId, int Score);
public record ResultsOpenRequest(bool ResultsOpen); public record ResultsOpenRequest(bool ResultsOpen);
public record VoteFinalizeRequest(bool Final); public record VoteFinalizeRequest(bool Final);

View File

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

View File

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

View File

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

View File

@@ -103,6 +103,10 @@ namespace GameList.Data.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.HasIndex("IsOwner")
.IsUnique()
.HasFilter("IsOwner = 1");
b.HasIndex("NormalizedUsername") b.HasIndex("NormalizedUsername")
.IsUnique(); .IsUnique();

View File

@@ -11,14 +11,34 @@ public static class AdminEndpoints
{ {
var admin = app.MapGroup("/api/admin").RequireAuthorization().RequireRateLimiting("admin-sensitive").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) =>
admin.MapPost("/player-admin", async ([FromBody] SetPlayerAdminRequest request, AdminWorkflowService service) => await service.SetPlayerAdminAsync(request.PlayerId, request.IsAdmin)); {
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) => admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, [FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
{ {
@@ -26,7 +46,8 @@ public static class AdminEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.DeletePlayerAsync(playerId, player.Id, request.Password, ctx); 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) => admin.MapPost("/link-suggestions", async ([FromBody] LinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
@@ -35,7 +56,8 @@ public static class AdminEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.LinkSuggestionsAsync(player.Id, request.SourceSuggestionId, request.TargetSuggestionId); var result = await service.LinkSuggestionsAsync(player.Id, request.SourceSuggestionId, request.TargetSuggestionId);
return result.ToHttpResult(Results.Ok);
}); });
admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) => admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
@@ -44,7 +66,8 @@ public static class AdminEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.UnlinkSuggestionsAsync(player.Id, request.SuggestionId); var result = await service.UnlinkSuggestionsAsync(player.Id, request.SuggestionId);
return result.ToHttpResult(Results.Ok);
}); });
admin.MapPost("/reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) => admin.MapPost("/reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
@@ -53,7 +76,8 @@ public static class AdminEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.ResetAsync(player.Id, request.Password, ctx); 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) => admin.MapPost("/factory-reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) =>
@@ -62,7 +86,8 @@ public static class AdminEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.FactoryResetAsync(player.Id, request.Password, ctx); var result = await service.FactoryResetAsync(player.Id, request.Password, ctx);
return result.ToHttpResult(Results.Ok);
}); });
} }
} }

View File

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

View File

@@ -23,7 +23,7 @@ public static class AuthEndpoints
{ {
if (!AuthValidator.TryValidateRegistration(request, out var validated, out var registrationError)) if (!AuthValidator.TryValidateRegistration(request, out var validated, out var registrationError))
{ {
authAttemptMonitor.RecordFailure(ctx, "auth-register", request.Username.Trim(), "validation-failed"); authAttemptMonitor.RecordFailure(ctx, "auth-register", NormalizeActor(request.Username), "validation-failed");
return EndpointHelpers.BadRequestError(registrationError); return EndpointHelpers.BadRequestError(registrationError);
} }
@@ -31,7 +31,7 @@ public static class AuthEndpoints
if (exists) if (exists)
return EndpointHelpers.ConflictError("Username already taken."); return EndpointHelpers.ConflictError("Username already taken.");
var (hash, salt) = PasswordHasher.HashPassword(request.Password); var (hash, salt) = PasswordHasher.HashPassword(validated.Password);
var expectedAdminKey = config["ADMIN_PASSWORD"]; var expectedAdminKey = config["ADMIN_PASSWORD"];
var wantsAdmin = !string.IsNullOrWhiteSpace(validated.AdminKey); var wantsAdmin = !string.IsNullOrWhiteSpace(validated.AdminKey);
if (wantsAdmin) if (wantsAdmin)
@@ -68,7 +68,19 @@ public static class AuthEndpoints
}; };
db.Players.Add(player); db.Players.Add(player);
await db.SaveChangesAsync(); try
{
await db.SaveChangesAsync();
}
catch (DbUpdateException ex) when (isOwner && EndpointHelpers.IsSqliteConstraintViolation(ex, EndpointHelpers.SingleOwnerIndexName))
{
authAttemptMonitor.RecordFailure(ctx, "auth-register-admin", validated.NormalizedUsername, "bootstrap-admin-race");
return EndpointHelpers.BadRequestError("Admin registration via admin key is disabled once an owner account exists.");
}
catch (DbUpdateException ex) when (EndpointHelpers.IsSqliteConstraintViolation(ex, "IX_Players_NormalizedUsername"))
{
return EndpointHelpers.ConflictError("Username already taken.");
}
if (isAdmin) if (isAdmin)
authAttemptMonitor.RecordSuccess(ctx, "auth-register-admin", validated.NormalizedUsername); authAttemptMonitor.RecordSuccess(ctx, "auth-register-admin", validated.NormalizedUsername);
@@ -87,12 +99,12 @@ public static class AuthEndpoints
{ {
if (!AuthValidator.TryValidateLogin(request, out _, out var normalizedUsername, out var loginError)) if (!AuthValidator.TryValidateLogin(request, out _, out var normalizedUsername, out var loginError))
{ {
authAttemptMonitor.RecordFailure(ctx, "auth-login", request.Username.Trim(), "validation-failed"); authAttemptMonitor.RecordFailure(ctx, "auth-login", NormalizeActor(request.Username), "validation-failed");
return EndpointHelpers.BadRequestError(loginError); return EndpointHelpers.BadRequestError(loginError);
} }
var player = await db.Players.FirstOrDefaultAsync(p => p.NormalizedUsername == normalizedUsername); var player = await db.Players.FirstOrDefaultAsync(p => p.NormalizedUsername == normalizedUsername);
if (player == null || !PasswordHasher.Verify(request.Password, player.PasswordHash, player.PasswordSalt)) if (player == null || !PasswordHasher.Verify(request.Password ?? string.Empty, player.PasswordHash, player.PasswordSalt))
{ {
authAttemptMonitor.RecordFailure(ctx, "auth-login", normalizedUsername, "invalid-credentials"); authAttemptMonitor.RecordFailure(ctx, "auth-login", normalizedUsername, "invalid-credentials");
return EndpointHelpers.UnauthorizedError("Invalid username or password."); return EndpointHelpers.UnauthorizedError("Invalid username or password.");
@@ -123,4 +135,6 @@ public static class AuthEndpoints
return Results.NoContent(); return Results.NoContent();
}); });
} }
private static string NormalizeActor(string? username) => string.IsNullOrWhiteSpace(username) ? "(missing)" : username.Trim();
} }

View File

@@ -12,7 +12,7 @@ internal static class AuthValidator
public static bool TryValidateRegistration(RegisterRequest request, out ValidatedRegistration validated, out string error) public static bool TryValidateRegistration(RegisterRequest request, out ValidatedRegistration validated, out string error)
{ {
var username = (request.Username).Trim(); var username = (request.Username ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(username) || username.Length > MaxUsernameLength) if (string.IsNullOrWhiteSpace(username) || username.Length > MaxUsernameLength)
{ {
validated = default; validated = default;
@@ -61,14 +61,14 @@ internal static class AuthValidator
} }
var adminKey = EndpointHelpers.TrimTo(request.AdminKey, MaxAdminKeyLength); var adminKey = EndpointHelpers.TrimTo(request.AdminKey, MaxAdminKeyLength);
validated = new ValidatedRegistration(username, username.ToLowerInvariant(), displayName, adminKey); validated = new ValidatedRegistration(username, username.ToLowerInvariant(), password, displayName, adminKey);
error = string.Empty; error = string.Empty;
return true; return true;
} }
public static bool TryValidateLogin(LoginRequest request, out string username, out string normalizedUsername, out string error) public static bool TryValidateLogin(LoginRequest request, out string username, out string normalizedUsername, out string error)
{ {
username = (request.Username).Trim(); username = (request.Username ?? string.Empty).Trim();
normalizedUsername = string.Empty; normalizedUsername = string.Empty;
if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password)) if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password))
@@ -94,5 +94,5 @@ internal static class AuthValidator
return true; return true;
} }
public readonly record struct ValidatedRegistration(string Username, string NormalizedUsername, string DisplayName, string? AdminKey); public readonly record struct ValidatedRegistration(string Username, string NormalizedUsername, string Password, string DisplayName, string? AdminKey);
} }

View File

@@ -1,5 +1,6 @@
using GameList.Data; using GameList.Data;
using GameList.Domain; using GameList.Domain;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using System.Net; using System.Net;
using System.Net.Sockets; using System.Net.Sockets;
@@ -9,6 +10,9 @@ namespace GameList.Endpoints;
internal static class EndpointHelpers internal static class EndpointHelpers
{ {
public const string SingleOwnerIndexName = "IX_Players_IsOwner";
public const string SuggestionLimitTriggerError = "suggestion_limit_exceeded";
public static async Task<Player?> GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db) public static async Task<Player?> GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db)
{ {
if (ctx.User.Identity?.IsAuthenticated != true) if (ctx.User.Identity?.IsAuthenticated != true)
@@ -108,6 +112,36 @@ internal static class EndpointHelpers
public static IResult UnauthorizedError(string detail = "Unauthorized") => Problem(StatusCodes.Status401Unauthorized, "Unauthorized", detail); public static IResult UnauthorizedError(string detail = "Unauthorized") => Problem(StatusCodes.Status401Unauthorized, "Unauthorized", detail);
public static IResult ToHttpResult<T>(this ServiceResult<T> result, Func<T, IResult> onSuccess)
{
if (result.IsSuccess)
return onSuccess(result.Value!);
return ToHttpError(result.Error!);
}
public static IResult ToHttpResult(this ServiceResult<Unit> result, Func<IResult> onSuccess)
{
if (result.IsSuccess)
return onSuccess();
return ToHttpError(result.Error!);
}
public static bool IsSqliteConstraintViolation(DbUpdateException ex)
{
return ex.InnerException is SqliteException sqliteEx
&& sqliteEx.SqliteErrorCode == 19;
}
public static bool IsSqliteConstraintViolation(DbUpdateException ex, string containsMessage)
{
if (!IsSqliteConstraintViolation(ex))
return false;
return ex.InnerException?.Message.Contains(containsMessage, StringComparison.OrdinalIgnoreCase) == true;
}
private static IResult Problem(int statusCode, string title, string detail) private static IResult Problem(int statusCode, string title, string detail)
{ {
return Results.Problem( return Results.Problem(
@@ -142,6 +176,18 @@ internal static class EndpointHelpers
|| path.EndsWith(".avif", StringComparison.Ordinal); || path.EndsWith(".avif", StringComparison.Ordinal);
} }
private static IResult ToHttpError(ServiceError error)
{
return error.Code switch
{
ServiceErrorCode.BadRequest => BadRequestError(error.Detail),
ServiceErrorCode.Unauthorized => UnauthorizedError(error.Detail),
ServiceErrorCode.NotFound => NotFoundError(error.Detail),
ServiceErrorCode.Conflict => ConflictError(error.Detail),
_ => Problem(StatusCodes.Status500InternalServerError, "Internal Server Error", "Unhandled service error.")
};
}
public static HttpMessageHandler CreateImageValidationHandler() public static HttpMessageHandler CreateImageValidationHandler()
{ {
return new SocketsHttpHandler return new SocketsHttpHandler

View File

@@ -18,7 +18,8 @@ public static class ResultsEndpoints
if (player is null) if (player is null)
return EndpointHelpers.UnauthorizedError(); return EndpointHelpers.UnauthorizedError();
return await service.GetResultsAsync(player.Id); var result = await service.GetResultsAsync(player.Id);
return result.ToHttpResult(Results.Ok);
}); });
} }
} }

View File

@@ -7,15 +7,15 @@ namespace GameList.Endpoints;
internal sealed class ResultsWorkflowService(AppDbContext db) internal sealed class ResultsWorkflowService(AppDbContext db)
{ {
public async Task<IResult> GetResultsAsync(Guid playerId) public async Task<ServiceResult<IReadOnlyList<ResultItemDto>>> GetResultsAsync(Guid playerId)
{ {
var appState = await db.AppState.AsNoTracking().SingleAsync(); var appState = await db.AppState.AsNoTracking().SingleAsync();
if (!appState.ResultsOpen) if (!appState.ResultsOpen)
return EndpointHelpers.BadRequestError("Results are locked until the admin enables them."); return ServiceResult<IReadOnlyList<ResultItemDto>>.Failure(ServiceError.BadRequest("Results are locked until the admin enables them."));
var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId);
if (phase != Phase.Results) if (phase != Phase.Results)
return EndpointHelpers.PhaseMismatch(Phase.Results, phase); return ServiceResult<IReadOnlyList<ResultItemDto>>.Failure(ServiceError.PhaseMismatch(Phase.Results, phase));
var results = await db var results = await db
.Suggestions.AsNoTracking() .Suggestions.AsNoTracking()
@@ -49,7 +49,7 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
var rootIndex = EndpointHelpers.BuildLinkRoots(results.Select(r => (r.Id, r.ParentSuggestionId))); var rootIndex = EndpointHelpers.BuildLinkRoots(results.Select(r => (r.Id, r.ParentSuggestionId)));
var nameLookup = results.ToDictionary(r => r.Id, r => r.Name); var nameLookup = results.ToDictionary(r => r.Id, r => r.Name);
var shaped = results.Select(r => IReadOnlyList<ResultItemDto> shaped = results.Select(r =>
{ {
var linkedIds = EndpointHelpers.LinkedIdsFor(r.Id, rootIndex) var linkedIds = EndpointHelpers.LinkedIdsFor(r.Id, rootIndex)
.Where(id => id != r.Id) .Where(id => id != r.Id)
@@ -80,8 +80,8 @@ internal sealed class ResultsWorkflowService(AppDbContext db)
linkedIds, linkedIds,
linkedTitles linkedTitles
); );
}); }).ToList();
return Results.Ok(shaped); return ServiceResult<IReadOnlyList<ResultItemDto>>.Success(shaped);
} }
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
using System.Net; using System.Net;
using System.Net.Http.Json; using System.Net.Http.Json;
using System.Text.Json; using System.Text.Json;
using GameList.Data;
using GameList.Domain;
using GameList.Infrastructure; using GameList.Infrastructure;
using GameList.Tests.Support; using GameList.Tests.Support;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -210,6 +212,29 @@ public class AuthTests
Assert.Equal(HttpStatusCode.BadRequest, badKey.StatusCode); Assert.Equal(HttpStatusCode.BadRequest, badKey.StatusCode);
} }
[Fact]
public async Task Register_and_login_with_null_fields_return_bad_request()
{
await using var factory = new TestWebApplicationFactory();
var client = factory.CreateClientWithCookies();
var register = await client.PostAsJsonAsync("/api/auth/register", new
{
Username = (string?)null,
Password = (string?)null,
DisplayName = (string?)null,
AdminKey = (string?)null
});
Assert.Equal(HttpStatusCode.BadRequest, register.StatusCode);
var login = await client.PostAsJsonAsync("/api/auth/login", new
{
Username = (string?)null,
Password = (string?)null
});
Assert.Equal(HttpStatusCode.BadRequest, login.StatusCode);
}
[Fact] [Fact]
public async Task Non_admin_cannot_access_admin_routes() public async Task Non_admin_cannot_access_admin_routes()
{ {
@@ -247,4 +272,32 @@ public class AuthTests
resp.EnsureSuccessStatusCode(); resp.EnsureSuccessStatusCode();
Assert.True(resp.Headers.TryGetValues("Set-Cookie", out var cookies) && cookies.Any(c => c.Contains("player"))); Assert.True(resp.Headers.TryGetValues("Set-Cookie", out var cookies) && cookies.Any(c => c.Contains("player")));
} }
[Fact]
public async Task Owner_uniqueness_is_enforced_by_database_constraint()
{
await using var factory = new TestWebApplicationFactory();
var ownerClient = factory.CreateClientWithCookies();
await ownerClient.RegisterAsync("owner1", admin: true);
var thrown = await Assert.ThrowsAsync<DbUpdateException>(() => factory.WithDbContextAsync(async db =>
{
var (hash, salt) = PasswordHasher.HashPassword("Pass123!");
db.Players.Add(new Player
{
Id = Guid.NewGuid(),
Username = "owner2",
NormalizedUsername = "owner2",
PasswordHash = hash,
PasswordSalt = salt,
DisplayName = "Owner2",
IsOwner = true,
IsAdmin = true
});
await db.SaveChangesAsync();
}));
Assert.Contains("Players.IsOwner", thrown.InnerException?.Message ?? thrown.Message, StringComparison.OrdinalIgnoreCase);
}
} }

View File

@@ -9,7 +9,6 @@ using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.TestHost; using Microsoft.AspNetCore.TestHost;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using System.Text.Json; using System.Text.Json;
using System.Net.Http.Json; using System.Net.Http.Json;
@@ -28,34 +27,10 @@ public class HelperTests
} }
[Fact] [Fact]
public void UpdateIndexMetaBase_rewrites_content_value() public void Program_does_not_include_runtime_index_rewrite_hook()
{ {
var webRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); var hasRewriteMethod = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).Any(m => m.Name.Contains("UpdateIndexMetaBase", StringComparison.Ordinal));
Directory.CreateDirectory(webRoot); Assert.False(hasRewriteMethod);
var index = Path.Combine(webRoot, "index.html");
File.WriteAllText(index, "<meta name=\"app-base\" content=\"\">");
var env = new FakeEnv { WebRootPath = webRoot };
var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("UpdateIndexMetaBase"));
method.Invoke(null, [env, "/pick"]);
var text = File.ReadAllText(index);
Assert.Contains("content=\"/pick\"", text);
}
[Fact]
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));
} }
[Fact] [Fact]
@@ -349,16 +324,6 @@ public class HelperTests
Assert.DoesNotContain("data-name=\"${v.name}\"", adminJs, StringComparison.Ordinal); Assert.DoesNotContain("data-name=\"${v.name}\"", adminJs, StringComparison.Ordinal);
} }
private class FakeEnv : IWebHostEnvironment
{
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!;
}
private static ForwardedHeadersOptions BuildForwardedHeadersOptionsForTest(IConfiguration config) private static ForwardedHeadersOptions BuildForwardedHeadersOptionsForTest(IConfiguration config)
{ {
var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("BuildForwardedHeadersOptions")); var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("BuildForwardedHeadersOptions"));

View File

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

View File

@@ -26,7 +26,7 @@ internal class TestWebApplicationFactory : WebApplicationFactory<Program>
services.Remove(descriptor); services.Remove(descriptor);
} }
_connection = new SqliteConnection("Data Source=:memory:;Cache=Shared"); _connection = new SqliteConnection($"Data Source=file:tests-{Guid.NewGuid():N}?mode=memory&cache=shared");
_connection.Open(); _connection.Open();
services.AddDbContext<AppDbContext>(options => { options.UseSqlite(_connection); }); services.AddDbContext<AppDbContext>(options => { options.UseSqlite(_connection); });
@@ -44,7 +44,6 @@ internal class TestWebApplicationFactory : WebApplicationFactory<Program>
using var scope = host.Services.CreateScope(); using var scope = host.Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.EnsureCreated();
db.Database.Migrate(); db.Database.Migrate();
return host; return host;

4
IIS.md
View File

@@ -8,6 +8,7 @@
## Publish ## Publish
- From repo root: `dotnet publish -c Release -o publish` - From repo root: `dotnet publish -c Release -o publish`
- Before first start (and after every new migration): run `dotnet ef database update` from repo root against the target environment.
- Copy `publish/` contents to site directory (keep `App_Data` writable by the app pool user). - Copy `publish/` contents to site directory (keep `App_Data` writable by the app pool user).
- Set environment variables in web.config or IIS config: - Set environment variables in web.config or IIS config:
- `ASPNETCORE_ENVIRONMENT=Production` - `ASPNETCORE_ENVIRONMENT=Production`
@@ -21,6 +22,9 @@
- Optional: enable stdout logging in `web.config` during troubleshooting only; disable afterward. - Optional: enable stdout logging in `web.config` during troubleshooting only; disable afterward.
- Data protection keys are persisted to `App_Data/keys`; ensure this folder is deployed and writable so auth cookies stay valid across app pool recycles. - Data protection keys are persisted to `App_Data/keys`; ensure this folder is deployed and writable so auth cookies stay valid across app pool recycles.
- Frontend base path: set `<meta name="app-base" content="/picknplay">` in `wwwroot/index.html` for production so API calls include the subpath (keep blank for local/root). - Frontend base path: 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 ## Permissions
- Grant modify rights to the app pool identity on `App_Data` (DB file + wal). - Grant modify rights to the app pool identity on `App_Data` (DB file + wal).

View File

@@ -146,7 +146,6 @@ var basePath = builder.Configuration["BasePath"];
if (!string.IsNullOrWhiteSpace(basePath)) if (!string.IsNullOrWhiteSpace(basePath))
{ {
app.UsePathBase(basePath); app.UsePathBase(basePath);
UpdateIndexMetaBase(app.Environment, basePath);
} }
app.UseGlobalExceptionLogging(); app.UseGlobalExceptionLogging();
@@ -154,13 +153,6 @@ app.UseAuthentication();
app.UseMiddleware<EnsurePlayerExistsMiddleware>(); app.UseMiddleware<EnsurePlayerExistsMiddleware>();
app.UseAuthorization(); 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.UseDefaultFiles();
app.UseStaticFiles(); app.UseStaticFiles();
@@ -274,42 +266,4 @@ static Task WriteUnauthorizedChallengeAsync(HttpContext context)
return context.Response.WriteAsJsonAsync(problem); return context.Response.WriteAsJsonAsync(problem);
} }
static void UpdateIndexMetaBase(IWebHostEnvironment env, string basePath)
{
try
{
var indexPath = Path.Combine(env.WebRootPath, "index.html");
if (!File.Exists(indexPath))
return;
var text = File.ReadAllText(indexPath);
var marker = "name=\"app-base\"";
var contentKey = "content=\"";
var markerIndex = text.IndexOf(marker, StringComparison.OrdinalIgnoreCase);
if (markerIndex < 0)
return;
var contentIndex = text.IndexOf(contentKey, markerIndex, StringComparison.OrdinalIgnoreCase);
if (contentIndex < 0)
return;
var valueStart = contentIndex + contentKey.Length;
var valueEnd = text.IndexOf('"', valueStart);
if (valueEnd < 0)
return;
var current = text[valueStart..valueEnd];
var normalized = basePath.EndsWith('/') ? basePath.TrimEnd('/') : basePath;
if (current == normalized)
return;
var updated = text[..valueStart] + normalized + text[valueEnd..];
File.WriteAllText(indexPath, updated);
}
catch
{
// If we can't rewrite, continue; frontend can still be set manually.
}
}
public partial class Program; public partial class Program;

View File

@@ -6,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: 1. Restore and build:
`dotnet build GameList.sln` `dotnet build GameList.sln`
2. Run tests: 2. Apply DB migrations explicitly:
`dotnet ef database update`
3. Run tests:
`dotnet test GameList.Tests/GameList.Tests.csproj` `dotnet test GameList.Tests/GameList.Tests.csproj`
3. Run locally: 4. Run locally:
`dotnet run --project GameList.csproj` `dotnet run --project GameList.csproj`
4. Open: 5. Open:
`http://localhost:5000` (or the URL shown by `dotnet run`) `http://localhost:5000` (or the URL shown by `dotnet run`)
## Frontend Tooling ## Frontend Tooling
@@ -25,21 +27,24 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
- Authentication: username/password with HttpOnly `player` cookie. - Authentication: username/password with HttpOnly `player` cookie.
- Admin authorization: authenticated account with `IsAdmin=true`. - Admin authorization: authenticated account with `IsAdmin=true`.
- Owner model: first valid admin-key registration becomes `owner`; admins can grant/revoke admin role for non-owner accounts. - Owner model: first valid admin-key registration becomes `owner`; admins can grant/revoke admin role for non-owner accounts.
- Core invariants are DB-enforced: single owner account and non-joker suggestion cap.
- Gameplay phases: `Suggest`, `Vote`, `Results`. - Gameplay phases: `Suggest`, `Vote`, `Results`.
- Storage: SQLite database under `App_Data/gamelist.db`. - Storage: SQLite database under `App_Data/gamelist.db`.
- Migrations are deployment-time operations (`dotnet ef database update`); app startup does not auto-migrate.
- Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement. - Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement.
## Module Ownership ## Module Ownership
- `Program.cs`: startup wiring, middleware order, route registration. - `Program.cs`: startup wiring, middleware order, route registration.
- `Endpoints/`: HTTP endpoint transport + request orchestration. - `Endpoints/`: endpoint adapters plus application workflow services (`ServiceResult<T>` outputs mapped to HTTP at the edge).
- `Infrastructure/`: filters, middleware, identity helpers. - `Infrastructure/`: filters, middleware, identity helpers.
- `Data/`: EF Core `DbContext` and migrations. - `Data/`: EF Core `DbContext` and migrations.
- `Domain/`: entities and enums. - `Domain/`: entities and enums.
- `Contracts/`: request/response DTOs. - `Contracts/`: request/response DTOs.
- `wwwroot/`: static frontend assets. - `wwwroot/`: static frontend assets.
- `GameList.Tests/`: integration and helper tests. - `GameList.Tests/`: integration and helper tests.
- `scripts/`: deployment scripts. - `scripts/`: deployment scripts (`scripts/deploy-ftp.ps1`, `scripts/deploy-ftp1.ps1`).
- `deploy.ps1`: local shortcut wrapper that runs FTP deploy using `scripts/deploy-ftp.profile.psd1`.
## Operations ## Operations
@@ -55,4 +60,5 @@ GitHub Actions workflow: `.github/workflows/ci.yml`
- Restores dependencies - Restores dependencies
- Runs frontend lint and format checks - Runs frontend lint and format checks
- Builds with warnings treated as errors - Builds with warnings treated as errors
- Runs `GameList.Tests` - Runs `GameList.Tests` with coverage collection
- Enforces minimum coverage thresholds (line 90%, branch 70%)

View File

@@ -33,7 +33,9 @@ stateDiagram-v2
### 1) Authentication & Identity ### 1) Authentication & Identity
- Register success (player, admin key path) issues cookie, trims fields, stores normalized username, hashes password. - Register success (player, admin key path) issues cookie, trims fields, stores normalized username, hashes password.
- Register rejects missing/long username, weak password policy violations, 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. - 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. - `/api/auth/options` reports owner presence for registration UI behavior.
- Login success updates LastLoginAt and sets DisplayName if null; rejects wrong password/username; enforces length limits. - Login success updates LastLoginAt and sets DisplayName if null; rejects wrong password/username; enforces length limits.
- Logout clears cookie. - Logout clears cookie.
@@ -50,9 +52,10 @@ stateDiagram-v2
### 3) Suggestions ### 3) Suggestions
- GET /mine returns only callers suggestions ordered by CreatedAt. - GET /mine returns only callers suggestions ordered by CreatedAt.
- POST /: success with valid data; enforces ≤5 per player; trims optional fields; requires display name; rejects bad image URL/ext, unreachable image (mocked), invalid game/youtube URLs, invalid player counts, missing name/too long. - POST /: success with valid data; enforces ≤5 per player; trims optional fields; requires display name; rejects bad image URL/ext, unreachable image (mocked), invalid game/youtube URLs, invalid player counts, missing name/too long.
- DB trigger also enforces suggestion cap for non-joker inserts, protecting against concurrent over-limit writes.
- Joker path: when phase=Vote and HasJoker=true allows creation, consumes joker, resets VotesFinal for all players. - Joker path: when phase=Vote and HasJoker=true allows creation, consumes joker, resets VotesFinal for all players.
- Phase gating: non-admin cannot create/update/delete outside Suggest (except joker create); admin bypasses phase checks for update/delete. - Phase gating: non-admin cannot create/update/delete outside Suggest (except joker create); admin bypasses phase checks for update/delete.
- PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; validation mirrors create. - PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; screenshot reachability check is skipped when screenshot URL is unchanged.
- DELETE /{id}: player deletes own in Suggest; admin any time; also breaks child links and deletes related votes. - DELETE /{id}: player deletes own in Suggest; admin any time; also breaks child links and deletes related votes.
- GET /all: accessible from Vote+, orders by CreatedAt, includes link metadata, enforces phase mismatch before Vote. - GET /all: accessible from Vote+, orders by CreatedAt, includes link metadata, enforces phase mismatch before Vote.
@@ -60,6 +63,7 @@ stateDiagram-v2
- GET /mine: only in Vote, returns player votes; unauthorized/phase mismatch handled. - GET /mine: only in Vote, returns player votes; unauthorized/phase mismatch handled.
- POST /: creates or updates vote; rejects score outside 010; rejects when VotesFinal=true; enforces display name requirement and phase gating. - POST /: creates or updates vote; rejects score outside 010; rejects when VotesFinal=true; enforces display name requirement and phase gating.
- Linked votes: when suggestions are linked, a single post updates all linked IDs; invalid suggestionId returns 400; linking root detection works for nested links. - Linked votes: when suggestions are linked, a single post updates all linked IDs; invalid suggestionId returns 400; linking root detection works for nested links.
- Concurrent vote upserts are handled with retry logic around unique-key conflicts to avoid server errors.
- Finalize: POST /finalize toggles VotesFinal flag; allowed only in Vote. - Finalize: POST /finalize toggles VotesFinal flag; allowed only in Vote.
### 5) Results ### 5) Results
@@ -84,13 +88,18 @@ stateDiagram-v2
- EndpointHelpers.IsValidImageUrl/IsValidHttpUrl: accepts empty, http/https; rejects others/invalid ext. - EndpointHelpers.IsValidImageUrl/IsValidHttpUrl: accepts empty, http/https; rejects others/invalid ext.
- IsReachableImageAsync: with mocked Http responses covers head success, get fallback, redirect rejection, size guard, and private/reserved host range detection (IPv4/IPv6). - IsReachableImageAsync: with mocked Http responses covers head success, get fallback, redirect rejection, size guard, and private/reserved host range detection (IPv4/IPv6).
- BuildLinkRoots/LinkedIdsFor/FindRootId: cover disjoint groups, chains, cycles guard (visited set), non-existent ids. - BuildLinkRoots/LinkedIdsFor/FindRootId: cover disjoint groups, chains, cycles guard (visited set), non-existent ids.
- UpdateIndexMetaBase (Program.cs): rewrites app-base meta when BasePath set; no change when matching/marker missing; safe exceptions swallowed. - Program startup avoids runtime frontend file rewrites; BasePath remains purely configuration/deploy managed.
- Global exception handler returns 500 with JSON body and logs error. - Global exception handler returns 500 with JSON body and logs error.
- /health returns {status:"ok"}. - /health returns {status:"ok"}.
- Security middleware tests validate response headers and rate-limiting behavior on auth/admin routes. - 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. - 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 ## Execution Notes
- Use named test data builders for players/suggestions to keep cases small and isolated. - Use named test data builders for players/suggestions to keep cases small and isolated.
- Reset in-memory DB per test to avoid cross-contamination; assert timestamps using time providers or approximate windows. - Reset in-memory DB per test to avoid cross-contamination; assert timestamps using time providers or approximate windows.
- Cover success + failure for every endpoint status path to reach 100% line/branch coverage. - Cover success + failure for endpoint status paths and critical helper branches to stay above enforced thresholds.

17
deploy.ps1 Normal file
View File

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

View File

@@ -4,8 +4,8 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"lint": "eslint \"wwwroot/**/*.js\"", "lint": "eslint \"wwwroot/**/*.js\"",
"format": "prettier --write \"eslint.config.js\" \"wwwroot/js/i18n.js\" \"wwwroot/js/{admin-ui,app-admin-handlers,app-auth-handlers,app-vote-nav-handlers,auth-ui,modals-ui,results-ui,suggestions-ui,ui-runtime,ui-utils,ui,votes-ui}.js\"", "format": "prettier --write \"eslint.config.js\" \"wwwroot/**/*.js\"",
"format:check": "prettier --check \"eslint.config.js\" \"wwwroot/js/i18n.js\" \"wwwroot/js/{admin-ui,app-admin-handlers,app-auth-handlers,app-vote-nav-handlers,auth-ui,modals-ui,results-ui,suggestions-ui,ui-runtime,ui-utils,ui,votes-ui}.js\"" "format:check": "prettier --check \"eslint.config.js\" \"wwwroot/**/*.js\""
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "9.21.0", "@eslint/js": "9.21.0",

View File

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

View File

@@ -53,13 +53,17 @@ try {
Invoke-Step -Name "Run tests" -Action { Invoke-Step -Name "Run tests" -Action {
if ($SkipBuild) { if ($SkipBuild) {
dotnet test GameList.Tests/GameList.Tests.csproj --verbosity normal dotnet test GameList.Tests/GameList.Tests.csproj --verbosity normal --collect:"XPlat Code Coverage"
} }
else { else {
dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal --collect:"XPlat Code Coverage"
} }
} }
Invoke-Step -Name "Enforce coverage thresholds" -Action {
pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70
}
Write-Host "CI checks passed." Write-Host "CI checks passed."
} }
finally { finally {

View File

@@ -0,0 +1,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"
}

View File

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

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

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

View File

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

View File

@@ -99,6 +99,7 @@
</div> </div>
</div> </div>
</div> </div>
</div>
</section> </section>
<main class="grid"> <main class="grid">

View File

@@ -5,77 +5,107 @@ const basePath = normalizeBase(rawBase);
const withBase = (path) => `${basePath}${path}`; const withBase = (path) => `${basePath}${path}`;
function normalizeBase(value) { function normalizeBase(value) {
if (!value) return ""; if (!value) return "";
if (!value.startsWith("/")) return `/${value}`; if (!value.startsWith("/")) return `/${value}`;
return value.endsWith("/") ? value.slice(0, -1) : value; return value.endsWith("/") ? value.slice(0, -1) : value;
} }
async function request(path, { method = "GET", body } = {}) { async function request(path, { method = "GET", body } = {}) {
const res = await fetch(withBase(path), { const res = await fetch(withBase(path), {
method, method,
credentials: "same-origin", credentials: "same-origin",
headers: defaultHeaders, headers: defaultHeaders,
body: body ? JSON.stringify(body) : undefined, body: body ? JSON.stringify(body) : undefined,
}); });
if (!res.ok) { if (!res.ok) {
let msg = `${res.status}`; let msg = `${res.status}`;
try { try {
const data = await res.json(); const data = await res.json();
msg = data.error || data.detail || data.title || JSON.stringify(data); msg =
} catch { /* ignore */ } data.error || data.detail || data.title || JSON.stringify(data);
const err = new Error(msg); } catch {
err.status = res.status; /* ignore */
throw err; }
} const err = new Error(msg);
return res.status === 204 ? null : res.json(); err.status = res.status;
throw err;
}
return res.status === 204 ? null : res.json();
} }
export const api = { export const api = {
state: () => request("/api/state"), state: () => request("/api/state"),
me: () => request("/api/me"), me: () => request("/api/me"),
authOptions: () => request("/api/auth/options"), authOptions: () => request("/api/auth/options"),
register: (payload) => request("/api/auth/register", { method: "POST", body: payload }), register: (payload) =>
login: (payload) => request("/api/auth/login", { method: "POST", body: payload }), request("/api/auth/register", { method: "POST", body: payload }),
logout: () => request("/api/auth/logout", { method: "POST" }), login: (payload) =>
request("/api/auth/login", { method: "POST", body: payload }),
logout: () => request("/api/auth/logout", { method: "POST" }),
mySuggestions: () => request("/api/suggestions/mine"), mySuggestions: () => request("/api/suggestions/mine"),
createSuggestion: (payload) => request("/api/suggestions", { method: "POST", body: payload }), createSuggestion: (payload) =>
deleteSuggestion: (id) => request(`/api/suggestions/${id}`, { method: "DELETE" }), request("/api/suggestions", { method: "POST", body: payload }),
updateSuggestion: (id, payload) => request(`/api/suggestions/${id}`, { method: "PUT", body: payload }), deleteSuggestion: (id) =>
allSuggestions: () => request("/api/suggestions/all"), 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"), myVotes: () => request("/api/votes/mine"),
vote: (suggestionId, score) => request("/api/votes", { method: "POST", body: { suggestionId, score } }), vote: (suggestionId, score) =>
finalizeVotes: (final) => request("/api/votes/finalize", { method: "POST", body: { final } }), request("/api/votes", {
method: "POST",
body: { suggestionId, score },
}),
finalizeVotes: (final) =>
request("/api/votes/finalize", { method: "POST", body: { final } }),
results: () => request("/api/results"), results: () => request("/api/results"),
nextPhase: () => request("/api/me/phase/next", { method: "POST" }), nextPhase: () => request("/api/me/phase/next", { method: "POST" }),
prevPhase: () => request("/api/me/phase/prev", { method: "POST" }), prevPhase: () => request("/api/me/phase/prev", { method: "POST" }),
}; };
export const adminApi = { export const adminApi = {
setResultsOpen: (resultsOpen) => request("/api/admin/results", { method: "POST", body: { resultsOpen } }), setResultsOpen: (resultsOpen) =>
voteStatus: () => request("/api/admin/vote-status"), request("/api/admin/results", {
reset: (password) => method: "POST",
request("/api/admin/reset", { method: "POST", body: { password } }), body: { resultsOpen },
factoryReset: (password) => }),
request("/api/admin/factory-reset", { method: "POST", body: { password } }), voteStatus: () => request("/api/admin/vote-status"),
grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }), reset: (password) =>
setPlayerAdmin: (playerId, isAdmin) => request("/api/admin/reset", { method: "POST", body: { password } }),
request("/api/admin/player-admin", { factoryReset: (password) =>
method: "POST", request("/api/admin/factory-reset", {
body: { playerId, isAdmin }, method: "POST",
}), body: { password },
setPlayerPhase: (playerId, phase) => }),
request("/api/admin/player-phase", { method: "POST", body: { playerId, phase } }), grantJoker: (playerId) =>
deletePlayer: (playerId, password) => request("/api/admin/joker", { method: "POST", body: { playerId } }),
request(`/api/admin/players/${playerId}`, { setPlayerAdmin: (playerId, isAdmin) =>
method: "DELETE", request("/api/admin/player-admin", {
body: { password }, method: "POST",
}), body: { playerId, isAdmin },
linkSuggestions: (sourceSuggestionId, targetSuggestionId) => }),
request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }), setPlayerPhase: (playerId, phase) =>
unlinkSuggestions: (suggestionId) => request("/api/admin/player-phase", {
request("/api/admin/unlink-suggestions", { method: "POST", body: { suggestionId } }), method: "POST",
body: { playerId, phase },
}),
deletePlayer: (playerId, password) =>
request(`/api/admin/players/${playerId}`, {
method: "DELETE",
body: { password },
}),
linkSuggestions: (sourceSuggestionId, targetSuggestionId) =>
request("/api/admin/link-suggestions", {
method: "POST",
body: { sourceSuggestionId, targetSuggestionId },
}),
unlinkSuggestions: (suggestionId) =>
request("/api/admin/unlink-suggestions", {
method: "POST",
body: { suggestionId },
}),
}; };

View File

@@ -114,6 +114,7 @@ function setupLoginFormHandlers({
if (err?.status === 401) if (err?.status === 401)
return toast(t("auth.invalidCredentials"), true); return toast(t("auth.invalidCredentials"), true);
if (handleAuthError(err, clearUserState)) return; if (handleAuthError(err, clearUserState)) return;
toast(err?.message || t("toast.unexpected"), true);
} }
}); });
} }

View File

@@ -1,5 +1,20 @@
import { api, adminApi } from "./api.js"; import { api, adminApi } from "./api.js";
import { handleAuthError, renderAllSuggestions, renderCounts, renderMySuggestions, renderPhasePill, renderPhaseTitles, renderResults, renderVotes, renderWelcome, setAuthUI, syncVoteScores, updatePhaseNav, openResultsRelockModal, openSuggestionsChangedModal } from "./ui.js"; import {
handleAuthError,
renderAllSuggestions,
renderCounts,
renderMySuggestions,
renderPhasePill,
renderPhaseTitles,
renderResults,
renderVotes,
renderWelcome,
setAuthUI,
syncVoteScores,
updatePhaseNav,
openResultsRelockModal,
openSuggestionsChangedModal,
} from "./ui.js";
import { state, clearUserState } from "./state.js"; import { state, clearUserState } from "./state.js";
export async function loadState() { export async function loadState() {
@@ -86,18 +101,26 @@ export async function loadResults() {
} }
export async function refreshPhaseData() { export async function refreshPhaseData() {
const before = buildRefreshSnapshot();
try { try {
const prevPhase = state.phase; const prevPhase = state.phase;
const prevResultsOpen = state.resultsOpen; const prevResultsOpen = state.resultsOpen;
await loadState(); await loadState();
await Promise.all([loadSuggestData(), loadSuggestionsData(), loadResults()]); await Promise.all([
loadSuggestData(),
loadSuggestionsData(),
loadResults(),
]);
if (state.phase === "Vote") { if (state.phase === "Vote") {
if (!state.votesRendered) await loadVoteData(); if (!state.votesRendered) await loadVoteData();
} else { } else {
state.votesRendered = false; state.votesRendered = false;
await loadVoteData(); await loadVoteData();
} }
if (state.me?.isAdmin) { const adminCard = document.getElementById("admin-card");
const adminPanelVisible =
!!adminCard && !adminCard.classList.contains("hidden");
if (state.me?.isAdmin && adminPanelVisible) {
state.adminVoteStatus = await adminApi.voteStatus(); state.adminVoteStatus = await adminApi.voteStatus();
} }
if ( if (
@@ -109,12 +132,34 @@ export async function refreshPhaseData() {
openResultsRelockModal(); openResultsRelockModal();
} }
updatePhaseNav(); updatePhaseNav();
const after = buildRefreshSnapshot();
return before !== after;
} catch (err) { } catch (err) {
if (handleAuthError(err, clearUserState)) return; if (handleAuthError(err, clearUserState)) return;
throw err; throw err;
} }
} }
function buildRefreshSnapshot() {
return JSON.stringify({
phase: state.phase,
resultsOpen: state.resultsOpen,
votesFinal: state.votesFinal,
hasJoker: state.hasJoker,
counts: state.counts
? [
state.counts.players,
state.counts.suggestions,
state.counts.votes,
]
: null,
mineCount: state.mySuggestions?.length ?? 0,
allSig: state.allSuggestionsSig ?? "",
voteCount: state.myVotes?.length ?? 0,
resultsCount: state.results?.length ?? 0,
});
}
export function signatureSuggestions(list) { export function signatureSuggestions(list) {
return JSON.stringify( return JSON.stringify(
list.map((s) => [ list.map((s) => [

View File

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

View File

@@ -49,16 +49,6 @@ export function renderMySuggestions() {
export function renderAllSuggestions() { export function renderAllSuggestions() {
renderAdminLinker(); renderAdminLinker();
const list = $("all-suggestions");
if (!list) return;
list.innerHTML = "";
const allowEdit = true;
const allowDelete = !!state.me?.isAdmin;
sortByName(state.allSuggestions).forEach((s) =>
list.appendChild(
buildCard(s, { showAuthor: true, allowEdit, allowDelete }),
),
);
renderPhaseTitles(); renderPhaseTitles();
} }

View File

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