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