Rebuild on Angular 22 + ASP.NET Core 10, containerised

The 2018 stack (Angular 5.2 / CLI 1.7, PHP, MySQL) had not been touched since
July 2018. Rebuilt rather than upgraded in place: the frontend was 17 major
versions behind, and of ~16,700 lines of PHP only ~150 were application logic —
the rest was four near-identical vendored copies of php-crud-api plus
class.upload.php.

Backend — ASP.NET Core 10, EF Core, SQLite
  * ASP.NET Core Identity (PBKDF2) + JWT bearer auth
  * Clean REST API replacing php-crud-api's filter[]/transform query syntax
  * Box art uploads re-encoded to WebP via SkiaSharp
  * Imports the 105 games recovered from the 2018 dump on first run

Frontend — Angular 22, zoneless, signals, Material 22
  * Standalone components, lazy routes, functional guards and interceptor
  * Vitest replaces Karma/Jasmine; fonts and icons bundled, no CDN calls
  * No provideAnimations: @angular/animations is deprecated in v22 and
    Material no longer depends on it (pinned by a test)

Docker
  * Multi-stage builds for both services, non-root at runtime
  * nginx serves the SPA and reverse-proxies the API, so everything is
    same-origin; one volume holds the database, uploads and DP keys

Security issues in the old code, not carried across:
  * Two endpoints exposed unauthenticated CRUD over every table
  * The client chose whose rows to read (filter[]=userId,eq,N); ownership now
    comes from the JWT subject server-side
  * Login was hardcoded to a single username
  * crypt() with one global salt, silently truncating passwords to 8 chars
  * JWT secret was the literal string "testing", tokens never expired
  * Token travelled in the query string rather than a header
  * Uploads were anonymous with the path built from the client filename
  * Access-Control-Allow-Origin: *

The live MySQL password committed in 2018 remains in git history and must be
rotated independently of this change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 18:46:33 -04:00
co-authored by Claude Opus 5
parent 10757575c2
commit 2a7d90b2d5
165 changed files with 14908 additions and 31942 deletions
@@ -0,0 +1,86 @@
using System.ComponentModel.DataAnnotations;
namespace LudosData.Api.Contracts;
/// <summary>A page of results plus the totals the paginator needs.</summary>
public record PagedResult<T>(IReadOnlyList<T> Items, int Page, int PageSize, int Total)
{
public int TotalPages => PageSize > 0 ? (int)Math.Ceiling(Total / (double)PageSize) : 0;
}
/// <summary>
/// A game as returned to the client. <c>Art</c> is the stored filename; <c>ArtUrl</c>
/// is the ready-to-use URL built server-side, so the client never has to
/// string-concatenate upload paths the way the old grid did.
/// </summary>
public record GameResponse(
int Id,
string Title,
string? System,
string? Genre,
string? Year,
string? Developer,
string? Publisher,
string? Art,
string? ArtUrl,
string? Description,
bool Own,
bool Dumped,
bool Played,
bool Finished,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
/// <summary>
/// Create/update payload. Deliberately has no Id and no OwnerId — the route supplies
/// the former and the JWT the latter, so neither can be spoofed by the client.
/// </summary>
public record GameRequest
{
[Required(AllowEmptyStrings = false), MaxLength(200)]
public string Title { get; init; } = string.Empty;
[MaxLength(50)] public string? System { get; init; }
[MaxLength(50)] public string? Genre { get; init; }
[MaxLength(50)] public string? Year { get; init; }
[MaxLength(100)] public string? Developer { get; init; }
[MaxLength(100)] public string? Publisher { get; init; }
[MaxLength(200)] public string? Art { get; init; }
[MaxLength(10_000)] public string? Description { get; init; }
public bool Own { get; init; }
public bool Dumped { get; init; }
public bool Played { get; init; }
public bool Finished { get; init; }
}
/// <summary>Query string for the library list, bound from [FromQuery].</summary>
public record GameQuery
{
/// <summary>Free-text match against title, developer and publisher.</summary>
public string? Search { get; init; }
public string? System { get; init; }
public string? Genre { get; init; }
public bool? Own { get; init; }
public bool? Dumped { get; init; }
public bool? Played { get; init; }
public bool? Finished { get; init; }
[Range(1, int.MaxValue)] public int Page { get; init; } = 1;
/// <summary>Capped at 100 to keep a hostile or buggy client from asking for everything.</summary>
[Range(1, 100)] public int PageSize { get; init; } = 20;
/// <summary>One of: title, system, genre, year, developer, publisher, created, updated.</summary>
public string Sort { get; init; } = "title";
/// <summary>"asc" or "desc".</summary>
public string Dir { get; init; } = "asc";
}
/// <summary>Distinct values present in the user's library, for filter dropdowns.</summary>
public record FacetsResponse(IReadOnlyList<string> Systems, IReadOnlyList<string> Genres);
public record UploadResponse(string FileName, string Url);