diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5600d54..63d1931 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,4 +40,7 @@ jobs: run: dotnet build GameList.sln --no-restore -warnaserror - name: Test - run: dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal + run: dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal --collect:"XPlat Code Coverage" + + - name: Enforce coverage thresholds + run: pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70 diff --git a/.gitignore b/.gitignore index ac1de16..646165f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,12 +11,17 @@ node_modules/ # User secrets / configs appsettings.Development.json +scripts/deploy-ftp.profile.psd1 *.user *.suo # Logs *.log +# Test results / coverage artifacts +TestResults/ +coverage.cobertura.xml + # SQLite data App_Data/ *.db diff --git a/API.md b/API.md index c70d4a3..043c835 100644 --- a/API.md +++ b/API.md @@ -11,6 +11,7 @@ POST /api/auth/logout Display names are set during registration and are immutable afterward. Passwords must be 8-128 chars and contain uppercase, lowercase and number. The first account created with a valid `adminKey` becomes both `IsAdmin=true` and `IsOwner=true`. +Owner bootstrap is also enforced by a database uniqueness constraint (`IsOwner=true` can only exist once), so concurrent owner registration races fail safely with `400`. ## State (requires auth) GET /api/state — returns currentPhase (for caller), votesFinal, resultsOpen, updatedAt, counts (players/suggestions/votes) @@ -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) DELETE /api/suggestions/{id} — delete (non-admin only in Suggest; admin any time) GET /api/suggestions/all — all suggestions (from Vote onward), includes author, link metadata +Suggestion limit is enforced in both app logic and DB trigger; concurrent writes that exceed limit return `400`. ## Votes (requires auth + Vote phase) GET /api/votes/mine POST /api/votes — upsert vote; if suggestion is in a linked group, applies the same score to all linked siblings POST /api/votes/finalize — `{ final: bool }` toggles caller’s finalized status (blocks further vote edits when true) +Vote upsert includes conflict handling for concurrent writes against the unique `(PlayerId, SuggestionId)` index. ## Results (requires auth + Results phase + resultsOpen) GET /api/results — leaderboard with totals, counts, averages, caller’s vote, media/links, link metadata diff --git a/Contracts/AuthRequests.cs b/Contracts/AuthRequests.cs index 24fb124..4ccdf9b 100644 --- a/Contracts/AuthRequests.cs +++ b/Contracts/AuthRequests.cs @@ -1,5 +1,5 @@ namespace GameList.Contracts; -public record RegisterRequest(string Username, string Password, string? DisplayName, string? AdminKey); +public record RegisterRequest(string? Username, string? Password, string? DisplayName, string? AdminKey); -public record LoginRequest(string Username, string Password); +public record LoginRequest(string? Username, string? Password); diff --git a/Contracts/Dtos.cs b/Contracts/Dtos.cs index 199424e..ccfe022 100644 --- a/Contracts/Dtos.cs +++ b/Contracts/Dtos.cs @@ -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? LinkedIds = null, IReadOnlyList? 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 LinkedIds, IReadOnlyList LinkedTitles); + public record VoteRequest(int SuggestionId, int Score); +public record VoteRecordDto(int SuggestionId, int Score); + public record ResultsOpenRequest(bool ResultsOpen); public record VoteFinalizeRequest(bool Final); diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 768558e..0e7bda9 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -23,6 +23,7 @@ public class AppDbContext(DbContextOptions options) : DbContext(op builder.Property(p => p.PasswordSalt).IsRequired(); builder.Property(p => p.IsAdmin).HasDefaultValue(false); builder.Property(p => p.IsOwner).HasDefaultValue(false); + builder.HasIndex(p => p.IsOwner).HasFilter($"{nameof(Player.IsOwner)} = 1").IsUnique(); builder.Property(p => p.HasJoker).HasDefaultValue(false); builder.Property(p => p.CurrentPhase).HasDefaultValue(Phase.Suggest); builder.Property(p => p.VotesFinal).HasDefaultValue(false); diff --git a/Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.Designer.cs b/Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.Designer.cs new file mode 100644 index 0000000..34dde65 --- /dev/null +++ b/Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.Designer.cs @@ -0,0 +1,255 @@ +// +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 + { + /// + 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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ResultsOpen") + .HasColumnType("INTEGER"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("CurrentPhase") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(0); + + b.Property("DisplayName") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("HasJoker") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("IsAdmin") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("IsOwner") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("PasswordSalt") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("GameUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("Genre") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("MinPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("ParentSuggestionId") + .HasColumnType("INTEGER"); + + b.Property("PlayerId") + .HasColumnType("TEXT"); + + b.Property("ScreenshotUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("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("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("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 + } + } +} diff --git a/Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.cs b/Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.cs new file mode 100644 index 0000000..2a83de1 --- /dev/null +++ b/Data/Migrations/20260208203323_HardenOwnerAndSuggestionInvariants.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GameList.Data.Migrations +{ + /// + public partial class HardenOwnerAndSuggestionInvariants : Migration + { + /// + 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; + """ + ); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql("DROP TRIGGER IF EXISTS TR_Suggestions_MaxFivePerPlayer;"); + + migrationBuilder.DropIndex( + name: "IX_Players_IsOwner", + table: "Players"); + } + } +} diff --git a/Data/Migrations/AppDbContextModelSnapshot.cs b/Data/Migrations/AppDbContextModelSnapshot.cs index 88e7b95..eb5115e 100644 --- a/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/Data/Migrations/AppDbContextModelSnapshot.cs @@ -103,6 +103,10 @@ namespace GameList.Data.Migrations b.HasKey("Id"); + b.HasIndex("IsOwner") + .IsUnique() + .HasFilter("IsOwner = 1"); + b.HasIndex("NormalizedUsername") .IsUnique(); diff --git a/Endpoints/AdminEndpoints.cs b/Endpoints/AdminEndpoints.cs index 7aebdcc..c546e9b 100644 --- a/Endpoints/AdminEndpoints.cs +++ b/Endpoints/AdminEndpoints.cs @@ -11,14 +11,34 @@ public static class AdminEndpoints { var admin = app.MapGroup("/api/admin").RequireAuthorization().RequireRateLimiting("admin-sensitive").AddEndpointFilter(); - 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-admin", async ([FromBody] SetPlayerAdminRequest request, AdminWorkflowService service) => await service.SetPlayerAdminAsync(request.PlayerId, request.IsAdmin)); + admin.MapPost("/player-phase", async ([FromBody] SetPlayerPhaseRequest request, AdminWorkflowService service) => + { + var result = await service.SetPlayerPhaseAsync(request.PlayerId, request.Phase); + return result.ToHttpResult(Results.Ok); + }); + admin.MapPost("/player-admin", async ([FromBody] SetPlayerAdminRequest request, AdminWorkflowService service) => + { + var result = await service.SetPlayerAdminAsync(request.PlayerId, request.IsAdmin); + return result.ToHttpResult(Results.Ok); + }); admin.MapDelete("/players/{playerId:guid}", async (Guid playerId, [FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) => { @@ -26,7 +46,8 @@ public static class AdminEndpoints if (player is null) 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) => @@ -35,7 +56,8 @@ public static class AdminEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.LinkSuggestionsAsync(player.Id, request.SourceSuggestionId, request.TargetSuggestionId); + var result = await service.LinkSuggestionsAsync(player.Id, request.SourceSuggestionId, request.TargetSuggestionId); + return result.ToHttpResult(Results.Ok); }); admin.MapPost("/unlink-suggestions", async ([FromBody] UnlinkSuggestionsRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) => @@ -44,7 +66,8 @@ public static class AdminEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.UnlinkSuggestionsAsync(player.Id, request.SuggestionId); + var result = await service.UnlinkSuggestionsAsync(player.Id, request.SuggestionId); + return result.ToHttpResult(Results.Ok); }); admin.MapPost("/reset", async ([FromBody] AdminPasswordRequest request, HttpContext ctx, AppDbContext db, AdminWorkflowService service) => @@ -53,7 +76,8 @@ public static class AdminEndpoints if (player is null) 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) => @@ -62,7 +86,8 @@ public static class AdminEndpoints if (player is null) 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); }); } } diff --git a/Endpoints/AdminWorkflowService.cs b/Endpoints/AdminWorkflowService.cs index dac48e9..18c6819 100644 --- a/Endpoints/AdminWorkflowService.cs +++ b/Endpoints/AdminWorkflowService.cs @@ -8,7 +8,7 @@ namespace GameList.Endpoints; internal sealed class AdminWorkflowService(AppDbContext db) { - public async Task SetResultsOpenAsync(bool resultsOpen) + public async Task> SetResultsOpenAsync(bool resultsOpen) { var state = await db.AppState.SingleAsync(); state.ResultsOpen = resultsOpen; @@ -29,81 +29,81 @@ internal sealed class AdminWorkflowService(AppDbContext db) await db.SaveChangesAsync(); await tx.CommitAsync(); var currentState = await db.AppState.AsNoTracking().SingleAsync(); - return Results.Ok(new AdminResultsStateResponse(currentState.ResultsOpen, currentState.UpdatedAt)); + return ServiceResult.Success(new AdminResultsStateResponse(currentState.ResultsOpen, currentState.UpdatedAt)); } - public async Task GetVoteStatusAsync() + public async Task> GetVoteStatusAsync() { var voters = await db.Players.AsNoTracking().Include(p => p.Suggestions).OrderBy(p => p.DisplayName ?? p.Username).Select(p => new VoteStatusDto(p.Id, p.DisplayName ?? p.Username, p.Username, p.CurrentPhase, p.VotesFinal, p.HasJoker, p.IsAdmin, p.IsOwner, p.Suggestions.Count, p.Suggestions.Select(s => s.Name).ToList())).ToListAsync(); var waiting = voters.Where(v => !v.Finalized).Select(v => v.Name).ToList(); var ready = waiting.Count == 0; - return Results.Ok(new VoteStatusResponse(voters, ready, waiting)); + return ServiceResult.Success(new VoteStatusResponse(voters, ready, waiting)); } - public async Task GrantJokerAsync(Guid playerId) + public async Task> GrantJokerAsync(Guid playerId) { var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId); if (player is null) - return EndpointHelpers.NotFoundError("Player not found."); + return ServiceResult.Failure(ServiceError.NotFound("Player not found.")); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); if (phase != Phase.Vote) - return EndpointHelpers.BadRequestError("Player must be in the Vote phase to receive a joker."); + return ServiceResult.Failure(ServiceError.BadRequest("Player must be in the Vote phase to receive a joker.")); player.HasJoker = true; player.VotesFinal = false; await db.SaveChangesAsync(); - return Results.Ok(new AdminGrantJokerResponse(player.Id, player.HasJoker)); + return ServiceResult.Success(new AdminGrantJokerResponse(player.Id, player.HasJoker)); } - public async Task SetPlayerPhaseAsync(Guid playerId, Phase phase) + public async Task> SetPlayerPhaseAsync(Guid playerId, Phase phase) { if (phase != Phase.Suggest) - return EndpointHelpers.BadRequestError("Only transition to Suggest is supported."); + return ServiceResult.Failure(ServiceError.BadRequest("Only transition to Suggest is supported.")); var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId); if (player is null) - return EndpointHelpers.NotFoundError("Player not found."); + return ServiceResult.Failure(ServiceError.NotFound("Player not found.")); var currentPhase = await EndpointHelpers.GetCurrentPhaseAsync(db, player.Id); if (currentPhase != Phase.Vote) - return EndpointHelpers.BadRequestError("Player must currently be in the Vote phase."); + return ServiceResult.Failure(ServiceError.BadRequest("Player must currently be in the Vote phase.")); player.CurrentPhase = Phase.Suggest; player.VotesFinal = false; await db.SaveChangesAsync(); - return Results.Ok(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal)); + return ServiceResult.Success(new AdminSetPlayerPhaseResponse(player.Id, player.CurrentPhase, player.VotesFinal)); } - public async Task SetPlayerAdminAsync(Guid playerId, bool isAdmin) + public async Task> SetPlayerAdminAsync(Guid playerId, bool isAdmin) { var player = await db.Players.FirstOrDefaultAsync(p => p.Id == playerId); if (player is null) - return EndpointHelpers.NotFoundError("Player not found."); + return ServiceResult.Failure(ServiceError.NotFound("Player not found.")); if (player.IsOwner) - return EndpointHelpers.BadRequestError("Owner permissions cannot be changed."); + return ServiceResult.Failure(ServiceError.BadRequest("Owner permissions cannot be changed.")); player.IsAdmin = isAdmin; await db.SaveChangesAsync(); - return Results.Ok(new AdminSetPlayerAdminResponse(player.Id, player.IsAdmin)); + return ServiceResult.Success(new AdminSetPlayerAdminResponse(player.Id, player.IsAdmin)); } - public async Task DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password, HttpContext ctx) + public async Task> DeletePlayerAsync(Guid playerId, Guid adminPlayerId, string password, HttpContext ctx) { var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx); if (passwordError is not null) - return passwordError; + return ServiceResult.Failure(passwordError); var player = await db.Players.Include(p => p.Suggestions).FirstOrDefaultAsync(p => p.Id == playerId); if (player is null) - return EndpointHelpers.NotFoundError("Player not found."); + return ServiceResult.Failure(ServiceError.NotFound("Player not found.")); if (player.IsOwner) - return EndpointHelpers.BadRequestError("Owner account cannot be deleted."); + return ServiceResult.Failure(ServiceError.BadRequest("Owner account cannot be deleted.")); await using var tx = await db.Database.BeginTransactionAsync(); @@ -121,30 +121,30 @@ internal sealed class AdminWorkflowService(AppDbContext db) await db.SaveChangesAsync(); await tx.CommitAsync(); - return Results.Ok(new AdminDeletePlayerResponse(playerId)); + return ServiceResult.Success(new AdminDeletePlayerResponse(playerId)); } - public async Task LinkSuggestionsAsync(Guid adminPlayerId, int sourceSuggestionId, int targetSuggestionId) + public async Task> LinkSuggestionsAsync(Guid adminPlayerId, int sourceSuggestionId, int targetSuggestionId) { var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId); if (phase != Phase.Vote) - return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); + return ServiceResult.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase)); if (sourceSuggestionId == targetSuggestionId) - return EndpointHelpers.BadRequestError("Pick two different games to link."); + return ServiceResult.Failure(ServiceError.BadRequest("Pick two different games to link.")); var suggestions = await db.Suggestions.ToListAsync(); var source = suggestions.FirstOrDefault(s => s.Id == sourceSuggestionId); var target = suggestions.FirstOrDefault(s => s.Id == targetSuggestionId); if (source is null || target is null) - return EndpointHelpers.NotFoundError("Suggestion not found."); + return ServiceResult.Failure(ServiceError.NotFound("Suggestion not found.")); var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId))); if (!rootIndex.TryGetValue(source.Id, out var sourceRoot) || !rootIndex.TryGetValue(target.Id, out var targetRoot)) - return EndpointHelpers.NotFoundError("Suggestion not found."); + return ServiceResult.Failure(ServiceError.NotFound("Suggestion not found.")); if (sourceRoot == targetRoot) - return EndpointHelpers.BadRequestError("These games are already linked."); + return ServiceResult.Failure(ServiceError.BadRequest("These games are already linked.")); var affectedRootIds = new HashSet { @@ -176,23 +176,23 @@ internal sealed class AdminWorkflowService(AppDbContext db) await tx.CommitAsync(); - return Results.Ok(new AdminLinkSuggestionsResponse(targetRoot, affectedIds, await db.Players.CountAsync())); + return ServiceResult.Success(new AdminLinkSuggestionsResponse(targetRoot, affectedIds, await db.Players.CountAsync())); } - public async Task UnlinkSuggestionsAsync(Guid adminPlayerId, int suggestionId) + public async Task> UnlinkSuggestionsAsync(Guid adminPlayerId, int suggestionId) { var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, adminPlayerId); if (phase != Phase.Vote) - return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); + return ServiceResult.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase)); var suggestions = await db.Suggestions.ToListAsync(); var target = suggestions.FirstOrDefault(s => s.Id == suggestionId); if (target is null) - return Results.Ok(new AdminUnlinkSuggestionsResponse(Array.Empty(), 0)); + return ServiceResult.Success(new AdminUnlinkSuggestionsResponse(Array.Empty(), 0)); var rootIndex = EndpointHelpers.BuildLinkRoots(suggestions.Select(s => (s.Id, s.ParentSuggestionId))); if (!rootIndex.TryGetValue(target.Id, out var rootId)) - return Results.Ok(new AdminUnlinkSuggestionsResponse(Array.Empty(), 0)); + return ServiceResult.Success(new AdminUnlinkSuggestionsResponse(Array.Empty(), 0)); 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(); - return Results.Ok(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync())); + return ServiceResult.Success(new AdminUnlinkSuggestionsResponse(groupIds, await db.Players.CountAsync())); } - public async Task ResetAsync(Guid adminPlayerId, string password, HttpContext ctx) + public async Task> ResetAsync(Guid adminPlayerId, string password, HttpContext ctx) { var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx); if (passwordError is not null) - return passwordError; + return ServiceResult.Failure(passwordError); await using var tx = await db.Database.BeginTransactionAsync(); @@ -232,14 +232,14 @@ internal sealed class AdminWorkflowService(AppDbContext db) await db.SaveChangesAsync(); await tx.CommitAsync(); - return Results.Ok(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt)); + return ServiceResult.Success(new AdminResetStateResponse(Phase.Suggest, state.ResultsOpen, state.UpdatedAt)); } - public async Task FactoryResetAsync(Guid adminPlayerId, string password, HttpContext ctx) + public async Task> FactoryResetAsync(Guid adminPlayerId, string password, HttpContext ctx) { var passwordError = await ValidateAdminPasswordAsync(adminPlayerId, password, ctx); if (passwordError is not null) - return passwordError; + return ServiceResult.Failure(passwordError); await using var tx = await db.Database.BeginTransactionAsync(); @@ -254,24 +254,24 @@ internal sealed class AdminWorkflowService(AppDbContext db) await tx.CommitAsync(); - return Results.Ok(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt)); + return ServiceResult.Success(new AdminResetStateResponse(Phase.Suggest, fresh.ResultsOpen, fresh.UpdatedAt)); } - private async Task ValidateAdminPasswordAsync(Guid adminPlayerId, string password, HttpContext ctx) + private async Task ValidateAdminPasswordAsync(Guid adminPlayerId, string password, HttpContext ctx) { if (string.IsNullOrWhiteSpace(password)) - return EndpointHelpers.BadRequestError("Admin password is required."); + return ServiceError.BadRequest("Admin password is required."); var admin = await db.Players.AsNoTracking().FirstOrDefaultAsync(p => p.Id == adminPlayerId && p.IsAdmin); if (admin is null) - return EndpointHelpers.UnauthorizedError(); + return ServiceError.Unauthorized(); var monitor = ctx.RequestServices.GetRequiredService(); var verified = PasswordHasher.Verify(password, admin.PasswordHash, admin.PasswordSalt); if (!verified) { 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); diff --git a/Endpoints/AuthEndpoints.cs b/Endpoints/AuthEndpoints.cs index 9c54d1e..b4da1a5 100644 --- a/Endpoints/AuthEndpoints.cs +++ b/Endpoints/AuthEndpoints.cs @@ -23,7 +23,7 @@ public static class AuthEndpoints { 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); } @@ -31,7 +31,7 @@ public static class AuthEndpoints if (exists) return EndpointHelpers.ConflictError("Username already taken."); - var (hash, salt) = PasswordHasher.HashPassword(request.Password); + var (hash, salt) = PasswordHasher.HashPassword(validated.Password); var expectedAdminKey = config["ADMIN_PASSWORD"]; var wantsAdmin = !string.IsNullOrWhiteSpace(validated.AdminKey); if (wantsAdmin) @@ -68,7 +68,19 @@ public static class AuthEndpoints }; db.Players.Add(player); - await db.SaveChangesAsync(); + try + { + await db.SaveChangesAsync(); + } + catch (DbUpdateException ex) when (isOwner && EndpointHelpers.IsSqliteConstraintViolation(ex, EndpointHelpers.SingleOwnerIndexName)) + { + authAttemptMonitor.RecordFailure(ctx, "auth-register-admin", validated.NormalizedUsername, "bootstrap-admin-race"); + return EndpointHelpers.BadRequestError("Admin registration via admin key is disabled once an owner account exists."); + } + catch (DbUpdateException ex) when (EndpointHelpers.IsSqliteConstraintViolation(ex, "IX_Players_NormalizedUsername")) + { + return EndpointHelpers.ConflictError("Username already taken."); + } if (isAdmin) authAttemptMonitor.RecordSuccess(ctx, "auth-register-admin", validated.NormalizedUsername); @@ -87,12 +99,12 @@ public static class AuthEndpoints { 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); } var player = await db.Players.FirstOrDefaultAsync(p => p.NormalizedUsername == normalizedUsername); - if (player == null || !PasswordHasher.Verify(request.Password, player.PasswordHash, player.PasswordSalt)) + if (player == null || !PasswordHasher.Verify(request.Password ?? string.Empty, player.PasswordHash, player.PasswordSalt)) { authAttemptMonitor.RecordFailure(ctx, "auth-login", normalizedUsername, "invalid-credentials"); return EndpointHelpers.UnauthorizedError("Invalid username or password."); @@ -123,4 +135,6 @@ public static class AuthEndpoints return Results.NoContent(); }); } + + private static string NormalizeActor(string? username) => string.IsNullOrWhiteSpace(username) ? "(missing)" : username.Trim(); } diff --git a/Endpoints/AuthValidator.cs b/Endpoints/AuthValidator.cs index 6d3dd09..26c4d6e 100644 --- a/Endpoints/AuthValidator.cs +++ b/Endpoints/AuthValidator.cs @@ -12,7 +12,7 @@ internal static class AuthValidator public static bool TryValidateRegistration(RegisterRequest request, out ValidatedRegistration validated, out string error) { - var username = (request.Username).Trim(); + var username = (request.Username ?? string.Empty).Trim(); if (string.IsNullOrWhiteSpace(username) || username.Length > MaxUsernameLength) { validated = default; @@ -61,14 +61,14 @@ internal static class AuthValidator } var adminKey = EndpointHelpers.TrimTo(request.AdminKey, MaxAdminKeyLength); - validated = new ValidatedRegistration(username, username.ToLowerInvariant(), displayName, adminKey); + validated = new ValidatedRegistration(username, username.ToLowerInvariant(), password, displayName, adminKey); error = string.Empty; return true; } public static bool TryValidateLogin(LoginRequest request, out string username, out string normalizedUsername, out string error) { - username = (request.Username).Trim(); + username = (request.Username ?? string.Empty).Trim(); normalizedUsername = string.Empty; if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password)) @@ -94,5 +94,5 @@ internal static class AuthValidator 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); } diff --git a/Endpoints/EndpointHelpers.cs b/Endpoints/EndpointHelpers.cs index 061abc6..f98e6d8 100644 --- a/Endpoints/EndpointHelpers.cs +++ b/Endpoints/EndpointHelpers.cs @@ -1,5 +1,6 @@ using GameList.Data; using GameList.Domain; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using System.Net; using System.Net.Sockets; @@ -9,6 +10,9 @@ namespace GameList.Endpoints; internal static class EndpointHelpers { + public const string SingleOwnerIndexName = "IX_Players_IsOwner"; + public const string SuggestionLimitTriggerError = "suggestion_limit_exceeded"; + public static async Task GetAuthenticatedPlayer(HttpContext ctx, AppDbContext db) { 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 ToHttpResult(this ServiceResult result, Func onSuccess) + { + if (result.IsSuccess) + return onSuccess(result.Value!); + + return ToHttpError(result.Error!); + } + + public static IResult ToHttpResult(this ServiceResult result, Func onSuccess) + { + if (result.IsSuccess) + return onSuccess(); + + return ToHttpError(result.Error!); + } + + public static bool IsSqliteConstraintViolation(DbUpdateException ex) + { + return ex.InnerException is SqliteException sqliteEx + && sqliteEx.SqliteErrorCode == 19; + } + + public static bool IsSqliteConstraintViolation(DbUpdateException ex, string containsMessage) + { + if (!IsSqliteConstraintViolation(ex)) + return false; + + return ex.InnerException?.Message.Contains(containsMessage, StringComparison.OrdinalIgnoreCase) == true; + } + private static IResult Problem(int statusCode, string title, string detail) { return Results.Problem( @@ -142,6 +176,18 @@ internal static class EndpointHelpers || path.EndsWith(".avif", StringComparison.Ordinal); } + private static IResult ToHttpError(ServiceError error) + { + return error.Code switch + { + ServiceErrorCode.BadRequest => BadRequestError(error.Detail), + ServiceErrorCode.Unauthorized => UnauthorizedError(error.Detail), + ServiceErrorCode.NotFound => NotFoundError(error.Detail), + ServiceErrorCode.Conflict => ConflictError(error.Detail), + _ => Problem(StatusCodes.Status500InternalServerError, "Internal Server Error", "Unhandled service error.") + }; + } + public static HttpMessageHandler CreateImageValidationHandler() { return new SocketsHttpHandler diff --git a/Endpoints/ResultsEndpoints.cs b/Endpoints/ResultsEndpoints.cs index 2840f0c..3863d6a 100644 --- a/Endpoints/ResultsEndpoints.cs +++ b/Endpoints/ResultsEndpoints.cs @@ -18,7 +18,8 @@ public static class ResultsEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.GetResultsAsync(player.Id); + var result = await service.GetResultsAsync(player.Id); + return result.ToHttpResult(Results.Ok); }); } } diff --git a/Endpoints/ResultsWorkflowService.cs b/Endpoints/ResultsWorkflowService.cs index aed8267..4453869 100644 --- a/Endpoints/ResultsWorkflowService.cs +++ b/Endpoints/ResultsWorkflowService.cs @@ -7,15 +7,15 @@ namespace GameList.Endpoints; internal sealed class ResultsWorkflowService(AppDbContext db) { - public async Task GetResultsAsync(Guid playerId) + public async Task>> GetResultsAsync(Guid playerId) { var appState = await db.AppState.AsNoTracking().SingleAsync(); if (!appState.ResultsOpen) - return EndpointHelpers.BadRequestError("Results are locked until the admin enables them."); + return ServiceResult>.Failure(ServiceError.BadRequest("Results are locked until the admin enables them.")); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase != Phase.Results) - return EndpointHelpers.PhaseMismatch(Phase.Results, phase); + return ServiceResult>.Failure(ServiceError.PhaseMismatch(Phase.Results, phase)); var results = await db .Suggestions.AsNoTracking() @@ -49,7 +49,7 @@ internal sealed class ResultsWorkflowService(AppDbContext db) var rootIndex = EndpointHelpers.BuildLinkRoots(results.Select(r => (r.Id, r.ParentSuggestionId))); var nameLookup = results.ToDictionary(r => r.Id, r => r.Name); - var shaped = results.Select(r => + IReadOnlyList shaped = results.Select(r => { var linkedIds = EndpointHelpers.LinkedIdsFor(r.Id, rootIndex) .Where(id => id != r.Id) @@ -80,8 +80,8 @@ internal sealed class ResultsWorkflowService(AppDbContext db) linkedIds, linkedTitles ); - }); + }).ToList(); - return Results.Ok(shaped); + return ServiceResult>.Success(shaped); } } diff --git a/Endpoints/ServiceResult.cs b/Endpoints/ServiceResult.cs new file mode 100644 index 0000000..d3818c8 --- /dev/null +++ b/Endpoints/ServiceResult.cs @@ -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? Value, ServiceError? Error) +{ + public bool IsSuccess => Error is null; + + public static ServiceResult Success(T value) => new(value, null); + + public static ServiceResult Failure(ServiceError error) => new(default, error); +} diff --git a/Endpoints/StateEndpoints.cs b/Endpoints/StateEndpoints.cs index b439139..fdbaad4 100644 --- a/Endpoints/StateEndpoints.cs +++ b/Endpoints/StateEndpoints.cs @@ -14,7 +14,8 @@ public static class StateEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.GetStateAsync(player); + var result = await service.GetStateAsync(player); + return result.ToHttpResult(Results.Ok); }); group.MapGet("/me", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) => @@ -23,7 +24,8 @@ public static class StateEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.GetMeAsync(player); + var result = await service.GetMeAsync(player); + return result.ToHttpResult(Results.Ok); }); group.MapPost("/me/phase/next", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) => @@ -32,7 +34,8 @@ public static class StateEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.NextPhaseAsync(player); + var result = await service.NextPhaseAsync(player); + return result.ToHttpResult(Results.Ok); }); group.MapPost("/me/phase/prev", async (HttpContext ctx, AppDbContext db, StateWorkflowService service) => @@ -41,7 +44,8 @@ public static class StateEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.PrevPhaseAsync(player); + var result = await service.PrevPhaseAsync(player); + return result.ToHttpResult(Results.Ok); }); } diff --git a/Endpoints/StateWorkflowService.cs b/Endpoints/StateWorkflowService.cs index 8c4512f..4344ce0 100644 --- a/Endpoints/StateWorkflowService.cs +++ b/Endpoints/StateWorkflowService.cs @@ -7,22 +7,22 @@ namespace GameList.Endpoints; internal sealed class StateWorkflowService(AppDbContext db) { - public async Task GetStateAsync(Player player) + public async Task> GetStateAsync(Player player) { var state = await db.AppState.AsNoTracking().SingleAsync(); var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen); var summary = new StateSummaryResponse(phase, player.VotesFinal, player.HasJoker, state.ResultsOpen, state.UpdatedAt, await db.Players.CountAsync(), await db.Suggestions.CountAsync(), await db.Votes.CountAsync()); - return Results.Ok(summary); + return ServiceResult.Success(summary); } - public async Task GetMeAsync(Player player) + public async Task> GetMeAsync(Player player) { var state = await db.AppState.AsNoTracking().SingleAsync(); var phase = EndpointHelpers.GetCurrentPhase(player.CurrentPhase, state.ResultsOpen); - return Results.Ok(new MeResponse(player.Id, player.Username, player.DisplayName, player.IsAdmin, player.IsOwner, phase, player.VotesFinal, player.HasJoker)); + return ServiceResult.Success(new MeResponse(player.Id, player.Username, player.DisplayName, player.IsAdmin, player.IsOwner, phase, player.VotesFinal, player.HasJoker)); } - public async Task NextPhaseAsync(Player player) + public async Task> NextPhaseAsync(Player player) { var appState = await db.AppState.SingleAsync(); 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); if (!hasSuggestions) - return EndpointHelpers.BadRequestError("Add at least one suggestion before entering the Vote phase."); + return ServiceResult.Failure(ServiceError.BadRequest("Add at least one suggestion before entering the Vote phase.")); } if (next == Phase.Results && !appState.ResultsOpen) - return EndpointHelpers.BadRequestError("Results are locked until the admin enables them."); + return ServiceResult.Failure(ServiceError.BadRequest("Results are locked until the admin enables them.")); player.CurrentPhase = next; player.VotesFinal = false; // moving forward clears any prior finalize shouldSave = true; - return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen)); + return ServiceResult.Success(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen)); } finally { @@ -53,10 +53,10 @@ internal sealed class StateWorkflowService(AppDbContext db) } } - public async Task PrevPhaseAsync(Player player) + public async Task> PrevPhaseAsync(Player player) { if (!player.IsAdmin) - return EndpointHelpers.BadRequestError("Only admins can move backward."); + return ServiceResult.Failure(ServiceError.BadRequest("Only admins can move backward.")); var appState = await db.AppState.SingleAsync(); _ = EndpointHelpers.ReconcilePlayerPhase(player, appState.ResultsOpen); @@ -64,7 +64,7 @@ internal sealed class StateWorkflowService(AppDbContext db) player.CurrentPhase = PrevPhase(player.CurrentPhase); player.VotesFinal = false; await db.SaveChangesAsync(); - return Results.Ok(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen)); + return ServiceResult.Success(new PhaseTransitionResponse(player.CurrentPhase, appState.ResultsOpen)); } private static Phase NextPhase(Phase current) => current switch diff --git a/Endpoints/SuggestEndpoints.cs b/Endpoints/SuggestEndpoints.cs index 6c78d4a..98e9219 100644 --- a/Endpoints/SuggestEndpoints.cs +++ b/Endpoints/SuggestEndpoints.cs @@ -17,7 +17,8 @@ public static class SuggestEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.GetMineAsync(player.Id); + var result = await service.GetMineAsync(player.Id); + return result.ToHttpResult(Results.Ok); }); group.MapPost("/", async ([FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => @@ -26,7 +27,7 @@ public static class SuggestEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.CreateAsync( + var result = await service.CreateAsync( player.Id, new SuggestionInput( request.Name, @@ -39,6 +40,8 @@ public static class SuggestEndpoints request.MaxPlayers ) ); + + return result.ToHttpResult(payload => Results.Created($"/api/suggestions/{payload.Id}", payload)); }).AddEndpointFilter(new PhaseOrJokerFilter()); group.MapDelete("/{id:int}", async (int id, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => @@ -47,7 +50,8 @@ public static class SuggestEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.DeleteAsync(player.Id, id); + var result = await service.DeleteAsync(player.Id, id); + return result.ToHttpResult(Results.NoContent); }); group.MapPut("/{id:int}", async (int id, [FromBody] SuggestionRequest request, HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => @@ -56,7 +60,7 @@ public static class SuggestEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.UpdateAsync( + var result = await service.UpdateAsync( player.Id, id, new SuggestionInput( @@ -70,6 +74,8 @@ public static class SuggestEndpoints request.MaxPlayers ) ); + + return result.ToHttpResult(Results.Ok); }); group.MapGet("/all", async (HttpContext ctx, AppDbContext db, SuggestionWorkflowService service) => @@ -78,7 +84,8 @@ public static class SuggestEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.GetAllAsync(player.Id); + var result = await service.GetAllAsync(player.Id); + return result.ToHttpResult(Results.Ok); }); } } diff --git a/Endpoints/SuggestionValidator.cs b/Endpoints/SuggestionValidator.cs index 84ea3bf..6641788 100644 --- a/Endpoints/SuggestionValidator.cs +++ b/Endpoints/SuggestionValidator.cs @@ -1,8 +1,14 @@ +using System.Collections.Concurrent; + namespace GameList.Endpoints; internal static class SuggestionValidator { - public static async Task ValidateAsync(SuggestionInput input, IHttpClientFactory httpFactory) + private static readonly ConcurrentDictionary ImageReachabilityCache = new(StringComparer.OrdinalIgnoreCase); + private static readonly TimeSpan ReachableCacheTtl = TimeSpan.FromMinutes(15); + private static readonly TimeSpan UnreachableCacheTtl = TimeSpan.FromMinutes(2); + + public static async Task ValidateAsync(SuggestionInput input, IHttpClientFactory httpFactory, bool shouldValidateImageReachability = true) { if (string.IsNullOrWhiteSpace(input.Name) || input.Name.Length > 100) return "Name is required and must be <= 100 characters."; @@ -10,7 +16,7 @@ internal static class SuggestionValidator if (!EndpointHelpers.IsValidImageUrl(input.ScreenshotUrl)) return "Screenshot URL must be http(s) and end with an image file extension."; - if (!await EndpointHelpers.IsReachableImageAsync(input.ScreenshotUrl, httpFactory)) + if (shouldValidateImageReachability && !await IsReachableImageCachedAsync(input.ScreenshotUrl, httpFactory)) return "Screenshot URL could not be validated as an image. Use a public image link (http/https, no redirects, max 5 MB)."; if (!EndpointHelpers.IsValidHttpUrl(input.GameUrl)) @@ -22,6 +28,21 @@ internal static class SuggestionValidator return ValidatePlayers(input.MinPlayers, input.MaxPlayers); } + private static async Task IsReachableImageCachedAsync(string? url, IHttpClientFactory httpFactory) + { + if (string.IsNullOrWhiteSpace(url)) + return true; + + var normalized = url.Trim(); + if (ImageReachabilityCache.TryGetValue(normalized, out var cached) && cached.ExpiresAt > DateTimeOffset.UtcNow) + return cached.Reachable; + + var reachable = await EndpointHelpers.IsReachableImageAsync(normalized, httpFactory); + var ttl = reachable ? ReachableCacheTtl : UnreachableCacheTtl; + ImageReachabilityCache[normalized] = (reachable, DateTimeOffset.UtcNow.Add(ttl)); + return reachable; + } + private static string? ValidatePlayers(int? minPlayers, int? maxPlayers) { if (minPlayers is null && maxPlayers is null) diff --git a/Endpoints/SuggestionWorkflowService.cs b/Endpoints/SuggestionWorkflowService.cs index 242cc36..08dc54b 100644 --- a/Endpoints/SuggestionWorkflowService.cs +++ b/Endpoints/SuggestionWorkflowService.cs @@ -7,7 +7,7 @@ namespace GameList.Endpoints; internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFactory httpFactory) { - public async Task GetMineAsync(Guid playerId) + public async Task>> GetMineAsync(Guid playerId) { var mine = await db.Suggestions .AsNoTracking() @@ -29,18 +29,19 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact }) .ToListAsync(); - var ordered = mine + IReadOnlyList ordered = mine .OrderBy(s => s.CreatedAt) - .Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId)); + .Select(s => new SuggestionDto(s.Id, s.Name, s.Genre, s.Description, s.ScreenshotUrl, s.YoutubeUrl, s.GameUrl, s.MinPlayers, s.MaxPlayers, s.ParentSuggestionId)) + .ToList(); - return Results.Ok(ordered); + return ServiceResult>.Success(ordered); } - public async Task CreateAsync(Guid playerId, SuggestionInput input) + public async Task> CreateAsync(Guid playerId, SuggestionInput input) { var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory); if (validationError is not null) - return EndpointHelpers.BadRequestError(validationError); + return ServiceResult.Failure(ServiceError.BadRequest(validationError)); var playerState = await db.Players .AsNoTracking() @@ -55,14 +56,14 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); var usingJoker = phase == Phase.Vote && playerState.HasJoker; if (phase != Phase.Suggest && !usingJoker) - return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); + return ServiceResult.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase)); if (string.IsNullOrWhiteSpace(playerState.DisplayName)) - return EndpointHelpers.BadRequestError("Set a display name before submitting suggestions."); + return ServiceResult.Failure(ServiceError.BadRequest("Set a display name before submitting suggestions.")); - var existingCount = await db.Suggestions.CountAsync(s => s.PlayerId == playerId); + var existingCount = await db.Suggestions.AsNoTracking().CountAsync(s => s.PlayerId == playerId); if (!usingJoker && existingCount >= 5) - return EndpointHelpers.BadRequestError("You have reached the 5 suggestion limit."); + return ServiceResult.Failure(ServiceError.BadRequest("You have reached the 5 suggestion limit.")); var suggestion = new Suggestion { @@ -81,21 +82,29 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact db.Suggestions.Add(suggestion); - if (usingJoker) + try { - await db.Players - .Where(p => p.Id == playerId) - .ExecuteUpdateAsync(p => p.SetProperty(x => x.HasJoker, false)); - await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false)); + await db.SaveChangesAsync(); + + if (usingJoker) + { + await db.Players + .Where(p => p.Id == playerId) + .ExecuteUpdateAsync(p => p.SetProperty(x => x.HasJoker, false)); + await db.Players.ExecuteUpdateAsync(p => p.SetProperty(x => x.VotesFinal, false)); + } + + await tx.CommitAsync(); + } + catch (DbUpdateException ex) when (EndpointHelpers.IsSqliteConstraintViolation(ex, EndpointHelpers.SuggestionLimitTriggerError)) + { + return ServiceResult.Failure(ServiceError.BadRequest("You have reached the 5 suggestion limit.")); } - await db.SaveChangesAsync(); - await tx.CommitAsync(); - - return Results.Created($"/api/suggestions/{suggestion.Id}", new SuggestionCreatedResponse(suggestion.Id)); + return ServiceResult.Success(new SuggestionCreatedResponse(suggestion.Id)); } - public async Task DeleteAsync(Guid playerId, int suggestionId) + public async Task> DeleteAsync(Guid playerId, int suggestionId) { var actor = await db.Players .AsNoTracking() @@ -111,14 +120,14 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact { var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase != Phase.Suggest) - return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); + return ServiceResult.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase)); } var suggestion = isAdmin ? await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId) : await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId && s.PlayerId == playerId); if (suggestion == null) - return EndpointHelpers.NotFoundError("Suggestion not found."); + return ServiceResult.Failure(ServiceError.NotFound("Suggestion not found.")); await using var tx = await db.Database.BeginTransactionAsync(); @@ -131,15 +140,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact db.Suggestions.Remove(suggestion); await db.SaveChangesAsync(); await tx.CommitAsync(); - return Results.NoContent(); + return ServiceResult.Success(default); } - public async Task UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input) + public async Task> UpdateAsync(Guid playerId, int suggestionId, SuggestionInput input) { - var validationError = await SuggestionValidator.ValidateAsync(input, httpFactory); - if (validationError is not null) - return EndpointHelpers.BadRequestError(validationError); - var actor = await db.Players .AsNoTracking() .Where(p => p.Id == playerId) @@ -151,17 +156,22 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact var suggestion = await db.Suggestions.FirstOrDefaultAsync(s => s.Id == suggestionId); if (suggestion == null) - return EndpointHelpers.NotFoundError("Suggestion not found."); + return ServiceResult.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.Failure(ServiceError.BadRequest(validationError)); var isAdmin = actor.IsAdmin; if (!isAdmin) { if (suggestion.PlayerId != playerId) - return EndpointHelpers.UnauthorizedError(); + return ServiceResult.Failure(ServiceError.Unauthorized()); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase == Phase.Results) - return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); + return ServiceResult.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase)); if (phase == Phase.Suggest) { @@ -169,7 +179,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact } else if (phase != Phase.Vote) { - return EndpointHelpers.PhaseMismatch(Phase.Suggest, phase); + return ServiceResult.Failure(ServiceError.PhaseMismatch(Phase.Suggest, phase)); } ApplyEditableFields(suggestion, input); @@ -182,7 +192,7 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact await db.SaveChangesAsync(); - return Results.Ok(new SuggestionUpdatedResponse( + return ServiceResult.Success(new SuggestionUpdatedResponse( suggestion.Id, suggestion.Name, suggestion.Genre, @@ -195,11 +205,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact )); } - public async Task GetAllAsync(Guid playerId) + public async Task>> GetAllAsync(Guid playerId) { var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase < Phase.Vote) - return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); + return ServiceResult>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase)); var all = await db.Suggestions .AsNoTracking() @@ -225,12 +235,11 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact var rootIndex = EndpointHelpers.BuildLinkRoots(all.Select(s => (s.Id, s.ParentSuggestionId))); var nameLookup = all.ToDictionary(s => s.Id, s => s.Name); - var ordered = all.OrderBy(s => s.CreatedAt).Select(s => + IReadOnlyList ordered = all.OrderBy(s => s.CreatedAt).Select(s => { var linkedIds = EndpointHelpers.LinkedIdsFor(s.Id, rootIndex).Where(id => id != s.Id).ToList(); - return new - { + return new SuggestionAllDto( s.Id, s.Name, s.Genre, @@ -243,12 +252,12 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact s.Author, s.ParentSuggestionId, s.IsOwner, - LinkedIds = linkedIds, - LinkedTitles = linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList() - }; - }); + linkedIds, + linkedIds.Where(nameLookup.ContainsKey).Select(id => nameLookup[id]).ToList() + ); + }).ToList(); - return Results.Ok(ordered); + return ServiceResult>.Success(ordered); } private static void ApplyEditableFields(Suggestion suggestion, SuggestionInput input) @@ -261,4 +270,10 @@ internal sealed class SuggestionWorkflowService(AppDbContext db, IHttpClientFact suggestion.MinPlayers = input.MinPlayers; suggestion.MaxPlayers = input.MaxPlayers; } + + private static bool ShouldValidateScreenshotReachability(string? requestedScreenshotUrl, string? existingScreenshotUrl) + { + var normalizedRequested = EndpointHelpers.TrimTo(requestedScreenshotUrl, 2048); + return !string.Equals(normalizedRequested, existingScreenshotUrl, StringComparison.Ordinal); + } } diff --git a/Endpoints/VoteEndpoints.cs b/Endpoints/VoteEndpoints.cs index 0fdbf75..54df5a0 100644 --- a/Endpoints/VoteEndpoints.cs +++ b/Endpoints/VoteEndpoints.cs @@ -17,7 +17,8 @@ public static class VoteEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.GetMineAsync(player.Id); + var result = await service.GetMineAsync(player.Id); + return result.ToHttpResult(Results.Ok); }); group.MapPost("/", async (VoteRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) => @@ -25,7 +26,9 @@ public static class VoteEndpoints var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.UpsertAsync(player.Id, request.SuggestionId, request.Score); + + var result = await service.UpsertAsync(player.Id, request.SuggestionId, request.Score); + return result.ToHttpResult(Results.Ok); }); group.MapPost("/finalize", async (VoteFinalizeRequest request, HttpContext ctx, AppDbContext db, VoteWorkflowService service) => @@ -34,7 +37,8 @@ public static class VoteEndpoints if (player is null) return EndpointHelpers.UnauthorizedError(); - return await service.SetFinalizeAsync(player.Id, request.Final); + var result = await service.SetFinalizeAsync(player.Id, request.Final); + return result.ToHttpResult(Results.Ok); }); } } diff --git a/Endpoints/VoteWorkflowService.cs b/Endpoints/VoteWorkflowService.cs index 3aeec65..82b75b3 100644 --- a/Endpoints/VoteWorkflowService.cs +++ b/Endpoints/VoteWorkflowService.cs @@ -2,34 +2,31 @@ using GameList.Contracts; using GameList.Data; using GameList.Domain; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; namespace GameList.Endpoints; internal sealed class VoteWorkflowService(AppDbContext db) { - public async Task GetMineAsync(Guid playerId) + public async Task>> GetMineAsync(Guid playerId) { var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase != Phase.Vote) - return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); + return ServiceResult>.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase)); - var votes = await db.Votes + IReadOnlyList votes = await db.Votes .AsNoTracking() .Where(v => v.PlayerId == playerId) - .Select(v => new - { - v.SuggestionId, - v.Score - }) + .Select(v => new VoteRecordDto(v.SuggestionId, v.Score)) .ToListAsync(); - return Results.Ok(votes); + return ServiceResult>.Success(votes); } - public async Task UpsertAsync(Guid playerId, int suggestionId, int score) + public async Task> UpsertAsync(Guid playerId, int suggestionId, int score) { if (score is < 0 or > 10) - return EndpointHelpers.BadRequestError("Score must be between 0 and 10."); + return ServiceResult.Failure(ServiceError.BadRequest("Score must be between 0 and 10.")); var playerState = await db.Players .AsNoTracking() @@ -42,14 +39,14 @@ internal sealed class VoteWorkflowService(AppDbContext db) .FirstAsync(); if (playerState.VotesFinal) - return EndpointHelpers.BadRequestError("Votes are finalized. Unfinalize before changing scores."); + return ServiceResult.Failure(ServiceError.BadRequest("Votes are finalized. Unfinalize before changing scores.")); var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase != Phase.Vote) - return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); + return ServiceResult.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase)); if (string.IsNullOrWhiteSpace(playerState.DisplayName)) - return EndpointHelpers.BadRequestError("Set a display name before voting."); + return ServiceResult.Failure(ServiceError.BadRequest("Set a display name before voting.")); var linkMap = await db.Suggestions .AsNoTracking() @@ -61,7 +58,7 @@ internal sealed class VoteWorkflowService(AppDbContext db) .ToListAsync(); var rootIndex = EndpointHelpers.BuildLinkRoots(linkMap.Select(s => (s.Id, s.ParentSuggestionId))); if (!rootIndex.ContainsKey(suggestionId)) - return EndpointHelpers.BadRequestError("Suggestion not found."); + return ServiceResult.Failure(ServiceError.BadRequest("Suggestion not found.")); var linkedIds = EndpointHelpers.LinkedIdsFor(suggestionId, rootIndex); if (linkedIds.Count == 0) @@ -71,38 +68,67 @@ internal sealed class VoteWorkflowService(AppDbContext db) .Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId)) .ToListAsync(); - foreach (var linkedSuggestionId in linkedIds) + for (var attempt = 0; attempt < 2; attempt++) { - var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId); - if (vote == null) + foreach (var linkedSuggestionId in linkedIds) { - db.Votes.Add(new Vote + var vote = existingVotes.FirstOrDefault(v => v.SuggestionId == linkedSuggestionId); + if (vote == null) { - PlayerId = playerId, - SuggestionId = linkedSuggestionId, - Score = score - }); + db.Votes.Add(new Vote + { + PlayerId = playerId, + SuggestionId = linkedSuggestionId, + Score = score + }); + } + else + { + vote.Score = score; + } } - else + + try { - vote.Score = score; + await db.SaveChangesAsync(); + return ServiceResult.Success(new VoteUpsertResponse(linkedIds, score)); + } + catch (DbUpdateException ex) when (attempt == 0 && EndpointHelpers.IsSqliteConstraintViolation(ex)) + { + DetachAddedVotes(db.ChangeTracker.Entries()); + + await db.Votes + .Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId)) + .ExecuteUpdateAsync(v => v.SetProperty(x => x.Score, score)); + + existingVotes = await db.Votes + .Where(v => v.PlayerId == playerId && linkedIds.Contains(v.SuggestionId)) + .ToListAsync(); } } - await db.SaveChangesAsync(); - return Results.Ok(new VoteUpsertResponse(linkedIds, score)); + return ServiceResult.Failure(ServiceError.Conflict("Vote update conflict. Please retry.")); } - public async Task SetFinalizeAsync(Guid playerId, bool final) + public async Task> SetFinalizeAsync(Guid playerId, bool final) { var phase = await EndpointHelpers.GetCurrentPhaseAsync(db, playerId); if (phase != Phase.Vote) - return EndpointHelpers.PhaseMismatch(Phase.Vote, phase); + return ServiceResult.Failure(ServiceError.PhaseMismatch(Phase.Vote, phase)); var player = await db.Players.FirstAsync(p => p.Id == playerId); player.VotesFinal = final; await db.SaveChangesAsync(); - return Results.Ok(new VoteFinalizeResponse(player.VotesFinal)); + return ServiceResult.Success(new VoteFinalizeResponse(player.VotesFinal)); + } + + private static void DetachAddedVotes(IEnumerable> voteEntries) + { + foreach (var entry in voteEntries) + { + if (entry.State == EntityState.Added) + entry.State = EntityState.Detached; + } } } diff --git a/GameList.Tests/AuthTests.cs b/GameList.Tests/AuthTests.cs index 46283f3..12595f4 100644 --- a/GameList.Tests/AuthTests.cs +++ b/GameList.Tests/AuthTests.cs @@ -1,6 +1,8 @@ using System.Net; using System.Net.Http.Json; using System.Text.Json; +using GameList.Data; +using GameList.Domain; using GameList.Infrastructure; using GameList.Tests.Support; using Microsoft.EntityFrameworkCore; @@ -210,6 +212,29 @@ public class AuthTests Assert.Equal(HttpStatusCode.BadRequest, badKey.StatusCode); } + [Fact] + public async Task Register_and_login_with_null_fields_return_bad_request() + { + await using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + + var register = await client.PostAsJsonAsync("/api/auth/register", new + { + Username = (string?)null, + Password = (string?)null, + DisplayName = (string?)null, + AdminKey = (string?)null + }); + Assert.Equal(HttpStatusCode.BadRequest, register.StatusCode); + + var login = await client.PostAsJsonAsync("/api/auth/login", new + { + Username = (string?)null, + Password = (string?)null + }); + Assert.Equal(HttpStatusCode.BadRequest, login.StatusCode); + } + [Fact] public async Task Non_admin_cannot_access_admin_routes() { @@ -247,4 +272,32 @@ public class AuthTests resp.EnsureSuccessStatusCode(); Assert.True(resp.Headers.TryGetValues("Set-Cookie", out var cookies) && cookies.Any(c => c.Contains("player"))); } + + [Fact] + public async Task Owner_uniqueness_is_enforced_by_database_constraint() + { + await using var factory = new TestWebApplicationFactory(); + var ownerClient = factory.CreateClientWithCookies(); + await ownerClient.RegisterAsync("owner1", admin: true); + + var thrown = await Assert.ThrowsAsync(() => 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); + } } diff --git a/GameList.Tests/HelperTests.cs b/GameList.Tests/HelperTests.cs index 4e60309..5533a73 100644 --- a/GameList.Tests/HelperTests.cs +++ b/GameList.Tests/HelperTests.cs @@ -9,7 +9,6 @@ using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.TestHost; -using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Configuration; using System.Text.Json; using System.Net.Http.Json; @@ -28,34 +27,10 @@ public class HelperTests } [Fact] - public void UpdateIndexMetaBase_rewrites_content_value() + public void Program_does_not_include_runtime_index_rewrite_hook() { - var webRoot = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString()); - Directory.CreateDirectory(webRoot); - var index = Path.Combine(webRoot, "index.html"); - File.WriteAllText(index, ""); - - 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, ""); - - 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("", File.ReadAllText(index)); + var hasRewriteMethod = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).Any(m => m.Name.Contains("UpdateIndexMetaBase", StringComparison.Ordinal)); + Assert.False(hasRewriteMethod); } [Fact] @@ -349,16 +324,6 @@ public class HelperTests 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) { var method = typeof(Program).GetMethods(BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.Public).First(m => m.Name.Contains("BuildForwardedHeadersOptions")); diff --git a/GameList.Tests/SuggestionTests.cs b/GameList.Tests/SuggestionTests.cs index 46b6001..19cb02a 100644 --- a/GameList.Tests/SuggestionTests.cs +++ b/GameList.Tests/SuggestionTests.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Http.Json; using System.Text.Json; +using GameList.Domain; using GameList.Tests.Support; using Microsoft.EntityFrameworkCore; @@ -347,6 +348,45 @@ public class SuggestionTests Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } + [Fact] + public async Task Update_does_not_revalidate_unchanged_screenshot_url() + { + await using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + await client.RegisterAsync("reval"); + + var create = await client.PostAsJsonAsync("/api/suggestions", new + { + Name = "Reachable once", + Genre = (string?)null, + Description = (string?)null, + ScreenshotUrl = "http://example.com/shot.png", + YoutubeUrl = (string?)null, + GameUrl = (string?)null, + MinPlayers = (int?)null, + MaxPlayers = (int?)null + }); + create.EnsureSuccessStatusCode(); + var createdPayload = await create.Content.ReadFromJsonAsync(); + var suggestionId = createdPayload.GetProperty("id").GetInt32(); + + factory.HttpHandler.SetResponder(_ => new HttpResponseMessage(HttpStatusCode.BadRequest)); + + var update = await client.PutAsJsonAsync($"/api/suggestions/{suggestionId}", new + { + Name = "Reachable once", + Genre = "Updated", + Description = (string?)null, + ScreenshotUrl = "http://example.com/shot.png", + YoutubeUrl = (string?)null, + GameUrl = (string?)null, + MinPlayers = (int?)null, + MaxPlayers = (int?)null + }); + + update.EnsureSuccessStatusCode(); + } + [Fact] public async Task Get_all_requires_vote_phase() { @@ -626,4 +666,41 @@ public class SuggestionTests Assert.False(db.Votes.Any(v => v.SuggestionId == id)); }); } + + [Fact] + public async Task Suggestion_limit_is_enforced_by_database_trigger_without_joker() + { + await using var factory = new TestWebApplicationFactory(); + var client = factory.CreateClientWithCookies(); + await client.RegisterAsync("dbcap"); + + var playerId = await factory.WithDbContextAsync(async db => await db.Players.Select(p => p.Id).SingleAsync()); + + await factory.WithDbContextAsync(async db => + { + for (var i = 0; i < 5; i++) + { + db.Suggestions.Add(new Suggestion + { + PlayerId = playerId, + Name = $"Seed {i}" + }); + } + + await db.SaveChangesAsync(); + }); + + var thrown = await Assert.ThrowsAsync(() => 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); + } } diff --git a/GameList.Tests/Support/TestWebApplicationFactory.cs b/GameList.Tests/Support/TestWebApplicationFactory.cs index 5a4e1c3..e0655af 100644 --- a/GameList.Tests/Support/TestWebApplicationFactory.cs +++ b/GameList.Tests/Support/TestWebApplicationFactory.cs @@ -26,7 +26,7 @@ internal class TestWebApplicationFactory : WebApplicationFactory services.Remove(descriptor); } - _connection = new SqliteConnection("Data Source=:memory:;Cache=Shared"); + _connection = new SqliteConnection($"Data Source=file:tests-{Guid.NewGuid():N}?mode=memory&cache=shared"); _connection.Open(); services.AddDbContext(options => { options.UseSqlite(_connection); }); @@ -44,7 +44,6 @@ internal class TestWebApplicationFactory : WebApplicationFactory using var scope = host.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - db.Database.EnsureCreated(); db.Database.Migrate(); return host; diff --git a/IIS.md b/IIS.md index bad3291..997d8e1 100644 --- a/IIS.md +++ b/IIS.md @@ -8,6 +8,7 @@ ## Publish - From repo root: `dotnet publish -c Release -o publish` +- Before first start (and after every new migration): run `dotnet ef database update` from repo root against the target environment. - Copy `publish/` contents to site directory (keep `App_Data` writable by the app pool user). - Set environment variables in web.config or IIS config: - `ASPNETCORE_ENVIRONMENT=Production` @@ -21,6 +22,9 @@ - Optional: enable stdout logging in `web.config` during troubleshooting only; disable afterward. - Data protection keys are persisted to `App_Data/keys`; ensure this folder is deployed and writable so auth cookies stay valid across app pool recycles. - Frontend base path: set `` in `wwwroot/index.html` for production so API calls include the subpath (keep blank for local/root). +- Deployment script: copy `scripts/deploy-ftp.profile.sample.psd1` to `scripts/deploy-ftp.profile.psd1`, fill environment values, then run `pwsh ./scripts/deploy-ftp.ps1 -ProfilePath ./scripts/deploy-ftp.profile.psd1`. +- Shortcut command: run `pwsh ./deploy.ps1` from repo root to deploy with the local profile directly. +- Prefer `WinScpSessionName` in the deploy profile to avoid embedding FTP credentials in scripted URLs. ## Permissions - Grant modify rights to the app pool identity on `App_Data` (DB file + wal). diff --git a/Program.cs b/Program.cs index 97f6ab1..8748b25 100644 --- a/Program.cs +++ b/Program.cs @@ -146,7 +146,6 @@ var basePath = builder.Configuration["BasePath"]; if (!string.IsNullOrWhiteSpace(basePath)) { app.UsePathBase(basePath); - UpdateIndexMetaBase(app.Environment, basePath); } app.UseGlobalExceptionLogging(); @@ -154,13 +153,6 @@ app.UseAuthentication(); app.UseMiddleware(); app.UseAuthorization(); -// Ensure database and migrations are applied on startup -using (var scope = app.Services.CreateScope()) -{ - var db = scope.ServiceProvider.GetRequiredService(); - db.Database.Migrate(); -} - app.UseDefaultFiles(); app.UseStaticFiles(); @@ -274,42 +266,4 @@ static Task WriteUnauthorizedChallengeAsync(HttpContext context) return context.Response.WriteAsJsonAsync(problem); } -static void UpdateIndexMetaBase(IWebHostEnvironment env, string basePath) -{ - try - { - var indexPath = Path.Combine(env.WebRootPath, "index.html"); - if (!File.Exists(indexPath)) - return; - - var text = File.ReadAllText(indexPath); - var marker = "name=\"app-base\""; - var contentKey = "content=\""; - var markerIndex = text.IndexOf(marker, StringComparison.OrdinalIgnoreCase); - if (markerIndex < 0) - return; - - var contentIndex = text.IndexOf(contentKey, markerIndex, StringComparison.OrdinalIgnoreCase); - if (contentIndex < 0) - return; - - var valueStart = contentIndex + contentKey.Length; - var valueEnd = text.IndexOf('"', valueStart); - if (valueEnd < 0) - return; - - var current = text[valueStart..valueEnd]; - var normalized = basePath.EndsWith('/') ? basePath.TrimEnd('/') : basePath; - if (current == normalized) - return; - - var updated = text[..valueStart] + normalized + text[valueEnd..]; - File.WriteAllText(indexPath, updated); - } - catch - { - // If we can't rewrite, continue; frontend can still be set manually. - } -} - public partial class Program; diff --git a/README.md b/README.md index 87e0774..ed8e0e5 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,13 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS 1. Restore and build: `dotnet build GameList.sln` -2. Run tests: +2. Apply DB migrations explicitly: + `dotnet ef database update` +3. Run tests: `dotnet test GameList.Tests/GameList.Tests.csproj` -3. Run locally: +4. Run locally: `dotnet run --project GameList.csproj` -4. Open: +5. Open: `http://localhost:5000` (or the URL shown by `dotnet run`) ## Frontend Tooling @@ -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. - Admin authorization: authenticated account with `IsAdmin=true`. - Owner model: first valid admin-key registration becomes `owner`; admins can grant/revoke admin role for non-owner accounts. +- Core invariants are DB-enforced: single owner account and non-joker suggestion cap. - Gameplay phases: `Suggest`, `Vote`, `Results`. - Storage: SQLite database under `App_Data/gamelist.db`. +- Migrations are deployment-time operations (`dotnet ef database update`); app startup does not auto-migrate. - Security defaults: rate-limited auth/admin routes, baseline browser security headers, production HTTPS+HSTS enforcement. ## Module Ownership - `Program.cs`: startup wiring, middleware order, route registration. -- `Endpoints/`: HTTP endpoint transport + request orchestration. +- `Endpoints/`: endpoint adapters plus application workflow services (`ServiceResult` outputs mapped to HTTP at the edge). - `Infrastructure/`: filters, middleware, identity helpers. - `Data/`: EF Core `DbContext` and migrations. - `Domain/`: entities and enums. - `Contracts/`: request/response DTOs. - `wwwroot/`: static frontend assets. - `GameList.Tests/`: integration and helper tests. -- `scripts/`: deployment scripts. +- `scripts/`: deployment scripts (`scripts/deploy-ftp.ps1`, `scripts/deploy-ftp1.ps1`). +- `deploy.ps1`: local shortcut wrapper that runs FTP deploy using `scripts/deploy-ftp.profile.psd1`. ## Operations @@ -55,4 +60,5 @@ GitHub Actions workflow: `.github/workflows/ci.yml` - Restores dependencies - Runs frontend lint and format checks - Builds with warnings treated as errors -- Runs `GameList.Tests` +- Runs `GameList.Tests` with coverage collection +- Enforces minimum coverage thresholds (line 90%, branch 70%) diff --git a/TESTS.md b/TESTS.md index a1ff9a4..ad39e4a 100644 --- a/TESTS.md +++ b/TESTS.md @@ -33,7 +33,9 @@ stateDiagram-v2 ### 1) Authentication & Identity - Register success (player, admin key path) issues cookie, trims fields, stores normalized username, hashes password. - Register rejects missing/long username, weak password policy violations, missing display name, duplicate username, bad admin key, >24 chars username, >16 display name. +- Register/login null payload fields fail closed with `400` (no `500` on malformed JSON bodies). - Bootstrap-admin key path only works until the owner account exists; bootstrap admin is marked as owner. +- Database uniqueness guard enforces single owner row (`IsOwner=true`) even if writes bypass endpoint-level checks. - `/api/auth/options` reports owner presence for registration UI behavior. - Login success updates LastLoginAt and sets DisplayName if null; rejects wrong password/username; enforces length limits. - Logout clears cookie. @@ -50,9 +52,10 @@ stateDiagram-v2 ### 3) Suggestions - GET /mine returns only caller’s suggestions ordered by CreatedAt. - POST /: success with valid data; enforces ≤5 per player; trims optional fields; requires display name; rejects bad image URL/ext, unreachable image (mocked), invalid game/youtube URLs, invalid player counts, missing name/too long. +- DB trigger also enforces suggestion cap for non-joker inserts, protecting against concurrent over-limit writes. - Joker path: when phase=Vote and HasJoker=true allows creation, consumes joker, resets VotesFinal for all players. - Phase gating: non-admin cannot create/update/delete outside Suggest (except joker create); admin bypasses phase checks for update/delete. -- PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; validation mirrors create. +- PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; screenshot reachability check is skipped when screenshot URL is unchanged. - DELETE /{id}: player deletes own in Suggest; admin any time; also breaks child links and deletes related votes. - GET /all: accessible from Vote+, orders by CreatedAt, includes link metadata, enforces phase mismatch before Vote. @@ -60,6 +63,7 @@ stateDiagram-v2 - GET /mine: only in Vote, returns player votes; unauthorized/phase mismatch handled. - POST /: creates or updates vote; rejects score outside 0–10; rejects when VotesFinal=true; enforces display name requirement and phase gating. - Linked votes: when suggestions are linked, a single post updates all linked IDs; invalid suggestionId returns 400; linking root detection works for nested links. +- Concurrent vote upserts are handled with retry logic around unique-key conflicts to avoid server errors. - Finalize: POST /finalize toggles VotesFinal flag; allowed only in Vote. ### 5) Results @@ -84,13 +88,18 @@ stateDiagram-v2 - 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). - BuildLinkRoots/LinkedIdsFor/FindRootId: cover disjoint groups, chains, cycles guard (visited set), non-existent ids. -- UpdateIndexMetaBase (Program.cs): rewrites app-base meta when BasePath set; no change when matching/marker missing; safe exceptions swallowed. +- Program startup avoids runtime frontend file rewrites; BasePath remains purely configuration/deploy managed. - Global exception handler returns 500 with JSON body and logs error. - /health returns {status:"ok"}. - Security middleware tests validate response headers and rate-limiting behavior on auth/admin routes. - Frontend regression guard tests assert modal/admin JS no longer interpolate untrusted values in vulnerable patterns. +## Coverage Policy +- CI and local script enforce Cobertura thresholds from test coverage collection. +- Minimum line coverage: 90%. +- Minimum branch coverage: 70%. + ## Execution Notes - Use named test data builders for players/suggestions to keep cases small and isolated. - Reset in-memory DB per test to avoid cross-contamination; assert timestamps using time providers or approximate windows. -- Cover success + failure for every endpoint status path to reach 100% line/branch coverage. +- Cover success + failure for endpoint status paths and critical helper branches to stay above enforced thresholds. diff --git a/deploy.ps1 b/deploy.ps1 new file mode 100644 index 0000000..bf03d4c --- /dev/null +++ b/deploy.ps1 @@ -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 diff --git a/package.json b/package.json index 6c1b0d3..1eb02fc 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "type": "module", "scripts": { "lint": "eslint \"wwwroot/**/*.js\"", - "format": "prettier --write \"eslint.config.js\" \"wwwroot/js/i18n.js\" \"wwwroot/js/{admin-ui,app-admin-handlers,app-auth-handlers,app-vote-nav-handlers,auth-ui,modals-ui,results-ui,suggestions-ui,ui-runtime,ui-utils,ui,votes-ui}.js\"", - "format:check": "prettier --check \"eslint.config.js\" \"wwwroot/js/i18n.js\" \"wwwroot/js/{admin-ui,app-admin-handlers,app-auth-handlers,app-vote-nav-handlers,auth-ui,modals-ui,results-ui,suggestions-ui,ui-runtime,ui-utils,ui,votes-ui}.js\"" + "format": "prettier --write \"eslint.config.js\" \"wwwroot/**/*.js\"", + "format:check": "prettier --check \"eslint.config.js\" \"wwwroot/**/*.js\"" }, "devDependencies": { "@eslint/js": "9.21.0", diff --git a/scripts/check-coverage.ps1 b/scripts/check-coverage.ps1 new file mode 100644 index 0000000..05d0789 --- /dev/null +++ b/scripts/check-coverage.ps1 @@ -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." +} diff --git a/scripts/ci-local.ps1 b/scripts/ci-local.ps1 index 42fe9a4..5d4a90b 100644 --- a/scripts/ci-local.ps1 +++ b/scripts/ci-local.ps1 @@ -53,13 +53,17 @@ try { Invoke-Step -Name "Run tests" -Action { if ($SkipBuild) { - dotnet test GameList.Tests/GameList.Tests.csproj --verbosity normal + dotnet test GameList.Tests/GameList.Tests.csproj --verbosity normal --collect:"XPlat Code Coverage" } else { - dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal + dotnet test GameList.Tests/GameList.Tests.csproj --no-build --verbosity normal --collect:"XPlat Code Coverage" } } + Invoke-Step -Name "Enforce coverage thresholds" -Action { + pwsh ./scripts/check-coverage.ps1 -MinLineRate 0.90 -MinBranchRate 0.70 + } + Write-Host "CI checks passed." } finally { diff --git a/scripts/deploy-ftp.profile.sample.psd1 b/scripts/deploy-ftp.profile.sample.psd1 new file mode 100644 index 0000000..5e20b98 --- /dev/null +++ b/scripts/deploy-ftp.profile.sample.psd1 @@ -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" +} diff --git a/scripts/deploy-ftp.ps1 b/scripts/deploy-ftp.ps1 index 4ae6d78..bdea593 100644 --- a/scripts/deploy-ftp.ps1 +++ b/scripts/deploy-ftp.ps1 @@ -1,157 +1,251 @@ -# Hard-coded deploy settings. Fill these in before running. -$FtpHost = "xTr1m.com" -$FtpUser = "xTr1m" -$Password = $null # prompted at runtime -$RemoteDir = "/httpdocs/picknplay" -$ProjectPath = "..\\GameList.csproj" -$Configuration = "Release" -$Runtime = "win-x64" -$PublishDir = "$env:TEMP\\GameList-publish" -$SelfContained = $false -$WinScpPath = "C:\\Users\\frank\\AppData\\Local\\Programs\\WinSCP\\WinSCP.com" -$RecycleAppPool = $true -$AppPoolName = "xTr1m.com(domain)(4.0)(pool)" -$WinRmComputer = "xTr1m.com" -$WinRmCredentialUser = "Administrator" -$UseWinRmHttps = $true # set false if using HTTP + TrustedHosts -$RemoteSitePath = "C:\Inetpub\vhosts\xTr1m.com\httpdocs\picknplay" -$RunEfMigrations = $false # set to $false to skip remote database update +param( + [string]$ProfilePath = (Join-Path $PSScriptRoot "deploy-ftp.profile.psd1"), + [string]$Password, + [switch]$SkipRecycle, + [switch]$SkipMigrations +) -<#! +<# .SYNOPSIS - Publish the app and mirror the output to an FTP-deployed IIS site. + Publish the app and mirror output to an FTP-deployed IIS site. .DESCRIPTION + - Reads environment-specific settings from a PowerShell data file profile. - Builds with dotnet publish. - - Uses WinSCP (ftp) to mirror publish output into $RemoteDir (deletes extraneous remote files). - - Optionally recycles the IIS app pool remotely via WinRM (no RDP needed). - -.PREREQS - - WinSCP.com available in PATH or set $WinScpPath. - - FTP user must have write/delete rights to $RemoteDir. - - WinRM must be enabled for remote app pool recycle (set $RecycleAppPool = $false otherwise). + - Uses WinSCP to mirror publish output into remote directory (deletes extraneous files). + - Optionally recycles IIS app pool and runs EF migrations remotely over WinRM. .EXAMPLE - pwsh ./scripts/deploy-ftp.ps1 + pwsh ./scripts/deploy-ftp.ps1 -ProfilePath ./scripts/deploy-ftp.profile.psd1 #> Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" function Assert-Tool { - param([string]$Name) + param([Parameter(Mandatory = $true)][string]$Name) if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { - throw "Required tool '$Name' not found. Install it or update paths." + throw "Required tool '$Name' not found. Install it or update your deploy profile." } } -Assert-Tool "dotnet" -Assert-Tool $WinScpPath +function Require-ConfigValue { + param( + [Parameter(Mandatory = $true)][hashtable]$Config, + [Parameter(Mandatory = $true)][string]$Key + ) + + if (-not $Config.ContainsKey($Key) -or [string]::IsNullOrWhiteSpace([string]$Config[$Key])) { + throw "Missing required deploy profile value '$Key'." + } +} + +function Resolve-ProfilePath { + param( + [Parameter(Mandatory = $true)][string]$BaseDirectory, + [Parameter(Mandatory = $true)][string]$PathValue + ) + + $expanded = [Environment]::ExpandEnvironmentVariables($PathValue) + if ([System.IO.Path]::IsPathRooted($expanded)) { + return $expanded + } + + return [System.IO.Path]::GetFullPath((Join-Path $BaseDirectory $expanded)) +} + +function Read-PlainOrPrompt { + param( + [string]$Value, + [Parameter(Mandatory = $true)][string]$Prompt, + [bool]$Secure = $false + ) + + if (-not [string]::IsNullOrWhiteSpace($Value)) { + return $Value + } -function Read-PlainOrPrompt([object]$Value, [string]$Prompt, [bool]$Secure = $false) { - if ($Value -is [string] -and -not [string]::IsNullOrWhiteSpace($Value)) { return $Value } if ($Secure) { $pwd = Read-Host -Prompt $Prompt -AsSecureString $ptr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($pwd) - try { return [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr) } + try { + return [Runtime.InteropServices.Marshal]::PtrToStringUni($ptr) + } finally { - if ($ptr -ne [IntPtr]::Zero) { [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) } + if ($ptr -ne [IntPtr]::Zero) { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($ptr) + } } } + return Read-Host -Prompt $Prompt } -$Password = Read-PlainOrPrompt $Password "Password" $true -$WinRmAuth = "Basic" # Basic for local admin over HTTPS; use Default/Kerberos if joined to domain +function Invoke-WinRmScript { + param( + [Parameter(Mandatory = $true)][hashtable]$Config, + [Parameter(Mandatory = $true)][string]$PasswordValue, + [Parameter(Mandatory = $true)][scriptblock]$ScriptBlock, + [object[]]$ArgumentList = @() + ) + + Require-ConfigValue $Config "WinRmComputer" + Require-ConfigValue $Config "WinRmCredentialUser" + + $secure = ConvertTo-SecureString $PasswordValue -AsPlainText -Force + $cred = New-Object pscredential($Config.WinRmCredentialUser, $secure) + + $invokeParams = @{ + ComputerName = $Config.WinRmComputer + Credential = $cred + ScriptBlock = $ScriptBlock + ArgumentList = $ArgumentList + } + + if ($Config.ContainsKey("UseWinRmHttps") -and [bool]$Config.UseWinRmHttps) { + $invokeParams["UseSSL"] = $true + } + + if ($Config.ContainsKey("WinRmAuth") -and -not [string]::IsNullOrWhiteSpace([string]$Config.WinRmAuth)) { + $invokeParams["Authentication"] = [string]$Config.WinRmAuth + } + + Invoke-Command @invokeParams +} + +if (-not (Test-Path $ProfilePath)) { + throw "Deploy profile not found: $ProfilePath. Copy scripts/deploy-ftp.profile.sample.psd1 and fill environment-specific values." +} + +$resolvedProfilePath = (Resolve-Path $ProfilePath).Path +$profileDirectory = Split-Path -Parent $resolvedProfilePath +$config = Import-PowerShellDataFile -Path $resolvedProfilePath + +Require-ConfigValue $config "ProjectPath" +Require-ConfigValue $config "Configuration" +Require-ConfigValue $config "Runtime" +Require-ConfigValue $config "PublishDir" +Require-ConfigValue $config "WinScpPath" +Require-ConfigValue $config "RemoteDir" + +$winScpSessionName = if ($config.ContainsKey("WinScpSessionName")) { [string]$config.WinScpSessionName } else { "" } +$useStoredSession = -not [string]::IsNullOrWhiteSpace($winScpSessionName) + +if (-not $useStoredSession) { + Require-ConfigValue $config "FtpHost" + Require-ConfigValue $config "FtpUser" +} + +$projectPath = Resolve-ProfilePath $profileDirectory ([string]$config.ProjectPath) +$publishDir = Resolve-ProfilePath $profileDirectory ([string]$config.PublishDir) +$winScpPath = Resolve-ProfilePath $profileDirectory ([string]$config.WinScpPath) +$selfContained = if ($config.ContainsKey("SelfContained")) { [bool]$config.SelfContained } else { $false } +$recycleAppPool = if ($config.ContainsKey("RecycleAppPool")) { [bool]$config.RecycleAppPool } else { $false } +$runEfMigrations = if ($config.ContainsKey("RunEfMigrations")) { [bool]$config.RunEfMigrations } else { $false } +$recycleAppPool = $recycleAppPool -and -not $SkipRecycle +$runEfMigrations = $runEfMigrations -and -not $SkipMigrations + +$passwordFromEnv = $env:PICKNPLAY_FTP_PASSWORD +$passwordFromInput = if (-not [string]::IsNullOrWhiteSpace($Password)) { $Password } else { $passwordFromEnv } +$needsFtpPassword = -not $useStoredSession +$needsWinRmPassword = $recycleAppPool -or $runEfMigrations +$sharedPassword = "" + +if ($needsFtpPassword -or $needsWinRmPassword) { + $prompt = if ($needsFtpPassword -and $needsWinRmPassword) { "FTP/WinRM password" } elseif ($needsFtpPassword) { "FTP password" } else { "WinRM password" } + $sharedPassword = Read-PlainOrPrompt -Value $passwordFromInput -Prompt $prompt -Secure $true +} + +$passwordForSession = if ($needsFtpPassword) { $sharedPassword } else { "" } +$passwordForWinRm = if ($needsWinRmPassword) { $sharedPassword } else { "" } + +Assert-Tool "dotnet" +Assert-Tool $winScpPath Write-Host "1) Publishing..." -ForegroundColor Cyan -if (Test-Path $PublishDir) { Remove-Item $PublishDir -Recurse -Force -ErrorAction SilentlyContinue } -New-Item -ItemType Directory -Force -Path $PublishDir | Out-Null -$publishArgs = @("publish", $ProjectPath, "-c", $Configuration, "-r", $Runtime, "-o", $PublishDir) -if (-not $SelfContained) { $publishArgs += "--self-contained=false" } +if (Test-Path $publishDir) { + Remove-Item $publishDir -Recurse -Force -ErrorAction SilentlyContinue +} +New-Item -ItemType Directory -Force -Path $publishDir | Out-Null + +$publishArgs = @("publish", $projectPath, "-c", [string]$config.Configuration, "-r", [string]$config.Runtime, "-o", $publishDir) +if (-not $selfContained) { + $publishArgs += "--self-contained=false" +} dotnet @publishArgs -if ($RecycleAppPool) { +if ($recycleAppPool) { + Require-ConfigValue $config "AppPoolName" + $appPoolName = [string]$config.AppPoolName Write-Host "2) Stopping IIS app pool via WinRM..." -ForegroundColor Cyan - $sec = ConvertTo-SecureString $Password -AsPlainText -Force - $cred = New-Object pscredential($WinRmCredentialUser, $sec) - $invokeParams = @{ - ComputerName = $WinRmComputer - Credential = $cred - ScriptBlock = { - Import-Module WebAdministration - Stop-WebAppPool -Name $using:AppPoolName -ErrorAction SilentlyContinue - Get-Process GameList -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue - Get-Process dotnet -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*picknplay*" } | Stop-Process -Force -ErrorAction SilentlyContinue - } - } - if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true } - if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth } try { - Invoke-Command @invokeParams - } catch { + Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock { + param($poolName) + Import-Module WebAdministration + Stop-WebAppPool -Name $poolName -ErrorAction SilentlyContinue + Get-Process GameList -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue + Get-Process dotnet -ErrorAction SilentlyContinue | Where-Object { $_.Path -like "*picknplay*" } | Stop-Process -Force -ErrorAction SilentlyContinue + } -ArgumentList @($appPoolName) + } + catch { Write-Warning "WinRM stop failed: $($_.Exception.Message)." } } -Write-Host "3) Syncing via WinSCP (FTP mirror with delete)..." -ForegroundColor Cyan -$tempScript = New-TemporaryFile -@" -option batch continue -option confirm off -open ftp://$($FtpUser):$($Password.Replace('`n','').Replace('`r',''))@$FtpHost -lcd $PublishDir -cd $RemoteDir -synchronize remote . -delete -filemask="|web.config;App_Data/;logs/;GameList.Tests/" -exit -"@ | Set-Content -Path $tempScript -Encoding UTF8 +Write-Host "3) Syncing via WinSCP..." -ForegroundColor Cyan +$openCommand = if ($useStoredSession) { + "open `"$winScpSessionName`"" +} +else { + $ftpUser = [Uri]::EscapeDataString([string]$config.FtpUser) + $ftpPassword = [Uri]::EscapeDataString($passwordForSession.Replace("`n", "").Replace("`r", "")) + $ftpHost = [string]$config.FtpHost + "open ftp://$ftpUser`:$ftpPassword@$ftpHost" +} -& $WinScpPath "/ini=nul" "/script=$tempScript" +$tempScript = New-TemporaryFile +@( + "option batch continue" + "option confirm off" + $openCommand + "lcd `"$publishDir`"" + "cd $([string]$config.RemoteDir)" + "synchronize remote . -delete -filemask=`"|web.config;App_Data/;logs/;GameList.Tests/`"" + "exit" +) | Set-Content -Path $tempScript -Encoding UTF8 + +& $winScpPath "/ini=nul" "/script=$tempScript" Remove-Item $tempScript -ErrorAction SilentlyContinue -if ($RecycleAppPool) { +if ($recycleAppPool) { Write-Host "4) Starting IIS app pool via WinRM..." -ForegroundColor Cyan - $sec = ConvertTo-SecureString $Password -AsPlainText -Force - $cred = New-Object pscredential($WinRmCredentialUser, $sec) - $invokeParams = @{ - ComputerName = $WinRmComputer - Credential = $cred - ScriptBlock = { - Import-Module WebAdministration - Start-WebAppPool -Name $using:AppPoolName - } - } - if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true } - if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth } try { - Invoke-Command @invokeParams - } catch { + Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock { + param($poolName) + Import-Module WebAdministration + Start-WebAppPool -Name $poolName + } -ArgumentList @($appPoolName) + } + catch { Write-Warning "WinRM start failed: $($_.Exception.Message)." } } -if ($RunEfMigrations) { +if ($runEfMigrations) { + Require-ConfigValue $config "RemoteSitePath" Write-Host "5) Running EF Core migrations on remote site..." -ForegroundColor Cyan - $sec = ConvertTo-SecureString $Password -AsPlainText -Force - $cred = New-Object pscredential($WinRmCredentialUser, $sec) - $invokeParams = @{ - ComputerName = $WinRmComputer - Credential = $cred - ScriptBlock = { + try { + Invoke-WinRmScript -Config $config -PasswordValue $passwordForWinRm -ScriptBlock { param($sitePath) Set-Location $sitePath - if (-not (Get-Command dotnet ef -ErrorAction SilentlyContinue)) { - throw "dotnet ef not available on remote host. Install SDK or set `$RunEfMigrations = $false." + if (-not (Get-Command dotnet -ErrorAction SilentlyContinue)) { + throw "dotnet is not available on remote host." } + dotnet ef database update --no-build - } - ArgumentList = @($RemoteSitePath) + } -ArgumentList @([string]$config.RemoteSitePath) } - if ($UseWinRmHttps) { $invokeParams["UseSSL"] = $true } - if ($WinRmAuth) { $invokeParams["Authentication"] = $WinRmAuth } - try { - Invoke-Command @invokeParams - } catch { + catch { Write-Warning "WinRM migrations failed: $($_.Exception.Message)." } } diff --git a/scripts/deploy-ftp1.ps1 b/scripts/deploy-ftp1.ps1 new file mode 100644 index 0000000..91e4f0c --- /dev/null +++ b/scripts/deploy-ftp1.ps1 @@ -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 diff --git a/wwwroot/app.js b/wwwroot/app.js index 1a94be1..473e22f 100644 --- a/wwwroot/app.js +++ b/wwwroot/app.js @@ -1,246 +1,282 @@ -import { t, setLanguage, getLanguage, initI18n, onLanguageChange, faqMarkdown } from "./js/i18n.js"; +import { + t, + setLanguage, + getLanguage, + initI18n, + onLanguageChange, + faqMarkdown, +} from "./js/i18n.js"; import { state, clearUserState } from "./js/state.js"; import { toast } from "./js/dom.js"; import { - handleAuthError, - renderWelcome, - renderPhasePill, - renderCounts, - renderMySuggestions, - renderAllSuggestions, - renderVotes, - syncVoteScores, - renderResults, - renderPhaseTitles, - updatePhaseNav, - configureUiRuntime, + handleAuthError, + renderWelcome, + renderPhasePill, + renderCounts, + renderMySuggestions, + renderAllSuggestions, + renderVotes, + syncVoteScores, + renderResults, + renderPhaseTitles, + updatePhaseNav, + configureUiRuntime, } from "./js/ui.js"; -import { - loadSuggestData, - loadVoteData, - refreshPhaseData, -} from "./js/data.js"; +import { loadSuggestData, loadVoteData, refreshPhaseData } from "./js/data.js"; import { setupAuthHandlers } from "./js/app-auth-handlers.js"; import { setupAdminHandlers } from "./js/app-admin-handlers.js"; import { setupVoteNavigationHandlers } from "./js/app-vote-nav-handlers.js"; -const REFRESH_INTERVAL_MS = 4000; +const REFRESH_MIN_MS = 3000; +const REFRESH_MAX_MS = 20000; let refreshInFlight = null; let refreshTimerId = null; let refreshSchedulerStarted = false; +let unchangedRefreshCycles = 0; +let nextRefreshDelayMs = REFRESH_MIN_MS; async function runSerializedRefresh() { - if (refreshInFlight) return refreshInFlight; - refreshInFlight = refreshPhaseData().finally(() => { - refreshInFlight = null; - }); - return refreshInFlight; + if (refreshInFlight) return refreshInFlight; + refreshInFlight = refreshPhaseData().finally(() => { + refreshInFlight = null; + }); + return refreshInFlight; } async function refreshWithUiErrorHandling() { - try { - await runSerializedRefresh(); - } catch (err) { - if (!handleAuthError(err, clearUserState)) toast(err.message, true); - } + try { + const changed = await runSerializedRefresh(); + updateRefreshCadence(changed === true); + } catch (err) { + // Back off after transient failures to avoid hammering server/dependencies. + nextRefreshDelayMs = Math.min(nextRefreshDelayMs * 2, REFRESH_MAX_MS); + if (!handleAuthError(err, clearUserState)) toast(err.message, true); + } } function scheduleNextRefresh() { - refreshTimerId = window.setTimeout(async () => { - if (!document.hidden && !state.adminStatusSelectActive) { - await refreshWithUiErrorHandling(); - } - scheduleNextRefresh(); - }, REFRESH_INTERVAL_MS); + refreshTimerId = window.setTimeout(async () => { + if (!document.hidden && !state.adminStatusSelectActive) { + await refreshWithUiErrorHandling(); + } + scheduleNextRefresh(); + }, nextRefreshDelayMs); } function startRefreshScheduler() { - if (refreshSchedulerStarted) return; - refreshSchedulerStarted = true; + if (refreshSchedulerStarted) return; + refreshSchedulerStarted = true; - document.addEventListener("visibilitychange", () => { - if (!document.hidden && !state.adminStatusSelectActive) { - refreshWithUiErrorHandling(); + document.addEventListener("visibilitychange", () => { + if (!document.hidden && !state.adminStatusSelectActive) { + unchangedRefreshCycles = 0; + nextRefreshDelayMs = baseRefreshDelayForPhase(); + refreshWithUiErrorHandling(); + } + }); + + if (refreshTimerId !== null) { + window.clearTimeout(refreshTimerId); } - }); + scheduleNextRefresh(); +} - if (refreshTimerId !== null) { - window.clearTimeout(refreshTimerId); - } - scheduleNextRefresh(); +function updateRefreshCadence(changed) { + const base = baseRefreshDelayForPhase(); + if (changed) { + unchangedRefreshCycles = 0; + nextRefreshDelayMs = base; + return; + } + + unchangedRefreshCycles = Math.min(unchangedRefreshCycles + 1, 8); + const growth = Math.pow(1.35, unchangedRefreshCycles); + nextRefreshDelayMs = Math.min(Math.round(base * growth), REFRESH_MAX_MS); +} + +function baseRefreshDelayForPhase() { + switch (state.phase) { + case "Vote": + return REFRESH_MIN_MS; + case "Suggest": + return 5000; + case "Results": + return 7000; + default: + return 5000; + } } configureUiRuntime({ - refreshPhaseData: runSerializedRefresh, - loadSuggestData, - loadVoteData, - handleAuthError: (err) => handleAuthError(err, clearUserState), + refreshPhaseData: runSerializedRefresh, + loadSuggestData, + loadVoteData, + handleAuthError: (err) => handleAuthError(err, clearUserState), }); function setupHandlers() { - setupAuthHandlers({ runSerializedRefresh }); - setupAdminHandlers({ runSerializedRefresh }); - setupVoteNavigationHandlers({ runSerializedRefresh }); - setupLanguageSwitchers(); + setupAuthHandlers({ runSerializedRefresh }); + setupAdminHandlers({ runSerializedRefresh }); + setupVoteNavigationHandlers({ runSerializedRefresh }); + setupLanguageSwitchers(); - onLanguageChange(() => { - updateLanguageButtons(); - renderWelcome(); - renderPhasePill(); - renderCounts(); - renderPhaseTitles(); - renderMySuggestions(); - renderAllSuggestions(); - if (state.phase === "Vote") { - renderVotes(); - state.votesRendered = true; - syncVoteScores(); - } - if (state.phase === "Results") { - renderResults(); - } - updatePhaseNav(); - }); + onLanguageChange(() => { + updateLanguageButtons(); + renderWelcome(); + renderPhasePill(); + renderCounts(); + renderPhaseTitles(); + renderMySuggestions(); + renderAllSuggestions(); + if (state.phase === "Vote") { + renderVotes(); + state.votesRendered = true; + syncVoteScores(); + } + if (state.phase === "Results") { + renderResults(); + } + updatePhaseNav(); + }); - document.querySelectorAll(".help-chip").forEach((chip) => { - chip.addEventListener("click", () => openFaqModal()); - }); + document.querySelectorAll(".help-chip").forEach((chip) => { + chip.addEventListener("click", () => openFaqModal()); + }); } async function main() { - await initI18n(); - setupHandlers(); - await refreshWithUiErrorHandling(); - startRefreshScheduler(); + await initI18n(); + setupHandlers(); + await refreshWithUiErrorHandling(); + startRefreshScheduler(); } main(); function updateLanguageButtons() { - document.querySelectorAll(".lang-button").forEach((btn) => { - btn.textContent = "🌐"; - btn.title = t("lang.label"); - btn.setAttribute("aria-label", t("lang.label")); - }); + document.querySelectorAll(".lang-button").forEach((btn) => { + btn.textContent = "🌐"; + btn.title = t("lang.label"); + btn.setAttribute("aria-label", t("lang.label")); + }); } function setupLanguageSwitchers() { - const switches = document.querySelectorAll(".lang-switch"); - const closeAll = () => - switches.forEach((wrap) => wrap.querySelector(".lang-menu")?.classList.add("hidden")); + const switches = document.querySelectorAll(".lang-switch"); + const closeAll = () => + switches.forEach((wrap) => + wrap.querySelector(".lang-menu")?.classList.add("hidden"), + ); - switches.forEach((wrap) => { - const btn = wrap.querySelector(".lang-button"); - const menu = wrap.querySelector(".lang-menu"); - if (!btn || !menu) return; - btn.addEventListener("click", (e) => { - e.preventDefault(); - const isHidden = menu.classList.contains("hidden"); - closeAll(); - if (isHidden) menu.classList.remove("hidden"); + switches.forEach((wrap) => { + const btn = wrap.querySelector(".lang-button"); + const menu = wrap.querySelector(".lang-menu"); + if (!btn || !menu) return; + btn.addEventListener("click", (e) => { + e.preventDefault(); + const isHidden = menu.classList.contains("hidden"); + closeAll(); + if (isHidden) menu.classList.remove("hidden"); + }); + menu.querySelectorAll("[data-lang]").forEach((item) => + item.addEventListener("click", () => { + const lang = item.dataset.lang; + if (lang) setLanguage(lang); + closeAll(); + }), + ); }); - menu.querySelectorAll("[data-lang]").forEach((item) => - item.addEventListener("click", () => { - const lang = item.dataset.lang; - if (lang) setLanguage(lang); - closeAll(); - }), - ); - }); - document.addEventListener("click", (e) => { - if (!e.target.closest(".lang-switch")) closeAll(); - }); + document.addEventListener("click", (e) => { + if (!e.target.closest(".lang-switch")) closeAll(); + }); - updateLanguageButtons(); + updateLanguageButtons(); } function markdownToHtml(md) { - const lines = md.trim().split(/\r?\n/); - const html = []; - let inList = false; - let inParagraph = false; + const lines = md.trim().split(/\r?\n/); + const html = []; + let inList = false; + let inParagraph = false; - const escapeHtml = (text) => - text - .replace(/&/g, "&") - .replace(//g, ">"); + const escapeHtml = (text) => + text.replace(/&/g, "&").replace(//g, ">"); - const formatInline = (text) => - escapeHtml(text) - .replace(/\*\*(.+?)\*\*/g, "$1") - .replace(/`([^`]+)`/g, "$1"); + const formatInline = (text) => + escapeHtml(text) + .replace(/\*\*(.+?)\*\*/g, "$1") + .replace(/`([^`]+)`/g, "$1"); - const closeParagraph = () => { - if (inParagraph) { - html.push("

