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,118 @@
|
||||
using LudosData.Api.Auth;
|
||||
using LudosData.Api.Contracts;
|
||||
using LudosData.Api.Domain;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace LudosData.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
public class AuthController(
|
||||
UserManager<AppUser> userManager,
|
||||
SignInManager<AppUser> signInManager,
|
||||
ITokenService tokenService,
|
||||
ILogger<AuthController> logger) : ControllerBase
|
||||
{
|
||||
[HttpPost("register")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AuthResponse>> Register(RegisterRequest request)
|
||||
{
|
||||
var user = new AppUser
|
||||
{
|
||||
UserName = request.UserName,
|
||||
Email = request.Email,
|
||||
FirstName = request.FirstName,
|
||||
LastName = request.LastName,
|
||||
};
|
||||
|
||||
var result = await userManager.CreateAsync(user, request.Password);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
foreach (var error in result.Errors)
|
||||
{
|
||||
ModelState.AddModelError(error.Code, error.Description);
|
||||
}
|
||||
|
||||
return ValidationProblem(ModelState);
|
||||
}
|
||||
|
||||
logger.LogInformation("Registered user {UserName}", user.UserName);
|
||||
return Ok(BuildAuthResponse(user));
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AuthResponse>> Login(LoginRequest request)
|
||||
{
|
||||
var user = await userManager.FindByNameAsync(request.UserName);
|
||||
if (user is null)
|
||||
{
|
||||
// Same response as a bad password, so this endpoint cannot be used to
|
||||
// enumerate which usernames exist.
|
||||
return Unauthorized(new ProblemDetails { Title = "Invalid username or password." });
|
||||
}
|
||||
|
||||
var result = await signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true);
|
||||
|
||||
if (result.IsLockedOut)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status423Locked,
|
||||
new ProblemDetails { Title = "Account temporarily locked after too many failed attempts." });
|
||||
}
|
||||
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return Unauthorized(new ProblemDetails { Title = "Invalid username or password." });
|
||||
}
|
||||
|
||||
return Ok(BuildAuthResponse(user));
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<UserResponse>> Me()
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(User.GetUserId());
|
||||
return user is null ? Unauthorized() : Ok(ToUserResponse(user));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Availability check for the registration form. Replaces the old approach of
|
||||
/// querying the users table through the generic CRUD endpoint, which exposed
|
||||
/// every user column to anonymous callers; this returns only a boolean.
|
||||
/// </summary>
|
||||
[HttpGet("available")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AvailabilityResponse>> Available(
|
||||
[FromQuery] string? userName,
|
||||
[FromQuery] string? email)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(userName))
|
||||
{
|
||||
return Ok(new AvailabilityResponse(await userManager.FindByNameAsync(userName) is null));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
return Ok(new AvailabilityResponse(await userManager.FindByEmailAsync(email) is null));
|
||||
}
|
||||
|
||||
return BadRequest(new ProblemDetails { Title = "Provide either userName or email." });
|
||||
}
|
||||
|
||||
private AuthResponse BuildAuthResponse(AppUser user)
|
||||
{
|
||||
var (token, expiresAt) = tokenService.CreateAccessToken(user);
|
||||
return new AuthResponse(token, expiresAt, ToUserResponse(user));
|
||||
}
|
||||
|
||||
private static UserResponse ToUserResponse(AppUser user) => new(
|
||||
user.Id,
|
||||
user.UserName ?? string.Empty,
|
||||
user.Email,
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Art);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
using LudosData.Api.Auth;
|
||||
using LudosData.Api.Contracts;
|
||||
using LudosData.Api.Data;
|
||||
using LudosData.Api.Domain;
|
||||
using LudosData.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace LudosData.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The user's game library.
|
||||
///
|
||||
/// Every query starts from <c>Where(g => g.OwnerId == currentUserId)</c>, taken from
|
||||
/// the JWT subject. The old API took the owner id from a client-supplied query
|
||||
/// parameter (<c>filter[]=userId,eq,N</c>), which meant any valid token could read
|
||||
/// any other user's library by editing the number.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/games")]
|
||||
[Authorize]
|
||||
public class GamesController(
|
||||
LudosDbContext db,
|
||||
IImageStorage images,
|
||||
ILogger<GamesController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResult<GameResponse>>> List([FromQuery] GameQuery query, CancellationToken ct)
|
||||
{
|
||||
var ownerId = User.GetUserId();
|
||||
var q = db.Games.AsNoTracking().Where(g => g.OwnerId == ownerId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
var term = query.Search.Trim();
|
||||
q = q.Where(g =>
|
||||
EF.Functions.Like(g.Title, $"%{term}%") ||
|
||||
(g.Developer != null && EF.Functions.Like(g.Developer, $"%{term}%")) ||
|
||||
(g.Publisher != null && EF.Functions.Like(g.Publisher, $"%{term}%")));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.System)) q = q.Where(g => g.System == query.System);
|
||||
if (!string.IsNullOrWhiteSpace(query.Genre)) q = q.Where(g => g.Genre == query.Genre);
|
||||
|
||||
if (query.Own is { } own) q = q.Where(g => g.Own == own);
|
||||
if (query.Dumped is { } dumped) q = q.Where(g => g.Dumped == dumped);
|
||||
if (query.Played is { } played) q = q.Where(g => g.Played == played);
|
||||
if (query.Finished is { } finished) q = q.Where(g => g.Finished == finished);
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
|
||||
q = ApplySort(q, query.Sort, query.Dir);
|
||||
|
||||
var items = await q
|
||||
.Skip((query.Page - 1) * query.PageSize)
|
||||
.Take(query.PageSize)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Ok(new PagedResult<GameResponse>(
|
||||
items.Select(g => ToResponse(g, ownerId)).ToList(),
|
||||
query.Page,
|
||||
query.PageSize,
|
||||
total));
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
public async Task<ActionResult<GameResponse>> Get(int id, CancellationToken ct)
|
||||
{
|
||||
var ownerId = User.GetUserId();
|
||||
var game = await db.Games.AsNoTracking()
|
||||
.FirstOrDefaultAsync(g => g.Id == id && g.OwnerId == ownerId, ct);
|
||||
|
||||
// A game belonging to someone else is reported as 404, not 403 — the
|
||||
// response should not confirm that the id exists.
|
||||
return game is null ? NotFound() : Ok(ToResponse(game, ownerId));
|
||||
}
|
||||
|
||||
[HttpGet("facets")]
|
||||
public async Task<ActionResult<FacetsResponse>> Facets(CancellationToken ct)
|
||||
{
|
||||
var ownerId = User.GetUserId();
|
||||
var mine = db.Games.AsNoTracking().Where(g => g.OwnerId == ownerId);
|
||||
|
||||
var systems = await mine
|
||||
.Where(g => g.System != null && g.System != "")
|
||||
.Select(g => g.System!)
|
||||
.Distinct().OrderBy(s => s).ToListAsync(ct);
|
||||
|
||||
var genres = await mine
|
||||
.Where(g => g.Genre != null && g.Genre != "")
|
||||
.Select(g => g.Genre!)
|
||||
.Distinct().OrderBy(s => s).ToListAsync(ct);
|
||||
|
||||
return Ok(new FacetsResponse(systems, genres));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<GameResponse>> Create(GameRequest request, CancellationToken ct)
|
||||
{
|
||||
var ownerId = User.GetUserId();
|
||||
|
||||
var game = new Game { OwnerId = ownerId };
|
||||
Apply(request, game);
|
||||
|
||||
db.Games.Add(game);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
logger.LogInformation("User {OwnerId} created game {GameId}", ownerId, game.Id);
|
||||
return CreatedAtAction(nameof(Get), new { id = game.Id }, ToResponse(game, ownerId));
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<ActionResult<GameResponse>> Update(int id, GameRequest request, CancellationToken ct)
|
||||
{
|
||||
var ownerId = User.GetUserId();
|
||||
var game = await db.Games.FirstOrDefaultAsync(g => g.Id == id && g.OwnerId == ownerId, ct);
|
||||
if (game is null) return NotFound();
|
||||
|
||||
Apply(request, game);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return Ok(ToResponse(game, ownerId));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken ct)
|
||||
{
|
||||
var ownerId = User.GetUserId();
|
||||
var game = await db.Games.FirstOrDefaultAsync(g => g.Id == id && g.OwnerId == ownerId, ct);
|
||||
if (game is null) return NotFound();
|
||||
|
||||
db.Games.Remove(game);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
logger.LogInformation("User {OwnerId} deleted game {GameId}", ownerId, id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static IQueryable<Game> ApplySort(IQueryable<Game> q, string sort, string dir)
|
||||
{
|
||||
var descending = string.Equals(dir, "desc", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Allow-list rather than reflecting over the string, so the sort parameter
|
||||
// cannot reach the query shape in any way the API does not define.
|
||||
return (sort?.ToLowerInvariant()) switch
|
||||
{
|
||||
"system" => descending ? q.OrderByDescending(g => g.System) : q.OrderBy(g => g.System),
|
||||
"genre" => descending ? q.OrderByDescending(g => g.Genre) : q.OrderBy(g => g.Genre),
|
||||
"year" => descending ? q.OrderByDescending(g => g.Year) : q.OrderBy(g => g.Year),
|
||||
"developer" => descending ? q.OrderByDescending(g => g.Developer) : q.OrderBy(g => g.Developer),
|
||||
"publisher" => descending ? q.OrderByDescending(g => g.Publisher) : q.OrderBy(g => g.Publisher),
|
||||
"created" => descending ? q.OrderByDescending(g => g.CreatedAt) : q.OrderBy(g => g.CreatedAt),
|
||||
"updated" => descending ? q.OrderByDescending(g => g.UpdatedAt) : q.OrderBy(g => g.UpdatedAt),
|
||||
_ => descending ? q.OrderByDescending(g => g.Title) : q.OrderBy(g => g.Title),
|
||||
};
|
||||
}
|
||||
|
||||
private static void Apply(GameRequest request, Game game)
|
||||
{
|
||||
game.Title = request.Title.Trim();
|
||||
game.System = request.System?.Trim();
|
||||
game.Genre = request.Genre?.Trim();
|
||||
game.Year = request.Year?.Trim();
|
||||
game.Developer = request.Developer?.Trim();
|
||||
game.Publisher = request.Publisher?.Trim();
|
||||
game.Art = request.Art?.Trim();
|
||||
game.Description = request.Description;
|
||||
game.Own = request.Own;
|
||||
game.Dumped = request.Dumped;
|
||||
game.Played = request.Played;
|
||||
game.Finished = request.Finished;
|
||||
}
|
||||
|
||||
private GameResponse ToResponse(Game g, string ownerId) => new(
|
||||
g.Id, g.Title, g.System, g.Genre, g.Year, g.Developer, g.Publisher,
|
||||
g.Art, images.BuildUrl(ownerId, g.Art), g.Description,
|
||||
g.Own, g.Dumped, g.Played, g.Finished, g.CreatedAt, g.UpdatedAt);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using LudosData.Api.Auth;
|
||||
using LudosData.Api.Contracts;
|
||||
using LudosData.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace LudosData.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Box art uploads.
|
||||
///
|
||||
/// The PHP original accepted anonymous uploads, wrote every file into one hardcoded
|
||||
/// "ckoch" folder, and built the destination path from the client-supplied filename.
|
||||
/// This requires authentication, files land in the caller's own folder, and the
|
||||
/// stored name is generated server-side.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/images")]
|
||||
[Authorize]
|
||||
public class ImagesController(
|
||||
IImageStorage images,
|
||||
IOptions<ImageStorageOptions> options,
|
||||
ILogger<ImagesController> logger) : ControllerBase
|
||||
{
|
||||
private readonly ImageStorageOptions _options = options.Value;
|
||||
|
||||
[HttpPost]
|
||||
[RequestSizeLimit(6 * 1024 * 1024)]
|
||||
public async Task<ActionResult<UploadResponse>> Upload(IFormFile file, CancellationToken ct)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(new ProblemDetails { Title = "No file was uploaded." });
|
||||
}
|
||||
|
||||
if (file.Length > _options.MaxBytes)
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = $"File is larger than the {_options.MaxBytes / (1024 * 1024)} MB limit.",
|
||||
});
|
||||
}
|
||||
|
||||
var ownerId = User.GetUserId();
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
var fileName = await images.SaveAsync(stream, ownerId, ct);
|
||||
|
||||
return Ok(new UploadResponse(fileName, images.BuildUrl(ownerId, fileName)!));
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// The most common cause is bytes that are not a decodable image. The
|
||||
// detail is logged but not returned, so probing does not reveal the
|
||||
// internals of the decoder.
|
||||
logger.LogWarning(ex, "Rejected upload from user {OwnerId}", ownerId);
|
||||
return BadRequest(new ProblemDetails { Title = "The file could not be read as an image." });
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user