Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ddc618fc9c | ||
|
|
d34e6b4ced | ||
|
|
8e136f42f8 | ||
|
|
ca70bcef34 | ||
|
|
d5a0e42fed | ||
|
|
b69a5c9d14 | ||
|
|
771b34bb4b | ||
|
|
130921cf89 | ||
|
|
cd5c8fb24e | ||
|
|
55182a4da7 | ||
|
|
a99c8381b1 | ||
|
|
2a7d90b2d5 |
@@ -1,62 +0,0 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"project": {
|
||||
"name": "ludos-data"
|
||||
},
|
||||
"apps": [
|
||||
{
|
||||
"root": "src",
|
||||
"outDir": "dist",
|
||||
"assets": [
|
||||
"assets",
|
||||
"favicon.ico"
|
||||
],
|
||||
"index": "index.html",
|
||||
"main": "main.ts",
|
||||
"polyfills": "polyfills.ts",
|
||||
"test": "test.ts",
|
||||
"tsconfig": "tsconfig.app.json",
|
||||
"testTsconfig": "tsconfig.spec.json",
|
||||
"prefix": "app",
|
||||
"styles": [
|
||||
"styles.css"
|
||||
],
|
||||
"scripts": [
|
||||
|
||||
],
|
||||
"environmentSource": "environments/environment.ts",
|
||||
"environments": {
|
||||
"dev": "environments/environment.ts",
|
||||
"prod": "environments/environment.prod.ts"
|
||||
}
|
||||
}
|
||||
],
|
||||
"e2e": {
|
||||
"protractor": {
|
||||
"config": "./protractor.conf.js"
|
||||
}
|
||||
},
|
||||
"lint": [
|
||||
{
|
||||
"project": "src/tsconfig.app.json",
|
||||
"exclude": "**/node_modules/**"
|
||||
},
|
||||
{
|
||||
"project": "src/tsconfig.spec.json",
|
||||
"exclude": "**/node_modules/**"
|
||||
},
|
||||
{
|
||||
"project": "e2e/tsconfig.e2e.json",
|
||||
"exclude": "**/node_modules/**"
|
||||
}
|
||||
],
|
||||
"test": {
|
||||
"karma": {
|
||||
"config": "./karma.conf.js"
|
||||
}
|
||||
},
|
||||
"defaults": {
|
||||
"styleExt": "css",
|
||||
"component": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# Copy to .env and fill in. .env is gitignored — never commit real secrets.
|
||||
#
|
||||
# The 2018 version of this project committed its live database password to the
|
||||
# repository, which is why it now has to be treated as compromised. Keep secrets
|
||||
# in .env, and keep .env out of git.
|
||||
|
||||
# --- Required --------------------------------------------------------------
|
||||
# JWT signing key. Minimum 32 characters; the API refuses to start without it.
|
||||
# Generate one with: openssl rand -base64 48
|
||||
JWT_KEY=
|
||||
|
||||
# --- First-run seeding ------------------------------------------------------
|
||||
# On a database with no users, the API creates this account and imports the 105
|
||||
# games recovered from the 2018 MySQL dump. Once a user exists, this is ignored.
|
||||
# Password rules: 12+ chars, upper, lower and a digit.
|
||||
SEED_USERNAME=ckoch
|
||||
SEED_EMAIL=you@example.com
|
||||
SEED_PASSWORD=
|
||||
|
||||
# Set to false once you are past first run, or to start with an empty library.
|
||||
SEED_ENABLED=true
|
||||
|
||||
# --- Optional ---------------------------------------------------------------
|
||||
# Host port the web UI is published on.
|
||||
WEB_PORT=8080
|
||||
|
||||
# Token lifetime in minutes. Default is 12 hours; there is no refresh flow, so
|
||||
# expiry returns you to the login form.
|
||||
JWT_LIFETIME_MINUTES=720
|
||||
|
||||
JWT_ISSUER=LudosData
|
||||
JWT_AUDIENCE=LudosData
|
||||
CORS_ORIGIN=http://localhost:8080
|
||||
|
||||
# --- Market value (optional) ------------------------------------------------
|
||||
# Three routes, none of which block the others. Nothing here is required: with
|
||||
# all of it blank, prices can still be imported as a CSV or typed in by hand.
|
||||
#
|
||||
# 1. CSV PRICE GUIDE — no account, works immediately.
|
||||
# POST a CSV to /api/prices/import. Columns are matched by name, so a
|
||||
# PriceCharting bulk download, a spreadsheet you maintain, or any other list
|
||||
# all work. Nothing to configure here.
|
||||
#
|
||||
# 2. PRICECHARTING — paid, but access is immediate with no review, which makes
|
||||
# it the practical choice while an eBay keyset is in verification. Token
|
||||
# comes from the Subscriptions page, "API/Download" button.
|
||||
PRICECHARTING_TOKEN=
|
||||
|
||||
# 3. EBAY BROWSE — free, but the production keyset needs account verification.
|
||||
#
|
||||
# 1. Register at https://developer.ebay.com and create a developer account
|
||||
# 2. Create an application keyset (Application Keys -> Production)
|
||||
# 3. Copy the App ID (Client ID) and Cert ID (Client Secret) below
|
||||
#
|
||||
# Leave these blank and the pricing endpoints report 503 with an explanation;
|
||||
# nothing else is affected.
|
||||
#
|
||||
# IMPORTANT: Browse returns ACTIVE LISTINGS, which are asking prices, not
|
||||
# completed sales. eBay's sold-price data lives behind the Marketplace Insights
|
||||
# API, which is a limited release not open to new developers. Expect these
|
||||
# figures to read high — they are an upper bound, not a valuation.
|
||||
EBAY_CLIENT_ID=
|
||||
EBAY_CLIENT_SECRET=
|
||||
|
||||
# Set true to use eBay's sandbox while checking credentials.
|
||||
EBAY_USE_SANDBOX=false
|
||||
+32
-38
@@ -1,48 +1,42 @@
|
||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||
# Secrets — never commit. The 2018 version of this project committed live
|
||||
# database credentials, which is why they now have to be treated as compromised.
|
||||
.env
|
||||
*.env
|
||||
!.env.example
|
||||
|
||||
# compiled output
|
||||
/dist
|
||||
/dist-server
|
||||
/tmp
|
||||
/out-tsc
|
||||
# Runtime state
|
||||
data/
|
||||
uploads/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
# --- Frontend ---
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
frontend/.angular/
|
||||
npm-debug.log*
|
||||
yarn-error.log*
|
||||
testem.log
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
# --- Backend ---
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
*.user
|
||||
.vs/
|
||||
|
||||
# --- Editors / OS ---
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# misc
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# e2e
|
||||
/e2e/*.js
|
||||
/e2e/*.map
|
||||
|
||||
# System Files
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
src/app/game-grid/game-grid.component.html
|
||||
src/app/game-grid/game-grid.component.html
|
||||
src/app/game-grid/game-grid.component.ts
|
||||
src/app/game-grid/game-grid.component.html
|
||||
src/app/games.service.ts
|
||||
*.swp
|
||||
|
||||
# Cached libretro directory listings (regenerated on demand)
|
||||
tools/library/.cache/
|
||||
|
||||
@@ -1,27 +1,423 @@
|
||||
# LudosData
|
||||
|
||||
This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 1.7.0.
|
||||
A personal video game library: catalogue what you own, what you've dumped,
|
||||
played and finished.
|
||||
|
||||
## Development server
|
||||
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.
|
||||
|
||||
Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files.
|
||||
---
|
||||
|
||||
## Code scaffolding
|
||||
## Quick start
|
||||
|
||||
Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`.
|
||||
```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.
|
||||
|
||||
## Build
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `-prod` flag for a production build.
|
||||
Then open <http://localhost:8080> and sign in with the seed credentials.
|
||||
|
||||
## Running unit tests
|
||||
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.
|
||||
|
||||
Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io).
|
||||
> **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.
|
||||
|
||||
## Running end-to-end tests
|
||||
---
|
||||
|
||||
Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/).
|
||||
## Layout
|
||||
|
||||
## Further help
|
||||
```
|
||||
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
|
||||
```
|
||||
|
||||
To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md).
|
||||
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/library/fetch_art.py` fills in box art, 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, and neither source
|
||||
needs an account.
|
||||
|
||||
It tries two sources in order:
|
||||
|
||||
1. **[libretro-thumbnails](https://thumbnails.libretro.com)** — scanned retail
|
||||
boxes, named to the No-Intro / Redump conventions. Best art where it has any,
|
||||
but its coverage is the retro consoles. Its `Microsoft - Xbox 360` set exists
|
||||
but holds about a dozen entries.
|
||||
2. **English Wikipedia** — a cover on essentially every notable game article,
|
||||
which is what fills the Xbox 360 shelf. The exact filename is read from the
|
||||
article's infobox rather than guessed from file names, since filtering names
|
||||
for "box" also matches `Xbox-360-Pro-wController.png`. Calls are throttled to
|
||||
one per second and cached; the API returns 429 if pushed harder.
|
||||
|
||||
```bash
|
||||
cd tools/library
|
||||
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
|
||||
python3 fetch_art.py --password '...' --no-wikipedia # libretro only
|
||||
```
|
||||
|
||||
Always dry-run first; it prints every match with a similarity score, marks the
|
||||
source (`W` for Wikipedia), 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.
|
||||
|
||||
All 105 games currently have art: 93 from libretro, 12 from Wikipedia.
|
||||
|
||||
Two rows got art of the right *game* but the wrong *platform*, because the
|
||||
platform in the source data looks wrong — a Game Boy "Donkey Kong Country 2"
|
||||
(never released on that system; the handheld sequels were *Donkey Kong Land*)
|
||||
and a DS "Donkey Kong Country Returns" (a Wii game, later *Returns 3D* on 3DS).
|
||||
Fix the system field and re-run with `--overwrite` to correct them.
|
||||
|
||||
Art is publisher copyright. Fetching it for a private collection is ordinary
|
||||
practice for library software; redistributing it is a different question.
|
||||
|
||||
### Metadata enrichment
|
||||
|
||||
`tools/library/enrich_metadata.py` fills developer, publisher, year and
|
||||
description from the same Wikipedia articles the cover fetcher locates. Only
|
||||
empty fields are touched unless `--overwrite` is passed — anything typed by hand
|
||||
outranks anything derived here.
|
||||
|
||||
```bash
|
||||
cd tools/library
|
||||
python3 enrich_metadata.py --password '...' --dry-run
|
||||
python3 enrich_metadata.py --password '...'
|
||||
python3 enrich_metadata.py --password '...' --fields developer,publisher
|
||||
```
|
||||
|
||||
Coverage went from this to this:
|
||||
|
||||
| Field | Before | After |
|
||||
| --- | --- | --- |
|
||||
| year | 79% | 96% |
|
||||
| developer | 3.8% | 95% |
|
||||
| publisher | 2.9% | 96% |
|
||||
| description | 0% | 96% |
|
||||
|
||||
Developer, publisher and year are facts, written verbatim. Descriptions are
|
||||
article summaries, which are CC BY-SA, so each is stored with an attribution
|
||||
line naming the source article.
|
||||
|
||||
Parsing an infobox is messier than it looks, and the guards matter:
|
||||
|
||||
- **Series articles are rejected.** A substring test for `Infobox video game`
|
||||
also matches `Infobox video game series`, which resolved Banjo-Kazooie to the
|
||||
series overview instead of the 1998 game.
|
||||
- **Year is only filled when the article covers that platform.** An article
|
||||
spans every release, and its date block leads with the original — so a DS
|
||||
port would otherwise be dated to the SNES original.
|
||||
- **A search hit that neither covers the platform nor closely matches the title
|
||||
is discarded.** Our "Dragon Ball Z Budokai" surfaces "Dragon Ball Z: Shin
|
||||
Budokai", a different game on a different console. A blank field beats a
|
||||
confidently wrong one.
|
||||
- Platform headings (`'''PlayStation'''`), region codes (`JP`, `NA`), trailing
|
||||
platform annotations (`Rare (N64)`) and named template parameters (`title=`)
|
||||
are all stripped, since each one otherwise reads as the value itself.
|
||||
|
||||
Four games have no usable article: a typo'd title (`Brett Hull Hocky 95`),
|
||||
`Dragon Ball Z Budokai`, and two niche releases.
|
||||
|
||||
### Export and import
|
||||
|
||||
Until this existed, the only backup was the Docker volume.
|
||||
|
||||
- `GET /api/library/export?format=json|csv`
|
||||
- `POST /api/library/import?mode=Merge|Replace&dryRun=true` (multipart `file`)
|
||||
|
||||
Both are in the UI on the account page. JSON round-trips exactly and is the
|
||||
right choice for a backup; CSV opens in a spreadsheet and is written with a BOM
|
||||
so Excel does not mangle `Pokémon`. **Box art images are not bundled** — they
|
||||
live in the upload volume, and a library imported into a fresh instance will
|
||||
reference art that is not there until the fetcher runs again.
|
||||
|
||||
Rows are matched on **title + system**, so the same game on three consoles stays
|
||||
three entries. `Merge` adds and updates but never deletes; `Replace` wipes the
|
||||
library first and is confirmed twice in the UI. `dryRun` reports exactly what
|
||||
would happen and writes nothing.
|
||||
|
||||
### Collector fields
|
||||
|
||||
Beyond the four original flags, each game carries rating (1-10), notes,
|
||||
condition, region, what you paid and when, and a current market value.
|
||||
|
||||
`condition` is not cosmetic: price feeds quote per condition, and the gap
|
||||
between loose and sealed is routinely a multiple, so it selects which quoted
|
||||
price applies to a copy.
|
||||
|
||||
Market value is stored with **when it was captured** and **where it came from**.
|
||||
A figure with neither is not something you can reason about, and a collection
|
||||
total is only as good as its staleness. Editing an unrelated field leaves the
|
||||
timestamp alone; changing the figure moves it. A future price feed writes the
|
||||
same three columns.
|
||||
|
||||
**Money is stored as integer minor units.** SQLite has no decimal type, and EF
|
||||
Core's default maps `decimal` to TEXT, which compares lexically — `"9.00"` sorts
|
||||
above `"10.00"`, and SUM does not work at all. A value converter keeps the C#
|
||||
side as `decimal` while ordering and totalling behave.
|
||||
|
||||
**Enums travel as names, not ordinals.** `"Cib"` is self-describing in a payload,
|
||||
an export and a log line; `2` is not, and renumbering the enum would silently
|
||||
reinterpret every stored export.
|
||||
|
||||
New query parameters: `condition`, `region`, `minRating`, `hasValue`. New sort
|
||||
keys: `rating`, `value`, `price`, `purchased`.
|
||||
|
||||
### Market value
|
||||
|
||||
Three routes, deliberately independent, because each is blocked differently.
|
||||
|
||||
| Route | Blocked by | Cost | Basis |
|
||||
| --- | --- | --- | --- |
|
||||
| **CSV price guide** | nothing | free | whatever you supply |
|
||||
| **PriceCharting** | nothing — immediate on subscribing | paid | sale-derived, per condition |
|
||||
| **eBay Browse** | production keyset needs account verification | free | asking prices, reads high |
|
||||
| Manual entry | nothing | free | your own judgement |
|
||||
|
||||
```
|
||||
GET /api/prices/status which sources are usable
|
||||
POST /api/prices/refresh {provider, dryRun} price from a live source
|
||||
POST /api/prices/import (multipart CSV) apply a price list
|
||||
```
|
||||
|
||||
**The CSV route needs no account and works today.** Column names are matched by
|
||||
alias, so `product-name` / `console-name` / `loose-price` from a PriceCharting
|
||||
export and a hand-kept `title,system,loose,cib,new` sheet are both accepted, as
|
||||
are `$`, thousands separators and blank cells. Rows are matched on title +
|
||||
system, so the same game on two consoles is priced separately; rows for games
|
||||
you do not own are reported rather than added.
|
||||
|
||||
Free sources that do **not** work for this: eBay's completed-sales data sits
|
||||
behind the Marketplace Insights API, a limited release closed to new
|
||||
developers; NEXARDA and CheapShark price current retail and digital
|
||||
storefronts, not collectibles. Scraping PriceCharting violates their terms.
|
||||
|
||||
Deriving a price from eBay listings takes more than an average:
|
||||
|
||||
- **Listings are classified into loose / CIB / new** from their titles, since a
|
||||
mixed feed has no single price. Accessories are discarded — a "box only"
|
||||
listing at $45 counted as a copy would halve the loose estimate for a $130
|
||||
cartridge — as are reproductions and multi-game lots.
|
||||
- **The discard qualifier is required.** An early version matched a bare "box",
|
||||
which threw away "complete in box" and "with box and manual" — most of the CIB
|
||||
tier — while keeping exactly the listings the filter existed to remove. Caught
|
||||
by a test asserting on tiers rather than counts.
|
||||
- **Median with an interquartile trim.** One optimist asking 50x moves a mean
|
||||
and not a median.
|
||||
- **Sample counts travel with the estimate**, because a tier drawn from two
|
||||
listings deserves less confidence than one drawn from thirty.
|
||||
|
||||
Three prices are stored per game, and `marketValue` is whichever tier matches
|
||||
that copy's condition — so changing a condition re-prices it with no further
|
||||
lookup. Every value carries the source that wrote it and the moment it was
|
||||
captured.
|
||||
|
||||
**Using PriceCharting.** Subscribe, take the token from the Subscriptions page
|
||||
("API/Download"), and set `PRICECHARTING_TOKEN` in `.env`. Their API access
|
||||
comes with a paid subscription; the bulk CSV download is limited to their top
|
||||
tier, so check which tier you need before subscribing — this integration does
|
||||
per-game lookups and only needs the API.
|
||||
|
||||
Run a dry run first:
|
||||
|
||||
```bash
|
||||
curl -X POST localhost:8080/api/prices/refresh \
|
||||
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
|
||||
-d '{"provider":"pricecharting","dryRun":true,"limit":10}'
|
||||
```
|
||||
|
||||
The response reports **which product each game matched** — name, console and id
|
||||
— alongside the prices. That matters more than it sounds: a lookup for the DS
|
||||
"Chrono Trigger" that quietly resolves to the SNES original returns entirely
|
||||
plausible numbers for the wrong game. Check the matches, then run without
|
||||
`dryRun`.
|
||||
|
||||
Once a game is priced, the matched product id is stored and later refreshes look
|
||||
it up directly, so they are cheaper and cannot drift to a different edition.
|
||||
|
||||
Their published API docs are not reachable without an account, so the response
|
||||
parser follows the widely-used convention — integer pennies under hyphenated
|
||||
keys — and is tolerant enough that a naming mismatch reads as "no price" rather
|
||||
than throwing. `PriceChartingProvider.Parse` is the one place to adjust.
|
||||
|
||||
### Dashboard
|
||||
|
||||
`/dashboard` — one `GET /api/stats` call, aggregated server-side in a single pass
|
||||
over the library rather than a dozen grouped queries that could disagree.
|
||||
|
||||
Form was chosen before colour, and most of the page is deliberately not a chart:
|
||||
headline numbers are stat tiles, the backlog is a link into a filtered library
|
||||
view, and the breakdowns are horizontal bar lists with the value printed on each
|
||||
row — which doubles as the table view.
|
||||
|
||||
Colour decisions worth keeping:
|
||||
|
||||
- **One hue for the breakdown bars.** Identity is carried by the axis labels, so
|
||||
colour has nothing to encode; twelve systems in twelve hues would be twelve
|
||||
ways to be wrong, and a darker-where-bigger ramp would double-encode length.
|
||||
- **An ordinal ramp for the funnel**, because owned → played → finished are
|
||||
ordered stages, not peer categories.
|
||||
- **The palette was validated with the dataviz validator against this app's own
|
||||
card surfaces** (`#f8f2f6` light, `#1d1b1e` dark), not against a reference
|
||||
surface. That mattered: the documented ordinal light-end measured 1.91:1 here
|
||||
and failed the 2:1 floor, so the ramp starts a step darker.
|
||||
- **Status colour appears once**, on the stale-valuation notice, always with an
|
||||
icon and text so it never carries meaning alone.
|
||||
|
||||
The value card is deliberately wordy. A bare total silently mixes fresh and old
|
||||
valuations and excludes everything unpriced, so coverage, age and source travel
|
||||
with the figure, and a valuation older than 90 days is called out.
|
||||
|
||||
Theming note: the chart variables use `light-dark()` rather than a
|
||||
`prefers-color-scheme` block. Angular's emulated encapsulation scopes selectors
|
||||
in component styles, so a `:root`-prefixed media override never matches from
|
||||
there — and setting `color-scheme` on the container overrides how every
|
||||
descendant resolves `light-dark()`, which rendered light cards on a dark page.
|
||||
|
||||
### 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.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# Archive
|
||||
|
||||
`lazypugn_LudosData_2018-03-14_20-31-02.sql.zip` is the original MySQL dump of the
|
||||
2018 database, kept for provenance.
|
||||
|
||||
It contains a single `games` table with 105 rows. It predates the multi-user work
|
||||
that was in progress when the project was last touched, so it has **no `users`
|
||||
table and no `userId` column** — every game in it is unowned.
|
||||
|
||||
It has been converted to
|
||||
[`backend/src/LudosData.Api/Data/Seed/games.json`](../backend/src/LudosData.Api/Data/Seed/games.json),
|
||||
which the API imports on first run. Nothing reads the zip at runtime.
|
||||
|
||||
Conversion notes:
|
||||
- `id` was dropped; the new table assigns its own keys.
|
||||
- Empty strings became `null`, matching the new nullable columns.
|
||||
- `tinyint(1)` flags became booleans.
|
||||
- `Art` and `Description` were empty on every row, so no images were migrated.
|
||||
Binary file not shown.
@@ -1,34 +0,0 @@
|
||||
# PHP-API-AUTH
|
||||
|
||||
Single file PHP script that adds authentication to a [PHP-CRUD-API](https://github.com/mevdschee/php-crud-api) project.
|
||||
|
||||
## Requirements
|
||||
|
||||
- PHP 5.3 or higher
|
||||
|
||||
## Simple username + password
|
||||
|
||||
On API server
|
||||
|
||||
- login.html is loaded
|
||||
- sends username + password via POST to "api.php/"
|
||||
- api.php (POST on "/" gets hijacked by auth.php) is loaded
|
||||
- sends back csrf token + http-only session cookie
|
||||
- call API as: api.php?csrf=\[csrf token] (session cookie is sent automatically)
|
||||
- (when using Angular2 or Vue2 the CSRF token is sent automatically)
|
||||
|
||||
## With authentication server
|
||||
|
||||
On authentication server
|
||||
|
||||
- login_token.html is loaded
|
||||
- sends username + password via POST to "login_token.php"
|
||||
- login_token.php is loaded
|
||||
- sends token via POST to "api.php/"
|
||||
|
||||
On API server
|
||||
|
||||
- api.php (POST on "/" gets hijacked by auth.php) is loaded
|
||||
- sends back csrf token + http-only session cookie
|
||||
- call API as: api.php?csrf=\[csrf token] (session cookie is sent automatically)
|
||||
- (when using Angular2 or Vue2 the CSRF token is sent automatically)
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
// uncomment the lines below when running in stand-alone mode:
|
||||
|
||||
// for token+session based authentication (see "login_token.html" + "login_token.php"):
|
||||
|
||||
/*
|
||||
require 'auth.php';
|
||||
$auth = new PHP_API_AUTH(array(
|
||||
'secret'=>'someVeryLongPassPhraseChangeMe',
|
||||
));
|
||||
if ($auth->executeCommand()) exit(0);
|
||||
if (empty($_SESSION['user']) || !$auth->hasValidCsrfToken()) {
|
||||
header('HTTP/1.0 401 Unauthorized');
|
||||
exit(0);
|
||||
}
|
||||
*/
|
||||
|
||||
// for form+session based authentication (see "login.html"):
|
||||
|
||||
require 'auth.php';
|
||||
$auth = new PHP_API_AUTH(array(
|
||||
'authenticator'=>function($user,$pass){ if ($user=='admin' && $pass=='admin') $_SESSION['user']=$user; }
|
||||
));
|
||||
if ($auth->executeCommand()) exit(0);
|
||||
if (empty($_SESSION['user']) || !$auth->hasValidCsrfToken()) {
|
||||
header('HTTP/1.0 401 Unauthorized');
|
||||
exit(0);
|
||||
}
|
||||
|
||||
// include your api code here:
|
||||
//
|
||||
// see: https://github.com/mevdschee/php-crud-api
|
||||
//
|
||||
// placeholder for testing:
|
||||
// echo 'Access granted!';
|
||||
@@ -1,223 +0,0 @@
|
||||
<?php
|
||||
//var_dump($_SERVER['REQUEST_METHOD'],$_SERVER['PATH_INFO']); die();
|
||||
|
||||
class PHP_API_AUTH {
|
||||
|
||||
public function __construct($config) {
|
||||
extract($config);
|
||||
|
||||
$verb = isset($verb)?$verb:null;
|
||||
$path = isset($path)?$path:null;
|
||||
$username = isset($username)?$username:null;
|
||||
$password = isset($password)?$password:null;
|
||||
$token = isset($token)?$token:null;
|
||||
$authenticator = isset($authenticator)?$authenticator:null;
|
||||
|
||||
$method = isset($method)?$method:null;
|
||||
$request = isset($request)?$request:null;
|
||||
$post = isset($post)?$post:null;
|
||||
$origin = isset($origin)?$origin:null;
|
||||
|
||||
$time = isset($time)?$time:null;
|
||||
$leeway = isset($leeway)?$leeway:null;
|
||||
$ttl = isset($ttl)?$ttl:null;
|
||||
$algorithm = isset($algorithm)?$algorithm:null;
|
||||
$secret = isset($secret)?$secret:null;
|
||||
|
||||
$allow_origin = isset($allow_origin)?$allow_origin:null;
|
||||
|
||||
// defaults
|
||||
if (!$verb) {
|
||||
$verb = 'POST';
|
||||
}
|
||||
if (!$path) {
|
||||
$path = '';
|
||||
}
|
||||
if (!$username) {
|
||||
$username = 'username';
|
||||
}
|
||||
if (!$password) {
|
||||
$password = 'password';
|
||||
}
|
||||
if (!$token) {
|
||||
$token = 'token';
|
||||
}
|
||||
|
||||
if (!$method) {
|
||||
$method = $_SERVER['REQUEST_METHOD'];
|
||||
}
|
||||
if (!$request) {
|
||||
$request = isset($_SERVER['PATH_INFO'])?$_SERVER['PATH_INFO']:'';
|
||||
if (!$request) {
|
||||
$request = isset($_SERVER['ORIG_PATH_INFO'])?$_SERVER['ORIG_PATH_INFO']:'';
|
||||
}
|
||||
}
|
||||
if (!$post) {
|
||||
$post = 'php://input';
|
||||
}
|
||||
if (!$origin) {
|
||||
$origin = isset($_SERVER['HTTP_ORIGIN'])?$_SERVER['HTTP_ORIGIN']:'';
|
||||
}
|
||||
|
||||
if (!$time) {
|
||||
$time = time();
|
||||
}
|
||||
if (!$leeway) {
|
||||
$leeway = 5;
|
||||
}
|
||||
if (!$ttl) {
|
||||
$ttl = 30;
|
||||
}
|
||||
if (!$algorithm) {
|
||||
$algorithm = 'HS256';
|
||||
}
|
||||
|
||||
if ($allow_origin===null) {
|
||||
$allow_origin = '*';
|
||||
}
|
||||
|
||||
$request = trim($request,'/');
|
||||
|
||||
$this->settings = compact('verb', 'path', 'username', 'password', 'token', 'authenticator', 'method', 'request', 'post', 'origin', 'time', 'leeway', 'ttl', 'algorithm', 'secret', 'allow_origin');
|
||||
}
|
||||
|
||||
|
||||
protected function retrieveInput($post) {
|
||||
$input = (object)array();
|
||||
$data = trim(file_get_contents($post));
|
||||
if (strlen($data)>0) {
|
||||
if ($data[0]=='{') {
|
||||
$input = json_decode($data);
|
||||
} else {
|
||||
parse_str($data, $input);
|
||||
$input = (object)$input;
|
||||
}
|
||||
}
|
||||
return $input;
|
||||
}
|
||||
|
||||
protected function generateToken($claims,$time,$ttl,$algorithm,$secret) {
|
||||
$algorithms = array('HS256'=>'sha256','HS384'=>'sha384','HS512'=>'sha512');
|
||||
$header = array();
|
||||
$header['typ']='JWT';
|
||||
$header['alg']=$algorithm;
|
||||
$token = array();
|
||||
$token[0] = rtrim(strtr(base64_encode(json_encode((object)$header)),'+/','-_'),'=');
|
||||
$claims['iat'] = $time;
|
||||
$claims['exp'] = $time + $ttl;
|
||||
$token[1] = rtrim(strtr(base64_encode(json_encode((object)$claims)),'+/','-_'),'=');
|
||||
if (!isset($algorithms[$algorithm])) return false;
|
||||
$hmac = $algorithms[$algorithm];
|
||||
$signature = hash_hmac($hmac,"$token[0].$token[1]",$secret,true);
|
||||
$token[2] = rtrim(strtr(base64_encode($signature),'+/','-_'),'=');
|
||||
return implode('.',$token);
|
||||
}
|
||||
|
||||
protected function getVerifiedClaims($token,$time,$leeway,$ttl,$algorithm,$secret) {
|
||||
$algorithms = array('HS256'=>'sha256','HS384'=>'sha384','HS512'=>'sha512');
|
||||
if (!isset($algorithms[$algorithm])) return false;
|
||||
$hmac = $algorithms[$algorithm];
|
||||
$token = explode('.',$token);
|
||||
if (count($token)<3) return false;
|
||||
$header = json_decode(base64_decode(strtr($token[0],'-_','+/')),true);
|
||||
if (!$secret) return false;
|
||||
if ($header['typ']!='JWT') return false;
|
||||
if ($header['alg']!=$algorithm) return false;
|
||||
$signature = bin2hex(base64_decode(strtr($token[2],'-_','+/')));
|
||||
if ($signature!=hash_hmac($hmac,"$token[0].$token[1]",$secret)) return false;
|
||||
$claims = json_decode(base64_decode(strtr($token[1],'-_','+/')),true);
|
||||
if (!$claims) return false;
|
||||
if (isset($claims['nbf']) && $time+$leeway<$claims['nbf']) return false;
|
||||
if (isset($claims['iat']) && $time+$leeway<$claims['iat']) return false;
|
||||
if (isset($claims['exp']) && $time-$leeway>$claims['exp']) return false;
|
||||
if (isset($claims['iat']) && !isset($claims['exp'])) {
|
||||
if ($time-$leeway>$claims['iat']+$ttl) return false;
|
||||
}
|
||||
return $claims;
|
||||
}
|
||||
|
||||
protected function allowOrigin($origin,$allowOrigins) {
|
||||
if (isset($_SERVER['REQUEST_METHOD'])) {
|
||||
header('Access-Control-Allow-Credentials: true');
|
||||
header('Access-Control-Expose-Headers: X-XSRF-TOKEN');
|
||||
foreach (explode(',',$allowOrigins) as $o) {
|
||||
if (preg_match('/^'.str_replace('\*','.*',preg_quote(strtolower(trim($o)))).'$/',$origin)) {
|
||||
header('Access-Control-Allow-Origin: '.$origin);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected function headersCommand() {
|
||||
$headers = array();
|
||||
$headers[]='Access-Control-Allow-Headers: Content-Type, X-XSRF-TOKEN';
|
||||
$headers[]='Access-Control-Allow-Methods: OPTIONS, GET, PUT, POST, DELETE, PATCH';
|
||||
$headers[]='Access-Control-Allow-Credentials: true';
|
||||
$headers[]='Access-Control-Max-Age: 1728000';
|
||||
if (isset($_SERVER['REQUEST_METHOD'])) {
|
||||
foreach ($headers as $header) header($header);
|
||||
} else {
|
||||
echo json_encode($headers);
|
||||
}
|
||||
}
|
||||
|
||||
public function hasValidCsrfToken() {
|
||||
$csrf = isset($_SESSION['csrf'])?$_SESSION['csrf']:false;
|
||||
if (!$csrf) return false;
|
||||
$get = isset($_GET['csrf'])?$_GET['csrf']:false;
|
||||
$header = isset($_SERVER['HTTP_X_XSRF_TOKEN'])?$_SERVER['HTTP_X_XSRF_TOKEN']:false;
|
||||
return ($get == $csrf) || ($header == $csrf);
|
||||
}
|
||||
|
||||
public function executeCommand() {
|
||||
extract($this->settings);
|
||||
if ($origin) {
|
||||
$this->allowOrigin($origin,$allow_origin);
|
||||
}
|
||||
if ($method=='OPTIONS') {
|
||||
$this->headersCommand();
|
||||
return true;
|
||||
}
|
||||
$no_session = $authenticator && $secret;
|
||||
if (!$no_session) {
|
||||
ini_set('session.cookie_httponly', 1);
|
||||
session_start();
|
||||
if (!isset($_SESSION['csrf'])) {
|
||||
if (function_exists('random_int')) $_SESSION['csrf'] = 'N'.random_int(0,PHP_INT_MAX);
|
||||
else $_SESSION['csrf'] = 'N'.rand(0,PHP_INT_MAX);
|
||||
}
|
||||
}
|
||||
if ($method==$verb && trim($path,'/')==$request) {
|
||||
$input = $this->retrieveInput($post);
|
||||
if ($authenticator && isset($input->$username) && isset($input->$password)) {
|
||||
$authenticator($input->$username,$input->$password);
|
||||
if ($no_session) {
|
||||
echo json_encode($this->generateToken($_SESSION,$time,$ttl,$algorithm,$secret));
|
||||
} else {
|
||||
session_regenerate_id();
|
||||
setcookie('XSRF-TOKEN',$_SESSION['csrf'],0,'/');
|
||||
header('X-XSRF-TOKEN: '.$_SESSION['csrf']);
|
||||
echo json_encode($_SESSION['csrf']);
|
||||
}
|
||||
} elseif ($secret && isset($input->$token)) {
|
||||
$claims = $this->getVerifiedClaims($input->$token,$time,$leeway,$ttl,$algorithm,$secret);
|
||||
if ($claims) {
|
||||
foreach ($claims as $key=>$value) {
|
||||
$_SESSION[$key] = $value;
|
||||
}
|
||||
session_regenerate_id();
|
||||
setcookie('XSRF-TOKEN',$_SESSION['csrf'],0,'/');
|
||||
header('X-XSRF-TOKEN: '.$_SESSION['csrf']);
|
||||
echo json_encode($_SESSION['csrf']);
|
||||
}
|
||||
} else {
|
||||
if (!$no_session) {
|
||||
session_destroy();
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
<form method="post" action="loginInterface.php/">
|
||||
<input name="username" value="admin"/>
|
||||
<input name="password" value="admin"/>
|
||||
<input type="submit" value="ok">
|
||||
</form>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
<form method="post" action="login_token.php">
|
||||
<input name="username" value="admin"/>
|
||||
<input name="password" value="admin"/>
|
||||
<input type="submit" value="ok">
|
||||
</form>
|
||||
@@ -1,13 +0,0 @@
|
||||
<form method="post" action="api.php/">
|
||||
<input name="token" value=
|
||||
<?php
|
||||
require 'auth.php';
|
||||
|
||||
$auth = new PHP_API_AUTH(array(
|
||||
'secret'=>'someVeryLongPassPhraseChangeMe',
|
||||
'authenticator'=>function($user,$pass){ if ($user=='admin' && $pass=='admin') $_SESSION['user']=$user; }
|
||||
));
|
||||
$auth->executeCommand();
|
||||
?>/>
|
||||
<input type="submit" value="ok">
|
||||
</form>
|
||||
@@ -1,3 +0,0 @@
|
||||
<form method="post" action="api.php/">
|
||||
<input type="submit" value="logout">
|
||||
</form>
|
||||
@@ -0,0 +1,7 @@
|
||||
**/bin/
|
||||
**/obj/
|
||||
**/data/
|
||||
**/uploads/
|
||||
**/*.user
|
||||
**/.vs/
|
||||
**/.vscode/
|
||||
@@ -0,0 +1,50 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---- build ----------------------------------------------------------------
|
||||
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
# Restore against the project file alone so the layer caches across code edits.
|
||||
COPY src/LudosData.Api/LudosData.Api.csproj src/LudosData.Api/
|
||||
RUN dotnet restore src/LudosData.Api/LudosData.Api.csproj
|
||||
|
||||
COPY src/ src/
|
||||
RUN dotnet publish src/LudosData.Api/LudosData.Api.csproj \
|
||||
-c Release \
|
||||
-o /app/publish \
|
||||
--no-restore \
|
||||
/p:UseAppHost=false
|
||||
|
||||
# ---- runtime --------------------------------------------------------------
|
||||
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# The runtime image ships neither curl nor wget, so the container healthcheck
|
||||
# below has nothing to probe with unless one is added.
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Run as the non-root user the base image already ships with.
|
||||
ENV ASPNETCORE_HTTP_PORTS=8080 \
|
||||
DOTNET_RUNNING_IN_CONTAINER=true \
|
||||
ConnectionStrings__Default="Data Source=/data/ludos.db" \
|
||||
Uploads__RootPath=/data/uploads
|
||||
|
||||
COPY --from=build /app/publish .
|
||||
|
||||
# Writable mount point for the SQLite file and uploaded art. Declared as a volume
|
||||
# so an unmounted run still persists for the life of the container rather than
|
||||
# failing to open the database.
|
||||
RUN mkdir -p /data/uploads && chown -R $APP_UID:$APP_UID /data
|
||||
VOLUME ["/data"]
|
||||
|
||||
USER $APP_UID
|
||||
EXPOSE 8080
|
||||
|
||||
# Probes the app's own health endpoint, so "healthy" means it is actually
|
||||
# serving requests rather than merely that the process exists.
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \
|
||||
CMD curl -fsS http://localhost:8080/health || exit 1
|
||||
|
||||
ENTRYPOINT ["dotnet", "LudosData.Api.dll"]
|
||||
@@ -0,0 +1,8 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/LudosData.Api/LudosData.Api.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/LudosData.Api.Tests/LudosData.Api.Tests.csproj" />
|
||||
</Folder>
|
||||
</Solution>
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"sdk": {
|
||||
"version": "10.0.302",
|
||||
"rollForward": "latestFeature"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace LudosData.Api.Auth;
|
||||
|
||||
public class JwtOptions
|
||||
{
|
||||
public const string SectionName = "Jwt";
|
||||
|
||||
/// <summary>
|
||||
/// HMAC-SHA256 signing key. Supplied via the JWT__KEY environment variable —
|
||||
/// there is deliberately no default, so a misconfigured deployment fails to
|
||||
/// start rather than signing tokens with a guessable key.
|
||||
/// </summary>
|
||||
[Required(AllowEmptyStrings = false)]
|
||||
[MinLength(32, ErrorMessage = "Jwt:Key must be at least 32 characters.")]
|
||||
public string Key { get; set; } = string.Empty;
|
||||
|
||||
[Required] public string Issuer { get; set; } = "LudosData";
|
||||
[Required] public string Audience { get; set; } = "LudosData";
|
||||
|
||||
/// <summary>
|
||||
/// Access token lifetime. Twelve hours suits a single-user library app; there
|
||||
/// is no refresh token flow, so expiry sends the user back to the login form.
|
||||
/// </summary>
|
||||
[Range(1, 24 * 60 * 7)]
|
||||
public int LifetimeMinutes { get; set; } = 720;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using LudosData.Api.Domain;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
namespace LudosData.Api.Auth;
|
||||
|
||||
public interface ITokenService
|
||||
{
|
||||
(string Token, DateTimeOffset ExpiresAt) CreateAccessToken(AppUser user);
|
||||
}
|
||||
|
||||
public class TokenService(IOptions<JwtOptions> options) : ITokenService
|
||||
{
|
||||
private readonly JwtOptions _options = options.Value;
|
||||
|
||||
public (string Token, DateTimeOffset ExpiresAt) CreateAccessToken(AppUser user)
|
||||
{
|
||||
var expiresAt = DateTimeOffset.UtcNow.AddMinutes(_options.LifetimeMinutes);
|
||||
|
||||
var claims = new List<Claim>
|
||||
{
|
||||
// The subject is the only thing authorization trusts. Ownership checks
|
||||
// read it server-side; the client cannot influence which rows it sees.
|
||||
new(JwtRegisteredClaimNames.Sub, user.Id),
|
||||
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
|
||||
new(ClaimTypes.NameIdentifier, user.Id),
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(user.UserName))
|
||||
{
|
||||
claims.Add(new Claim(JwtRegisteredClaimNames.UniqueName, user.UserName));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(user.Email))
|
||||
{
|
||||
claims.Add(new Claim(JwtRegisteredClaimNames.Email, user.Email));
|
||||
}
|
||||
|
||||
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_options.Key));
|
||||
var token = new JwtSecurityToken(
|
||||
issuer: _options.Issuer,
|
||||
audience: _options.Audience,
|
||||
claims: claims,
|
||||
notBefore: DateTime.UtcNow,
|
||||
expires: expiresAt.UtcDateTime,
|
||||
signingCredentials: new SigningCredentials(key, SecurityAlgorithms.HmacSha256));
|
||||
|
||||
return (new JwtSecurityTokenHandler().WriteToken(token), expiresAt);
|
||||
}
|
||||
}
|
||||
|
||||
public static class ClaimsPrincipalExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// The authenticated user's id. Throws rather than returning null: every call
|
||||
/// site sits behind [Authorize], so a missing subject is a bug, not a branch.
|
||||
/// </summary>
|
||||
public static string GetUserId(this ClaimsPrincipal principal) =>
|
||||
principal.FindFirstValue(ClaimTypes.NameIdentifier)
|
||||
?? principal.FindFirstValue(JwtRegisteredClaimNames.Sub)
|
||||
?? throw new InvalidOperationException("Authenticated principal has no subject claim.");
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace LudosData.Api.Contracts;
|
||||
|
||||
public record RegisterRequest
|
||||
{
|
||||
[Required, MinLength(3), MaxLength(50)]
|
||||
public string UserName { get; init; } = string.Empty;
|
||||
|
||||
[Required, EmailAddress, MaxLength(256)]
|
||||
public string Email { get; init; } = string.Empty;
|
||||
|
||||
[Required, MinLength(12), MaxLength(128)]
|
||||
public string Password { get; init; } = string.Empty;
|
||||
|
||||
[MaxLength(100)] public string? FirstName { get; init; }
|
||||
[MaxLength(100)] public string? LastName { get; init; }
|
||||
}
|
||||
|
||||
public record LoginRequest
|
||||
{
|
||||
[Required] public string UserName { get; init; } = string.Empty;
|
||||
[Required] public string Password { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public record UserResponse(
|
||||
string Id,
|
||||
string UserName,
|
||||
string? Email,
|
||||
string? FirstName,
|
||||
string? LastName,
|
||||
string? Art);
|
||||
|
||||
public record AuthResponse(string Token, DateTimeOffset ExpiresAt, UserResponse User);
|
||||
|
||||
public record AvailabilityResponse(bool Available);
|
||||
@@ -0,0 +1,134 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
using LudosData.Api.Domain;
|
||||
|
||||
namespace LudosData.Api.Contracts;
|
||||
|
||||
/// <summary>A page of results plus the totals the paginator needs.</summary>
|
||||
public record PagedResult<T>(IReadOnlyList<T> Items, int Page, int PageSize, int Total)
|
||||
{
|
||||
public int TotalPages => PageSize > 0 ? (int)Math.Ceiling(Total / (double)PageSize) : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A game as returned to the client. <c>Art</c> is the stored filename; <c>ArtUrl</c>
|
||||
/// is the ready-to-use URL built server-side, so the client never has to
|
||||
/// string-concatenate upload paths the way the old grid did.
|
||||
/// </summary>
|
||||
public record GameResponse(
|
||||
int Id,
|
||||
string Title,
|
||||
string? System,
|
||||
string? Genre,
|
||||
string? Year,
|
||||
string? Developer,
|
||||
string? Publisher,
|
||||
string? Art,
|
||||
string? ArtUrl,
|
||||
string? Description,
|
||||
bool Own,
|
||||
bool Dumped,
|
||||
bool Played,
|
||||
bool Finished,
|
||||
int? Rating,
|
||||
string? Notes,
|
||||
GameCondition Condition,
|
||||
GameRegion Region,
|
||||
decimal? PurchasePrice,
|
||||
DateOnly? PurchaseDate,
|
||||
decimal? MarketValue,
|
||||
DateTimeOffset? MarketValueUpdatedAt,
|
||||
string? MarketValueSource,
|
||||
decimal? ValueLoose,
|
||||
decimal? ValueCib,
|
||||
decimal? ValueNew,
|
||||
DateTimeOffset CreatedAt,
|
||||
DateTimeOffset UpdatedAt);
|
||||
|
||||
/// <summary>
|
||||
/// Create/update payload. Deliberately has no Id and no OwnerId — the route supplies
|
||||
/// the former and the JWT the latter, so neither can be spoofed by the client.
|
||||
/// </summary>
|
||||
public record GameRequest
|
||||
{
|
||||
[Required(AllowEmptyStrings = false), MaxLength(200)]
|
||||
public string Title { get; init; } = string.Empty;
|
||||
|
||||
[MaxLength(50)] public string? System { get; init; }
|
||||
[MaxLength(50)] public string? Genre { get; init; }
|
||||
[MaxLength(50)] public string? Year { get; init; }
|
||||
[MaxLength(100)] public string? Developer { get; init; }
|
||||
[MaxLength(100)] public string? Publisher { get; init; }
|
||||
[MaxLength(200)] public string? Art { get; init; }
|
||||
[MaxLength(10_000)] public string? Description { get; init; }
|
||||
|
||||
public bool Own { get; init; }
|
||||
public bool Dumped { get; init; }
|
||||
public bool Played { get; init; }
|
||||
public bool Finished { get; init; }
|
||||
|
||||
[Range(1, 10)] public int? Rating { get; init; }
|
||||
[MaxLength(10_000)] public string? Notes { get; init; }
|
||||
|
||||
public GameCondition Condition { get; init; } = GameCondition.Unspecified;
|
||||
public GameRegion Region { get; init; } = GameRegion.Unspecified;
|
||||
|
||||
[Range(0, 1_000_000)] public decimal? PurchasePrice { get; init; }
|
||||
public DateOnly? PurchaseDate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Current estimated resale value. Accepted here so a figure can be entered
|
||||
/// by hand; a price feed will later write the same field, stamping
|
||||
/// MarketValueUpdatedAt and MarketValueSource as it goes.
|
||||
/// </summary>
|
||||
[Range(0, 1_000_000)] public decimal? MarketValue { get; init; }
|
||||
|
||||
[MaxLength(100)] public string? MarketValueSource { get; init; }
|
||||
|
||||
[Range(0, 1_000_000)] public decimal? ValueLoose { get; init; }
|
||||
[Range(0, 1_000_000)] public decimal? ValueCib { get; init; }
|
||||
[Range(0, 1_000_000)] public decimal? ValueNew { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Query string for the library list, bound from [FromQuery].</summary>
|
||||
public record GameQuery
|
||||
{
|
||||
/// <summary>Free-text match against title, developer and publisher.</summary>
|
||||
public string? Search { get; init; }
|
||||
|
||||
public string? System { get; init; }
|
||||
public string? Genre { get; init; }
|
||||
|
||||
public bool? Own { get; init; }
|
||||
public bool? Dumped { get; init; }
|
||||
public bool? Played { get; init; }
|
||||
public bool? Finished { get; init; }
|
||||
|
||||
public GameCondition? Condition { get; init; }
|
||||
public GameRegion? Region { get; init; }
|
||||
|
||||
/// <summary>Lowest personal score to include. Unrated games are excluded when set.</summary>
|
||||
[Range(1, 10)] public int? MinRating { get; init; }
|
||||
|
||||
/// <summary>Restrict to games that do, or do not, have a market value recorded.</summary>
|
||||
public bool? HasValue { get; init; }
|
||||
|
||||
[Range(1, int.MaxValue)] public int Page { get; init; } = 1;
|
||||
|
||||
/// <summary>Capped at 100 to keep a hostile or buggy client from asking for everything.</summary>
|
||||
[Range(1, 100)] public int PageSize { get; init; } = 20;
|
||||
|
||||
/// <summary>
|
||||
/// One of: title, system, genre, year, developer, publisher, rating,
|
||||
/// value, price, purchased, created, updated.
|
||||
/// </summary>
|
||||
public string Sort { get; init; } = "title";
|
||||
|
||||
/// <summary>"asc" or "desc".</summary>
|
||||
public string Dir { get; init; } = "asc";
|
||||
}
|
||||
|
||||
/// <summary>Distinct values present in the user's library, for filter dropdowns.</summary>
|
||||
public record FacetsResponse(IReadOnlyList<string> Systems, IReadOnlyList<string> Genres);
|
||||
|
||||
public record UploadResponse(string FileName, string Url);
|
||||
@@ -0,0 +1,77 @@
|
||||
using LudosData.Api.Domain;
|
||||
|
||||
namespace LudosData.Api.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// One game as it appears in an export file.
|
||||
///
|
||||
/// Deliberately has no id and no owner: an export is a portable description of a
|
||||
/// library, not a database dump. On import, rows are matched by title and
|
||||
/// system, so a file can move between accounts or instances.
|
||||
/// </summary>
|
||||
public record ExportGame
|
||||
{
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public string? System { get; init; }
|
||||
public string? Genre { get; init; }
|
||||
public string? Year { get; init; }
|
||||
public string? Developer { get; init; }
|
||||
public string? Publisher { get; init; }
|
||||
public string? Description { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Stored filename of the box art. The image itself is not bundled, so a
|
||||
/// file imported into a fresh instance will reference art that is not there
|
||||
/// until the fetcher is run again.
|
||||
/// </summary>
|
||||
public string? Art { get; init; }
|
||||
|
||||
public bool Own { get; init; }
|
||||
public bool Dumped { get; init; }
|
||||
public bool Played { get; init; }
|
||||
public bool Finished { get; init; }
|
||||
|
||||
// Collector fields travel with the export; a backup that quietly dropped
|
||||
// ratings, notes and valuations would not be a backup.
|
||||
public int? Rating { get; init; }
|
||||
public string? Notes { get; init; }
|
||||
public GameCondition Condition { get; init; }
|
||||
public GameRegion Region { get; init; }
|
||||
public decimal? PurchasePrice { get; init; }
|
||||
public DateOnly? PurchaseDate { get; init; }
|
||||
public decimal? MarketValue { get; init; }
|
||||
public DateTimeOffset? MarketValueUpdatedAt { get; init; }
|
||||
public string? MarketValueSource { get; init; }
|
||||
public decimal? ValueLoose { get; init; }
|
||||
public decimal? ValueCib { get; init; }
|
||||
public decimal? ValueNew { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>Envelope written by the JSON exporter.</summary>
|
||||
public record LibraryExport(
|
||||
string Format,
|
||||
int Version,
|
||||
DateTimeOffset ExportedAt,
|
||||
int Count,
|
||||
IReadOnlyList<ExportGame> Games);
|
||||
|
||||
public enum ImportMode
|
||||
{
|
||||
/// <summary>Update rows that match on title + system, insert the rest. Nothing is deleted.</summary>
|
||||
Merge = 0,
|
||||
|
||||
/// <summary>Delete the caller's entire library first, then insert the file.</summary>
|
||||
Replace = 1,
|
||||
}
|
||||
|
||||
public record ImportRowError(int Row, string Title, string Reason);
|
||||
|
||||
public record ImportResult(
|
||||
bool DryRun,
|
||||
ImportMode Mode,
|
||||
int Parsed,
|
||||
int Created,
|
||||
int Updated,
|
||||
int Deleted,
|
||||
int Skipped,
|
||||
IReadOnlyList<ImportRowError> Errors);
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace LudosData.Api.Contracts;
|
||||
|
||||
public record CountByLabel(string Label, int Count);
|
||||
|
||||
/// <summary>
|
||||
/// The owned → played → finished progression. Each stage is a subset of the one
|
||||
/// before it, so the numbers only make sense read in order.
|
||||
/// </summary>
|
||||
public record CompletionFunnel(int Owned, int Played, int Finished);
|
||||
|
||||
/// <summary>
|
||||
/// Collection value, reported with everything needed to judge it.
|
||||
///
|
||||
/// A bare total invites a false reading: it silently mixes games priced today
|
||||
/// with games priced months ago, and quietly excludes everything unpriced. So the
|
||||
/// coverage, the age range and the sources all travel with the figure.
|
||||
/// </summary>
|
||||
public record ValueSummary(
|
||||
decimal Total,
|
||||
int PricedCount,
|
||||
int UnpricedCount,
|
||||
decimal TotalPaid,
|
||||
int PaidCount,
|
||||
DateTimeOffset? OldestValuedAt,
|
||||
DateTimeOffset? NewestValuedAt,
|
||||
IReadOnlyList<string> Sources,
|
||||
/// <summary>What the collection would be worth if every copy were complete in box.</summary>
|
||||
decimal? TotalIfCib);
|
||||
|
||||
public record StatsResponse(
|
||||
int TotalGames,
|
||||
CompletionFunnel Funnel,
|
||||
int Backlog,
|
||||
int InProgress,
|
||||
int Dumped,
|
||||
int RatedCount,
|
||||
double? AverageRating,
|
||||
IReadOnlyList<CountByLabel> BySystem,
|
||||
IReadOnlyList<CountByLabel> ByGenre,
|
||||
IReadOnlyList<CountByLabel> ByDecade,
|
||||
IReadOnlyList<CountByLabel> ByCondition,
|
||||
/// <summary>Rating distribution, 1-10. Only scores actually used appear.</summary>
|
||||
IReadOnlyList<CountByLabel> ByRating,
|
||||
ValueSummary Value);
|
||||
@@ -0,0 +1,118 @@
|
||||
using LudosData.Api.Auth;
|
||||
using LudosData.Api.Contracts;
|
||||
using LudosData.Api.Domain;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace LudosData.Api.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/auth")]
|
||||
public class AuthController(
|
||||
UserManager<AppUser> userManager,
|
||||
SignInManager<AppUser> signInManager,
|
||||
ITokenService tokenService,
|
||||
ILogger<AuthController> logger) : ControllerBase
|
||||
{
|
||||
[HttpPost("register")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AuthResponse>> Register(RegisterRequest request)
|
||||
{
|
||||
var user = new AppUser
|
||||
{
|
||||
UserName = request.UserName,
|
||||
Email = request.Email,
|
||||
FirstName = request.FirstName,
|
||||
LastName = request.LastName,
|
||||
};
|
||||
|
||||
var result = await userManager.CreateAsync(user, request.Password);
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
foreach (var error in result.Errors)
|
||||
{
|
||||
ModelState.AddModelError(error.Code, error.Description);
|
||||
}
|
||||
|
||||
return ValidationProblem(ModelState);
|
||||
}
|
||||
|
||||
logger.LogInformation("Registered user {UserName}", user.UserName);
|
||||
return Ok(BuildAuthResponse(user));
|
||||
}
|
||||
|
||||
[HttpPost("login")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AuthResponse>> Login(LoginRequest request)
|
||||
{
|
||||
var user = await userManager.FindByNameAsync(request.UserName);
|
||||
if (user is null)
|
||||
{
|
||||
// Same response as a bad password, so this endpoint cannot be used to
|
||||
// enumerate which usernames exist.
|
||||
return Unauthorized(new ProblemDetails { Title = "Invalid username or password." });
|
||||
}
|
||||
|
||||
var result = await signInManager.CheckPasswordSignInAsync(user, request.Password, lockoutOnFailure: true);
|
||||
|
||||
if (result.IsLockedOut)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status423Locked,
|
||||
new ProblemDetails { Title = "Account temporarily locked after too many failed attempts." });
|
||||
}
|
||||
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
return Unauthorized(new ProblemDetails { Title = "Invalid username or password." });
|
||||
}
|
||||
|
||||
return Ok(BuildAuthResponse(user));
|
||||
}
|
||||
|
||||
[HttpGet("me")]
|
||||
[Authorize]
|
||||
public async Task<ActionResult<UserResponse>> Me()
|
||||
{
|
||||
var user = await userManager.FindByIdAsync(User.GetUserId());
|
||||
return user is null ? Unauthorized() : Ok(ToUserResponse(user));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Availability check for the registration form. Replaces the old approach of
|
||||
/// querying the users table through the generic CRUD endpoint, which exposed
|
||||
/// every user column to anonymous callers; this returns only a boolean.
|
||||
/// </summary>
|
||||
[HttpGet("available")]
|
||||
[AllowAnonymous]
|
||||
public async Task<ActionResult<AvailabilityResponse>> Available(
|
||||
[FromQuery] string? userName,
|
||||
[FromQuery] string? email)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(userName))
|
||||
{
|
||||
return Ok(new AvailabilityResponse(await userManager.FindByNameAsync(userName) is null));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(email))
|
||||
{
|
||||
return Ok(new AvailabilityResponse(await userManager.FindByEmailAsync(email) is null));
|
||||
}
|
||||
|
||||
return BadRequest(new ProblemDetails { Title = "Provide either userName or email." });
|
||||
}
|
||||
|
||||
private AuthResponse BuildAuthResponse(AppUser user)
|
||||
{
|
||||
var (token, expiresAt) = tokenService.CreateAccessToken(user);
|
||||
return new AuthResponse(token, expiresAt, ToUserResponse(user));
|
||||
}
|
||||
|
||||
private static UserResponse ToUserResponse(AppUser user) => new(
|
||||
user.Id,
|
||||
user.UserName ?? string.Empty,
|
||||
user.Email,
|
||||
user.FirstName,
|
||||
user.LastName,
|
||||
user.Art);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using LudosData.Api.Auth;
|
||||
using LudosData.Api.Contracts;
|
||||
using LudosData.Api.Data;
|
||||
using LudosData.Api.Domain;
|
||||
using LudosData.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace LudosData.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// The user's game library.
|
||||
///
|
||||
/// Every query starts from <c>Where(g => g.OwnerId == currentUserId)</c>, taken from
|
||||
/// the JWT subject. The old API took the owner id from a client-supplied query
|
||||
/// parameter (<c>filter[]=userId,eq,N</c>), which meant any valid token could read
|
||||
/// any other user's library by editing the number.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/games")]
|
||||
[Authorize]
|
||||
public class GamesController(
|
||||
LudosDbContext db,
|
||||
IImageStorage images,
|
||||
ILogger<GamesController> logger) : ControllerBase
|
||||
{
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<PagedResult<GameResponse>>> List([FromQuery] GameQuery query, CancellationToken ct)
|
||||
{
|
||||
var ownerId = User.GetUserId();
|
||||
var q = db.Games.AsNoTracking().Where(g => g.OwnerId == ownerId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.Search))
|
||||
{
|
||||
var term = query.Search.Trim();
|
||||
q = q.Where(g =>
|
||||
EF.Functions.Like(g.Title, $"%{term}%") ||
|
||||
(g.Developer != null && EF.Functions.Like(g.Developer, $"%{term}%")) ||
|
||||
(g.Publisher != null && EF.Functions.Like(g.Publisher, $"%{term}%")));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(query.System)) q = q.Where(g => g.System == query.System);
|
||||
if (!string.IsNullOrWhiteSpace(query.Genre)) q = q.Where(g => g.Genre == query.Genre);
|
||||
|
||||
if (query.Own is { } own) q = q.Where(g => g.Own == own);
|
||||
if (query.Dumped is { } dumped) q = q.Where(g => g.Dumped == dumped);
|
||||
if (query.Played is { } played) q = q.Where(g => g.Played == played);
|
||||
if (query.Finished is { } finished) q = q.Where(g => g.Finished == finished);
|
||||
|
||||
if (query.Condition is { } condition) q = q.Where(g => g.Condition == condition);
|
||||
if (query.Region is { } region) q = q.Where(g => g.Region == region);
|
||||
|
||||
// An unrated game is not a zero-rated one, so it drops out of a
|
||||
// minimum-rating filter rather than sorting to the bottom.
|
||||
if (query.MinRating is { } minRating) q = q.Where(g => g.Rating >= minRating);
|
||||
|
||||
if (query.HasValue is { } hasValue)
|
||||
{
|
||||
q = hasValue ? q.Where(g => g.MarketValue != null) : q.Where(g => g.MarketValue == null);
|
||||
}
|
||||
|
||||
var total = await q.CountAsync(ct);
|
||||
|
||||
q = ApplySort(q, query.Sort, query.Dir);
|
||||
|
||||
var items = await q
|
||||
.Skip((query.Page - 1) * query.PageSize)
|
||||
.Take(query.PageSize)
|
||||
.ToListAsync(ct);
|
||||
|
||||
return Ok(new PagedResult<GameResponse>(
|
||||
items.Select(g => ToResponse(g, ownerId)).ToList(),
|
||||
query.Page,
|
||||
query.PageSize,
|
||||
total));
|
||||
}
|
||||
|
||||
[HttpGet("{id:int}")]
|
||||
public async Task<ActionResult<GameResponse>> Get(int id, CancellationToken ct)
|
||||
{
|
||||
var ownerId = User.GetUserId();
|
||||
var game = await db.Games.AsNoTracking()
|
||||
.FirstOrDefaultAsync(g => g.Id == id && g.OwnerId == ownerId, ct);
|
||||
|
||||
// A game belonging to someone else is reported as 404, not 403 — the
|
||||
// response should not confirm that the id exists.
|
||||
return game is null ? NotFound() : Ok(ToResponse(game, ownerId));
|
||||
}
|
||||
|
||||
[HttpGet("facets")]
|
||||
public async Task<ActionResult<FacetsResponse>> Facets(CancellationToken ct)
|
||||
{
|
||||
var ownerId = User.GetUserId();
|
||||
var mine = db.Games.AsNoTracking().Where(g => g.OwnerId == ownerId);
|
||||
|
||||
var systems = await mine
|
||||
.Where(g => g.System != null && g.System != "")
|
||||
.Select(g => g.System!)
|
||||
.Distinct().OrderBy(s => s).ToListAsync(ct);
|
||||
|
||||
var genres = await mine
|
||||
.Where(g => g.Genre != null && g.Genre != "")
|
||||
.Select(g => g.Genre!)
|
||||
.Distinct().OrderBy(s => s).ToListAsync(ct);
|
||||
|
||||
return Ok(new FacetsResponse(systems, genres));
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<ActionResult<GameResponse>> Create(GameRequest request, CancellationToken ct)
|
||||
{
|
||||
var ownerId = User.GetUserId();
|
||||
|
||||
var game = new Game { OwnerId = ownerId };
|
||||
Apply(request, game);
|
||||
|
||||
db.Games.Add(game);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
logger.LogInformation("User {OwnerId} created game {GameId}", ownerId, game.Id);
|
||||
return CreatedAtAction(nameof(Get), new { id = game.Id }, ToResponse(game, ownerId));
|
||||
}
|
||||
|
||||
[HttpPut("{id:int}")]
|
||||
public async Task<ActionResult<GameResponse>> Update(int id, GameRequest request, CancellationToken ct)
|
||||
{
|
||||
var ownerId = User.GetUserId();
|
||||
var game = await db.Games.FirstOrDefaultAsync(g => g.Id == id && g.OwnerId == ownerId, ct);
|
||||
if (game is null) return NotFound();
|
||||
|
||||
Apply(request, game);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
return Ok(ToResponse(game, ownerId));
|
||||
}
|
||||
|
||||
[HttpDelete("{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken ct)
|
||||
{
|
||||
var ownerId = User.GetUserId();
|
||||
var game = await db.Games.FirstOrDefaultAsync(g => g.Id == id && g.OwnerId == ownerId, ct);
|
||||
if (game is null) return NotFound();
|
||||
|
||||
db.Games.Remove(game);
|
||||
await db.SaveChangesAsync(ct);
|
||||
|
||||
logger.LogInformation("User {OwnerId} deleted game {GameId}", ownerId, id);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private static IQueryable<Game> ApplySort(IQueryable<Game> q, string sort, string dir)
|
||||
{
|
||||
var descending = string.Equals(dir, "desc", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Allow-list rather than reflecting over the string, so the sort parameter
|
||||
// cannot reach the query shape in any way the API does not define.
|
||||
return (sort?.ToLowerInvariant()) switch
|
||||
{
|
||||
"system" => descending ? q.OrderByDescending(g => g.System) : q.OrderBy(g => g.System),
|
||||
"genre" => descending ? q.OrderByDescending(g => g.Genre) : q.OrderBy(g => g.Genre),
|
||||
"year" => descending ? q.OrderByDescending(g => g.Year) : q.OrderBy(g => g.Year),
|
||||
"developer" => descending ? q.OrderByDescending(g => g.Developer) : q.OrderBy(g => g.Developer),
|
||||
"publisher" => descending ? q.OrderByDescending(g => g.Publisher) : q.OrderBy(g => g.Publisher),
|
||||
"rating" => descending ? q.OrderByDescending(g => g.Rating) : q.OrderBy(g => g.Rating),
|
||||
"value" => descending ? q.OrderByDescending(g => g.MarketValue) : q.OrderBy(g => g.MarketValue),
|
||||
"price" => descending ? q.OrderByDescending(g => g.PurchasePrice) : q.OrderBy(g => g.PurchasePrice),
|
||||
"purchased" => descending ? q.OrderByDescending(g => g.PurchaseDate) : q.OrderBy(g => g.PurchaseDate),
|
||||
"created" => descending ? q.OrderByDescending(g => g.CreatedAt) : q.OrderBy(g => g.CreatedAt),
|
||||
"updated" => descending ? q.OrderByDescending(g => g.UpdatedAt) : q.OrderBy(g => g.UpdatedAt),
|
||||
_ => descending ? q.OrderByDescending(g => g.Title) : q.OrderBy(g => g.Title),
|
||||
};
|
||||
}
|
||||
|
||||
private static void Apply(GameRequest request, Game game)
|
||||
{
|
||||
game.Title = request.Title.Trim();
|
||||
game.System = request.System?.Trim();
|
||||
game.Genre = request.Genre?.Trim();
|
||||
game.Year = request.Year?.Trim();
|
||||
game.Developer = request.Developer?.Trim();
|
||||
game.Publisher = request.Publisher?.Trim();
|
||||
game.Art = request.Art?.Trim();
|
||||
game.Description = request.Description;
|
||||
game.Own = request.Own;
|
||||
game.Dumped = request.Dumped;
|
||||
game.Played = request.Played;
|
||||
game.Finished = request.Finished;
|
||||
|
||||
game.Rating = request.Rating;
|
||||
game.Notes = request.Notes;
|
||||
game.Condition = request.Condition;
|
||||
game.Region = request.Region;
|
||||
game.PurchasePrice = request.PurchasePrice;
|
||||
game.PurchaseDate = request.PurchaseDate;
|
||||
|
||||
var tiersChanged = request.ValueLoose != game.ValueLoose
|
||||
|| request.ValueCib != game.ValueCib
|
||||
|| request.ValueNew != game.ValueNew;
|
||||
|
||||
game.ValueLoose = request.ValueLoose;
|
||||
game.ValueCib = request.ValueCib;
|
||||
game.ValueNew = request.ValueNew;
|
||||
|
||||
// Only stamp the valuation when a figure actually changes, so an
|
||||
// unrelated edit does not make a stale price look freshly checked.
|
||||
if (request.MarketValue != game.MarketValue || tiersChanged)
|
||||
{
|
||||
game.MarketValue = request.MarketValue;
|
||||
// Tiers win where they exist: they came from a source, and they
|
||||
// follow the copy's condition.
|
||||
game.RecalculateEffectiveValue();
|
||||
|
||||
game.MarketValueUpdatedAt = game.MarketValue is null ? null : DateTimeOffset.UtcNow;
|
||||
game.MarketValueSource = game.MarketValue is null
|
||||
? null
|
||||
: request.MarketValueSource?.Trim() ?? "manual";
|
||||
}
|
||||
else
|
||||
{
|
||||
// Condition may have moved without any price changing, which puts a
|
||||
// different tier in play.
|
||||
game.RecalculateEffectiveValue();
|
||||
|
||||
if (request.MarketValueSource is { } source && game.MarketValue is not null)
|
||||
{
|
||||
game.MarketValueSource = source.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private GameResponse ToResponse(Game g, string ownerId) => new(
|
||||
g.Id, g.Title, g.System, g.Genre, g.Year, g.Developer, g.Publisher,
|
||||
g.Art, images.BuildUrl(ownerId, g.Art), g.Description,
|
||||
g.Own, g.Dumped, g.Played, g.Finished,
|
||||
g.Rating, g.Notes, g.Condition, g.Region,
|
||||
g.PurchasePrice, g.PurchaseDate,
|
||||
g.MarketValue, g.MarketValueUpdatedAt, g.MarketValueSource,
|
||||
g.ValueLoose, g.ValueCib, g.ValueNew,
|
||||
g.CreatedAt, g.UpdatedAt);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using LudosData.Api.Auth;
|
||||
using LudosData.Api.Contracts;
|
||||
using LudosData.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace LudosData.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Box art uploads.
|
||||
///
|
||||
/// The PHP original accepted anonymous uploads, wrote every file into one hardcoded
|
||||
/// "ckoch" folder, and built the destination path from the client-supplied filename.
|
||||
/// This requires authentication, files land in the caller's own folder, and the
|
||||
/// stored name is generated server-side.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/images")]
|
||||
[Authorize]
|
||||
public class ImagesController(
|
||||
IImageStorage images,
|
||||
IOptions<ImageStorageOptions> options,
|
||||
ILogger<ImagesController> logger) : ControllerBase
|
||||
{
|
||||
private readonly ImageStorageOptions _options = options.Value;
|
||||
|
||||
[HttpPost]
|
||||
[RequestSizeLimit(6 * 1024 * 1024)]
|
||||
public async Task<ActionResult<UploadResponse>> Upload(IFormFile file, CancellationToken ct)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(new ProblemDetails { Title = "No file was uploaded." });
|
||||
}
|
||||
|
||||
if (file.Length > _options.MaxBytes)
|
||||
{
|
||||
return BadRequest(new ProblemDetails
|
||||
{
|
||||
Title = $"File is larger than the {_options.MaxBytes / (1024 * 1024)} MB limit.",
|
||||
});
|
||||
}
|
||||
|
||||
var ownerId = User.GetUserId();
|
||||
|
||||
try
|
||||
{
|
||||
await using var stream = file.OpenReadStream();
|
||||
var fileName = await images.SaveAsync(stream, ownerId, ct);
|
||||
|
||||
return Ok(new UploadResponse(fileName, images.BuildUrl(ownerId, fileName)!));
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
// The most common cause is bytes that are not a decodable image. The
|
||||
// detail is logged but not returned, so probing does not reveal the
|
||||
// internals of the decoder.
|
||||
logger.LogWarning(ex, "Rejected upload from user {OwnerId}", ownerId);
|
||||
return BadRequest(new ProblemDetails { Title = "The file could not be read as an image." });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using LudosData.Api.Auth;
|
||||
using LudosData.Api.Contracts;
|
||||
using LudosData.Api.Data;
|
||||
using LudosData.Api.Domain;
|
||||
using LudosData.Api.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace LudosData.Api.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Export and import of the caller's whole library.
|
||||
///
|
||||
/// Until now the only backup was the Docker volume. This makes a library
|
||||
/// portable: JSON round-trips exactly, CSV opens in a spreadsheet.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/library")]
|
||||
[Authorize]
|
||||
public class LibraryController(
|
||||
LudosDbContext db,
|
||||
ILogger<LibraryController> logger) : ControllerBase
|
||||
{
|
||||
private const int ExportVersion = 1;
|
||||
|
||||
private static readonly string[] CsvHeaders =
|
||||
[
|
||||
"title", "system", "genre", "year", "developer", "publisher",
|
||||
"description", "art", "own", "dumped", "played", "finished",
|
||||
"rating", "notes", "condition", "region",
|
||||
"purchasePrice", "purchaseDate", "marketValue", "marketValueUpdatedAt",
|
||||
"marketValueSource", "valueLoose", "valueCib", "valueNew",
|
||||
];
|
||||
|
||||
// Must match the converter registered on the controllers, so an export
|
||||
// written with enum names is readable by the importer.
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() },
|
||||
};
|
||||
|
||||
// ---- export ----------------------------------------------------------
|
||||
|
||||
[HttpGet("export")]
|
||||
public async Task<IActionResult> Export([FromQuery] string format = "json", CancellationToken ct = default)
|
||||
{
|
||||
var ownerId = User.GetUserId();
|
||||
var games = await db.Games.AsNoTracking()
|
||||
.Where(g => g.OwnerId == ownerId)
|
||||
.OrderBy(g => g.Title)
|
||||
.ToListAsync(ct);
|
||||
|
||||
var rows = games.Select(ToExport).ToList();
|
||||
var stamp = DateTime.UtcNow.ToString("yyyy-MM-dd");
|
||||
|
||||
if (string.Equals(format, "csv", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var csv = Csv.Write(CsvHeaders, rows.Select(g => new List<string?>
|
||||
{
|
||||
g.Title, g.System, g.Genre, g.Year, g.Developer, g.Publisher,
|
||||
g.Description, g.Art,
|
||||
g.Own.ToString(), g.Dumped.ToString(), g.Played.ToString(), g.Finished.ToString(),
|
||||
g.Rating?.ToString(), g.Notes,
|
||||
g.Condition == GameCondition.Unspecified ? null : g.Condition.ToString(),
|
||||
g.Region == GameRegion.Unspecified ? null : g.Region.ToString(),
|
||||
// Invariant culture throughout: a comma decimal separator would
|
||||
// collide with the delimiter, and dates must not depend on locale.
|
||||
g.PurchasePrice?.ToString(CultureInfo.InvariantCulture),
|
||||
g.PurchaseDate?.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
|
||||
g.MarketValue?.ToString(CultureInfo.InvariantCulture),
|
||||
g.MarketValueUpdatedAt?.ToString("O", CultureInfo.InvariantCulture),
|
||||
g.MarketValueSource,
|
||||
g.ValueLoose?.ToString(CultureInfo.InvariantCulture),
|
||||
g.ValueCib?.ToString(CultureInfo.InvariantCulture),
|
||||
g.ValueNew?.ToString(CultureInfo.InvariantCulture),
|
||||
}));
|
||||
|
||||
// A BOM keeps Excel from mangling non-ASCII titles such as Pokémon.
|
||||
var bytes = new byte[] { 0xEF, 0xBB, 0xBF }.Concat(Encoding.UTF8.GetBytes(csv)).ToArray();
|
||||
return File(bytes, "text/csv; charset=utf-8", $"ludos-library-{stamp}.csv");
|
||||
}
|
||||
|
||||
if (!string.Equals(format, "json", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return BadRequest(new ProblemDetails { Title = "Format must be 'json' or 'csv'." });
|
||||
}
|
||||
|
||||
var payload = new LibraryExport("ludosdata.library", ExportVersion,
|
||||
DateTimeOffset.UtcNow, rows.Count, rows);
|
||||
|
||||
return File(JsonSerializer.SerializeToUtf8Bytes(payload, JsonOptions),
|
||||
"application/json", $"ludos-library-{stamp}.json");
|
||||
}
|
||||
|
||||
// ---- import ----------------------------------------------------------
|
||||
|
||||
[HttpPost("import")]
|
||||
[RequestSizeLimit(16 * 1024 * 1024)]
|
||||
public async Task<ActionResult<ImportResult>> Import(
|
||||
IFormFile file,
|
||||
[FromQuery] ImportMode mode = ImportMode.Merge,
|
||||
[FromQuery] bool dryRun = false,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (file is null || file.Length == 0)
|
||||
{
|
||||
return BadRequest(new ProblemDetails { Title = "No file was uploaded." });
|
||||
}
|
||||
|
||||
string text;
|
||||
using (var reader = new StreamReader(file.OpenReadStream(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true))
|
||||
{
|
||||
text = await reader.ReadToEndAsync(ct);
|
||||
}
|
||||
|
||||
List<ExportGame> incoming;
|
||||
var errors = new List<ImportRowError>();
|
||||
|
||||
try
|
||||
{
|
||||
incoming = LooksLikeJson(text)
|
||||
? ParseJson(text)
|
||||
: ParseCsv(text, errors);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
return BadRequest(new ProblemDetails { Title = $"The file is not valid JSON: {ex.Message}" });
|
||||
}
|
||||
catch (InvalidDataException ex)
|
||||
{
|
||||
return BadRequest(new ProblemDetails { Title = ex.Message });
|
||||
}
|
||||
|
||||
var ownerId = User.GetUserId();
|
||||
var existing = await db.Games.Where(g => g.OwnerId == ownerId).ToListAsync(ct);
|
||||
|
||||
// Title + system identifies a row: the same game legitimately appears
|
||||
// once per platform (three Donkey Kong Countrys, on SNES, GB and GBA).
|
||||
var index = existing
|
||||
.GroupBy(g => Key(g.Title, g.System))
|
||||
.ToDictionary(g => g.Key, g => g.First());
|
||||
|
||||
int created = 0, updated = 0, deleted = 0, skipped = 0;
|
||||
|
||||
if (mode == ImportMode.Replace)
|
||||
{
|
||||
deleted = existing.Count;
|
||||
if (!dryRun)
|
||||
{
|
||||
db.Games.RemoveRange(existing);
|
||||
}
|
||||
index.Clear();
|
||||
}
|
||||
|
||||
var seen = new HashSet<string>();
|
||||
|
||||
foreach (var row in incoming)
|
||||
{
|
||||
var title = row.Title?.Trim() ?? string.Empty;
|
||||
if (title.Length == 0)
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = Key(title, row.System);
|
||||
if (!seen.Add(key))
|
||||
{
|
||||
// Two rows for the same game in one file: first one wins.
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index.TryGetValue(key, out var target))
|
||||
{
|
||||
updated++;
|
||||
if (!dryRun) Apply(row, target);
|
||||
}
|
||||
else
|
||||
{
|
||||
created++;
|
||||
if (!dryRun)
|
||||
{
|
||||
var game = new Game { OwnerId = ownerId };
|
||||
Apply(row, game);
|
||||
db.Games.Add(game);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!dryRun)
|
||||
{
|
||||
await db.SaveChangesAsync(ct);
|
||||
logger.LogInformation(
|
||||
"User {OwnerId} imported {Created} new and {Updated} updated games ({Mode})",
|
||||
ownerId, created, updated, mode);
|
||||
}
|
||||
|
||||
return Ok(new ImportResult(
|
||||
dryRun, mode, incoming.Count, created, updated, deleted, skipped, errors));
|
||||
}
|
||||
|
||||
// ---- helpers ---------------------------------------------------------
|
||||
|
||||
private static bool LooksLikeJson(string text)
|
||||
{
|
||||
var trimmed = text.TrimStart('', ' ', '\t', '\r', '\n');
|
||||
return trimmed.StartsWith('{') || trimmed.StartsWith('[');
|
||||
}
|
||||
|
||||
/// <summary>Accepts either the export envelope or a bare array of games.</summary>
|
||||
private static List<ExportGame> ParseJson(string text)
|
||||
{
|
||||
var trimmed = text.TrimStart('', ' ', '\t', '\r', '\n');
|
||||
|
||||
if (trimmed.StartsWith('['))
|
||||
{
|
||||
return JsonSerializer.Deserialize<List<ExportGame>>(trimmed, JsonOptions) ?? [];
|
||||
}
|
||||
|
||||
var envelope = JsonSerializer.Deserialize<LibraryExport>(trimmed, JsonOptions);
|
||||
return envelope?.Games?.ToList()
|
||||
?? throw new InvalidDataException("The JSON file contains no games.");
|
||||
}
|
||||
|
||||
private static List<ExportGame> ParseCsv(string text, List<ImportRowError> errors)
|
||||
{
|
||||
var rows = Csv.Parse(text);
|
||||
if (rows.Count == 0)
|
||||
{
|
||||
throw new InvalidDataException("The CSV file is empty.");
|
||||
}
|
||||
|
||||
var header = rows[0].Select(h => h.Trim().ToLowerInvariant()).ToList();
|
||||
var titleAt = header.IndexOf("title");
|
||||
if (titleAt < 0)
|
||||
{
|
||||
throw new InvalidDataException("The CSV file has no 'title' column.");
|
||||
}
|
||||
|
||||
string? Field(List<string> row, string name)
|
||||
{
|
||||
var at = header.IndexOf(name);
|
||||
if (at < 0 || at >= row.Count) return null;
|
||||
var value = row[at].Trim();
|
||||
return value.Length == 0 ? null : value;
|
||||
}
|
||||
|
||||
bool Flag(List<string> row, string name)
|
||||
{
|
||||
var value = Field(row, name);
|
||||
return value is not null
|
||||
&& (value.Equals("true", StringComparison.OrdinalIgnoreCase)
|
||||
|| value is "1" or "yes" or "y");
|
||||
}
|
||||
|
||||
var games = new List<ExportGame>();
|
||||
|
||||
for (var i = 1; i < rows.Count; i++)
|
||||
{
|
||||
var row = rows[i];
|
||||
var title = titleAt < row.Count ? row[titleAt].Trim() : string.Empty;
|
||||
|
||||
if (title.Length == 0)
|
||||
{
|
||||
// Row number as a person counts them: header is row 1.
|
||||
errors.Add(new ImportRowError(i + 1, string.Empty, "Missing title"));
|
||||
continue;
|
||||
}
|
||||
|
||||
games.Add(new ExportGame
|
||||
{
|
||||
Title = title,
|
||||
System = Field(row, "system"),
|
||||
Genre = Field(row, "genre"),
|
||||
Year = Field(row, "year"),
|
||||
Developer = Field(row, "developer"),
|
||||
Publisher = Field(row, "publisher"),
|
||||
Description = Field(row, "description"),
|
||||
Art = Field(row, "art"),
|
||||
Own = Flag(row, "own"),
|
||||
Dumped = Flag(row, "dumped"),
|
||||
Played = Flag(row, "played"),
|
||||
Finished = Flag(row, "finished"),
|
||||
|
||||
Rating = ParseInt(Field(row, "rating")),
|
||||
Notes = Field(row, "notes"),
|
||||
Condition = ParseEnum<GameCondition>(Field(row, "condition")),
|
||||
Region = ParseEnum<GameRegion>(Field(row, "region")),
|
||||
PurchasePrice = ParseMoney(Field(row, "purchaseprice")),
|
||||
PurchaseDate = ParseDate(Field(row, "purchasedate")),
|
||||
MarketValue = ParseMoney(Field(row, "marketvalue")),
|
||||
MarketValueUpdatedAt = ParseTimestamp(Field(row, "marketvalueupdatedat")),
|
||||
MarketValueSource = Field(row, "marketvaluesource"),
|
||||
ValueLoose = ParseMoney(Field(row, "valueloose")),
|
||||
ValueCib = ParseMoney(Field(row, "valuecib")),
|
||||
ValueNew = ParseMoney(Field(row, "valuenew")),
|
||||
});
|
||||
}
|
||||
|
||||
return games;
|
||||
}
|
||||
|
||||
// Parsers are forgiving: a spreadsheet round-trip is a normal way for these
|
||||
// files to arrive, and one unreadable cell should not cost the whole row.
|
||||
private static int? ParseInt(string? value) =>
|
||||
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed : null;
|
||||
|
||||
private static decimal? ParseMoney(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
// Tolerate a currency symbol and thousands separators from a spreadsheet.
|
||||
var cleaned = value.Trim().TrimStart('$', '£', '€').Replace(",", string.Empty);
|
||||
return decimal.TryParse(cleaned, NumberStyles.Number, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed : null;
|
||||
}
|
||||
|
||||
private static DateOnly? ParseDate(string? value) =>
|
||||
DateOnly.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed)
|
||||
? parsed : null;
|
||||
|
||||
private static DateTimeOffset? ParseTimestamp(string? value) =>
|
||||
DateTimeOffset.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind, out var parsed)
|
||||
? parsed : null;
|
||||
|
||||
private static T ParseEnum<T>(string? value) where T : struct, Enum =>
|
||||
Enum.TryParse<T>(value, ignoreCase: true, out var parsed) ? parsed : default;
|
||||
|
||||
private static string Key(string title, string? system) =>
|
||||
$"{title.Trim().ToLowerInvariant()} | ||||