tools/cover-art/fetch_art.py matches each game against libretro-thumbnails
by title + system and attaches the result through the app's own
POST /api/images, so fetched art goes through the same validation and WebP
re-encoding as a manual upload. Standard library only.
Matching bridges a personal catalogue and a ROM-naming one:
* accents stripped, so "Pokemon Yellow" reaches "Pokémon"
* roman numerals folded to digits, so the SNES "Final Fantasy 2" lands on
"Final Fantasy II" and the PS1 "Final Fantasy V" on its own entry
* trailing articles unwound ("Sims 2, The" -> "The Sims 2")
* subtitle containment in both directions, since our rows sometimes omit
what the catalogue carries ("Wave Race 64" vs "... - Kawasaki Jet Ski")
and sometimes carry what it omits ("Donkey Kong Country 2: Diddy's Kong
Quest" vs the GBA set's "Donkey Kong Country 2")
* a sequel guard, so containment cannot collapse "Donkey Kong Country 2"
onto "Donkey Kong Country"
* fuzzy enough to absorb typos: "Brett Hull Hocky 95" finds "Hockey 95"
93 of 105 games now have art. The remainder: 10 Xbox 360 titles, which
libretro has no thumbnail set for, and two rows whose platform looks wrong
in the source data (a Game Boy "Donkey Kong Country 2", which was never
released on that system, and a DS "Donkey Kong Country Returns", which was
Wii and later 3DS).
Real art also invalidated a layout assumption: the grid used object-fit:
cover, which was fine for uniform placeholders but crops actual boxes, whose
aspect ratios run from near-square SNES to tall N64. Switched the grid and
the editor preview to object-fit: contain so the whole cover is visible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
206 lines
7.9 KiB
Markdown
206 lines
7.9 KiB
Markdown
# LudosData
|
|
|
|
A personal video game library: catalogue what you own, what you've dumped,
|
|
played and finished.
|
|
|
|
Originally built in 2018 on Angular 5 + PHP + MySQL. Rebuilt in 2026 on
|
|
**Angular 22** and **ASP.NET Core 10** with **SQLite**, running in Docker.
|
|
|
|
---
|
|
|
|
## Quick start
|
|
|
|
```bash
|
|
cp .env.example .env
|
|
# Generate a signing key and put it in .env as JWT_KEY:
|
|
openssl rand -base64 48
|
|
# Also set SEED_USERNAME / SEED_EMAIL / SEED_PASSWORD for the first account.
|
|
|
|
docker compose up --build
|
|
```
|
|
|
|
Then open <http://localhost:8080> and sign in with the seed credentials.
|
|
|
|
On first run the API creates that account and imports the **105 games** recovered
|
|
from the 2018 database dump. Seeding only happens while the database has no users.
|
|
|
|
> **Password rules:** 12+ characters, with an uppercase, a lowercase and a digit.
|
|
> The API refuses to start if `JWT_KEY` is missing or shorter than 32 characters —
|
|
> that is deliberate, so a misconfigured deployment fails loudly instead of
|
|
> signing tokens with a guessable key.
|
|
|
|
---
|
|
|
|
## Layout
|
|
|
|
```
|
|
backend/ ASP.NET Core 10 Web API (C#)
|
|
src/LudosData.Api/
|
|
Domain/ Game, AppUser
|
|
Data/ DbContext, migrations, seeder, games.json
|
|
Auth/ JWT options, token service
|
|
Controllers/ auth, games, images
|
|
Services/ image storage
|
|
frontend/ Angular 22 SPA
|
|
src/app/
|
|
core/ models, services, guard, HTTP interceptor
|
|
features/ login, register, game-grid, game-edit, account
|
|
shared/ toolbar, confirm dialog
|
|
archive/ the original 2018 MySQL dump, for provenance
|
|
```
|
|
|
|
Everything stateful lives in one Docker volume (`ludos-data`): the SQLite file,
|
|
uploaded box art, and the Data Protection keys. Back that volume up and you have
|
|
backed up the whole application.
|
|
|
|
---
|
|
|
|
## Development
|
|
|
|
Host tooling (Node 24, .NET 10) is installed via Homebrew. `dotnet-ef` needs
|
|
`~/.dotnet/tools` on `PATH`, which `~/.bashrc.d/dotnet.sh` sets up.
|
|
|
|
```bash
|
|
# API on http://localhost:5099
|
|
cd backend/src/LudosData.Api
|
|
Jwt__Key="a-dev-key-of-at-least-32-characters!!" dotnet run
|
|
|
|
# SPA on http://localhost:4200, proxying /api and /uploads to :5099
|
|
cd frontend
|
|
npm start
|
|
```
|
|
|
|
```bash
|
|
cd frontend && npm test # vitest
|
|
cd backend && dotnet build # 0 warnings expected
|
|
```
|
|
|
|
### Cover art
|
|
|
|
`tools/cover-art/fetch_art.py` fills in box art from
|
|
[libretro-thumbnails](https://thumbnails.libretro.com), matching on title +
|
|
system and pushing each image through the app's own `POST /api/images`, so it
|
|
gets the same validation and WebP re-encoding as a manual upload. Standard
|
|
library only — no virtualenv needed.
|
|
|
|
```bash
|
|
cd tools/cover-art
|
|
python3 fetch_art.py --password '...' --dry-run # report matches, change nothing
|
|
python3 fetch_art.py --password '...' # download and attach
|
|
python3 fetch_art.py --password '...' --overwrite # also replace existing art
|
|
```
|
|
|
|
Always dry-run first; it prints every match with a similarity score and flags
|
|
anything below 0.95 for eyeballing.
|
|
|
|
Matching handles the gaps between a personal catalogue and a ROM-naming one:
|
|
accents (`Pokemon` → `Pokémon`), roman numerals (our SNES `Final Fantasy 2` is
|
|
the catalogue's `Final Fantasy II`), trailing articles (`Sims 2, The`), missing
|
|
subtitles in either direction, and outright typos — `Brett Hull Hocky 95` finds
|
|
`Brett Hull Hockey 95`. A sequel guard stops `Donkey Kong Country 2` from
|
|
silently taking `Donkey Kong Country`'s box.
|
|
|
|
**Xbox 360 is not covered** — libretro has no thumbnail set for it, so those 10
|
|
titles are reported as unsupported. They need a source such as IGDB, which
|
|
requires a free Twitch developer client ID and secret.
|
|
|
|
Art is publisher copyright. Fetching it for a private collection is ordinary
|
|
practice for library software; redistributing it is a different question.
|
|
|
|
### Database changes
|
|
|
|
```bash
|
|
cd backend/src/LudosData.Api
|
|
dotnet ef migrations add <Name> --output-dir Data/Migrations
|
|
```
|
|
|
|
Migrations are applied automatically at startup.
|
|
|
|
---
|
|
|
|
## API
|
|
|
|
All `/api/games` and `/api/images` routes require `Authorization: Bearer <token>`.
|
|
|
|
| Method | Route | Notes |
|
|
| --- | --- | --- |
|
|
| `POST` | `/api/auth/register` | Returns a token; signs the new user straight in |
|
|
| `POST` | `/api/auth/login` | Returns `{ token, expiresAt, user }` |
|
|
| `GET` | `/api/auth/me` | Current user |
|
|
| `GET` | `/api/auth/available?userName=` / `?email=` | Returns only a boolean |
|
|
| `GET` | `/api/games` | `search, system, genre, own, dumped, played, finished, page, pageSize, sort, dir` |
|
|
| `GET` | `/api/games/{id}` | |
|
|
| `GET` | `/api/games/facets` | Distinct systems and genres, for filter dropdowns |
|
|
| `POST` | `/api/games` | |
|
|
| `PUT` | `/api/games/{id}` | |
|
|
| `DELETE` | `/api/games/{id}` | |
|
|
| `POST` | `/api/images` | multipart `file`; re-encodes to WebP |
|
|
| `GET` | `/health` | Anonymous |
|
|
|
|
**Ownership is always taken from the JWT subject, never from the request.** A game
|
|
belonging to another user returns `404`, not `403`, so the response does not
|
|
confirm that the id exists.
|
|
|
|
---
|
|
|
|
## Security notes
|
|
|
|
### Rotate the old database password
|
|
|
|
The 2018 code committed live MySQL credentials to this repository
|
|
(`interfaceServices/dbConfig.php`, and again in four other files). They are in git
|
|
history. **That password must be considered compromised and rotated**, regardless
|
|
of this rewrite. Removing the files does not remove them from history.
|
|
|
|
The new stack keeps secrets in `.env`, which is gitignored.
|
|
|
|
### What was fixed in the rewrite
|
|
|
|
The old backend was ~16,700 lines of PHP, of which ~16,400 were vendored
|
|
third-party code — four near-identical copies of `php-crud-api` plus
|
|
`class.upload.php`. Only ~150 lines were application logic. These problems were
|
|
not carried across:
|
|
|
|
| Old behaviour | Now |
|
|
| --- | --- |
|
|
| Two endpoints exposed unauthenticated CRUD over every table | Every data route requires a valid token |
|
|
| Client chose whose rows to read (`filter[]=userId,eq,N`) | Owner comes from the JWT subject, server-side |
|
|
| Login hardcoded to a single username | Any registered user can sign in |
|
|
| `crypt()` with one global salt, silently truncating passwords to 8 chars | ASP.NET Core Identity (PBKDF2, per-user salt) |
|
|
| JWT secret was the literal string `"testing"`, tokens never expired | Key required from config, 12-hour expiry |
|
|
| Token passed in the query string | `Authorization: Bearer` header |
|
|
| Uploads anonymous, path built from the client filename | Authenticated, server-generated name, per-user folder, must decode as an image |
|
|
| `Access-Control-Allow-Origin: *` | Explicit origin allowlist |
|
|
|
|
Passwords could not be migrated — the old hashes are unrecoverable by design.
|
|
|
|
### Known accepted risk
|
|
|
|
`npm audit` reports a moderate advisory in `@hono/node-server`, reached
|
|
transitively through `@angular/cli`'s MCP server feature. It is:
|
|
|
|
- **dev-only** — `npm audit --omit=dev` reports 0 vulnerabilities, and it is not in the browser bundle
|
|
- a **Windows-only** path traversal, on a Linux-only toolchain here
|
|
|
|
`npm audit fix --force` would downgrade Angular CLI to 21.0.4, a breaking change.
|
|
Overriding the dependency means forcing a major bump the MCP SDK does not accept
|
|
(`^1.19.9`). Left as-is deliberately; revisit when Angular CLI updates the SDK.
|
|
|
|
---
|
|
|
|
## Notable version facts (as of 2026-08)
|
|
|
|
- **Angular 22.1** is **zoneless** — there is no `zone.js` in the dependency tree.
|
|
Component state must be signal-based for change detection to see it.
|
|
- **`@angular/animations` is deprecated in v22**; Material 22 no longer depends on
|
|
it. There is no `provideAnimations()` in `app.config.ts`, and a test pins that
|
|
Material still renders without one.
|
|
- Unit tests run on **Vitest**, not Karma/Jasmine.
|
|
- Fonts and Material icons are bundled from `node_modules`, so the app makes no
|
|
third-party requests at runtime.
|
|
- The backend pins two transitive packages (`Microsoft.OpenApi`,
|
|
`SQLitePCLRaw.lib.e_sqlite3`) to clear high-severity advisories. See the comment
|
|
in `LudosData.Api.csproj`.
|
|
- Image processing uses **SkiaSharp**, not ImageSharp: ImageSharp v4 requires a
|
|
paid licence key at build time.
|