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>
138 lines
4.4 KiB
HTML
138 lines
4.4 KiB
HTML
<app-toolbar />
|
|
|
|
@if (loading() || saving()) {
|
|
<mat-progress-bar mode="indeterminate" />
|
|
}
|
|
|
|
@if (loadFailed()) {
|
|
<div class="state-panel">
|
|
<mat-icon>error_outline</mat-icon>
|
|
<h2>Game not found</h2>
|
|
<p>It may have been removed, or it belongs to another account.</p>
|
|
<a mat-flat-button color="primary" routerLink="/games">Back to library</a>
|
|
</div>
|
|
} @else {
|
|
<form class="editor" [formGroup]="form" (ngSubmit)="submit()" novalidate>
|
|
<!-- Left column: art and actions -->
|
|
<mat-card class="art-panel">
|
|
<div class="art-frame">
|
|
@if (artPreview(); as preview) {
|
|
<img [src]="preview" alt="Box art preview" />
|
|
} @else {
|
|
<div class="art-placeholder">
|
|
<mat-icon>image</mat-icon>
|
|
<span>No box art</span>
|
|
</div>
|
|
}
|
|
|
|
@if (uploading()) {
|
|
<div class="art-overlay"><mat-progress-bar mode="indeterminate" /></div>
|
|
}
|
|
</div>
|
|
|
|
<input
|
|
#fileInput
|
|
type="file"
|
|
accept="image/*"
|
|
hidden
|
|
(change)="onFileSelected($event)"
|
|
/>
|
|
|
|
<div class="art-actions">
|
|
<button mat-stroked-button type="button" (click)="fileInput.click()" [disabled]="uploading()">
|
|
<mat-icon>upload</mat-icon>
|
|
{{ uploading() ? 'Uploading…' : 'Upload art' }}
|
|
</button>
|
|
|
|
@if (artPreview()) {
|
|
<button mat-button type="button" (click)="removeArt()" [disabled]="uploading()">
|
|
Remove
|
|
</button>
|
|
}
|
|
</div>
|
|
</mat-card>
|
|
|
|
<!-- Right column: fields -->
|
|
<mat-card class="fields-panel">
|
|
<h1 class="editor-title">{{ isEdit() ? 'Edit game' : 'New game' }}</h1>
|
|
|
|
<mat-form-field appearance="outline">
|
|
<mat-label>Title</mat-label>
|
|
<input matInput formControlName="title" required />
|
|
@if (form.controls.title.touched && form.controls.title.invalid) {
|
|
<mat-error>Title is required</mat-error>
|
|
}
|
|
</mat-form-field>
|
|
|
|
<div class="field-row">
|
|
<mat-form-field appearance="outline">
|
|
<mat-label>System</mat-label>
|
|
<mat-select formControlName="system">
|
|
<mat-option value="">—</mat-option>
|
|
@for (option of systems; track option) {
|
|
<mat-option [value]="option">{{ option }}</mat-option>
|
|
}
|
|
</mat-select>
|
|
</mat-form-field>
|
|
|
|
<mat-form-field appearance="outline">
|
|
<mat-label>Genre</mat-label>
|
|
<mat-select formControlName="genre">
|
|
<mat-option value="">—</mat-option>
|
|
@for (option of genres; track option) {
|
|
<mat-option [value]="option">{{ option }}</mat-option>
|
|
}
|
|
</mat-select>
|
|
</mat-form-field>
|
|
|
|
<mat-form-field appearance="outline">
|
|
<mat-label>Year</mat-label>
|
|
<input matInput formControlName="year" inputmode="numeric" placeholder="1998" />
|
|
</mat-form-field>
|
|
</div>
|
|
|
|
<div class="field-row">
|
|
<mat-form-field appearance="outline">
|
|
<mat-label>Developer</mat-label>
|
|
<input matInput formControlName="developer" />
|
|
</mat-form-field>
|
|
|
|
<mat-form-field appearance="outline">
|
|
<mat-label>Publisher</mat-label>
|
|
<input matInput formControlName="publisher" />
|
|
</mat-form-field>
|
|
</div>
|
|
|
|
<mat-form-field appearance="outline">
|
|
<mat-label>Description</mat-label>
|
|
<textarea matInput formControlName="description" rows="4"></textarea>
|
|
</mat-form-field>
|
|
|
|
<fieldset class="flags">
|
|
<legend>Collection status</legend>
|
|
<mat-checkbox formControlName="own">Own</mat-checkbox>
|
|
<mat-checkbox formControlName="dumped">Dumped</mat-checkbox>
|
|
<mat-checkbox formControlName="played">Played</mat-checkbox>
|
|
<mat-checkbox formControlName="finished">Finished</mat-checkbox>
|
|
</fieldset>
|
|
|
|
<div class="editor-actions">
|
|
<a mat-button routerLink="/games">Cancel</a>
|
|
|
|
@if (isEdit()) {
|
|
<button mat-button type="button" class="danger" (click)="confirmRemove()" [disabled]="saving()">
|
|
<mat-icon>delete_outline</mat-icon>
|
|
Remove
|
|
</button>
|
|
}
|
|
|
|
<span class="spacer"></span>
|
|
|
|
<button mat-flat-button color="primary" type="submit" [disabled]="saving() || uploading()">
|
|
{{ saving() ? 'Saving…' : isEdit() ? 'Save changes' : 'Add game' }}
|
|
</button>
|
|
</div>
|
|
</mat-card>
|
|
</form>
|
|
}
|