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,136 @@
|
||||
using System.Text.Json;
|
||||
using LudosData.Api.Domain;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace LudosData.Api.Data;
|
||||
|
||||
public class SeedOptions
|
||||
{
|
||||
public const string SectionName = "Seed";
|
||||
|
||||
/// <summary>When false, migrations still run but no user or games are created.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies migrations and, on a genuinely empty database, creates the initial user
|
||||
/// and imports the 105 games recovered from the 2018 MySQL dump.
|
||||
///
|
||||
/// The dump predates the multi-user work, so it has no users table and no per-game
|
||||
/// owner: every imported game is assigned to the seed user.
|
||||
/// </summary>
|
||||
public static class DbSeeder
|
||||
{
|
||||
public static async Task MigrateAndSeedAsync(IServiceProvider services, CancellationToken ct = default)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var sp = scope.ServiceProvider;
|
||||
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger("DbSeeder");
|
||||
var db = sp.GetRequiredService<LudosDbContext>();
|
||||
|
||||
await db.Database.MigrateAsync(ct);
|
||||
|
||||
var options = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeedOptions>>().Value;
|
||||
if (!options.Enabled)
|
||||
{
|
||||
logger.LogInformation("Seeding disabled; skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (await db.Users.AnyAsync(ct))
|
||||
{
|
||||
logger.LogInformation("Database already has users; skipping seed.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.UserName) || string.IsNullOrWhiteSpace(options.Password))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Seeding is enabled but Seed:UserName / Seed:Password are not set, so no initial user was " +
|
||||
"created and the {Count} games from the 2018 dump were not imported. Set SEED__USERNAME, " +
|
||||
"SEED__EMAIL and SEED__PASSWORD and restart, or register a user and import manually.",
|
||||
await CountSeedGamesAsync(ct));
|
||||
return;
|
||||
}
|
||||
|
||||
var userManager = sp.GetRequiredService<UserManager<AppUser>>();
|
||||
|
||||
var user = new AppUser
|
||||
{
|
||||
UserName = options.UserName,
|
||||
Email = string.IsNullOrWhiteSpace(options.Email) ? $"{options.UserName}@localhost" : options.Email,
|
||||
EmailConfirmed = true,
|
||||
};
|
||||
|
||||
var created = await userManager.CreateAsync(user, options.Password);
|
||||
if (!created.Succeeded)
|
||||
{
|
||||
var errors = string.Join("; ", created.Errors.Select(e => e.Description));
|
||||
logger.LogError("Could not create the seed user: {Errors}", errors);
|
||||
return;
|
||||
}
|
||||
|
||||
var games = await LoadSeedGamesAsync(ct);
|
||||
foreach (var game in games)
|
||||
{
|
||||
game.OwnerId = user.Id;
|
||||
db.Games.Add(game);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
logger.LogInformation(
|
||||
"Seeded user {UserName} with {Count} games from the 2018 dump.", user.UserName, games.Count);
|
||||
}
|
||||
|
||||
private static async Task<List<Game>> LoadSeedGamesAsync(CancellationToken ct)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Data", "Seed", "games.json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(path);
|
||||
var records = await JsonSerializer.DeserializeAsync<List<SeedGame>>(
|
||||
stream, new JsonSerializerOptions(JsonSerializerDefaults.Web), ct) ?? [];
|
||||
|
||||
return records.Select(r => new Game
|
||||
{
|
||||
Title = r.Title,
|
||||
System = r.System,
|
||||
Genre = r.Genre,
|
||||
Year = r.Year,
|
||||
Developer = r.Developer,
|
||||
Publisher = r.Publisher,
|
||||
Art = r.Art,
|
||||
Description = r.Description,
|
||||
Own = r.Own,
|
||||
Dumped = r.Dumped,
|
||||
Played = r.Played,
|
||||
Finished = r.Finished,
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static async Task<int> CountSeedGamesAsync(CancellationToken ct) =>
|
||||
(await LoadSeedGamesAsync(ct)).Count;
|
||||
|
||||
private sealed record SeedGame(
|
||||
string Title,
|
||||
string? System,
|
||||
string? Genre,
|
||||
string? Year,
|
||||
string? Developer,
|
||||
string? Publisher,
|
||||
string? Art,
|
||||
string? Description,
|
||||
bool Own,
|
||||
bool Dumped,
|
||||
bool Played,
|
||||
bool Finished);
|
||||
}
|
||||
Reference in New Issue
Block a user