From f33545b184bf1efe94aa30732ae13530f5a01adc Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Mon, 2 Feb 2026 19:39:32 +0100 Subject: [PATCH] Limit player name lengths and fix vote UI defaults --- Data/AppDbContext.cs | 6 +- ...2183354_LimitPlayerNameLengths.Designer.cs | 217 ++++++++++++++++++ .../20260202183354_LimitPlayerNameLengths.cs | 80 +++++++ Data/Migrations/AppDbContextModelSnapshot.cs | 14 +- Domain/Player.cs | 6 +- Endpoints/AuthEndpoints.cs | 10 +- Endpoints/StateEndpoints.cs | 7 +- wwwroot/app.js | 9 +- wwwroot/index.html | 6 +- wwwroot/js/i18n.js | 2 + wwwroot/styles.css | 3 +- 11 files changed, 334 insertions(+), 26 deletions(-) create mode 100644 Data/Migrations/20260202183354_LimitPlayerNameLengths.Designer.cs create mode 100644 Data/Migrations/20260202183354_LimitPlayerNameLengths.cs diff --git a/Data/AppDbContext.cs b/Data/AppDbContext.cs index 4fcf0b9..9e69392 100644 --- a/Data/AppDbContext.cs +++ b/Data/AppDbContext.cs @@ -19,9 +19,9 @@ public class AppDbContext : DbContext modelBuilder.Entity(builder => { builder.HasKey(p => p.Id); - builder.Property(p => p.DisplayName).HasMaxLength(64); - builder.Property(p => p.Username).IsRequired().HasMaxLength(64); - builder.Property(p => p.NormalizedUsername).IsRequired().HasMaxLength(64); + builder.Property(p => p.DisplayName).HasMaxLength(16); + builder.Property(p => p.Username).IsRequired().HasMaxLength(24); + builder.Property(p => p.NormalizedUsername).IsRequired().HasMaxLength(24); builder.HasIndex(p => p.NormalizedUsername).IsUnique(); builder.Property(p => p.PasswordHash).IsRequired(); builder.Property(p => p.PasswordSalt).IsRequired(); diff --git a/Data/Migrations/20260202183354_LimitPlayerNameLengths.Designer.cs b/Data/Migrations/20260202183354_LimitPlayerNameLengths.Designer.cs new file mode 100644 index 0000000..4786de1 --- /dev/null +++ b/Data/Migrations/20260202183354_LimitPlayerNameLengths.Designer.cs @@ -0,0 +1,217 @@ +// +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("20260202183354_LimitPlayerNameLengths")] + partial class LimitPlayerNameLengths + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + + modelBuilder.Entity("GameList.Domain.AppState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CurrentPhase") + .HasColumnType("INTEGER"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("AppState"); + + b.HasData( + new + { + Id = 1, + CurrentPhase = 0, + UpdatedAt = new DateTimeOffset(new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), new TimeSpan(0, 0, 0, 0, 0)) + }); + }); + + modelBuilder.Entity("GameList.Domain.Player", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("IsAdmin") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER") + .HasDefaultValue(false); + + b.Property("LastLoginAt") + .HasColumnType("TEXT"); + + b.Property("NormalizedUsername") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("PasswordSalt") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(24) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedUsername") + .IsUnique(); + + b.ToTable("Players"); + }); + + modelBuilder.Entity("GameList.Domain.Suggestion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("GameUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("Genre") + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("MaxPlayers") + .HasColumnType("INTEGER"); + + b.Property("MinPlayers") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("TEXT"); + + b.Property("ScreenshotUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.Property("YoutubeUrl") + .HasMaxLength(2048) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("PlayerId"); + + b.ToTable("Suggestions"); + }); + + modelBuilder.Entity("GameList.Domain.Vote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("PlayerId") + .HasColumnType("TEXT"); + + b.Property("Score") + .HasColumnType("INTEGER"); + + b.Property("SuggestionId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("SuggestionId"); + + b.HasIndex("PlayerId", "SuggestionId") + .IsUnique(); + + b.ToTable("Votes"); + }); + + modelBuilder.Entity("GameList.Domain.Suggestion", b => + { + b.HasOne("GameList.Domain.Player", "Player") + .WithMany("Suggestions") + .HasForeignKey("PlayerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + 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("Votes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Data/Migrations/20260202183354_LimitPlayerNameLengths.cs b/Data/Migrations/20260202183354_LimitPlayerNameLengths.cs new file mode 100644 index 0000000..c5b2901 --- /dev/null +++ b/Data/Migrations/20260202183354_LimitPlayerNameLengths.cs @@ -0,0 +1,80 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GameList.Data.Migrations +{ + /// + public partial class LimitPlayerNameLengths : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Username", + table: "Players", + type: "TEXT", + maxLength: 24, + nullable: false, + oldClrType: typeof(string), + oldType: "TEXT", + oldMaxLength: 64); + + migrationBuilder.AlterColumn( + name: "NormalizedUsername", + table: "Players", + type: "TEXT", + maxLength: 24, + nullable: false, + oldClrType: typeof(string), + oldType: "TEXT", + oldMaxLength: 64); + + migrationBuilder.AlterColumn( + name: "DisplayName", + table: "Players", + type: "TEXT", + maxLength: 16, + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT", + oldMaxLength: 64, + oldNullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterColumn( + name: "Username", + table: "Players", + type: "TEXT", + maxLength: 64, + nullable: false, + oldClrType: typeof(string), + oldType: "TEXT", + oldMaxLength: 24); + + migrationBuilder.AlterColumn( + name: "NormalizedUsername", + table: "Players", + type: "TEXT", + maxLength: 64, + nullable: false, + oldClrType: typeof(string), + oldType: "TEXT", + oldMaxLength: 24); + + migrationBuilder.AlterColumn( + name: "DisplayName", + table: "Players", + type: "TEXT", + maxLength: 64, + nullable: true, + oldClrType: typeof(string), + oldType: "TEXT", + oldMaxLength: 16, + oldNullable: true); + } + } +} diff --git a/Data/Migrations/AppDbContextModelSnapshot.cs b/Data/Migrations/AppDbContextModelSnapshot.cs index 7e5df74..41134f6 100644 --- a/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/Data/Migrations/AppDbContextModelSnapshot.cs @@ -31,7 +31,7 @@ namespace GameList.Data.Migrations b.HasKey("Id"); - b.ToTable("AppState", (string)null); + b.ToTable("AppState"); b.HasData( new @@ -52,7 +52,7 @@ namespace GameList.Data.Migrations .HasColumnType("TEXT"); b.Property("DisplayName") - .HasMaxLength(64) + .HasMaxLength(16) .HasColumnType("TEXT"); b.Property("IsAdmin") @@ -65,7 +65,7 @@ namespace GameList.Data.Migrations b.Property("NormalizedUsername") .IsRequired() - .HasMaxLength(64) + .HasMaxLength(24) .HasColumnType("TEXT"); b.Property("PasswordHash") @@ -78,7 +78,7 @@ namespace GameList.Data.Migrations b.Property("Username") .IsRequired() - .HasMaxLength(64) + .HasMaxLength(24) .HasColumnType("TEXT"); b.HasKey("Id"); @@ -86,7 +86,7 @@ namespace GameList.Data.Migrations b.HasIndex("NormalizedUsername") .IsUnique(); - b.ToTable("Players", (string)null); + b.ToTable("Players"); }); modelBuilder.Entity("GameList.Domain.Suggestion", b => @@ -136,7 +136,7 @@ namespace GameList.Data.Migrations b.HasIndex("PlayerId"); - b.ToTable("Suggestions", (string)null); + b.ToTable("Suggestions"); }); modelBuilder.Entity("GameList.Domain.Vote", b => @@ -164,7 +164,7 @@ namespace GameList.Data.Migrations b.HasIndex("PlayerId", "SuggestionId") .IsUnique(); - b.ToTable("Votes", (string)null); + b.ToTable("Votes"); }); modelBuilder.Entity("GameList.Domain.Suggestion", b => diff --git a/Domain/Player.cs b/Domain/Player.cs index 21ac961..a71d44a 100644 --- a/Domain/Player.cs +++ b/Domain/Player.cs @@ -6,13 +6,13 @@ public class Player { public Guid Id { get; set; } = Guid.NewGuid(); - [MaxLength(64)] + [MaxLength(16)] public string? DisplayName { get; set; } - [MaxLength(64)] + [MaxLength(24)] public string Username { get; set; } = string.Empty; - [MaxLength(64)] + [MaxLength(24)] public string NormalizedUsername { get; set; } = string.Empty; public byte[] PasswordHash { get; set; } = Array.Empty(); diff --git a/Endpoints/AuthEndpoints.cs b/Endpoints/AuthEndpoints.cs index 7d1aecb..a5bfdaa 100644 --- a/Endpoints/AuthEndpoints.cs +++ b/Endpoints/AuthEndpoints.cs @@ -17,13 +17,13 @@ public static class AuthEndpoints group.MapPost("/register", async ([FromBody] RegisterRequest request, HttpContext ctx, AppDbContext db, IConfiguration config) => { var username = request.Username?.Trim(); - if (string.IsNullOrWhiteSpace(username) || username.Length > 64) - return Results.BadRequest(new { error = "Username is required and must be <= 64 characters." }); + if (string.IsNullOrWhiteSpace(username) || username.Length > 24) + return Results.BadRequest(new { error = "Username is required and must be <= 24 characters." }); if (string.IsNullOrWhiteSpace(request.Password)) return Results.BadRequest(new { error = "Password is required." }); - var displayName = EndpointHelpers.TrimTo(request.DisplayName, 64); + var displayName = EndpointHelpers.TrimTo(request.DisplayName, 16); if (string.IsNullOrWhiteSpace(displayName)) return Results.BadRequest(new { error = "Display name is required." }); var normalized = username.ToLowerInvariant(); @@ -69,6 +69,8 @@ public static class AuthEndpoints var username = request.Username?.Trim(); if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password)) return Results.BadRequest(new { error = "Username and password are required." }); + if (username.Length > 24) + return Results.BadRequest(new { error = "Username must be <= 24 characters." }); var normalized = username.ToLowerInvariant(); var player = await db.Players.FirstOrDefaultAsync(p => p.NormalizedUsername == normalized); @@ -77,7 +79,7 @@ public static class AuthEndpoints if (string.IsNullOrWhiteSpace(player.DisplayName)) { - player.DisplayName = player.Username; + player.DisplayName = EndpointHelpers.TrimTo(player.Username, 16); } player.LastLoginAt = DateTimeOffset.UtcNow; await db.SaveChangesAsync(); diff --git a/Endpoints/StateEndpoints.cs b/Endpoints/StateEndpoints.cs index 9e5fa17..7abd9c5 100644 --- a/Endpoints/StateEndpoints.cs +++ b/Endpoints/StateEndpoints.cs @@ -32,15 +32,16 @@ public static class StateEndpoints app.MapPost("/api/me/name", async ([FromBody] SetNameRequest request, HttpContext ctx, AppDbContext db) => { - if (string.IsNullOrWhiteSpace(request.Name) || request.Name.Length > 64) + var name = EndpointHelpers.TrimTo(request.Name, 16); + if (string.IsNullOrWhiteSpace(name)) { - return Results.BadRequest(new { error = "Name is required and must be <= 64 characters." }); + return Results.BadRequest(new { error = "Name is required and must be <= 16 characters." }); } var player = await EndpointHelpers.GetAuthenticatedPlayer(ctx, db); if (player is null) return Results.Unauthorized(); - player.DisplayName = request.Name.Trim(); + player.DisplayName = name; await db.SaveChangesAsync(); return Results.Ok(new { player.Id, player.DisplayName }); }); diff --git a/wwwroot/app.js b/wwwroot/app.js index 066d1dc..d1b537a 100644 --- a/wwwroot/app.js +++ b/wwwroot/app.js @@ -195,7 +195,8 @@ function renderVotes() { const votesMap = Object.fromEntries(state.myVotes.map((v) => [v.suggestionId, v.score])); state.allSuggestions.forEach((s) => { const li = buildCard(s, { showAuthor: true, allowEdit: !!state.me?.isAdmin }); - const current = votesMap[s.id] ?? 0; + const hasVote = Object.prototype.hasOwnProperty.call(votesMap, s.id); + const current = hasVote ? votesMap[s.id] : 5; // start neutral when no prior vote const footer = document.createElement("div"); footer.className = "vote-controls"; footer.innerHTML = ` @@ -339,6 +340,7 @@ function setupHandlers() { e.preventDefault(); const username = $("login-username").value.trim(); const password = $("login-password").value; + if (username.length > 24) return toast("Username must be 24 characters or fewer.", true); if (!username || !password) return toast(t("auth.needCredentials"), true); try { await api.login({ username, password }); @@ -362,6 +364,9 @@ function setupHandlers() { const password = $("register-password").value; const displayName = $("register-displayName").value.trim(); const adminKey = $("register-adminkey").value.trim(); + if (!displayName) return toast(t("toast.displayNameRequired") || "Display name is required.", true); + if (username.length > 24) return toast("Username must be 24 characters or fewer.", true); + if (displayName.length > 16) return toast("Display name must be 16 characters or fewer.", true); if (!username || !password) return toast(t("auth.needCredentials"), true); try { await api.register({ username, password, displayName, adminKey }); @@ -496,7 +501,7 @@ function buildCard(s, { showAuthor = false, allowDelete = false, allowEdit = fal ${visual}
-

${s.name}

+

${s.name}

${showAuthor && s.author ? `${s.author}` : ""} ${allowEdit ? `` : ""} diff --git a/wwwroot/index.html b/wwwroot/index.html index 192b0f9..344b98d 100644 --- a/wwwroot/index.html +++ b/wwwroot/index.html @@ -31,7 +31,7 @@