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
+29
View File
@@ -0,0 +1,29 @@
import { inject } from '@angular/core';
import { CanActivateFn, Router } from '@angular/router';
import { AuthService } from './auth.service';
/**
* Functional route guard — the modern replacement for the class-based
* `AuthGuard implements CanActivate` this app used in 2018.
*/
export const authGuard: CanActivateFn = (_route, state) => {
const auth = inject(AuthService);
const router = inject(Router);
if (auth.isLoggedIn()) {
return true;
}
return router.createUrlTree(['/login'], {
queryParams: { returnUrl: state.url },
});
};
/** Keeps an already-signed-in user off the login and register pages. */
export const guestGuard: CanActivateFn = () => {
const auth = inject(AuthService);
const router = inject(Router);
return auth.isLoggedIn() ? router.createUrlTree(['/games']) : true;
};
@@ -0,0 +1,99 @@
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { AuthService } from './auth.service';
import { authInterceptor } from './auth.interceptor';
describe('authInterceptor', () => {
let http: HttpClient;
let httpMock: HttpTestingController;
let auth: AuthService;
beforeEach(() => {
localStorage.clear();
TestBed.configureTestingModule({
providers: [
provideRouter([{ path: 'login', children: [] }]),
provideHttpClient(withInterceptors([authInterceptor])),
provideHttpClientTesting(),
],
});
http = TestBed.inject(HttpClient);
httpMock = TestBed.inject(HttpTestingController);
auth = TestBed.inject(AuthService);
});
afterEach(() => {
httpMock.verify();
localStorage.clear();
});
/** Signs in through AuthService, which is what actually stores the session. */
function signIn(): void {
auth.login('ckoch', 'pw').subscribe();
httpMock.expectOne('/api/auth/login').flush({
token: 'test-token',
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
user: { id: 'u1', userName: 'ckoch', email: null, firstName: null, lastName: null, art: null },
});
}
it('sends the token as a bearer header, never in the query string', () => {
signIn();
http.get('/api/games').subscribe();
const req = httpMock.expectOne((r) => r.url === '/api/games');
expect(req.request.headers.get('Authorization')).toBe('Bearer test-token');
// The 2018 client appended ?token=... which leaks into logs and history.
expect(req.request.urlWithParams).not.toContain('token');
req.flush({ items: [], page: 1, pageSize: 20, total: 0, totalPages: 0 });
});
it('does not attach the token to non-API requests', () => {
signIn();
http.get('/assets/config.json').subscribe();
const req = httpMock.expectOne('/assets/config.json');
expect(req.request.headers.has('Authorization')).toBe(false);
req.flush({});
});
it('clears the stored session when the API rejects the token', () => {
signIn();
expect(auth.isLoggedIn()).toBe(true);
http.get('/api/games').subscribe({ error: () => undefined });
httpMock
.expectOne('/api/games')
.flush({ title: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
expect(auth.isLoggedIn()).toBe(false);
expect(auth.token()).toBeNull();
});
it('treats an expired stored session as signed out', () => {
localStorage.setItem(
'ludos.session',
JSON.stringify({
token: 'stale',
expiresAt: new Date(Date.now() - 1000).toISOString(),
user: { id: 'u1', userName: 'ckoch' },
}),
);
// A fresh injector picks the value up from storage on construction.
TestBed.resetTestingModule();
TestBed.configureTestingModule({
providers: [provideRouter([{ path: 'login', children: [] }]), provideHttpClient(), provideHttpClientTesting()],
});
expect(TestBed.inject(AuthService).isLoggedIn()).toBe(false);
});
});
+41
View File
@@ -0,0 +1,41 @@
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
import { inject } from '@angular/core';
import { Router } from '@angular/router';
import { catchError, throwError } from 'rxjs';
import { AuthService } from './auth.service';
/**
* Attaches the bearer token to same-origin API calls and turns a 401 into a
* redirect back to the login form.
*
* The old client put the token in the query string (`?token=...`), which leaks
* it into server logs, browser history and Referer headers. It belongs in the
* Authorization header.
*/
export const authInterceptor: HttpInterceptorFn = (req, next) => {
const auth = inject(AuthService);
const router = inject(Router);
const token = auth.token();
const isApiCall = req.url.startsWith('/api/');
const request =
token && isApiCall
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
: req;
return next(request).pipe(
catchError((error: unknown) => {
if (error instanceof HttpErrorResponse && error.status === 401 && isApiCall) {
// Expired or rejected token: drop it and bounce to login, remembering
// where the user was trying to go.
auth.logout();
void router.navigate(['/login'], {
queryParams: { returnUrl: router.url },
});
}
return throwError(() => error);
}),
);
};
+92
View File
@@ -0,0 +1,92 @@
import { HttpClient } from '@angular/common/http';
import { Injectable, computed, inject, signal } from '@angular/core';
import { Observable, tap } from 'rxjs';
import { AuthResponse, User } from './models';
interface StoredSession {
token: string;
expiresAt: string;
user: User;
}
const STORAGE_KEY = 'ludos.session';
@Injectable({ providedIn: 'root' })
export class AuthService {
private readonly http = inject(HttpClient);
private readonly session = signal<StoredSession | null>(readStoredSession());
readonly user = computed(() => this.session()?.user ?? null);
readonly token = computed(() => this.session()?.token ?? null);
/**
* True only while a stored token is present and unexpired. Checking expiry
* here means a stale token sends the user to the login form rather than
* producing a wall of 401s.
*/
readonly isLoggedIn = computed(() => {
const current = this.session();
if (!current) {
return false;
}
return new Date(current.expiresAt).getTime() > Date.now();
});
login(userName: string, password: string): Observable<AuthResponse> {
return this.http
.post<AuthResponse>('/api/auth/login', { userName, password })
.pipe(tap((response) => this.store(response)));
}
register(payload: {
userName: string;
email: string;
password: string;
firstName?: string;
lastName?: string;
}): Observable<AuthResponse> {
return this.http
.post<AuthResponse>('/api/auth/register', payload)
.pipe(tap((response) => this.store(response)));
}
/** Availability check for the registration form; returns true when free. */
isAvailable(field: 'userName' | 'email', value: string): Observable<{ available: boolean }> {
return this.http.get<{ available: boolean }>('/api/auth/available', {
params: { [field]: value },
});
}
logout(): void {
localStorage.removeItem(STORAGE_KEY);
this.session.set(null);
}
private store(response: AuthResponse): void {
const stored: StoredSession = {
token: response.token,
expiresAt: response.expiresAt,
user: response.user,
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
this.session.set(stored);
}
}
function readStoredSession(): StoredSession | null {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) {
return null;
}
try {
const parsed = JSON.parse(raw) as StoredSession;
return parsed?.token && parsed?.user ? parsed : null;
} catch {
// Corrupt or hand-edited storage should not wedge startup.
localStorage.removeItem(STORAGE_KEY);
return null;
}
}
+53
View File
@@ -0,0 +1,53 @@
import { HttpClient, HttpParams } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable } from 'rxjs';
import { Facets, Game, GameQuery, GameRequest, PagedResult, UploadResponse } from './models';
@Injectable({ providedIn: 'root' })
export class GamesService {
private readonly http = inject(HttpClient);
/**
* Note what is absent: the caller never passes a user id. Ownership is taken
* from the bearer token server-side, so there is no filter for a client to
* tamper with the way there was in the old php-crud-api query string.
*/
list(query: GameQuery): Observable<PagedResult<Game>> {
let params = new HttpParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null && value !== '') {
params = params.set(key, String(value));
}
}
return this.http.get<PagedResult<Game>>('/api/games', { params });
}
get(id: number): Observable<Game> {
return this.http.get<Game>(`/api/games/${id}`);
}
facets(): Observable<Facets> {
return this.http.get<Facets>('/api/games/facets');
}
create(game: GameRequest): Observable<Game> {
return this.http.post<Game>('/api/games', game);
}
update(id: number, game: GameRequest): Observable<Game> {
return this.http.put<Game>(`/api/games/${id}`, game);
}
remove(id: number): Observable<void> {
return this.http.delete<void>(`/api/games/${id}`);
}
uploadArt(file: File): Observable<UploadResponse> {
const form = new FormData();
form.append('file', file, file.name);
return this.http.post<UploadResponse>('/api/images', form);
}
}
+108
View File
@@ -0,0 +1,108 @@
/** Mirrors the API contracts in backend/src/LudosData.Api/Contracts. */
export interface Game {
id: number;
title: string;
system: string | null;
genre: string | null;
year: string | null;
developer: string | null;
publisher: string | null;
/** Stored filename. */
art: string | null;
/** Ready-to-use URL, built by the API. Null when no art was uploaded. */
artUrl: string | null;
description: string | null;
own: boolean;
dumped: boolean;
played: boolean;
finished: boolean;
createdAt: string;
updatedAt: string;
}
/** Create/update payload. No id and no owner — the API derives both. */
export interface GameRequest {
title: string;
system: string | null;
genre: string | null;
year: string | null;
developer: string | null;
publisher: string | null;
art: string | null;
description: string | null;
own: boolean;
dumped: boolean;
played: boolean;
finished: boolean;
}
export interface PagedResult<T> {
items: T[];
page: number;
pageSize: number;
total: number;
totalPages: number;
}
export interface GameQuery {
search?: string;
system?: string;
genre?: string;
own?: boolean;
dumped?: boolean;
played?: boolean;
finished?: boolean;
page?: number;
pageSize?: number;
sort?: string;
dir?: 'asc' | 'desc';
}
export interface Facets {
systems: string[];
genres: string[];
}
export interface User {
id: string;
userName: string;
email: string | null;
firstName: string | null;
lastName: string | null;
art: string | null;
}
export interface AuthResponse {
token: string;
expiresAt: string;
user: User;
}
export interface UploadResponse {
fileName: string;
url: string;
}
/** RFC 7807 body returned by the API for validation failures. */
export interface ProblemDetails {
title?: string;
detail?: string;
status?: number;
errors?: Record<string, string[]>;
}
export const EMPTY_GAME: GameRequest = {
title: '',
system: null,
genre: null,
year: null,
developer: null,
publisher: null,
art: null,
description: null,
own: true,
dumped: false,
played: false,
finished: false,
};