Compare commits
12
Commits
10757575c2
...
master
| 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
|
# Runtime state
|
||||||
/dist
|
data/
|
||||||
/dist-server
|
uploads/
|
||||||
/tmp
|
*.db
|
||||||
/out-tsc
|
*.db-shm
|
||||||
|
*.db-wal
|
||||||
|
|
||||||
# dependencies
|
# --- Frontend ---
|
||||||
/node_modules
|
node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.angular/
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
testem.log
|
||||||
|
|
||||||
# IDEs and editors
|
# --- Backend ---
|
||||||
/.idea
|
[Bb]in/
|
||||||
|
[Oo]bj/
|
||||||
|
*.user
|
||||||
|
.vs/
|
||||||
|
|
||||||
|
# --- Editors / OS ---
|
||||||
|
.idea/
|
||||||
.project
|
.project
|
||||||
.classpath
|
.classpath
|
||||||
.c9/
|
|
||||||
*.launch
|
|
||||||
.settings/
|
|
||||||
*.sublime-workspace
|
*.sublime-workspace
|
||||||
|
|
||||||
# IDE - VSCode
|
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/settings.json
|
|
||||||
!.vscode/tasks.json
|
|
||||||
!.vscode/launch.json
|
|
||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
|
!.vscode/launch.json
|
||||||
# misc
|
!.vscode/tasks.json
|
||||||
/.sass-cache
|
|
||||||
/connect.lock
|
|
||||||
/coverage
|
|
||||||
/libpeerconnection.log
|
|
||||||
npm-debug.log
|
|
||||||
testem.log
|
|
||||||
/typings
|
|
||||||
|
|
||||||
# e2e
|
|
||||||
/e2e/*.js
|
|
||||||
/e2e/*.map
|
|
||||||
|
|
||||||
# System Files
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
src/app/game-grid/game-grid.component.html
|
*.swp
|
||||||
src/app/game-grid/game-grid.component.html
|
|
||||||
src/app/game-grid/game-grid.component.ts
|
# Cached libretro directory listings (regenerated on demand)
|
||||||
src/app/game-grid/game-grid.component.html
|
tools/library/.cache/
|
||||||
src/app/games.service.ts
|
|
||||||
|
|||||||
@@ -1,27 +1,423 @@
|
|||||||
# LudosData
|
# 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()} | ||||||