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

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

View File

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

5
.gitignore vendored
View File

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

3
API.md
View File

@@ -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 callers finalized status (blocks further vote edits when true)
Vote upsert includes conflict handling for concurrent writes against the unique `(PlayerId, SuggestionId)` index.
## Results (requires auth + Results phase + resultsOpen)
GET /api/results — leaderboard with totals, counts, averages, callers vote, media/links, link metadata

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ public class AppDbContext(DbContextOptions<AppDbContext> 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);

View File

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

View File

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

View File

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

View File

@@ -11,14 +11,34 @@ public static class AdminEndpoints
{
var admin = app.MapGroup("/api/admin").RequireAuthorization().RequireRateLimiting("admin-sensitive").AddEndpointFilter<AdminOnlyFilter>();
admin.MapPost("/results", async ([FromBody] ResultsOpenRequest request, AdminWorkflowService service) => await service.SetResultsOpenAsync(request.ResultsOpen));
admin.MapPost("/results", async ([FromBody] ResultsOpenRequest request, AdminWorkflowService service) =>
{
var result = await service.SetResultsOpenAsync(request.ResultsOpen);
return result.ToHttpResult(Results.Ok);
});
admin.MapGet("/vote-status", async (AdminWorkflowService service) => await service.GetVoteStatusAsync());
admin.MapGet("/vote-status", async (AdminWorkflowService service) =>
{
var result = await service.GetVoteStatusAsync();
return result.ToHttpResult(Results.Ok);
});
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) => await service.GrantJokerAsync(request.PlayerId));
admin.MapPost("/joker", async ([FromBody] GrantJokerRequest request, AdminWorkflowService service) =>
{
var result = await service.GrantJokerAsync(request.PlayerId);
return result.ToHttpResult(Results.Ok);
});
admin.MapPost("/player-phase", async ([FromBody] SetPlayerPhaseRequest request, AdminWorkflowService service) => await service.SetPlayerPhaseAsync(request.PlayerId, request.Phase));
admin.MapPost("/player-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);
});
}
}

View File

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

View File

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

View File

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

View File

@@ -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<Player?> 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<T>(this ServiceResult<T> result, Func<T, IResult> onSuccess)
{
if (result.IsSuccess)
return onSuccess(result.Value!);
return ToHttpError(result.Error!);
}
public static IResult ToHttpResult(this ServiceResult<Unit> result, Func<IResult> onSuccess)
{
if (result.IsSuccess)
return onSuccess();
return ToHttpError(result.Error!);
}
public static bool IsSqliteConstraintViolation(DbUpdateException ex)
{
return ex.InnerException is SqliteException sqliteEx
&& sqliteEx.SqliteErrorCode == 19;
}
public static bool IsSqliteConstraintViolation(DbUpdateException ex, string containsMessage)
{
if (!IsSqliteConstraintViolation(ex))
return false;
return ex.InnerException?.Message.Contains(containsMessage, StringComparison.OrdinalIgnoreCase) == true;
}
private static IResult Problem(int statusCode, string title, string detail)
{
return Results.Problem(
@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<DbUpdateException>(() => factory.WithDbContextAsync(async db =>
{
var (hash, salt) = PasswordHasher.HashPassword("Pass123!");
db.Players.Add(new Player
{
Id = Guid.NewGuid(),
Username = "owner2",
NormalizedUsername = "owner2",
PasswordHash = hash,
PasswordSalt = salt,
DisplayName = "Owner2",
IsOwner = true,
IsAdmin = true
});
await db.SaveChangesAsync();
}));
Assert.Contains("Players.IsOwner", thrown.InnerException?.Message ?? thrown.Message, StringComparison.OrdinalIgnoreCase);
}
}

View File

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

View File

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

View File

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

4
IIS.md
View File

