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:
@@ -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);
|
||||
Reference in New Issue
Block a user