"); - inParagraph = false; - } - }; + const closeParagraph = () => { + if (inParagraph) { + html.push("

"); + inParagraph = false; + } + }; - const closeList = () => { - if (inList) { - html.push(""); - inList = false; - } - }; + const closeList = () => { + if (inList) { + html.push(""); + inList = false; + } + }; - lines.forEach((rawLine) => { - const line = rawLine.trimEnd(); - const trimmed = line.trim(); - if (!trimmed) { - closeParagraph(); - closeList(); - return; - } + lines.forEach((rawLine) => { + const line = rawLine.trimEnd(); + const trimmed = line.trim(); + if (!trimmed) { + closeParagraph(); + closeList(); + return; + } - if (/^-{5,}$/.test(trimmed)) { - closeParagraph(); - closeList(); - html.push('
'); - return; - } + if (/^-{5,}$/.test(trimmed)) { + closeParagraph(); + closeList(); + html.push('
'); + return; + } - const heading = trimmed.match(/^(#{1,3})\s+(.*)$/); - if (heading) { - closeParagraph(); - closeList(); - const level = heading[1].length; - const tag = level === 1 ? "h2" : level === 2 ? "h3" : "h4"; - html.push(`<${tag}>${formatInline(heading[2].trim())}`); - return; - } + const heading = trimmed.match(/^(#{1,3})\s+(.*)$/); + if (heading) { + closeParagraph(); + closeList(); + const level = heading[1].length; + const tag = level === 1 ? "h2" : level === 2 ? "h3" : "h4"; + html.push(`<${tag}>${formatInline(heading[2].trim())}`); + return; + } - if (/^[*-]\s+/.test(trimmed)) { - closeParagraph(); - if (!inList) { - html.push("
    "); - inList = true; - } - const text = trimmed.replace(/^[*-]\s+/, ""); - html.push(`
  • ${formatInline(text)}
  • `); - return; - } + if (/^[*-]\s+/.test(trimmed)) { + closeParagraph(); + if (!inList) { + html.push("
      "); + inList = true; + } + const text = trimmed.replace(/^[*-]\s+/, ""); + html.push(`
    • ${formatInline(text)}
    • `); + return; + } - if (!inParagraph) { - html.push("

      "); - inParagraph = true; - } - html.push(formatInline(trimmed)); - }); + if (!inParagraph) { + html.push("

      "); + inParagraph = true; + } + html.push(formatInline(trimmed)); + }); - closeParagraph(); - closeList(); - return html.join("\n"); + closeParagraph(); + closeList(); + return html.join("\n"); } function openFaqModal() { - const overlay = document.createElement("div"); - overlay.className = "edit-modal"; - const panel = document.createElement("div"); - panel.className = "edit-panel faq-panel"; - panel.innerHTML = ` + const overlay = document.createElement("div"); + overlay.className = "edit-modal"; + const panel = document.createElement("div"); + panel.className = "edit-panel faq-panel"; + panel.innerHTML = `

      ${t("help.title")}

      @@ -250,16 +286,20 @@ function openFaqModal() {
      `; - const list = panel.querySelector(".faq-list"); - const lang = getLanguage(); - const md = faqMarkdown[lang] ?? faqMarkdown.en; - list.innerHTML = markdownToHtml(md); + const list = panel.querySelector(".faq-list"); + const lang = getLanguage(); + const md = faqMarkdown[lang] ?? faqMarkdown.en; + list.innerHTML = markdownToHtml(md); - const close = () => overlay.remove(); - overlay.addEventListener("click", (e) => { - if (e.target.classList.contains("edit-modal") || e.target.classList.contains("lightbox-close")) close(); - }); + const close = () => overlay.remove(); + overlay.addEventListener("click", (e) => { + if ( + e.target.classList.contains("edit-modal") || + e.target.classList.contains("lightbox-close") + ) + close(); + }); - overlay.appendChild(panel); - document.body.appendChild(overlay); + overlay.appendChild(panel); + document.body.appendChild(overlay); } diff --git a/wwwroot/index.html b/wwwroot/index.html index ab63a91..dea666c 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -99,6 +99,7 @@ +
      diff --git a/wwwroot/js/api.js b/wwwroot/js/api.js index e42f9bd..34b712f 100644 --- a/wwwroot/js/api.js +++ b/wwwroot/js/api.js @@ -5,77 +5,107 @@ const basePath = normalizeBase(rawBase); const withBase = (path) => `${basePath}${path}`; function normalizeBase(value) { - if (!value) return ""; - if (!value.startsWith("/")) return `/${value}`; - return value.endsWith("/") ? value.slice(0, -1) : value; + if (!value) return ""; + if (!value.startsWith("/")) return `/${value}`; + return value.endsWith("/") ? value.slice(0, -1) : value; } async function request(path, { method = "GET", body } = {}) { - const res = await fetch(withBase(path), { - method, - credentials: "same-origin", - headers: defaultHeaders, - body: body ? JSON.stringify(body) : undefined, - }); + const res = await fetch(withBase(path), { + method, + credentials: "same-origin", + headers: defaultHeaders, + body: body ? JSON.stringify(body) : undefined, + }); - if (!res.ok) { - let msg = `${res.status}`; - try { - const data = await res.json(); - msg = data.error || data.detail || data.title || JSON.stringify(data); - } catch { /* ignore */ } - const err = new Error(msg); - err.status = res.status; - throw err; - } - return res.status === 204 ? null : res.json(); + if (!res.ok) { + let msg = `${res.status}`; + try { + const data = await res.json(); + msg = + data.error || data.detail || data.title || JSON.stringify(data); + } catch { + /* ignore */ + } + const err = new Error(msg); + err.status = res.status; + throw err; + } + return res.status === 204 ? null : res.json(); } export const api = { - state: () => request("/api/state"), - me: () => request("/api/me"), - authOptions: () => request("/api/auth/options"), - register: (payload) => request("/api/auth/register", { method: "POST", body: payload }), - login: (payload) => request("/api/auth/login", { method: "POST", body: payload }), - logout: () => request("/api/auth/logout", { method: "POST" }), + state: () => request("/api/state"), + me: () => request("/api/me"), + authOptions: () => request("/api/auth/options"), + register: (payload) => + request("/api/auth/register", { method: "POST", body: payload }), + login: (payload) => + request("/api/auth/login", { method: "POST", body: payload }), + logout: () => request("/api/auth/logout", { method: "POST" }), - mySuggestions: () => request("/api/suggestions/mine"), - createSuggestion: (payload) => request("/api/suggestions", { method: "POST", body: payload }), - deleteSuggestion: (id) => request(`/api/suggestions/${id}`, { method: "DELETE" }), - updateSuggestion: (id, payload) => request(`/api/suggestions/${id}`, { method: "PUT", body: payload }), - allSuggestions: () => request("/api/suggestions/all"), + mySuggestions: () => request("/api/suggestions/mine"), + createSuggestion: (payload) => + request("/api/suggestions", { method: "POST", body: payload }), + deleteSuggestion: (id) => + request(`/api/suggestions/${id}`, { method: "DELETE" }), + updateSuggestion: (id, payload) => + request(`/api/suggestions/${id}`, { method: "PUT", body: payload }), + allSuggestions: () => request("/api/suggestions/all"), - myVotes: () => request("/api/votes/mine"), - vote: (suggestionId, score) => request("/api/votes", { method: "POST", body: { suggestionId, score } }), - finalizeVotes: (final) => request("/api/votes/finalize", { method: "POST", body: { final } }), + myVotes: () => request("/api/votes/mine"), + vote: (suggestionId, score) => + request("/api/votes", { + method: "POST", + body: { suggestionId, score }, + }), + finalizeVotes: (final) => + request("/api/votes/finalize", { method: "POST", body: { final } }), - results: () => request("/api/results"), - nextPhase: () => request("/api/me/phase/next", { method: "POST" }), - prevPhase: () => request("/api/me/phase/prev", { method: "POST" }), + results: () => request("/api/results"), + nextPhase: () => request("/api/me/phase/next", { method: "POST" }), + prevPhase: () => request("/api/me/phase/prev", { method: "POST" }), }; export const adminApi = { - setResultsOpen: (resultsOpen) => request("/api/admin/results", { method: "POST", body: { resultsOpen } }), - voteStatus: () => request("/api/admin/vote-status"), - reset: (password) => - request("/api/admin/reset", { method: "POST", body: { password } }), - factoryReset: (password) => - request("/api/admin/factory-reset", { method: "POST", body: { password } }), - grantJoker: (playerId) => request("/api/admin/joker", { method: "POST", body: { playerId } }), - setPlayerAdmin: (playerId, isAdmin) => - request("/api/admin/player-admin", { - method: "POST", - body: { playerId, isAdmin }, - }), - setPlayerPhase: (playerId, phase) => - request("/api/admin/player-phase", { method: "POST", body: { playerId, phase } }), - deletePlayer: (playerId, password) => - request(`/api/admin/players/${playerId}`, { - method: "DELETE", - body: { password }, - }), - linkSuggestions: (sourceSuggestionId, targetSuggestionId) => - request("/api/admin/link-suggestions", { method: "POST", body: { sourceSuggestionId, targetSuggestionId } }), - unlinkSuggestions: (suggestionId) => - request("/api/admin/unlink-suggestions", { method: "POST", body: { suggestionId } }), + setResultsOpen: (resultsOpen) => + request("/api/admin/results", { + method: "POST", + body: { resultsOpen }, + }), + voteStatus: () => request("/api/admin/vote-status"), + reset: (password) => + request("/api/admin/reset", { method: "POST", body: { password } }), + factoryReset: (password) => + request("/api/admin/factory-reset", { + method: "POST", + body: { password }, + }), + grantJoker: (playerId) => + request("/api/admin/joker", { method: "POST", body: { playerId } }), + setPlayerAdmin: (playerId, isAdmin) => + request("/api/admin/player-admin", { + method: "POST", + body: { playerId, isAdmin }, + }), + setPlayerPhase: (playerId, phase) => + request("/api/admin/player-phase", { + method: "POST", + body: { playerId, phase }, + }), + deletePlayer: (playerId, password) => + request(`/api/admin/players/${playerId}`, { + method: "DELETE", + body: { password }, + }), + linkSuggestions: (sourceSuggestionId, targetSuggestionId) => + request("/api/admin/link-suggestions", { + method: "POST", + body: { sourceSuggestionId, targetSuggestionId }, + }), + unlinkSuggestions: (suggestionId) => + request("/api/admin/unlink-suggestions", { + method: "POST", + body: { suggestionId }, + }), }; diff --git a/wwwroot/js/app-auth-handlers.js b/wwwroot/js/app-auth-handlers.js index 86402bf..03a8327 100644 --- a/wwwroot/js/app-auth-handlers.js +++ b/wwwroot/js/app-auth-handlers.js @@ -114,6 +114,7 @@ function setupLoginFormHandlers({ if (err?.status === 401) return toast(t("auth.invalidCredentials"), true); if (handleAuthError(err, clearUserState)) return; + toast(err?.message || t("toast.unexpected"), true); } }); } diff --git a/wwwroot/js/data.js b/wwwroot/js/data.js index 5d8284c..652ff30 100644 --- a/wwwroot/js/data.js +++ b/wwwroot/js/data.js @@ -1,5 +1,20 @@ import { api, adminApi } from "./api.js"; -import { handleAuthError, renderAllSuggestions, renderCounts, renderMySuggestions, renderPhasePill, renderPhaseTitles, renderResults, renderVotes, renderWelcome, setAuthUI, syncVoteScores, updatePhaseNav, openResultsRelockModal, openSuggestionsChangedModal } from "./ui.js"; +import { + handleAuthError, + renderAllSuggestions, + renderCounts, + renderMySuggestions, + renderPhasePill, + renderPhaseTitles, + renderResults, + renderVotes, + renderWelcome, + setAuthUI, + syncVoteScores, + updatePhaseNav, + openResultsRelockModal, + openSuggestionsChangedModal, +} from "./ui.js"; import { state, clearUserState } from "./state.js"; export async function loadState() { @@ -86,18 +101,26 @@ export async function loadResults() { } export async function refreshPhaseData() { + const before = buildRefreshSnapshot(); try { const prevPhase = state.phase; const prevResultsOpen = state.resultsOpen; await loadState(); - await Promise.all([loadSuggestData(), loadSuggestionsData(), loadResults()]); + await Promise.all([ + loadSuggestData(), + loadSuggestionsData(), + loadResults(), + ]); if (state.phase === "Vote") { if (!state.votesRendered) await loadVoteData(); } else { state.votesRendered = false; await loadVoteData(); } - if (state.me?.isAdmin) { + const adminCard = document.getElementById("admin-card"); + const adminPanelVisible = + !!adminCard && !adminCard.classList.contains("hidden"); + if (state.me?.isAdmin && adminPanelVisible) { state.adminVoteStatus = await adminApi.voteStatus(); } if ( @@ -109,12 +132,34 @@ export async function refreshPhaseData() { openResultsRelockModal(); } updatePhaseNav(); + const after = buildRefreshSnapshot(); + return before !== after; } catch (err) { if (handleAuthError(err, clearUserState)) return; throw err; } } +function buildRefreshSnapshot() { + return JSON.stringify({ + phase: state.phase, + resultsOpen: state.resultsOpen, + votesFinal: state.votesFinal, + hasJoker: state.hasJoker, + counts: state.counts + ? [ + state.counts.players, + state.counts.suggestions, + state.counts.votes, + ] + : null, + mineCount: state.mySuggestions?.length ?? 0, + allSig: state.allSuggestionsSig ?? "", + voteCount: state.myVotes?.length ?? 0, + resultsCount: state.results?.length ?? 0, + }); +} + export function signatureSuggestions(list) { return JSON.stringify( list.map((s) => [ diff --git a/wwwroot/js/dom.js b/wwwroot/js/dom.js index fec73c3..53f0474 100644 --- a/wwwroot/js/dom.js +++ b/wwwroot/js/dom.js @@ -1,6 +1,7 @@ export const $ = (id) => document.getElementById(id); -const toastEl = typeof document !== "undefined" ? document.getElementById("toast") : null; +const toastEl = + typeof document !== "undefined" ? document.getElementById("toast") : null; export function toast(msg, isError = false) { if (!toastEl) return; diff --git a/wwwroot/js/suggestions-ui.js b/wwwroot/js/suggestions-ui.js index 8b17bdd..2b9b640 100644 --- a/wwwroot/js/suggestions-ui.js +++ b/wwwroot/js/suggestions-ui.js @@ -49,16 +49,6 @@ export function renderMySuggestions() { export function renderAllSuggestions() { renderAdminLinker(); - const list = $("all-suggestions"); - if (!list) return; - list.innerHTML = ""; - const allowEdit = true; - const allowDelete = !!state.me?.isAdmin; - sortByName(state.allSuggestions).forEach((s) => - list.appendChild( - buildCard(s, { showAuthor: true, allowEdit, allowDelete }), - ), - ); renderPhaseTitles(); } diff --git a/wwwroot/js/votes-ui.js b/wwwroot/js/votes-ui.js index 838fb2e..7f719d1 100644 --- a/wwwroot/js/votes-ui.js +++ b/wwwroot/js/votes-ui.js @@ -261,15 +261,6 @@ export function updatePhaseNav() { } } - const voteNext = $("nav-vote-next"); - if (voteNext) { - const locked = !state.resultsOpen && !isAdmin; - voteNext.disabled = locked; - voteNext.textContent = locked - ? t("nav.waitingForResults") - : t("nav.next"); - } - const adminResultsToggle = $("results-open"); if (adminResultsToggle) { adminResultsToggle.textContent = state.resultsOpen