@@ -8,6 +8,7 @@
## Publish
- From repo root: `dotnet publish -c Release -o publish`
- Before first start (and after every new migration): run `dotnet ef database update` from repo root against the target environment.
- Copy `publish/` contents to site directory (keep `App_Data` writable by the app pool user).
- Set environment variables in web.config or IIS config:
- `ASPNETCORE_ENVIRONMENT=Production`
@@ -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 `<meta name="app-base" content="/picknplay">` in `wwwroot/index.html` for production so API calls include the subpath (keep blank for local/root).
- Deployment script: copy `scripts/deploy-ftp.profile.sample.psd1` to `scripts/deploy-ftp.profile.psd1`, fill environment values, then run `pwsh ./scripts/deploy-ftp.ps1 -ProfilePath ./scripts/deploy-ftp.profile.psd1`.
- Shortcut command: run `pwsh ./deploy.ps1` from repo root to deploy with the local profile directly.
- Prefer `WinScpSessionName` in the deploy profile to avoid embedding FTP credentials in scripted URLs.
## Permissions
- Grant modify rights to the app pool identity on `App_Data` (DB file + wal).

View File

@@ -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<EnsurePlayerExistsMiddleware>();
app.UseAuthorization();
// Ensure database and migrations are applied on startup
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
db.Database.Migrate();
}
app.UseDefaultFiles();
app.UseStaticFiles();
@@ -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;

View File

@@ -6,11 +6,13 @@ Pick'n'Play is a .NET 10 ASP.NET Core Minimal API app with a static HTML/CSS/JS
1. Restore and build:
`dotnet build GameList.sln`
2. Run tests:
2. Apply DB migrations explicitly:
`dotnet ef database update`
3. Run tests:
`dotnet test GameList.Tests/GameList.Tests.csproj`
3. Run locally:
4. Run locally:
`dotnet run --project GameList.csproj`
4. Open:
5. Open:
`http://localhost:5000` (or the URL shown by `dotnet run`)
## Frontend Tooling
@@ -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<T>` outputs mapped to HTTP at the edge).
- `Infrastructure/`: filters, middleware, identity helpers.
- `Data/`: EF Core `DbContext` and migrations.
- `Domain/`: entities and enums.
- `Contracts/`: request/response DTOs.
- `wwwroot/`: static frontend assets.
- `GameList.Tests/`: integration and helper tests.
- `scripts/`: deployment scripts.
- `scripts/`: deployment scripts (`scripts/deploy-ftp.ps1`, `scripts/deploy-ftp1.ps1`).
- `deploy.ps1`: local shortcut wrapper that runs FTP deploy using `scripts/deploy-ftp.profile.psd1`.
## Operations
@@ -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%)

View File

@@ -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 callers suggestions ordered by CreatedAt.
- POST /: success with valid data; enforces ≤5 per player; trims optional fields; requires display name; rejects bad image URL/ext, unreachable image (mocked), invalid game/youtube URLs, invalid player counts, missing name/too long.
- DB trigger also enforces suggestion cap for non-joker inserts, protecting against concurrent over-limit writes.
- Joker path: when phase=Vote and HasJoker=true allows creation, consumes joker, resets VotesFinal for all players.
- Phase gating: non-admin cannot create/update/delete outside Suggest (except joker create); admin bypasses phase checks for update/delete.
- PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; validation mirrors create.
- PUT /{id}: player can edit own in Suggest; name locked outside Suggest; admin can edit any time; screenshot reachability check is skipped when screenshot URL is unchanged.
- DELETE /{id}: player deletes own in Suggest; admin any time; also breaks child links and deletes related votes.
- GET /all: accessible from Vote+, orders by CreatedAt, includes link metadata, enforces phase mismatch before Vote.
@@ -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 010; rejects when VotesFinal=true; enforces display name requirement and phase gating.
- Linked votes: when suggestions are linked, a single post updates all linked IDs; invalid suggestionId returns 400; linking root detection works for nested links.
- Concurrent vote upserts are handled with retry logic around unique-key conflicts to avoid server errors.
- Finalize: POST /finalize toggles VotesFinal flag; allowed only in Vote.
### 5) Results
@@ -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.

17
deploy.ps1 Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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