Rebuild on Angular 22 + ASP.NET Core 10, containerised
The 2018 stack (Angular 5.2 / CLI 1.7, PHP, MySQL) had not been touched since
July 2018. Rebuilt rather than upgraded in place: the frontend was 17 major
versions behind, and of ~16,700 lines of PHP only ~150 were application logic —
the rest was four near-identical vendored copies of php-crud-api plus
class.upload.php.
Backend — ASP.NET Core 10, EF Core, SQLite
* ASP.NET Core Identity (PBKDF2) + JWT bearer auth
* Clean REST API replacing php-crud-api's filter[]/transform query syntax
* Box art uploads re-encoded to WebP via SkiaSharp
* Imports the 105 games recovered from the 2018 dump on first run
Frontend — Angular 22, zoneless, signals, Material 22
* Standalone components, lazy routes, functional guards and interceptor
* Vitest replaces Karma/Jasmine; fonts and icons bundled, no CDN calls
* No provideAnimations: @angular/animations is deprecated in v22 and
Material no longer depends on it (pinned by a test)
Docker
* Multi-stage builds for both services, non-root at runtime
* nginx serves the SPA and reverse-proxies the API, so everything is
same-origin; one volume holds the database, uploads and DP keys
Security issues in the old code, not carried across:
* Two endpoints exposed unauthenticated CRUD over every table
* The client chose whose rows to read (filter[]=userId,eq,N); ownership now
comes from the JWT subject server-side
* Login was hardcoded to a single username
* crypt() with one global salt, silently truncating passwords to 8 chars
* JWT secret was the literal string "testing", tokens never expired
* Token travelled in the query string rather than a header
* Uploads were anonymous with the path built from the client filename
* Access-Control-Allow-Origin: *
The live MySQL password committed in 2018 remains in git history and must be
rotated independently of this change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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,33 @@
|
||||
# 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
|
||||
+29
-38
@@ -1,48 +1,39 @@
|
||||
# See http://help.github.com/ignore-files/ for more about ignoring files.
|
||||
# Secrets — never commit. The 2018 version of this project committed live
|
||||
# database credentials, which is why they now have to be treated as compromised.
|
||||
.env
|
||||
*.env
|
||||
!.env.example
|
||||
|
||||
# compiled output
|
||||
/dist
|
||||
/dist-server
|
||||
/tmp
|
||||
/out-tsc
|
||||
# Runtime state
|
||||
data/
|
||||
uploads/
|
||||
*.db
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
# --- Frontend ---
|
||||
node_modules/
|
||||
frontend/dist/
|
||||
frontend/.angular/
|
||||
npm-debug.log*
|
||||
yarn-error.log*
|
||||
testem.log
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
# --- Backend ---
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
*.user
|
||||
.vs/
|
||||
|
||||
# --- Editors / OS ---
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# IDE - VSCode
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
|
||||
# misc
|
||||
/.sass-cache
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
npm-debug.log
|
||||
testem.log
|
||||
/typings
|
||||
|
||||
# e2e
|
||||
/e2e/*.js
|
||||
/e2e/*.map
|
||||
|
||||
# System Files
|
||||
!.vscode/launch.json
|
||||
!.vscode/tasks.json
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
src/app/game-grid/game-grid.component.html
|
||||
src/app/game-grid/game-grid.component.html
|
||||
src/app/game-grid/game-grid.component.ts
|
||||
src/app/game-grid/game-grid.component.html
|
||||
src/app/games.service.ts
|
||||
*.swp
|
||||
|
||||
@@ -1,27 +1,173 @@
|
||||
# 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
|
||||
```
|
||||
|
||||
### 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,5 @@
|
||||
<Solution>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/LudosData.Api/LudosData.Api.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,86 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
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,
|
||||
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; }
|
||||
}
|
||||
|
||||
/// <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; }
|
||||
|
||||
[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, 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,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,179 @@
|
||||
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);
|
||||
|
||||
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),
|
||||
"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;
|
||||
}
|
||||
|
||||
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.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,136 @@
|
||||
using System.Text.Json;
|
||||
using LudosData.Api.Domain;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace LudosData.Api.Data;
|
||||
|
||||
public class SeedOptions
|
||||
{
|
||||
public const string SectionName = "Seed";
|
||||
|
||||
/// <summary>When false, migrations still run but no user or games are created.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
public string UserName { get; set; } = string.Empty;
|
||||
public string Email { get; set; } = string.Empty;
|
||||
public string Password { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Applies migrations and, on a genuinely empty database, creates the initial user
|
||||
/// and imports the 105 games recovered from the 2018 MySQL dump.
|
||||
///
|
||||
/// The dump predates the multi-user work, so it has no users table and no per-game
|
||||
/// owner: every imported game is assigned to the seed user.
|
||||
/// </summary>
|
||||
public static class DbSeeder
|
||||
{
|
||||
public static async Task MigrateAndSeedAsync(IServiceProvider services, CancellationToken ct = default)
|
||||
{
|
||||
using var scope = services.CreateScope();
|
||||
var sp = scope.ServiceProvider;
|
||||
|
||||
var logger = sp.GetRequiredService<ILoggerFactory>().CreateLogger("DbSeeder");
|
||||
var db = sp.GetRequiredService<LudosDbContext>();
|
||||
|
||||
await db.Database.MigrateAsync(ct);
|
||||
|
||||
var options = sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SeedOptions>>().Value;
|
||||
if (!options.Enabled)
|
||||
{
|
||||
logger.LogInformation("Seeding disabled; skipping.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (await db.Users.AnyAsync(ct))
|
||||
{
|
||||
logger.LogInformation("Database already has users; skipping seed.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.UserName) || string.IsNullOrWhiteSpace(options.Password))
|
||||
{
|
||||
logger.LogWarning(
|
||||
"Seeding is enabled but Seed:UserName / Seed:Password are not set, so no initial user was " +
|
||||
"created and the {Count} games from the 2018 dump were not imported. Set SEED__USERNAME, " +
|
||||
"SEED__EMAIL and SEED__PASSWORD and restart, or register a user and import manually.",
|
||||
await CountSeedGamesAsync(ct));
|
||||
return;
|
||||
}
|
||||
|
||||
var userManager = sp.GetRequiredService<UserManager<AppUser>>();
|
||||
|
||||
var user = new AppUser
|
||||
{
|
||||
UserName = options.UserName,
|
||||
Email = string.IsNullOrWhiteSpace(options.Email) ? $"{options.UserName}@localhost" : options.Email,
|
||||
EmailConfirmed = true,
|
||||
};
|
||||
|
||||
var created = await userManager.CreateAsync(user, options.Password);
|
||||
if (!created.Succeeded)
|
||||
{
|
||||
var errors = string.Join("; ", created.Errors.Select(e => e.Description));
|
||||
logger.LogError("Could not create the seed user: {Errors}", errors);
|
||||
return;
|
||||
}
|
||||
|
||||
var games = await LoadSeedGamesAsync(ct);
|
||||
foreach (var game in games)
|
||||
{
|
||||
game.OwnerId = user.Id;
|
||||
db.Games.Add(game);
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(ct);
|
||||
logger.LogInformation(
|
||||
"Seeded user {UserName} with {Count} games from the 2018 dump.", user.UserName, games.Count);
|
||||
}
|
||||
|
||||
private static async Task<List<Game>> LoadSeedGamesAsync(CancellationToken ct)
|
||||
{
|
||||
var path = Path.Combine(AppContext.BaseDirectory, "Data", "Seed", "games.json");
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
await using var stream = File.OpenRead(path);
|
||||
var records = await JsonSerializer.DeserializeAsync<List<SeedGame>>(
|
||||
stream, new JsonSerializerOptions(JsonSerializerDefaults.Web), ct) ?? [];
|
||||
|
||||
return records.Select(r => new Game
|
||||
{
|
||||
Title = r.Title,
|
||||
System = r.System,
|
||||
Genre = r.Genre,
|
||||
Year = r.Year,
|
||||
Developer = r.Developer,
|
||||
Publisher = r.Publisher,
|
||||
Art = r.Art,
|
||||
Description = r.Description,
|
||||
Own = r.Own,
|
||||
Dumped = r.Dumped,
|
||||
Played = r.Played,
|
||||
Finished = r.Finished,
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
private static async Task<int> CountSeedGamesAsync(CancellationToken ct) =>
|
||||
(await LoadSeedGamesAsync(ct)).Count;
|
||||
|
||||
private sealed record SeedGame(
|
||||
string Title,
|
||||
string? System,
|
||||
string? Genre,
|
||||
string? Year,
|
||||
string? Developer,
|
||||
string? Publisher,
|
||||
string? Art,
|
||||
string? Description,
|
||||
bool Own,
|
||||
bool Dumped,
|
||||
bool Played,
|
||||
bool Finished);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using LudosData.Api.Domain;
|
||||
using Microsoft.AspNetCore.Identity.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace LudosData.Api.Data;
|
||||
|
||||
public class LudosDbContext(DbContextOptions<LudosDbContext> options)
|
||||
: IdentityDbContext<AppUser>(options)
|
||||
{
|
||||
public DbSet<Game> Games => Set<Game>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
|
||||
builder.Entity<Game>(game =>
|
||||
{
|
||||
game.HasOne(g => g.Owner)
|
||||
.WithMany(u => u.Games)
|
||||
.HasForeignKey(g => g.OwnerId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
// Every list query filters by owner first, then narrows or sorts on
|
||||
// these columns, so they lead the composite indexes.
|
||||
game.HasIndex(g => new { g.OwnerId, g.Title });
|
||||
game.HasIndex(g => new { g.OwnerId, g.System });
|
||||
game.HasIndex(g => new { g.OwnerId, g.Genre });
|
||||
});
|
||||
}
|
||||
|
||||
public override int SaveChanges()
|
||||
{
|
||||
StampTimestamps();
|
||||
return base.SaveChanges();
|
||||
}
|
||||
|
||||
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
StampTimestamps();
|
||||
return base.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private void StampTimestamps()
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
|
||||
foreach (var entry in ChangeTracker.Entries<Game>())
|
||||
{
|
||||
if (entry.State == EntityState.Added)
|
||||
{
|
||||
entry.Entity.CreatedAt = now;
|
||||
entry.Entity.UpdatedAt = now;
|
||||
}
|
||||
else if (entry.State == EntityState.Modified)
|
||||
{
|
||||
entry.Entity.UpdatedAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+367
@@ -0,0 +1,367 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using LudosData.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LudosData.Api.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(LudosDbContext))]
|
||||
[Migration("20260803220157_InitialCreate")]
|
||||
partial class InitialCreate
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
|
||||
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Art")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Art")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Developer")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Dumped")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Finished")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Own")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("OwnerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Played")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Publisher")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("System")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Year")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerId", "Genre");
|
||||
|
||||
b.HasIndex("OwnerId", "System");
|
||||
|
||||
b.HasIndex("OwnerId", "Title");
|
||||
|
||||
b.ToTable("Games");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
|
||||
{
|
||||
b.HasOne("LudosData.Api.Domain.AppUser", "Owner")
|
||||
.WithMany("Games")
|
||||
.HasForeignKey("OwnerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("LudosData.Api.Domain.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("LudosData.Api.Domain.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("LudosData.Api.Domain.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("LudosData.Api.Domain.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
|
||||
{
|
||||
b.Navigation("Games");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LudosData.Api.Data.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InitialCreate : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetRoles",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||
NormalizedName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetRoles", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUsers",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<string>(type: "TEXT", nullable: false),
|
||||
FirstName = table.Column<string>(type: "TEXT", nullable: true),
|
||||
LastName = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Art = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
UserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||
NormalizedUserName = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||
Email = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||
NormalizedEmail = table.Column<string>(type: "TEXT", maxLength: 256, nullable: true),
|
||||
EmailConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
PasswordHash = table.Column<string>(type: "TEXT", nullable: true),
|
||||
SecurityStamp = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ConcurrencyStamp = table.Column<string>(type: "TEXT", nullable: true),
|
||||
PhoneNumber = table.Column<string>(type: "TEXT", nullable: true),
|
||||
PhoneNumberConfirmed = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
TwoFactorEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
LockoutEnd = table.Column<DateTimeOffset>(type: "TEXT", nullable: true),
|
||||
LockoutEnabled = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
AccessFailedCount = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUsers", x => x.Id);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetRoleClaims",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
RoleId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetRoleClaims_AspNetRoles_RoleId",
|
||||
column: x => x.RoleId,
|
||||
principalTable: "AspNetRoles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserClaims",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
UserId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
ClaimType = table.Column<string>(type: "TEXT", nullable: true),
|
||||
ClaimValue = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUserClaims", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserClaims_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserLogins",
|
||||
columns: table => new
|
||||
{
|
||||
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
|
||||
ProviderKey = table.Column<string>(type: "TEXT", nullable: false),
|
||||
ProviderDisplayName = table.Column<string>(type: "TEXT", nullable: true),
|
||||
UserId = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey });
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserLogins_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserRoles",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
RoleId = table.Column<string>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId });
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserRoles_AspNetRoles_RoleId",
|
||||
column: x => x.RoleId,
|
||||
principalTable: "AspNetRoles",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserRoles_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AspNetUserTokens",
|
||||
columns: table => new
|
||||
{
|
||||
UserId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
LoginProvider = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
Value = table.Column<string>(type: "TEXT", nullable: true)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name });
|
||||
table.ForeignKey(
|
||||
name: "FK_AspNetUserTokens_AspNetUsers_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Games",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<int>(type: "INTEGER", nullable: false)
|
||||
.Annotation("Sqlite:Autoincrement", true),
|
||||
Title = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
|
||||
System = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true),
|
||||
Genre = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true),
|
||||
Year = table.Column<string>(type: "TEXT", maxLength: 50, nullable: true),
|
||||
Developer = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
|
||||
Publisher = table.Column<string>(type: "TEXT", maxLength: 100, nullable: true),
|
||||
Art = table.Column<string>(type: "TEXT", maxLength: 200, nullable: true),
|
||||
Description = table.Column<string>(type: "TEXT", nullable: true),
|
||||
Own = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Dumped = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Played = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
Finished = table.Column<bool>(type: "INTEGER", nullable: false),
|
||||
OwnerId = table.Column<string>(type: "TEXT", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Games", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Games_AspNetUsers_OwnerId",
|
||||
column: x => x.OwnerId,
|
||||
principalTable: "AspNetUsers",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetRoleClaims_RoleId",
|
||||
table: "AspNetRoleClaims",
|
||||
column: "RoleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "RoleNameIndex",
|
||||
table: "AspNetRoles",
|
||||
column: "NormalizedName",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUserClaims_UserId",
|
||||
table: "AspNetUserClaims",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUserLogins_UserId",
|
||||
table: "AspNetUserLogins",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AspNetUserRoles_RoleId",
|
||||
table: "AspNetUserRoles",
|
||||
column: "RoleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "EmailIndex",
|
||||
table: "AspNetUsers",
|
||||
column: "NormalizedEmail");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "UserNameIndex",
|
||||
table: "AspNetUsers",
|
||||
column: "NormalizedUserName",
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Games_OwnerId_Genre",
|
||||
table: "Games",
|
||||
columns: new[] { "OwnerId", "Genre" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Games_OwnerId_System",
|
||||
table: "Games",
|
||||
columns: new[] { "OwnerId", "System" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Games_OwnerId_Title",
|
||||
table: "Games",
|
||||
columns: new[] { "OwnerId", "Title" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetRoleClaims");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserClaims");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserLogins");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserRoles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUserTokens");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Games");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetRoles");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "AspNetUsers");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using LudosData.Api.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace LudosData.Api.Data.Migrations
|
||||
{
|
||||
[DbContext(typeof(LudosDbContext))]
|
||||
partial class LudosDbContextModelSnapshot : ModelSnapshot
|
||||
{
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
|
||||
|
||||
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("AccessFailedCount")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Art")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("EmailConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("FirstName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LastName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("LockoutEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTimeOffset?>("LockoutEnd")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedEmail")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedUserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("PhoneNumberConfirmed")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("SecurityStamp")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("TwoFactorEnabled")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("UserName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedEmail")
|
||||
.HasDatabaseName("EmailIndex");
|
||||
|
||||
b.HasIndex("NormalizedUserName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("UserNameIndex");
|
||||
|
||||
b.ToTable("AspNetUsers", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Art")
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Developer")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Dumped")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("Finished")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Genre")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Own")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("OwnerId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Played")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Publisher")
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("System")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Year")
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("OwnerId", "Genre");
|
||||
|
||||
b.HasIndex("OwnerId", "System");
|
||||
|
||||
b.HasIndex("OwnerId", "Title");
|
||||
|
||||
b.ToTable("Games");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ConcurrencyStamp")
|
||||
.IsConcurrencyToken()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("NormalizedName")
|
||||
.HasMaxLength(256)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("NormalizedName")
|
||||
.IsUnique()
|
||||
.HasDatabaseName("RoleNameIndex");
|
||||
|
||||
b.ToTable("AspNetRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetRoleClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.Property<int>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("ClaimType")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ClaimValue")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserClaims", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderKey")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("ProviderDisplayName")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("LoginProvider", "ProviderKey");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("AspNetUserLogins", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("RoleId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "RoleId");
|
||||
|
||||
b.HasIndex("RoleId");
|
||||
|
||||
b.ToTable("AspNetUserRoles", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.Property<string>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("LoginProvider")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Value")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("UserId", "LoginProvider", "Name");
|
||||
|
||||
b.ToTable("AspNetUserTokens", (string)null);
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LudosData.Api.Domain.Game", b =>
|
||||
{
|
||||
b.HasOne("LudosData.Api.Domain.AppUser", "Owner")
|
||||
.WithMany("Games")
|
||||
.HasForeignKey("OwnerId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Owner");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b =>
|
||||
{
|
||||
b.HasOne("LudosData.Api.Domain.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b =>
|
||||
{
|
||||
b.HasOne("LudosData.Api.Domain.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b =>
|
||||
{
|
||||
b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("RoleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("LudosData.Api.Domain.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b =>
|
||||
{
|
||||
b.HasOne("LudosData.Api.Domain.AppUser", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("LudosData.Api.Domain.AppUser", b =>
|
||||
{
|
||||
b.Navigation("Games");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,20 @@
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
|
||||
namespace LudosData.Api.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// Application user. Extends IdentityUser, which supplies Id, UserName, Email,
|
||||
/// PasswordHash (PBKDF2 with a per-user salt), lockout and security stamp.
|
||||
/// </summary>
|
||||
public class AppUser : IdentityUser
|
||||
{
|
||||
public string? FirstName { get; set; }
|
||||
public string? LastName { get; set; }
|
||||
|
||||
/// <summary>Filename of the user's avatar, relative to their upload folder.</summary>
|
||||
public string? Art { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
|
||||
public ICollection<Game> Games { get; set; } = new List<Game>();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
|
||||
namespace LudosData.Api.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// A single entry in a user's game library. Mirrors the columns of the original
|
||||
/// MySQL `games` table so the 2018 dump imports without transformation, with the
|
||||
/// addition of ownership and audit fields.
|
||||
/// </summary>
|
||||
public class Game
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
[Required]
|
||||
[MaxLength(200)]
|
||||
public string Title { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Console/platform the game runs on, e.g. "SNES", "PS2".</summary>
|
||||
[MaxLength(50)]
|
||||
public string? System { get; set; }
|
||||
|
||||
[MaxLength(50)]
|
||||
public string? Genre { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Release year. Kept as a string rather than an int: the original column was
|
||||
/// varchar(50) and holds values like "" and "1996" — some entries were never
|
||||
/// filled in, and a few real-world cases want ranges.
|
||||
/// </summary>
|
||||
[MaxLength(50)]
|
||||
public string? Year { get; set; }
|
||||
|
||||
[MaxLength(100)]
|
||||
public string? Developer { get; set; }
|
||||
|
||||
[MaxLength(100)]
|
||||
public string? Publisher { get; set; }
|
||||
|
||||
/// <summary>Filename of the uploaded box art, relative to the owner's upload folder.</summary>
|
||||
[MaxLength(200)]
|
||||
public string? Art { get; set; }
|
||||
|
||||
public string? Description { get; set; }
|
||||
|
||||
public bool Own { get; set; }
|
||||
public bool Dumped { get; set; }
|
||||
public bool Played { get; set; }
|
||||
public bool Finished { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Owning user. Every query is filtered on this server-side, from the JWT subject —
|
||||
/// it is never accepted from the client.
|
||||
/// </summary>
|
||||
[Required]
|
||||
public string OwnerId { get; set; } = string.Empty;
|
||||
public AppUser? Owner { get; set; }
|
||||
|
||||
public DateTimeOffset CreatedAt { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
|
||||
<PackageReference Include="SkiaSharp" Version="4.151.0" />
|
||||
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="4.151.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- Transitive pins that lift two high-severity advisories out of the graph.
|
||||
Both stay within the same major version the framework packages expect.
|
||||
GHSA-v5pm-xwqc-g5wc: Microsoft.AspNetCore.OpenApi 10.0.10 pulls
|
||||
Microsoft.OpenApi 2.0.0; the fix landed in 2.7.5.
|
||||
GHSA-2m69-gcr7-jv3q: EF Core's SQLite provider pulls lib.e_sqlite3
|
||||
2.1.11, which bundles a vulnerable SQLite; 2.1.12 is outside the range. -->
|
||||
<PackageReference Include="Microsoft.OpenApi" Version="2.11.0" />
|
||||
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- The 105 games recovered from the 2018 MySQL dump, read by DbSeeder at startup.
|
||||
Update, not Include: the SDK already globs JSON files in as Content. -->
|
||||
<Content Update="Data\Seed\games.json" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,168 @@
|
||||
using System.Text;
|
||||
using LudosData.Api.Auth;
|
||||
using LudosData.Api.Data;
|
||||
using LudosData.Api.Domain;
|
||||
using LudosData.Api.Services;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Options
|
||||
// ---------------------------------------------------------------------------
|
||||
builder.Services.AddOptions<JwtOptions>()
|
||||
.Bind(builder.Configuration.GetSection(JwtOptions.SectionName))
|
||||
.ValidateDataAnnotations()
|
||||
// Validating on start means a deployment with a missing or too-short signing
|
||||
// key fails immediately and loudly, instead of issuing weak tokens.
|
||||
.ValidateOnStart();
|
||||
|
||||
builder.Services.Configure<ImageStorageOptions>(
|
||||
builder.Configuration.GetSection(ImageStorageOptions.SectionName));
|
||||
builder.Services.Configure<SeedOptions>(
|
||||
builder.Configuration.GetSection(SeedOptions.SectionName));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data
|
||||
// ---------------------------------------------------------------------------
|
||||
var connectionString = builder.Configuration.GetConnectionString("Default")
|
||||
?? "Data Source=data/ludos.db";
|
||||
|
||||
// Make sure the SQLite file's directory exists before EF tries to open it.
|
||||
var dataSource = new Microsoft.Data.Sqlite.SqliteConnectionStringBuilder(connectionString).DataSource;
|
||||
var dataDirectory = Path.GetDirectoryName(Path.GetFullPath(dataSource));
|
||||
if (!string.IsNullOrEmpty(dataDirectory))
|
||||
{
|
||||
Directory.CreateDirectory(dataDirectory);
|
||||
}
|
||||
|
||||
builder.Services.AddDbContext<LudosDbContext>(options => options.UseSqlite(connectionString));
|
||||
|
||||
// Keep Data Protection keys on the same persistent volume as the database.
|
||||
// Without this they live in the container filesystem and are regenerated on
|
||||
// every restart, which silently invalidates Identity-issued tokens such as
|
||||
// password-reset and email-confirmation links.
|
||||
var keysDirectory = builder.Configuration["DataProtection:KeysPath"]
|
||||
?? Path.Combine(dataDirectory ?? ".", "keys");
|
||||
Directory.CreateDirectory(keysDirectory);
|
||||
|
||||
builder.Services
|
||||
.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(keysDirectory))
|
||||
.SetApplicationName("LudosData");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity + JWT
|
||||
// ---------------------------------------------------------------------------
|
||||
builder.Services
|
||||
.AddIdentityCore<AppUser>(options =>
|
||||
{
|
||||
options.User.RequireUniqueEmail = true;
|
||||
|
||||
options.Password.RequiredLength = 12;
|
||||
options.Password.RequireDigit = true;
|
||||
options.Password.RequireLowercase = true;
|
||||
options.Password.RequireUppercase = true;
|
||||
options.Password.RequireNonAlphanumeric = false;
|
||||
|
||||
options.Lockout.MaxFailedAccessAttempts = 10;
|
||||
options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
|
||||
})
|
||||
.AddSignInManager()
|
||||
.AddEntityFrameworkStores<LudosDbContext>();
|
||||
|
||||
var jwtSection = builder.Configuration.GetSection(JwtOptions.SectionName);
|
||||
var signingKey = jwtSection["Key"] ?? string.Empty;
|
||||
|
||||
builder.Services
|
||||
.AddAuthentication(options =>
|
||||
{
|
||||
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
|
||||
})
|
||||
.AddJwtBearer(options =>
|
||||
{
|
||||
options.TokenValidationParameters = new TokenValidationParameters
|
||||
{
|
||||
ValidateIssuer = true,
|
||||
ValidateAudience = true,
|
||||
ValidateLifetime = true,
|
||||
ValidateIssuerSigningKey = true,
|
||||
ValidIssuer = jwtSection["Issuer"] ?? "LudosData",
|
||||
ValidAudience = jwtSection["Audience"] ?? "LudosData",
|
||||
// A real key is required by JwtOptions validation on start; this
|
||||
// placeholder only exists so DI can build before that check runs.
|
||||
IssuerSigningKey = new SymmetricSecurityKey(
|
||||
Encoding.UTF8.GetBytes(signingKey.Length >= 32 ? signingKey : new string('0', 32))),
|
||||
ClockSkew = TimeSpan.FromMinutes(1),
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Application services
|
||||
// ---------------------------------------------------------------------------
|
||||
builder.Services.AddScoped<ITokenService, TokenService>();
|
||||
builder.Services.AddSingleton<IImageStorage, ImageStorage>();
|
||||
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddProblemDetails();
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
const string SpaCorsPolicy = "spa";
|
||||
var allowedOrigins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>()
|
||||
?? ["http://localhost:4200"];
|
||||
|
||||
builder.Services.AddCors(options => options.AddPolicy(SpaCorsPolicy, policy => policy
|
||||
// Explicit origins, not AllowAnyOrigin. The old API sent
|
||||
// `Access-Control-Allow-Origin: *` on every response.
|
||||
.WithOrigins(allowedOrigins)
|
||||
.AllowAnyHeader()
|
||||
.AllowAnyMethod()));
|
||||
|
||||
builder.Services.AddHealthChecks();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pipeline
|
||||
// ---------------------------------------------------------------------------
|
||||
app.UseExceptionHandler();
|
||||
app.UseStatusCodePages();
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapOpenApi();
|
||||
}
|
||||
|
||||
app.UseCors(SpaCorsPolicy);
|
||||
|
||||
// Serve uploaded box art from the configured folder (a Docker volume in
|
||||
// production) rather than from wwwroot, so user content and app files stay apart.
|
||||
var uploadOptions = app.Services.GetRequiredService<IOptions<ImageStorageOptions>>().Value;
|
||||
var uploadRoot = Path.GetFullPath(uploadOptions.RootPath);
|
||||
Directory.CreateDirectory(uploadRoot);
|
||||
|
||||
app.UseStaticFiles(new StaticFileOptions
|
||||
{
|
||||
FileProvider = new PhysicalFileProvider(uploadRoot),
|
||||
RequestPath = uploadOptions.RequestPath,
|
||||
ServeUnknownFileTypes = false,
|
||||
});
|
||||
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
|
||||
app.MapControllers();
|
||||
app.MapHealthChecks("/health").AllowAnonymous();
|
||||
|
||||
await DbSeeder.MigrateAndSeedAsync(app.Services);
|
||||
|
||||
app.Run();
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "http://localhost:5044",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": false,
|
||||
"applicationUrl": "https://localhost:7008;http://localhost:5044",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using SkiaSharp;
|
||||
|
||||
namespace LudosData.Api.Services;
|
||||
|
||||
public interface IImageStorage
|
||||
{
|
||||
Task<string> SaveAsync(Stream source, string ownerId, CancellationToken ct = default);
|
||||
string? BuildUrl(string ownerId, string? fileName);
|
||||
}
|
||||
|
||||
public class ImageStorageOptions
|
||||
{
|
||||
public const string SectionName = "Uploads";
|
||||
|
||||
/// <summary>Filesystem root for uploads. In Docker this is a mounted volume.</summary>
|
||||
public string RootPath { get; set; } = "uploads";
|
||||
|
||||
/// <summary>Public URL prefix these files are served under.</summary>
|
||||
public string RequestPath { get; set; } = "/uploads";
|
||||
|
||||
/// <summary>Max accepted upload size. Enforced again at the endpoint.</summary>
|
||||
public long MaxBytes { get; set; } = 5 * 1024 * 1024;
|
||||
|
||||
/// <summary>Stored images are downscaled to at most this width, preserving aspect.</summary>
|
||||
public int MaxWidth { get; set; } = 500;
|
||||
|
||||
/// <summary>WebP quality, 1-100.</summary>
|
||||
public int Quality { get; set; } = 82;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stores box art on disk as WebP, re-encoded from whatever was uploaded.
|
||||
///
|
||||
/// Unlike the PHP version this replaces, the uploaded filename is never used to
|
||||
/// build the destination path — the name is generated server-side and the
|
||||
/// extension is fixed, so a crafted filename cannot traverse directories or land
|
||||
/// an executable in a served folder. Decoding the bytes and re-encoding them also
|
||||
/// means only pixel data survives: any payload smuggled in metadata is dropped.
|
||||
/// </summary>
|
||||
public class ImageStorage(
|
||||
IOptions<ImageStorageOptions> options,
|
||||
ILogger<ImageStorage> logger) : IImageStorage
|
||||
{
|
||||
private readonly ImageStorageOptions _options = options.Value;
|
||||
|
||||
public async Task<string> SaveAsync(Stream source, string ownerId, CancellationToken ct = default)
|
||||
{
|
||||
// Buffer first: SKBitmap.Decode wants a seekable stream, and the caller's
|
||||
// request stream is not. The endpoint has already bounded the length.
|
||||
using var buffer = new MemoryStream();
|
||||
await source.CopyToAsync(buffer, ct);
|
||||
buffer.Position = 0;
|
||||
|
||||
// Decoding is the real content check — anything Skia cannot parse as an
|
||||
// image returns null here, before a byte is persisted.
|
||||
using var decoded = SKBitmap.Decode(buffer)
|
||||
?? throw new InvalidDataException("The uploaded bytes are not a decodable image.");
|
||||
|
||||
using var final = Downscale(decoded);
|
||||
|
||||
var directory = DirectoryFor(ownerId);
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
var fileName = $"{Guid.NewGuid():N}.webp";
|
||||
var fullPath = Path.Combine(directory, fileName);
|
||||
|
||||
using (var image = SKImage.FromBitmap(final))
|
||||
using (var data = image.Encode(SKEncodedImageFormat.Webp, _options.Quality))
|
||||
{
|
||||
if (data is null)
|
||||
{
|
||||
throw new InvalidDataException("The image could not be encoded as WebP.");
|
||||
}
|
||||
|
||||
await using var output = File.Create(fullPath);
|
||||
data.SaveTo(output);
|
||||
}
|
||||
|
||||
logger.LogInformation("Stored upload {FileName} for user {OwnerId}", fileName, ownerId);
|
||||
return fileName;
|
||||
}
|
||||
|
||||
public string? BuildUrl(string ownerId, string? fileName) =>
|
||||
string.IsNullOrWhiteSpace(fileName)
|
||||
? null
|
||||
: $"{_options.RequestPath}/{ownerId}/{fileName}";
|
||||
|
||||
private SKBitmap Downscale(SKBitmap source)
|
||||
{
|
||||
if (source.Width <= _options.MaxWidth)
|
||||
{
|
||||
return source.Copy();
|
||||
}
|
||||
|
||||
var height = (int)Math.Round(source.Height * (_options.MaxWidth / (double)source.Width));
|
||||
var info = new SKImageInfo(_options.MaxWidth, Math.Max(1, height));
|
||||
|
||||
return source.Resize(info, new SKSamplingOptions(SKFilterMode.Linear, SKMipmapMode.Linear))
|
||||
?? throw new InvalidDataException("The image could not be resized.");
|
||||
}
|
||||
|
||||
private string DirectoryFor(string ownerId)
|
||||
{
|
||||
// ownerId is an Identity-generated GUID string, but this is defence in
|
||||
// depth: only the bare filename component is ever joined onto the root.
|
||||
var safeOwner = Path.GetFileName(ownerId);
|
||||
if (string.IsNullOrWhiteSpace(safeOwner))
|
||||
{
|
||||
throw new ArgumentException("Invalid owner id.", nameof(ownerId));
|
||||
}
|
||||
|
||||
return Path.Combine(Path.GetFullPath(_options.RootPath), safeOwner);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"ConnectionStrings": {
|
||||
"Default": "Data Source=data/ludos.db"
|
||||
},
|
||||
"Jwt": {
|
||||
"Issuer": "LudosData",
|
||||
"Audience": "LudosData",
|
||||
"LifetimeMinutes": 720
|
||||
},
|
||||
"Uploads": {
|
||||
"RootPath": "uploads",
|
||||
"RequestPath": "/uploads",
|
||||
"MaxBytes": 5242880,
|
||||
"MaxWidth": 500
|
||||
},
|
||||
"Cors": {
|
||||
"AllowedOrigins": [ "http://localhost:4200", "http://localhost:8080" ]
|
||||
},
|
||||
"Seed": {
|
||||
"Enabled": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
# LudosData — full stack.
|
||||
#
|
||||
# cp .env.example .env # then edit the secrets
|
||||
# docker compose up --build
|
||||
#
|
||||
# The SPA is served by nginx on http://localhost:8080, which also reverse-proxies
|
||||
# /api and /uploads to the API container. Because everything is same-origin in
|
||||
# this setup, the browser never issues a cross-origin request and CORS is not in
|
||||
# play at all — the API's CORS policy only matters for `ng serve` on :4200.
|
||||
|
||||
services:
|
||||
api:
|
||||
build:
|
||||
context: ./backend
|
||||
image: ludosdata-api
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: Production
|
||||
|
||||
# Required. Startup fails loudly if this is missing or under 32 chars.
|
||||
Jwt__Key: ${JWT_KEY:?JWT_KEY is required — see .env.example}
|
||||
Jwt__Issuer: ${JWT_ISSUER:-LudosData}
|
||||
Jwt__Audience: ${JWT_AUDIENCE:-LudosData}
|
||||
Jwt__LifetimeMinutes: ${JWT_LIFETIME_MINUTES:-720}
|
||||
|
||||
# Creates the first account and imports the 105 games from the 2018 dump,
|
||||
# but only while the database has no users at all.
|
||||
Seed__Enabled: ${SEED_ENABLED:-true}
|
||||
Seed__UserName: ${SEED_USERNAME:-}
|
||||
Seed__Email: ${SEED_EMAIL:-}
|
||||
Seed__Password: ${SEED_PASSWORD:-}
|
||||
|
||||
# Only consulted when the SPA is served from somewhere other than nginx.
|
||||
Cors__AllowedOrigins__0: ${CORS_ORIGIN:-http://localhost:8080}
|
||||
Cors__AllowedOrigins__1: http://localhost:4200
|
||||
volumes:
|
||||
# SQLite file and uploaded box art. This is the only stateful thing in the
|
||||
# stack — back this volume up and you have backed up everything.
|
||||
- ludos-data:/data
|
||||
expose:
|
||||
- "8080"
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:8080/health || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 20s
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ./frontend
|
||||
image: ludosdata-web
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
api:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${WEB_PORT:-8080}:8080"
|
||||
healthcheck:
|
||||
# 127.0.0.1, not localhost: nginx listens on IPv4 only, and BusyBox wget
|
||||
# resolves localhost to ::1 first and gets connection-refused.
|
||||
test: ["CMD-SHELL", "wget -q --spider http://127.0.0.1:8080/ || exit 1"]
|
||||
interval: 15s
|
||||
timeout: 3s
|
||||
retries: 5
|
||||
start_period: 10s
|
||||
|
||||
volumes:
|
||||
ludos-data:
|
||||
@@ -1,14 +0,0 @@
|
||||
import { AppPage } from './app.po';
|
||||
|
||||
describe('ludos-data App', () => {
|
||||
let page: AppPage;
|
||||
|
||||
beforeEach(() => {
|
||||
page = new AppPage();
|
||||
});
|
||||
|
||||
it('should display welcome message', () => {
|
||||
page.navigateTo();
|
||||
expect(page.getParagraphText()).toEqual('Welcome to app!');
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
import { browser, by, element } from 'protractor';
|
||||
|
||||
export class AppPage {
|
||||
navigateTo() {
|
||||
return browser.get('/');
|
||||
}
|
||||
|
||||
getParagraphText() {
|
||||
return element(by.css('app-root h1')).getText();
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "../out-tsc/e2e",
|
||||
"baseUrl": "./",
|
||||
"module": "commonjs",
|
||||
"target": "es5",
|
||||
"types": [
|
||||
"jasmine",
|
||||
"jasminewd2",
|
||||
"node"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.angular/
|
||||
.vscode/
|
||||
*.log
|
||||
@@ -0,0 +1,17 @@
|
||||
# Editor configuration, see https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.ts]
|
||||
quote_type = single
|
||||
ij_typescript_use_double_quotes = false
|
||||
|
||||
[*.md]
|
||||
max_line_length = off
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,44 @@
|
||||
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
|
||||
|
||||
# Compiled output
|
||||
/dist
|
||||
/tmp
|
||||
/out-tsc
|
||||
/bazel-out
|
||||
|
||||
# Node
|
||||
/node_modules
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
|
||||
# IDEs and editors
|
||||
.idea/
|
||||
.project
|
||||
.classpath
|
||||
.c9/
|
||||
*.launch
|
||||
.settings/
|
||||
*.sublime-workspace
|
||||
|
||||
# Visual Studio Code
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/mcp.json
|
||||
.history/*
|
||||
|
||||
# Miscellaneous
|
||||
/.angular/cache
|
||||
.sass-cache/
|
||||
/connect.lock
|
||||
/coverage
|
||||
/libpeerconnection.log
|
||||
testem.log
|
||||
/typings
|
||||
__screenshots__/
|
||||
|
||||
# System files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.html",
|
||||
"options": {
|
||||
"parser": "angular"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+4
@@ -0,0 +1,4 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
|
||||
"recommendations": ["angular.ng-template"]
|
||||
}
|
||||
Vendored
+20
@@ -0,0 +1,20 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "ng serve",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"url": "http://localhost:4200/"
|
||||
},
|
||||
{
|
||||
"name": "ng test",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: test",
|
||||
"url": "http://localhost:9876/debug.html"
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+42
@@ -0,0 +1,42 @@
|
||||
{
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "test",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "typescript",
|
||||
"pattern": "$tsc",
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": {
|
||||
"regexp": "Changes detected"
|
||||
},
|
||||
"endsPattern": {
|
||||
"regexp": "bundle generation (complete|failed)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---- build ----------------------------------------------------------------
|
||||
# Pinned to the same Node major the project declares in package.json engines.
|
||||
FROM node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
|
||||
# npm ci against the lockfile alone, so this layer caches across source edits.
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run build:prod
|
||||
|
||||
# ---- runtime --------------------------------------------------------------
|
||||
# Unprivileged nginx: listens on 8080 and runs as a non-root user out of the box.
|
||||
FROM nginxinc/nginx-unprivileged:alpine AS runtime
|
||||
|
||||
COPY --chown=nginx:nginx nginx.conf /etc/nginx/conf.d/default.conf
|
||||
COPY --chown=nginx:nginx security-headers.conf /etc/nginx/snippets/security-headers.conf
|
||||
COPY --from=build --chown=nginx:nginx /app/dist/ludos-web/browser /usr/share/nginx/html
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD ["wget", "-q", "--spider", "http://127.0.0.1:8080/"]
|
||||
@@ -0,0 +1,59 @@
|
||||
# LudosWeb
|
||||
|
||||
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 22.1.2.
|
||||
|
||||
## Development server
|
||||
|
||||
To start a local development server, run:
|
||||
|
||||
```bash
|
||||
ng serve
|
||||
```
|
||||
|
||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
|
||||
```bash
|
||||
ng generate component component-name
|
||||
```
|
||||
|
||||
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
|
||||
|
||||
```bash
|
||||
ng generate --help
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To build the project run:
|
||||
|
||||
```bash
|
||||
ng build
|
||||
```
|
||||
|
||||
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
|
||||
|
||||
## Running unit tests
|
||||
|
||||
To execute unit tests with the [Vitest](https://vitest.dev/) test runner, use the following command:
|
||||
|
||||
```bash
|
||||
ng test
|
||||
```
|
||||
|
||||
## Running end-to-end tests
|
||||
|
||||
For end-to-end (e2e) testing, run:
|
||||
|
||||
```bash
|
||||
ng e2e
|
||||
```
|
||||
|
||||
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.
|
||||
@@ -0,0 +1,85 @@
|
||||
{
|
||||
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
|
||||
"version": 1,
|
||||
"cli": {
|
||||
"packageManager": "npm"
|
||||
},
|
||||
"newProjectRoot": "projects",
|
||||
"projects": {
|
||||
"ludos-web": {
|
||||
"projectType": "application",
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"style": "scss"
|
||||
}
|
||||
},
|
||||
"root": "",
|
||||
"sourceRoot": "src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"browser": "src/main.ts",
|
||||
"tsConfig": "tsconfig.app.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "public"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"@fontsource/roboto/400.css",
|
||||
"@fontsource/roboto/500.css",
|
||||
"@fontsource/roboto/700.css",
|
||||
"material-icons/iconfont/filled.css",
|
||||
"src/styles.scss"
|
||||
]
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "500kB",
|
||||
"maximumError": "1MB"
|
||||
},
|
||||
{
|
||||
"type": "anyComponentStyle",
|
||||
"maximumWarning": "4kB",
|
||||
"maximumError": "8kB"
|
||||
}
|
||||
],
|
||||
"outputHashing": "all"
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "ludos-web:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "ludos-web:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development",
|
||||
"options": {
|
||||
"proxyConfig": "proxy.conf.json"
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular/build:unit-test"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
# Serves the built Angular bundle and reverse-proxies the API, so the browser
|
||||
# sees a single origin and never makes a cross-origin request.
|
||||
|
||||
server {
|
||||
listen 8080;
|
||||
server_name _;
|
||||
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Client uploads are capped server-side too; this stops oversized bodies
|
||||
# from being buffered all the way to the API first.
|
||||
client_max_body_size 6m;
|
||||
|
||||
gzip on;
|
||||
gzip_types text/css application/javascript application/json image/svg+xml;
|
||||
gzip_min_length 1024;
|
||||
|
||||
# Do not advertise the exact nginx version.
|
||||
server_tokens off;
|
||||
|
||||
include /etc/nginx/snippets/security-headers.conf;
|
||||
|
||||
# Hashed build assets are immutable, so they can be cached hard.
|
||||
location ~* \.(?:js|css|woff2?|ttf|eot|svg|png|jpg|jpeg|gif|webp|ico)$ {
|
||||
include /etc/nginx/snippets/security-headers.conf;
|
||||
# add_header alone, not `expires`: using both emits two Cache-Control
|
||||
# headers with overlapping directives.
|
||||
add_header Cache-Control "public, max-age=31536000, immutable" always;
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
location /api/ {
|
||||
proxy_pass http://api:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# Uploaded box art, served straight off the API's volume.
|
||||
#
|
||||
# `^~` matters: without it, a regex location wins over a prefix location, so
|
||||
# /uploads/<id>/<name>.webp would fall into the static-asset block above and
|
||||
# 404 against nginx's own filesystem instead of being proxied.
|
||||
location ^~ /uploads/ {
|
||||
include /etc/nginx/snippets/security-headers.conf;
|
||||
proxy_pass http://api:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
add_header Cache-Control "public, max-age=2592000" always;
|
||||
}
|
||||
|
||||
location /health {
|
||||
proxy_pass http://api:8080/health;
|
||||
}
|
||||
|
||||
# index.html must never be cached, or clients keep booting old bundles.
|
||||
# Declared before `location /` so the internal rewrite below lands here.
|
||||
location = /index.html {
|
||||
include /etc/nginx/snippets/security-headers.conf;
|
||||
add_header Cache-Control "no-store, no-cache, must-revalidate" always;
|
||||
}
|
||||
|
||||
# Angular owns routing: any unknown path returns index.html so a deep link
|
||||
# or a refresh on /games/12 does not 404.
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
Generated
+7953
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "ludos-web",
|
||||
"version": "0.0.0",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test",
|
||||
"build:prod": "ng build --configuration production",
|
||||
"lint": "ng lint",
|
||||
"format": "prettier --write \"src/**/*.{ts,html,scss}\""
|
||||
},
|
||||
"private": true,
|
||||
"packageManager": "npm@11.16.0",
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^22.1.0",
|
||||
"@angular/common": "^22.1.0",
|
||||
"@angular/compiler": "^22.1.0",
|
||||
"@angular/core": "^22.1.0",
|
||||
"@angular/forms": "^22.1.0",
|
||||
"@angular/material": "^22.1.0",
|
||||
"@angular/platform-browser": "^22.1.0",
|
||||
"@angular/router": "^22.1.0",
|
||||
"@fontsource/roboto": "^5.3.0",
|
||||
"material-icons": "^1.13.14",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^22.1.2",
|
||||
"@angular/cli": "^22.1.2",
|
||||
"@angular/compiler-cli": "^22.1.0",
|
||||
"jsdom": "^28.0.0",
|
||||
"prettier": "^3.8.1",
|
||||
"typescript": "~6.0.2",
|
||||
"vitest": "^4.0.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=24 <25"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"/api": {
|
||||
"target": "http://localhost:5099",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
},
|
||||
"/uploads": {
|
||||
"target": "http://localhost:5099",
|
||||
"secure": false,
|
||||
"changeOrigin": true
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,15 @@
|
||||
# Included into every location that sets its own add_header.
|
||||
#
|
||||
# nginx only inherits add_header directives into a nested block when that block
|
||||
# declares none of its own. A location that sets Cache-Control therefore silently
|
||||
# drops everything defined at server level, so these are included explicitly
|
||||
# rather than relying on inheritance.
|
||||
|
||||
add_header X-Content-Type-Options nosniff always;
|
||||
add_header X-Frame-Options DENY always;
|
||||
add_header Referrer-Policy strict-origin-when-cross-origin always;
|
||||
|
||||
# The app loads no third-party scripts, styles, fonts or images: Angular bundles
|
||||
# everything and the API is same-origin. 'unsafe-inline' for styles is required
|
||||
# by Angular Material's runtime style injection.
|
||||
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core';
|
||||
import { provideRouter, withComponentInputBinding, withInMemoryScrolling } from '@angular/router';
|
||||
|
||||
import { authInterceptor } from './core/auth.interceptor';
|
||||
import { routes } from './app.routes';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(
|
||||
routes,
|
||||
// Lets a component read route params via input() instead of injecting
|
||||
// ActivatedRoute and reading a snapshot.
|
||||
withComponentInputBinding(),
|
||||
withInMemoryScrolling({ scrollPositionRestoration: 'top' }),
|
||||
),
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
// No provideAnimations here: @angular/animations is deprecated as of v22 and
|
||||
// Angular Material 22 no longer depends on it. Component motion now comes
|
||||
// from CSS, with animate.enter / animate.leave for element transitions.
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Routes } from '@angular/router';
|
||||
|
||||
import { authGuard, guestGuard } from './core/auth.guard';
|
||||
|
||||
/**
|
||||
* Every feature is lazily loaded, so the login page does not ship the editor's
|
||||
* code. In 2018 this was a single eager NgModule containing everything.
|
||||
*/
|
||||
export const routes: Routes = [
|
||||
{ path: '', pathMatch: 'full', redirectTo: 'games' },
|
||||
|
||||
{
|
||||
path: 'login',
|
||||
canActivate: [guestGuard],
|
||||
title: 'Sign in · LudosData',
|
||||
loadComponent: () => import('./features/login/login').then((m) => m.Login),
|
||||
},
|
||||
{
|
||||
path: 'register',
|
||||
canActivate: [guestGuard],
|
||||
title: 'Create account · LudosData',
|
||||
loadComponent: () => import('./features/register/register').then((m) => m.Register),
|
||||
},
|
||||
{
|
||||
path: 'games',
|
||||
canActivate: [authGuard],
|
||||
title: 'Library · LudosData',
|
||||
loadComponent: () => import('./features/game-grid/game-grid').then((m) => m.GameGrid),
|
||||
},
|
||||
{
|
||||
path: 'games/new',
|
||||
canActivate: [authGuard],
|
||||
title: 'New game · LudosData',
|
||||
loadComponent: () => import('./features/game-edit/game-edit').then((m) => m.GameEdit),
|
||||
},
|
||||
{
|
||||
path: 'games/:id',
|
||||
canActivate: [authGuard],
|
||||
title: 'Edit game · LudosData',
|
||||
loadComponent: () => import('./features/game-edit/game-edit').then((m) => m.GameEdit),
|
||||
},
|
||||
{
|
||||
path: 'account',
|
||||
canActivate: [authGuard],
|
||||
title: 'Account · LudosData',
|
||||
loadComponent: () => import('./features/account/account').then((m) => m.Account),
|
||||
},
|
||||
|
||||
{ path: '**', redirectTo: 'games' },
|
||||
];
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
template: '<router-outlet />',
|
||||
})
|
||||
export class App {}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { inject } from '@angular/core';
|
||||
import { CanActivateFn, Router } from '@angular/router';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
/**
|
||||
* Functional route guard — the modern replacement for the class-based
|
||||
* `AuthGuard implements CanActivate` this app used in 2018.
|
||||
*/
|
||||
export const authGuard: CanActivateFn = (_route, state) => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
if (auth.isLoggedIn()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return router.createUrlTree(['/login'], {
|
||||
queryParams: { returnUrl: state.url },
|
||||
});
|
||||
};
|
||||
|
||||
/** Keeps an already-signed-in user off the login and register pages. */
|
||||
export const guestGuard: CanActivateFn = () => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
return auth.isLoggedIn() ? router.createUrlTree(['/games']) : true;
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import { HttpClient, provideHttpClient, withInterceptors } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
import { authInterceptor } from './auth.interceptor';
|
||||
|
||||
describe('authInterceptor', () => {
|
||||
let http: HttpClient;
|
||||
let httpMock: HttpTestingController;
|
||||
let auth: AuthService;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideRouter([{ path: 'login', children: [] }]),
|
||||
provideHttpClient(withInterceptors([authInterceptor])),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
});
|
||||
|
||||
http = TestBed.inject(HttpClient);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
auth = TestBed.inject(AuthService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpMock.verify();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
/** Signs in through AuthService, which is what actually stores the session. */
|
||||
function signIn(): void {
|
||||
auth.login('ckoch', 'pw').subscribe();
|
||||
httpMock.expectOne('/api/auth/login').flush({
|
||||
token: 'test-token',
|
||||
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
|
||||
user: { id: 'u1', userName: 'ckoch', email: null, firstName: null, lastName: null, art: null },
|
||||
});
|
||||
}
|
||||
|
||||
it('sends the token as a bearer header, never in the query string', () => {
|
||||
signIn();
|
||||
|
||||
http.get('/api/games').subscribe();
|
||||
const req = httpMock.expectOne((r) => r.url === '/api/games');
|
||||
|
||||
expect(req.request.headers.get('Authorization')).toBe('Bearer test-token');
|
||||
// The 2018 client appended ?token=... which leaks into logs and history.
|
||||
expect(req.request.urlWithParams).not.toContain('token');
|
||||
req.flush({ items: [], page: 1, pageSize: 20, total: 0, totalPages: 0 });
|
||||
});
|
||||
|
||||
it('does not attach the token to non-API requests', () => {
|
||||
signIn();
|
||||
|
||||
http.get('/assets/config.json').subscribe();
|
||||
const req = httpMock.expectOne('/assets/config.json');
|
||||
|
||||
expect(req.request.headers.has('Authorization')).toBe(false);
|
||||
req.flush({});
|
||||
});
|
||||
|
||||
it('clears the stored session when the API rejects the token', () => {
|
||||
signIn();
|
||||
expect(auth.isLoggedIn()).toBe(true);
|
||||
|
||||
http.get('/api/games').subscribe({ error: () => undefined });
|
||||
httpMock
|
||||
.expectOne('/api/games')
|
||||
.flush({ title: 'Unauthorized' }, { status: 401, statusText: 'Unauthorized' });
|
||||
|
||||
expect(auth.isLoggedIn()).toBe(false);
|
||||
expect(auth.token()).toBeNull();
|
||||
});
|
||||
|
||||
it('treats an expired stored session as signed out', () => {
|
||||
localStorage.setItem(
|
||||
'ludos.session',
|
||||
JSON.stringify({
|
||||
token: 'stale',
|
||||
expiresAt: new Date(Date.now() - 1000).toISOString(),
|
||||
user: { id: 'u1', userName: 'ckoch' },
|
||||
}),
|
||||
);
|
||||
|
||||
// A fresh injector picks the value up from storage on construction.
|
||||
TestBed.resetTestingModule();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideRouter([{ path: 'login', children: [] }]), provideHttpClient(), provideHttpClientTesting()],
|
||||
});
|
||||
|
||||
expect(TestBed.inject(AuthService).isLoggedIn()).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { HttpErrorResponse, HttpInterceptorFn } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import { Router } from '@angular/router';
|
||||
import { catchError, throwError } from 'rxjs';
|
||||
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
/**
|
||||
* Attaches the bearer token to same-origin API calls and turns a 401 into a
|
||||
* redirect back to the login form.
|
||||
*
|
||||
* The old client put the token in the query string (`?token=...`), which leaks
|
||||
* it into server logs, browser history and Referer headers. It belongs in the
|
||||
* Authorization header.
|
||||
*/
|
||||
export const authInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const auth = inject(AuthService);
|
||||
const router = inject(Router);
|
||||
|
||||
const token = auth.token();
|
||||
const isApiCall = req.url.startsWith('/api/');
|
||||
|
||||
const request =
|
||||
token && isApiCall
|
||||
? req.clone({ setHeaders: { Authorization: `Bearer ${token}` } })
|
||||
: req;
|
||||
|
||||
return next(request).pipe(
|
||||
catchError((error: unknown) => {
|
||||
if (error instanceof HttpErrorResponse && error.status === 401 && isApiCall) {
|
||||
// Expired or rejected token: drop it and bounce to login, remembering
|
||||
// where the user was trying to go.
|
||||
auth.logout();
|
||||
void router.navigate(['/login'], {
|
||||
queryParams: { returnUrl: router.url },
|
||||
});
|
||||
}
|
||||
return throwError(() => error);
|
||||
}),
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Injectable, computed, inject, signal } from '@angular/core';
|
||||
import { Observable, tap } from 'rxjs';
|
||||
|
||||
import { AuthResponse, User } from './models';
|
||||
|
||||
interface StoredSession {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'ludos.session';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AuthService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
private readonly session = signal<StoredSession | null>(readStoredSession());
|
||||
|
||||
readonly user = computed(() => this.session()?.user ?? null);
|
||||
readonly token = computed(() => this.session()?.token ?? null);
|
||||
|
||||
/**
|
||||
* True only while a stored token is present and unexpired. Checking expiry
|
||||
* here means a stale token sends the user to the login form rather than
|
||||
* producing a wall of 401s.
|
||||
*/
|
||||
readonly isLoggedIn = computed(() => {
|
||||
const current = this.session();
|
||||
if (!current) {
|
||||
return false;
|
||||
}
|
||||
return new Date(current.expiresAt).getTime() > Date.now();
|
||||
});
|
||||
|
||||
login(userName: string, password: string): Observable<AuthResponse> {
|
||||
return this.http
|
||||
.post<AuthResponse>('/api/auth/login', { userName, password })
|
||||
.pipe(tap((response) => this.store(response)));
|
||||
}
|
||||
|
||||
register(payload: {
|
||||
userName: string;
|
||||
email: string;
|
||||
password: string;
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
}): Observable<AuthResponse> {
|
||||
return this.http
|
||||
.post<AuthResponse>('/api/auth/register', payload)
|
||||
.pipe(tap((response) => this.store(response)));
|
||||
}
|
||||
|
||||
/** Availability check for the registration form; returns true when free. */
|
||||
isAvailable(field: 'userName' | 'email', value: string): Observable<{ available: boolean }> {
|
||||
return this.http.get<{ available: boolean }>('/api/auth/available', {
|
||||
params: { [field]: value },
|
||||
});
|
||||
}
|
||||
|
||||
logout(): void {
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
this.session.set(null);
|
||||
}
|
||||
|
||||
private store(response: AuthResponse): void {
|
||||
const stored: StoredSession = {
|
||||
token: response.token,
|
||||
expiresAt: response.expiresAt,
|
||||
user: response.user,
|
||||
};
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(stored));
|
||||
this.session.set(stored);
|
||||
}
|
||||
}
|
||||
|
||||
function readStoredSession(): StoredSession | null {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as StoredSession;
|
||||
return parsed?.token && parsed?.user ? parsed : null;
|
||||
} catch {
|
||||
// Corrupt or hand-edited storage should not wedge startup.
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
import { Facets, Game, GameQuery, GameRequest, PagedResult, UploadResponse } from './models';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class GamesService {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
/**
|
||||
* Note what is absent: the caller never passes a user id. Ownership is taken
|
||||
* from the bearer token server-side, so there is no filter for a client to
|
||||
* tamper with the way there was in the old php-crud-api query string.
|
||||
*/
|
||||
list(query: GameQuery): Observable<PagedResult<Game>> {
|
||||
let params = new HttpParams();
|
||||
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
params = params.set(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return this.http.get<PagedResult<Game>>('/api/games', { params });
|
||||
}
|
||||
|
||||
get(id: number): Observable<Game> {
|
||||
return this.http.get<Game>(`/api/games/${id}`);
|
||||
}
|
||||
|
||||
facets(): Observable<Facets> {
|
||||
return this.http.get<Facets>('/api/games/facets');
|
||||
}
|
||||
|
||||
create(game: GameRequest): Observable<Game> {
|
||||
return this.http.post<Game>('/api/games', game);
|
||||
}
|
||||
|
||||
update(id: number, game: GameRequest): Observable<Game> {
|
||||
return this.http.put<Game>(`/api/games/${id}`, game);
|
||||
}
|
||||
|
||||
remove(id: number): Observable<void> {
|
||||
return this.http.delete<void>(`/api/games/${id}`);
|
||||
}
|
||||
|
||||
uploadArt(file: File): Observable<UploadResponse> {
|
||||
const form = new FormData();
|
||||
form.append('file', file, file.name);
|
||||
return this.http.post<UploadResponse>('/api/images', form);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/** Mirrors the API contracts in backend/src/LudosData.Api/Contracts. */
|
||||
|
||||
export interface Game {
|
||||
id: number;
|
||||
title: string;
|
||||
system: string | null;
|
||||
genre: string | null;
|
||||
year: string | null;
|
||||
developer: string | null;
|
||||
publisher: string | null;
|
||||
/** Stored filename. */
|
||||
art: string | null;
|
||||
/** Ready-to-use URL, built by the API. Null when no art was uploaded. */
|
||||
artUrl: string | null;
|
||||
description: string | null;
|
||||
own: boolean;
|
||||
dumped: boolean;
|
||||
played: boolean;
|
||||
finished: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** Create/update payload. No id and no owner — the API derives both. */
|
||||
export interface GameRequest {
|
||||
title: string;
|
||||
system: string | null;
|
||||
genre: string | null;
|
||||
year: string | null;
|
||||
developer: string | null;
|
||||
publisher: string | null;
|
||||
art: string | null;
|
||||
description: string | null;
|
||||
own: boolean;
|
||||
dumped: boolean;
|
||||
played: boolean;
|
||||
finished: boolean;
|
||||
}
|
||||
|
||||
export interface PagedResult<T> {
|
||||
items: T[];
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface GameQuery {
|
||||
search?: string;
|
||||
system?: string;
|
||||
genre?: string;
|
||||
own?: boolean;
|
||||
dumped?: boolean;
|
||||
played?: boolean;
|
||||
finished?: boolean;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
sort?: string;
|
||||
dir?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
export interface Facets {
|
||||
systems: string[];
|
||||
genres: string[];
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
userName: string;
|
||||
email: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
art: string | null;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
token: string;
|
||||
expiresAt: string;
|
||||
user: User;
|
||||
}
|
||||
|
||||
export interface UploadResponse {
|
||||
fileName: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** RFC 7807 body returned by the API for validation failures. */
|
||||
export interface ProblemDetails {
|
||||
title?: string;
|
||||
detail?: string;
|
||||
status?: number;
|
||||
errors?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export const EMPTY_GAME: GameRequest = {
|
||||
title: '',
|
||||
system: null,
|
||||
genre: null,
|
||||
year: null,
|
||||
developer: null,
|
||||
publisher: null,
|
||||
art: null,
|
||||
description: null,
|
||||
own: true,
|
||||
dumped: false,
|
||||
played: false,
|
||||
finished: false,
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatListModule } from '@angular/material/list';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { Toolbar } from '../../shared/toolbar';
|
||||
|
||||
@Component({
|
||||
selector: 'app-account',
|
||||
imports: [Toolbar, RouterLink, MatCardModule, MatIconModule, MatButtonModule, MatListModule],
|
||||
template: `
|
||||
<app-toolbar />
|
||||
|
||||
<div class="page">
|
||||
<mat-card>
|
||||
<mat-card-header>
|
||||
<mat-card-title>Account</mat-card-title>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
@if (user(); as currentUser) {
|
||||
<mat-list>
|
||||
<mat-list-item>
|
||||
<mat-icon matListItemIcon>person</mat-icon>
|
||||
<div matListItemTitle>{{ currentUser.userName }}</div>
|
||||
<div matListItemLine>Username</div>
|
||||
</mat-list-item>
|
||||
|
||||
@if (currentUser.email) {
|
||||
<mat-list-item>
|
||||
<mat-icon matListItemIcon>mail</mat-icon>
|
||||
<div matListItemTitle>{{ currentUser.email }}</div>
|
||||
<div matListItemLine>Email</div>
|
||||
</mat-list-item>
|
||||
}
|
||||
|
||||
@if (currentUser.firstName || currentUser.lastName) {
|
||||
<mat-list-item>
|
||||
<mat-icon matListItemIcon>badge</mat-icon>
|
||||
<div matListItemTitle>
|
||||
{{ currentUser.firstName }} {{ currentUser.lastName }}
|
||||
</div>
|
||||
<div matListItemLine>Name</div>
|
||||
</mat-list-item>
|
||||
}
|
||||
</mat-list>
|
||||
}
|
||||
</mat-card-content>
|
||||
|
||||
<mat-card-actions>
|
||||
<a mat-button routerLink="/games">Back to library</a>
|
||||
<button mat-button (click)="logout()">Sign out</button>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
</div>
|
||||
`,
|
||||
styles: `
|
||||
.page {
|
||||
max-width: 40rem;
|
||||
margin-inline: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class Account {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly user = this.auth.user;
|
||||
|
||||
logout(): void {
|
||||
this.auth.logout();
|
||||
void this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
<app-toolbar />
|
||||
|
||||
@if (loading() || saving()) {
|
||||
<mat-progress-bar mode="indeterminate" />
|
||||
}
|
||||
|
||||
@if (loadFailed()) {
|
||||
<div class="state-panel">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
<h2>Game not found</h2>
|
||||
<p>It may have been removed, or it belongs to another account.</p>
|
||||
<a mat-flat-button color="primary" routerLink="/games">Back to library</a>
|
||||
</div>
|
||||
} @else {
|
||||
<form class="editor" [formGroup]="form" (ngSubmit)="submit()" novalidate>
|
||||
<!-- Left column: art and actions -->
|
||||
<mat-card class="art-panel">
|
||||
<div class="art-frame">
|
||||
@if (artPreview(); as preview) {
|
||||
<img [src]="preview" alt="Box art preview" />
|
||||
} @else {
|
||||
<div class="art-placeholder">
|
||||
<mat-icon>image</mat-icon>
|
||||
<span>No box art</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (uploading()) {
|
||||
<div class="art-overlay"><mat-progress-bar mode="indeterminate" /></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<input
|
||||
#fileInput
|
||||
type="file"
|
||||
accept="image/*"
|
||||
hidden
|
||||
(change)="onFileSelected($event)"
|
||||
/>
|
||||
|
||||
<div class="art-actions">
|
||||
<button mat-stroked-button type="button" (click)="fileInput.click()" [disabled]="uploading()">
|
||||
<mat-icon>upload</mat-icon>
|
||||
{{ uploading() ? 'Uploading…' : 'Upload art' }}
|
||||
</button>
|
||||
|
||||
@if (artPreview()) {
|
||||
<button mat-button type="button" (click)="removeArt()" [disabled]="uploading()">
|
||||
Remove
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</mat-card>
|
||||
|
||||
<!-- Right column: fields -->
|
||||
<mat-card class="fields-panel">
|
||||
<h1 class="editor-title">{{ isEdit() ? 'Edit game' : 'New game' }}</h1>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Title</mat-label>
|
||||
<input matInput formControlName="title" required />
|
||||
@if (form.controls.title.touched && form.controls.title.invalid) {
|
||||
<mat-error>Title is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div class="field-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>System</mat-label>
|
||||
<mat-select formControlName="system">
|
||||
<mat-option value="">—</mat-option>
|
||||
@for (option of systems; track option) {
|
||||
<mat-option [value]="option">{{ option }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Genre</mat-label>
|
||||
<mat-select formControlName="genre">
|
||||
<mat-option value="">—</mat-option>
|
||||
@for (option of genres; track option) {
|
||||
<mat-option [value]="option">{{ option }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Year</mat-label>
|
||||
<input matInput formControlName="year" inputmode="numeric" placeholder="1998" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<div class="field-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Developer</mat-label>
|
||||
<input matInput formControlName="developer" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Publisher</mat-label>
|
||||
<input matInput formControlName="publisher" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Description</mat-label>
|
||||
<textarea matInput formControlName="description" rows="4"></textarea>
|
||||
</mat-form-field>
|
||||
|
||||
<fieldset class="flags">
|
||||
<legend>Collection status</legend>
|
||||
<mat-checkbox formControlName="own">Own</mat-checkbox>
|
||||
<mat-checkbox formControlName="dumped">Dumped</mat-checkbox>
|
||||
<mat-checkbox formControlName="played">Played</mat-checkbox>
|
||||
<mat-checkbox formControlName="finished">Finished</mat-checkbox>
|
||||
</fieldset>
|
||||
|
||||
<div class="editor-actions">
|
||||
<a mat-button routerLink="/games">Cancel</a>
|
||||
|
||||
@if (isEdit()) {
|
||||
<button mat-button type="button" class="danger" (click)="confirmRemove()" [disabled]="saving()">
|
||||
<mat-icon>delete_outline</mat-icon>
|
||||
Remove
|
||||
</button>
|
||||
}
|
||||
|
||||
<span class="spacer"></span>
|
||||
|
||||
<button mat-flat-button color="primary" type="submit" [disabled]="saving() || uploading()">
|
||||
{{ saving() ? 'Saving…' : isEdit() ? 'Save changes' : 'Add game' }}
|
||||
</button>
|
||||
</div>
|
||||
</mat-card>
|
||||
</form>
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
.editor {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(14rem, 20rem) minmax(0, 1fr);
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
padding: 1.5rem;
|
||||
max-width: 68rem;
|
||||
margin-inline: auto;
|
||||
|
||||
@media (max-width: 899px) {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.art-panel {
|
||||
padding: 1rem;
|
||||
position: sticky;
|
||||
top: 5rem;
|
||||
|
||||
@media (max-width: 899px) {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
.art-frame {
|
||||
position: relative;
|
||||
aspect-ratio: 3 / 4;
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
background: var(--mat-sys-surface-container-high);
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.art-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
align-content: center;
|
||||
gap: 0.5rem;
|
||||
height: 100%;
|
||||
color: var(--mat-sys-outline);
|
||||
|
||||
mat-icon {
|
||||
font-size: 3rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.art-overlay {
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
}
|
||||
|
||||
.art-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.fields-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 1.5rem;
|
||||
|
||||
@media (max-width: 599px) {
|
||||
padding: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.editor-title {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.25rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
|
||||
mat-form-field {
|
||||
flex: 1 1 10rem;
|
||||
}
|
||||
}
|
||||
|
||||
.flags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
border: 1px solid var(--mat-sys-outline-variant);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.75rem 1rem 1rem;
|
||||
margin: 0.5rem 0 1rem;
|
||||
|
||||
legend {
|
||||
padding-inline: 0.375rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
}
|
||||
|
||||
.editor-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--mat-sys-error);
|
||||
}
|
||||
}
|
||||
|
||||
.state-panel {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 4rem 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
mat-icon {
|
||||
font-size: 3rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { GameEdit } from './game-edit';
|
||||
|
||||
/**
|
||||
* These exist mainly to prove the Material components in this screen construct
|
||||
* and render without an animations provider. @angular/animations is deprecated
|
||||
* in v22 and Material no longer depends on it, but MatSelect, MatDialog and
|
||||
* MatCheckbox historically did — so this is worth pinning down in a test rather
|
||||
* than assuming.
|
||||
*/
|
||||
describe('GameEdit', () => {
|
||||
let fixture: ComponentFixture<GameEdit>;
|
||||
let httpMock: HttpTestingController;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [GameEdit],
|
||||
providers: [provideRouter([]), provideHttpClient(), provideHttpClientTesting()],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(GameEdit);
|
||||
httpMock = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
it('renders the new-game form without an animations provider', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const host: HTMLElement = fixture.nativeElement;
|
||||
expect(host.querySelector('.editor')).toBeTruthy();
|
||||
expect(host.textContent).toContain('New game');
|
||||
});
|
||||
|
||||
it('treats a missing route id as create mode and issues no fetch', () => {
|
||||
fixture.detectChanges();
|
||||
|
||||
const component = fixture.componentInstance;
|
||||
expect(component['isEdit']()).toBe(false);
|
||||
|
||||
// Nothing to load when creating, so no outbound request should exist.
|
||||
httpMock.verify();
|
||||
});
|
||||
|
||||
it('builds a request payload that turns blank fields into null', () => {
|
||||
fixture.detectChanges();
|
||||
const component = fixture.componentInstance;
|
||||
|
||||
component['form'].patchValue({
|
||||
title: ' Chrono Trigger ',
|
||||
system: 'SNES',
|
||||
genre: '',
|
||||
year: '1995',
|
||||
developer: ' ',
|
||||
finished: true,
|
||||
});
|
||||
|
||||
const payload = component['toRequest']();
|
||||
|
||||
expect(payload.title).toBe('Chrono Trigger');
|
||||
expect(payload.system).toBe('SNES');
|
||||
expect(payload.genre).toBeNull();
|
||||
expect(payload.developer).toBeNull();
|
||||
expect(payload.year).toBe('1995');
|
||||
expect(payload.finished).toBe(true);
|
||||
// The payload must never carry an owner: the API takes it from the token.
|
||||
expect('ownerId' in payload).toBe(false);
|
||||
expect('userId' in payload).toBe(false);
|
||||
});
|
||||
|
||||
it('requires a title', () => {
|
||||
fixture.detectChanges();
|
||||
const form = fixture.componentInstance['form'];
|
||||
|
||||
expect(form.valid).toBe(false);
|
||||
form.patchValue({ title: 'Metroid' });
|
||||
expect(form.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, computed, inject, input, signal } from '@angular/core';
|
||||
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatCheckboxModule } from '@angular/material/checkbox';
|
||||
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
|
||||
import { ConfirmDialog, ConfirmDialogData } from '../../shared/confirm-dialog';
|
||||
import { GamesService } from '../../core/games.service';
|
||||
import { GameRequest } from '../../core/models';
|
||||
import { Toolbar } from '../../shared/toolbar';
|
||||
|
||||
/** Kept in sync with the values already present in the 2018 data. */
|
||||
const SYSTEMS = [
|
||||
'NES', 'SNES', 'N64', 'GC', 'WII', 'GB', 'GBA', 'DS',
|
||||
'PS1', 'PS2', 'PSP', '360', 'PC',
|
||||
];
|
||||
|
||||
const GENRES = [
|
||||
'action', 'adventure', 'card', 'fighter', 'fps', 'lightgun',
|
||||
'platformer', 'racing', 'rpg', 'simulation', 'sports', 'strategy',
|
||||
];
|
||||
|
||||
@Component({
|
||||
selector: 'app-game-edit',
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
RouterLink,
|
||||
Toolbar,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatCheckboxModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressBarModule,
|
||||
MatDialogModule,
|
||||
],
|
||||
templateUrl: './game-edit.html',
|
||||
styleUrl: './game-edit.scss',
|
||||
})
|
||||
export class GameEdit {
|
||||
private readonly games = inject(GamesService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly fb = inject(NonNullableFormBuilder);
|
||||
private readonly snackBar = inject(MatSnackBar);
|
||||
private readonly dialog = inject(MatDialog);
|
||||
|
||||
/** Bound from the `:id` route param by withComponentInputBinding(). */
|
||||
readonly id = input<string | undefined>();
|
||||
|
||||
protected readonly systems = SYSTEMS;
|
||||
protected readonly genres = GENRES;
|
||||
|
||||
protected readonly isEdit = computed(() => !!this.id());
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly saving = signal(false);
|
||||
protected readonly uploading = signal(false);
|
||||
protected readonly loadFailed = signal(false);
|
||||
|
||||
/** Preview URL: a freshly-picked local file, or the stored art from the API. */
|
||||
protected readonly artPreview = signal<string | null>(null);
|
||||
|
||||
protected readonly form = this.fb.group({
|
||||
title: ['', [Validators.required, Validators.maxLength(200)]],
|
||||
system: [''],
|
||||
genre: [''],
|
||||
year: ['', Validators.maxLength(50)],
|
||||
developer: ['', Validators.maxLength(100)],
|
||||
publisher: ['', Validators.maxLength(100)],
|
||||
description: [''],
|
||||
art: [''],
|
||||
own: [true],
|
||||
dumped: [false],
|
||||
played: [false],
|
||||
finished: [false],
|
||||
});
|
||||
|
||||
constructor() {
|
||||
// input() is a signal, so this reacts if the route id ever changes without
|
||||
// the component being torn down.
|
||||
queueMicrotask(() => this.load());
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
const gameId = this.id();
|
||||
if (!gameId) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
this.games.get(Number(gameId)).subscribe({
|
||||
next: (game) => {
|
||||
this.form.patchValue({
|
||||
title: game.title,
|
||||
system: game.system ?? '',
|
||||
genre: game.genre ?? '',
|
||||
year: game.year ?? '',
|
||||
developer: game.developer ?? '',
|
||||
publisher: game.publisher ?? '',
|
||||
description: game.description ?? '',
|
||||
art: game.art ?? '',
|
||||
own: game.own,
|
||||
dumped: game.dumped,
|
||||
played: game.played,
|
||||
finished: game.finished,
|
||||
});
|
||||
this.artPreview.set(game.artUrl);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.loadFailed.set(true);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected onFileSelected(event: Event): void {
|
||||
const input = event.target as HTMLInputElement;
|
||||
const file = input.files?.[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the local file straight away rather than waiting for the round trip.
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => this.artPreview.set(String(reader.result));
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
this.uploading.set(true);
|
||||
this.games.uploadArt(file).subscribe({
|
||||
next: (result) => {
|
||||
this.form.controls.art.setValue(result.fileName);
|
||||
this.artPreview.set(result.url);
|
||||
this.uploading.set(false);
|
||||
// Clear the input so picking the same file again still fires a change.
|
||||
input.value = '';
|
||||
},
|
||||
error: (err: HttpErrorResponse) => {
|
||||
this.uploading.set(false);
|
||||
this.artPreview.set(null);
|
||||
input.value = '';
|
||||
this.snackBar.open(
|
||||
err.status === 400 ? 'That file could not be read as an image.' : 'Upload failed.',
|
||||
'Dismiss',
|
||||
{ duration: 5000 },
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected removeArt(): void {
|
||||
this.form.controls.art.setValue('');
|
||||
this.artPreview.set(null);
|
||||
}
|
||||
|
||||
protected submit(): void {
|
||||
if (this.form.invalid || this.saving()) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.saving.set(true);
|
||||
const payload = this.toRequest();
|
||||
|
||||
const request = this.isEdit()
|
||||
? this.games.update(Number(this.id()), payload)
|
||||
: this.games.create(payload);
|
||||
|
||||
request.subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open(this.isEdit() ? 'Game updated' : 'Game added', undefined, {
|
||||
duration: 3000,
|
||||
});
|
||||
void this.router.navigate(['/games']);
|
||||
},
|
||||
error: () => {
|
||||
this.saving.set(false);
|
||||
this.snackBar.open('Could not save the game.', 'Dismiss', { duration: 5000 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
protected confirmRemove(): void {
|
||||
const data: ConfirmDialogData = {
|
||||
title: 'Remove this game?',
|
||||
message: `"${this.form.controls.title.value}" will be deleted from your library. This cannot be undone.`,
|
||||
confirmLabel: 'Remove',
|
||||
destructive: true,
|
||||
};
|
||||
|
||||
this.dialog
|
||||
.open(ConfirmDialog, { data, width: '24rem' })
|
||||
.afterClosed()
|
||||
.subscribe((confirmed) => {
|
||||
if (confirmed) {
|
||||
this.remove();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private remove(): void {
|
||||
this.saving.set(true);
|
||||
this.games.remove(Number(this.id())).subscribe({
|
||||
next: () => {
|
||||
this.snackBar.open('Game removed', undefined, { duration: 3000 });
|
||||
void this.router.navigate(['/games']);
|
||||
},
|
||||
error: () => {
|
||||
this.saving.set(false);
|
||||
this.snackBar.open('Could not remove the game.', 'Dismiss', { duration: 5000 });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Empty strings become null, matching the API's nullable columns. */
|
||||
private toRequest(): GameRequest {
|
||||
const value = this.form.getRawValue();
|
||||
const blankToNull = (input: string) => {
|
||||
const trimmed = input.trim();
|
||||
return trimmed.length ? trimmed : null;
|
||||
};
|
||||
|
||||
return {
|
||||
title: value.title.trim(),
|
||||
system: blankToNull(value.system),
|
||||
genre: blankToNull(value.genre),
|
||||
year: blankToNull(value.year),
|
||||
developer: blankToNull(value.developer),
|
||||
publisher: blankToNull(value.publisher),
|
||||
description: blankToNull(value.description),
|
||||
art: blankToNull(value.art),
|
||||
own: value.own,
|
||||
dumped: value.dumped,
|
||||
played: value.played,
|
||||
finished: value.finished,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
<app-toolbar>
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="search-field">
|
||||
<mat-icon matPrefix>search</mat-icon>
|
||||
<input
|
||||
matInput
|
||||
type="search"
|
||||
placeholder="Search title, developer, publisher"
|
||||
[ngModel]="search()"
|
||||
(ngModelChange)="onSearch($event)"
|
||||
aria-label="Search games"
|
||||
/>
|
||||
</mat-form-field>
|
||||
</app-toolbar>
|
||||
|
||||
@if (loading()) {
|
||||
<mat-progress-bar mode="indeterminate" />
|
||||
}
|
||||
|
||||
<div class="filters">
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>System</mat-label>
|
||||
<mat-select
|
||||
[ngModel]="system()"
|
||||
(ngModelChange)="system.set($event); onFilterChange()"
|
||||
>
|
||||
<mat-option value="">All systems</mat-option>
|
||||
@for (option of facets().systems; track option) {
|
||||
<mat-option [value]="option">{{ option }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Genre</mat-label>
|
||||
<mat-select [ngModel]="genre()" (ngModelChange)="genre.set($event); onFilterChange()">
|
||||
<mat-option value="">All genres</mat-option>
|
||||
@for (option of facets().genres; track option) {
|
||||
<mat-option [value]="option">{{ option }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Status</mat-label>
|
||||
<mat-select [ngModel]="status()" (ngModelChange)="status.set($event); onFilterChange()">
|
||||
<mat-option value="">Any status</mat-option>
|
||||
<mat-option value="own">Owned</mat-option>
|
||||
<mat-option value="dumped">Dumped</mat-option>
|
||||
<mat-option value="played">Played</mat-option>
|
||||
<mat-option value="finished">Finished</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" subscriptSizing="dynamic">
|
||||
<mat-label>Sort by</mat-label>
|
||||
<mat-select [ngModel]="sort()" (ngModelChange)="sort.set($event)">
|
||||
<mat-option value="title">Title</mat-option>
|
||||
<mat-option value="system">System</mat-option>
|
||||
<mat-option value="genre">Genre</mat-option>
|
||||
<mat-option value="year">Year</mat-option>
|
||||
<mat-option value="created">Date added</mat-option>
|
||||
<mat-option value="updated">Last updated</mat-option>
|
||||
</mat-select>
|
||||
</mat-form-field>
|
||||
|
||||
<button
|
||||
mat-icon-button
|
||||
(click)="toggleDirection()"
|
||||
[attr.aria-label]="dir() === 'asc' ? 'Sort descending' : 'Sort ascending'"
|
||||
>
|
||||
<mat-icon>{{ dir() === 'asc' ? 'arrow_upward' : 'arrow_downward' }}</mat-icon>
|
||||
</button>
|
||||
|
||||
@if (hasFilters()) {
|
||||
<button mat-button (click)="clearFilters()">
|
||||
<mat-icon>filter_alt_off</mat-icon>
|
||||
Clear
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (failed()) {
|
||||
<div class="state-panel">
|
||||
<mat-icon>cloud_off</mat-icon>
|
||||
<h2>Could not load your library</h2>
|
||||
<p>The server did not respond. Check that the API is running, then try again.</p>
|
||||
</div>
|
||||
} @else if (!loading() && items().length === 0) {
|
||||
<div class="state-panel">
|
||||
<mat-icon>videogame_asset_off</mat-icon>
|
||||
@if (hasFilters()) {
|
||||
<h2>No games match those filters</h2>
|
||||
<button mat-button (click)="clearFilters()">Clear filters</button>
|
||||
} @else {
|
||||
<h2>Your library is empty</h2>
|
||||
<a mat-flat-button color="primary" routerLink="/games/new">Add your first game</a>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="grid">
|
||||
@for (game of items(); track game.id) {
|
||||
<mat-card class="game-card" [routerLink]="['/games', game.id]" tabindex="0">
|
||||
<div class="art">
|
||||
@if (game.artUrl) {
|
||||
<img [src]="game.artUrl" [alt]="game.title + ' box art'" loading="lazy" />
|
||||
} @else {
|
||||
<div class="art-placeholder" aria-hidden="true">
|
||||
<mat-icon>videogame_asset</mat-icon>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="body">
|
||||
<h3 class="title" [title]="game.title">{{ game.title }}</h3>
|
||||
<p class="meta">
|
||||
@if (game.system) {
|
||||
<span class="system">{{ game.system }}</span>
|
||||
}
|
||||
@if (game.year) {
|
||||
<span class="year">{{ game.year }}</span>
|
||||
}
|
||||
</p>
|
||||
|
||||
@if (badges(game).length) {
|
||||
<div class="badges">
|
||||
@for (badge of badges(game); track badge) {
|
||||
<span class="badge badge--{{ badge.toLowerCase() }}">{{ badge }}</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</mat-card>
|
||||
}
|
||||
</div>
|
||||
|
||||
<mat-paginator
|
||||
[length]="total()"
|
||||
[pageSize]="pageSize()"
|
||||
[pageIndex]="page() - 1"
|
||||
[pageSizeOptions]="pageSizeOptions"
|
||||
(page)="onPage($event)"
|
||||
aria-label="Select page"
|
||||
/>
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
.search-field {
|
||||
width: min(28rem, 40vw);
|
||||
|
||||
@media (max-width: 899px) {
|
||||
width: 12rem;
|
||||
}
|
||||
@media (max-width: 599px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.5rem 0.5rem;
|
||||
|
||||
mat-form-field {
|
||||
min-width: 10rem;
|
||||
}
|
||||
|
||||
@media (max-width: 599px) {
|
||||
padding-inline: 1rem;
|
||||
mat-form-field {
|
||||
flex: 1 1 8rem;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
/* Cards size themselves; no masonry library required for this. */
|
||||
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
|
||||
gap: 1rem;
|
||||
padding: 1rem 1.5rem;
|
||||
|
||||
@media (max-width: 599px) {
|
||||
grid-template-columns: repeat(auto-fill, minmax(8.5rem, 1fr));
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.game-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
|
||||
&:hover,
|
||||
&:focus-visible {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--mat-sys-level3);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--mat-sys-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.art {
|
||||
aspect-ratio: 3 / 4;
|
||||
background: var(--mat-sys-surface-container-high);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.art-placeholder {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
height: 100%;
|
||||
color: var(--mat-sys-outline);
|
||||
|
||||
mat-icon {
|
||||
font-size: 2.5rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
padding: 0.625rem 0.75rem 0.75rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
/* Two lines, then ellipsis — keeps every card the same height. */
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
}
|
||||
|
||||
.system {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--mat-sys-surface-container-highest);
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
&--finished {
|
||||
background: var(--mat-sys-primary);
|
||||
color: var(--mat-sys-on-primary);
|
||||
}
|
||||
&--played {
|
||||
background: var(--mat-sys-tertiary-container);
|
||||
color: var(--mat-sys-on-tertiary-container);
|
||||
}
|
||||
}
|
||||
|
||||
.state-panel {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 4rem 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--mat-sys-on-surface-variant);
|
||||
|
||||
mat-icon {
|
||||
font-size: 3rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
max-width: 28rem;
|
||||
}
|
||||
}
|
||||
|
||||
mat-paginator {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Component, computed, effect, inject, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatChipsModule } from '@angular/material/chips';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { MatSelectModule } from '@angular/material/select';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { Subject, debounceTime, switchMap } from 'rxjs';
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
|
||||
import { GamesService } from '../../core/games.service';
|
||||
import { Facets, Game } from '../../core/models';
|
||||
import { Toolbar } from '../../shared/toolbar';
|
||||
|
||||
type SortKey = 'title' | 'system' | 'genre' | 'year' | 'created' | 'updated';
|
||||
|
||||
@Component({
|
||||
selector: 'app-game-grid',
|
||||
imports: [
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
Toolbar,
|
||||
MatCardModule,
|
||||
MatIconModule,
|
||||
MatButtonModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatSelectModule,
|
||||
MatPaginatorModule,
|
||||
MatProgressBarModule,
|
||||
MatChipsModule,
|
||||
],
|
||||
templateUrl: './game-grid.html',
|
||||
styleUrl: './game-grid.scss',
|
||||
})
|
||||
export class GameGrid {
|
||||
private readonly games = inject(GamesService);
|
||||
|
||||
protected readonly search = signal('');
|
||||
protected readonly system = signal<string>('');
|
||||
protected readonly genre = signal<string>('');
|
||||
protected readonly status = signal<'' | 'own' | 'played' | 'finished' | 'dumped'>('');
|
||||
protected readonly sort = signal<SortKey>('title');
|
||||
protected readonly dir = signal<'asc' | 'desc'>('asc');
|
||||
|
||||
protected readonly page = signal(1);
|
||||
protected readonly pageSize = signal(24);
|
||||
protected readonly pageSizeOptions = [12, 24, 48, 96];
|
||||
|
||||
protected readonly items = signal<Game[]>([]);
|
||||
protected readonly total = signal(0);
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly failed = signal(false);
|
||||
|
||||
protected readonly facets = toSignal(this.games.facets(), {
|
||||
initialValue: { systems: [], genres: [] } as Facets,
|
||||
});
|
||||
|
||||
protected readonly hasFilters = computed(
|
||||
() => !!this.search() || !!this.system() || !!this.genre() || !!this.status(),
|
||||
);
|
||||
|
||||
/** Debounced trigger, so typing in the search box does not fire a request per keystroke. */
|
||||
private readonly reload$ = new Subject<void>();
|
||||
|
||||
constructor() {
|
||||
this.reload$
|
||||
.pipe(
|
||||
// Collapses bursts of signal changes (typing, or a filter that also
|
||||
// resets the page) into one request. switchMap then cancels any
|
||||
// in-flight response that a newer query has superseded.
|
||||
debounceTime(250),
|
||||
switchMap(() => {
|
||||
this.loading.set(true);
|
||||
this.failed.set(false);
|
||||
return this.games.list({
|
||||
search: this.search().trim() || undefined,
|
||||
system: this.system() || undefined,
|
||||
genre: this.genre() || undefined,
|
||||
...this.statusFilter(),
|
||||
page: this.page(),
|
||||
pageSize: this.pageSize(),
|
||||
sort: this.sort(),
|
||||
dir: this.dir(),
|
||||
});
|
||||
}),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.items.set(result.items);
|
||||
this.total.set(result.total);
|
||||
this.loading.set(false);
|
||||
},
|
||||
error: () => {
|
||||
this.loading.set(false);
|
||||
this.failed.set(true);
|
||||
},
|
||||
});
|
||||
|
||||
// Any filter change resets to page 1 and refetches.
|
||||
effect(() => {
|
||||
this.search();
|
||||
this.system();
|
||||
this.genre();
|
||||
this.status();
|
||||
this.sort();
|
||||
this.dir();
|
||||
this.pageSize();
|
||||
this.page();
|
||||
this.reload$.next();
|
||||
});
|
||||
}
|
||||
|
||||
private statusFilter(): Record<string, boolean | undefined> {
|
||||
const value = this.status();
|
||||
return value ? { [value]: true } : {};
|
||||
}
|
||||
|
||||
protected onSearch(value: string): void {
|
||||
this.page.set(1);
|
||||
this.search.set(value);
|
||||
}
|
||||
|
||||
protected onFilterChange(): void {
|
||||
this.page.set(1);
|
||||
}
|
||||
|
||||
protected onPage(event: PageEvent): void {
|
||||
this.pageSize.set(event.pageSize);
|
||||
this.page.set(event.pageIndex + 1);
|
||||
}
|
||||
|
||||
protected clearFilters(): void {
|
||||
this.page.set(1);
|
||||
this.search.set('');
|
||||
this.system.set('');
|
||||
this.genre.set('');
|
||||
this.status.set('');
|
||||
}
|
||||
|
||||
protected toggleDirection(): void {
|
||||
this.dir.set(this.dir() === 'asc' ? 'desc' : 'asc');
|
||||
}
|
||||
|
||||
/** Badges shown on each card for the four collection flags. */
|
||||
protected badges(game: Game): string[] {
|
||||
const flags: string[] = [];
|
||||
if (game.own) flags.push('Own');
|
||||
if (game.dumped) flags.push('Dumped');
|
||||
if (game.played) flags.push('Played');
|
||||
if (game.finished) flags.push('Finished');
|
||||
return flags;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<div class="auth-shell">
|
||||
<mat-card class="auth-card">
|
||||
@if (loading()) {
|
||||
<mat-progress-bar mode="indeterminate" />
|
||||
}
|
||||
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon>videogame_asset</mat-icon>
|
||||
LudosData
|
||||
</mat-card-title>
|
||||
<mat-card-subtitle>Sign in to your game library</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Username</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="userName"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
@if (form.controls.userName.touched && form.controls.userName.invalid) {
|
||||
<mat-error>Username is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Password</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="password"
|
||||
[type]="showPassword() ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
matSuffix
|
||||
mat-icon-button
|
||||
type="button"
|
||||
(click)="showPassword.set(!showPassword())"
|
||||
[attr.aria-label]="showPassword() ? 'Hide password' : 'Show password'"
|
||||
>
|
||||
<mat-icon>{{ showPassword() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button>
|
||||
@if (form.controls.password.touched && form.controls.password.invalid) {
|
||||
<mat-error>Password is required</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@if (error(); as message) {
|
||||
<p class="form-error" role="alert">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
{{ message }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<button mat-flat-button color="primary" type="submit" [disabled]="loading()">
|
||||
{{ loading() ? 'Signing in…' : 'Sign in' }}
|
||||
</button>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
|
||||
<mat-card-actions>
|
||||
<span>No account yet?</span>
|
||||
<a mat-button routerLink="/register">Create one</a>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,54 @@
|
||||
.auth-shell {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-height: 100dvh;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.auth-card {
|
||||
width: min(100%, 26rem);
|
||||
overflow: hidden;
|
||||
|
||||
mat-card-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
mat-card-content {
|
||||
padding-top: 1.25rem;
|
||||
}
|
||||
|
||||
form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
button[type='submit'] {
|
||||
margin-top: 0.75rem;
|
||||
height: 2.75rem;
|
||||
}
|
||||
|
||||
mat-card-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.form-error {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--mat-sys-error);
|
||||
font-size: 0.875rem;
|
||||
|
||||
mat-icon {
|
||||
font-size: 1.125rem;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { NonNullableFormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-login',
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
RouterLink,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressBarModule,
|
||||
],
|
||||
templateUrl: './login.html',
|
||||
styleUrl: './login.scss',
|
||||
})
|
||||
export class Login {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly fb = inject(NonNullableFormBuilder);
|
||||
|
||||
/** Set by the router when the guard bounced an unauthenticated deep link. */
|
||||
readonly returnUrl = signal<string>('/games');
|
||||
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly showPassword = signal(false);
|
||||
|
||||
protected readonly form = this.fb.group({
|
||||
userName: ['', Validators.required],
|
||||
password: ['', Validators.required],
|
||||
});
|
||||
|
||||
constructor() {
|
||||
const url = new URLSearchParams(window.location.search).get('returnUrl');
|
||||
if (url?.startsWith('/')) {
|
||||
// Only same-origin paths, so a crafted link cannot bounce through login
|
||||
// to an external site.
|
||||
this.returnUrl.set(url);
|
||||
}
|
||||
}
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid || this.loading()) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
const { userName, password } = this.form.getRawValue();
|
||||
|
||||
this.auth.login(userName, password).subscribe({
|
||||
next: () => void this.router.navigateByUrl(this.returnUrl()),
|
||||
error: (err: HttpErrorResponse) => {
|
||||
this.loading.set(false);
|
||||
this.error.set(
|
||||
err.status === 423
|
||||
? 'Too many failed attempts. Try again in 15 minutes.'
|
||||
: 'Incorrect username or password.',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<div class="auth-shell">
|
||||
<mat-card class="auth-card">
|
||||
@if (loading()) {
|
||||
<mat-progress-bar mode="indeterminate" />
|
||||
}
|
||||
|
||||
<mat-card-header>
|
||||
<mat-card-title>
|
||||
<mat-icon>videogame_asset</mat-icon>
|
||||
Create account
|
||||
</mat-card-title>
|
||||
<mat-card-subtitle>Start cataloguing your collection</mat-card-subtitle>
|
||||
</mat-card-header>
|
||||
|
||||
<mat-card-content>
|
||||
<form [formGroup]="form" (ngSubmit)="submit()" novalidate>
|
||||
<div class="name-row">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>First name</mat-label>
|
||||
<input matInput formControlName="firstName" autocomplete="given-name" />
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Last name</mat-label>
|
||||
<input matInput formControlName="lastName" autocomplete="family-name" />
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Username</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="userName"
|
||||
autocomplete="username"
|
||||
autocapitalize="none"
|
||||
spellcheck="false"
|
||||
required
|
||||
/>
|
||||
@if (form.controls.userName.pending) {
|
||||
<mat-hint>Checking availability…</mat-hint>
|
||||
}
|
||||
@if (form.controls.userName.touched) {
|
||||
@if (form.controls.userName.hasError('required')) {
|
||||
<mat-error>Username is required</mat-error>
|
||||
} @else if (form.controls.userName.hasError('minlength')) {
|
||||
<mat-error>At least 3 characters</mat-error>
|
||||
} @else if (form.controls.userName.hasError('taken')) {
|
||||
<mat-error>That username is already taken</mat-error>
|
||||
}
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Email</mat-label>
|
||||
<input matInput formControlName="email" type="email" autocomplete="email" required />
|
||||
@if (form.controls.email.pending) {
|
||||
<mat-hint>Checking availability…</mat-hint>
|
||||
}
|
||||
@if (form.controls.email.touched) {
|
||||
@if (form.controls.email.hasError('required')) {
|
||||
<mat-error>Email is required</mat-error>
|
||||
} @else if (form.controls.email.hasError('email')) {
|
||||
<mat-error>Enter a valid email address</mat-error>
|
||||
} @else if (form.controls.email.hasError('taken')) {
|
||||
<mat-error>That email is already registered</mat-error>
|
||||
}
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Password</mat-label>
|
||||
<input
|
||||
matInput
|
||||
formControlName="password"
|
||||
[type]="showPassword() ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
required
|
||||
/>
|
||||
<button
|
||||
matSuffix
|
||||
mat-icon-button
|
||||
type="button"
|
||||
(click)="showPassword.set(!showPassword())"
|
||||
[attr.aria-label]="showPassword() ? 'Hide password' : 'Show password'"
|
||||
>
|
||||
<mat-icon>{{ showPassword() ? 'visibility_off' : 'visibility' }}</mat-icon>
|
||||
</button>
|
||||
@if (form.controls.password.touched && form.controls.password.invalid) {
|
||||
<mat-error>At least 12 characters, with upper, lower and a number</mat-error>
|
||||
} @else {
|
||||
<mat-hint>At least 12 characters, with upper, lower and a number</mat-hint>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
@if (error(); as message) {
|
||||
<p class="form-error" role="alert">
|
||||
<mat-icon>error_outline</mat-icon>
|
||||
{{ message }}
|
||||
</p>
|
||||
}
|
||||
|
||||
<button mat-flat-button color="primary" type="submit" [disabled]="loading()">
|
||||
{{ loading() ? 'Creating…' : 'Create account' }}
|
||||
</button>
|
||||
</form>
|
||||
</mat-card-content>
|
||||
|
||||
<mat-card-actions>
|
||||
<span>Already registered?</span>
|
||||
<a mat-button routerLink="/login">Sign in</a>
|
||||
</mat-card-actions>
|
||||
</mat-card>
|
||||
</div>
|
||||
@@ -0,0 +1,143 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import {
|
||||
AbstractControl,
|
||||
AsyncValidatorFn,
|
||||
NonNullableFormBuilder,
|
||||
ReactiveFormsModule,
|
||||
ValidationErrors,
|
||||
Validators,
|
||||
} from '@angular/forms';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatCardModule } from '@angular/material/card';
|
||||
import { MatFormFieldModule } from '@angular/material/form-field';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatInputModule } from '@angular/material/input';
|
||||
import { MatProgressBarModule } from '@angular/material/progress-bar';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { Observable, catchError, debounceTime, first, map, of, switchMap } from 'rxjs';
|
||||
|
||||
import { AuthService } from '../../core/auth.service';
|
||||
import { ProblemDetails } from '../../core/models';
|
||||
|
||||
/**
|
||||
* Async "is this taken?" validator.
|
||||
*
|
||||
* The 2018 version asked the generic CRUD endpoint for the whole users row to
|
||||
* answer this, which exposed every user column to anonymous callers. The API
|
||||
* now has a dedicated endpoint that returns only a boolean.
|
||||
*/
|
||||
function availabilityValidator(
|
||||
auth: AuthService,
|
||||
field: 'userName' | 'email',
|
||||
): AsyncValidatorFn {
|
||||
return (control: AbstractControl): Observable<ValidationErrors | null> => {
|
||||
const value = String(control.value ?? '').trim();
|
||||
if (!value) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
return of(value).pipe(
|
||||
debounceTime(400),
|
||||
switchMap((v) => auth.isAvailable(field, v)),
|
||||
map((result) => (result.available ? null : { taken: true })),
|
||||
// A failed availability check should not block submission; the server
|
||||
// enforces uniqueness regardless.
|
||||
catchError(() => of(null)),
|
||||
first(),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-register',
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
RouterLink,
|
||||
MatCardModule,
|
||||
MatFormFieldModule,
|
||||
MatInputModule,
|
||||
MatButtonModule,
|
||||
MatIconModule,
|
||||
MatProgressBarModule,
|
||||
],
|
||||
templateUrl: './register.html',
|
||||
styleUrl: '../login/login.scss',
|
||||
})
|
||||
export class Register {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
private readonly fb = inject(NonNullableFormBuilder);
|
||||
|
||||
protected readonly loading = signal(false);
|
||||
protected readonly error = signal<string | null>(null);
|
||||
protected readonly showPassword = signal(false);
|
||||
|
||||
protected readonly form = this.fb.group({
|
||||
firstName: [''],
|
||||
lastName: [''],
|
||||
userName: [
|
||||
'',
|
||||
[Validators.required, Validators.minLength(3), Validators.maxLength(50)],
|
||||
[availabilityValidator(this.auth, 'userName')],
|
||||
],
|
||||
email: [
|
||||
'',
|
||||
[Validators.required, Validators.email, Validators.maxLength(256)],
|
||||
[availabilityValidator(this.auth, 'email')],
|
||||
],
|
||||
// Mirrors the server's Identity policy, so the rules are visible before
|
||||
// submitting rather than coming back as an error.
|
||||
password: [
|
||||
'',
|
||||
[
|
||||
Validators.required,
|
||||
Validators.minLength(12),
|
||||
Validators.pattern(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).+$/),
|
||||
],
|
||||
],
|
||||
});
|
||||
|
||||
submit(): void {
|
||||
if (this.form.invalid || this.loading()) {
|
||||
this.form.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
this.loading.set(true);
|
||||
this.error.set(null);
|
||||
|
||||
const value = this.form.getRawValue();
|
||||
|
||||
this.auth
|
||||
.register({
|
||||
userName: value.userName.trim(),
|
||||
email: value.email.trim(),
|
||||
password: value.password,
|
||||
firstName: value.firstName.trim() || undefined,
|
||||
lastName: value.lastName.trim() || undefined,
|
||||
})
|
||||
.subscribe({
|
||||
// Registration returns a token, so the new account lands straight in
|
||||
// the library instead of being bounced back to the login form.
|
||||
next: () => void this.router.navigate(['/games']),
|
||||
error: (err: HttpErrorResponse) => {
|
||||
this.loading.set(false);
|
||||
this.error.set(describeProblem(err));
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function describeProblem(err: HttpErrorResponse): string {
|
||||
const problem = err.error as ProblemDetails | undefined;
|
||||
|
||||
if (problem?.errors) {
|
||||
const messages = Object.values(problem.errors).flat();
|
||||
if (messages.length) {
|
||||
return messages.join(' ');
|
||||
}
|
||||
}
|
||||
|
||||
return problem?.title ?? 'Could not create the account. Please try again.';
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
|
||||
|
||||
export interface ConfirmDialogData {
|
||||
title: string;
|
||||
message: string;
|
||||
confirmLabel?: string;
|
||||
destructive?: boolean;
|
||||
}
|
||||
|
||||
/** Replaces the old pattern of deleting immediately on click with no confirmation. */
|
||||
@Component({
|
||||
selector: 'app-confirm-dialog',
|
||||
imports: [MatDialogModule, MatButtonModule],
|
||||
template: `
|
||||
<h2 mat-dialog-title>{{ data.title }}</h2>
|
||||
<mat-dialog-content>{{ data.message }}</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button (click)="dialogRef.close(false)">Cancel</button>
|
||||
<button
|
||||
mat-flat-button
|
||||
[color]="data.destructive ? 'warn' : 'primary'"
|
||||
(click)="dialogRef.close(true)"
|
||||
cdkFocusInitial
|
||||
>
|
||||
{{ data.confirmLabel ?? 'Confirm' }}
|
||||
</button>
|
||||
</mat-dialog-actions>
|
||||
`,
|
||||
})
|
||||
export class ConfirmDialog {
|
||||
readonly dialogRef = inject(MatDialogRef<ConfirmDialog, boolean>);
|
||||
readonly data = inject<ConfirmDialogData>(MAT_DIALOG_DATA);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
import { MatToolbarModule } from '@angular/material/toolbar';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
|
||||
import { AuthService } from '../core/auth.service';
|
||||
|
||||
/** App bar shared by the library and editor screens. */
|
||||
@Component({
|
||||
selector: 'app-toolbar',
|
||||
imports: [MatToolbarModule, MatButtonModule, MatIconModule, MatMenuModule, RouterLink],
|
||||
template: `
|
||||
<mat-toolbar color="primary" class="toolbar">
|
||||
<a class="brand" routerLink="/games">
|
||||
<mat-icon>videogame_asset</mat-icon>
|
||||
<span class="brand-text">LudosData</span>
|
||||
</a>
|
||||
|
||||
<span class="spacer"></span>
|
||||
|
||||
<ng-content />
|
||||
|
||||
<a mat-button routerLink="/games/new">
|
||||
<mat-icon>add</mat-icon>
|
||||
<span class="label-md">New game</span>
|
||||
</a>
|
||||
|
||||
<button mat-icon-button [matMenuTriggerFor]="menu" aria-label="Account menu">
|
||||
<mat-icon>account_circle</mat-icon>
|
||||
</button>
|
||||
|
||||
<mat-menu #menu="matMenu">
|
||||
@if (user(); as currentUser) {
|
||||
<div class="menu-header">
|
||||
<strong>{{ currentUser.userName }}</strong>
|
||||
@if (currentUser.email) {
|
||||
<small>{{ currentUser.email }}</small>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<a mat-menu-item routerLink="/account">
|
||||
<mat-icon>person</mat-icon>
|
||||
<span>Account</span>
|
||||
</a>
|
||||
<button mat-menu-item (click)="logout()">
|
||||
<mat-icon>logout</mat-icon>
|
||||
<span>Sign out</span>
|
||||
</button>
|
||||
</mat-menu>
|
||||
</mat-toolbar>
|
||||
`,
|
||||
styles: `
|
||||
.toolbar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
.spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.menu-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem 1rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.menu-header small {
|
||||
opacity: 0.7;
|
||||
}
|
||||
/* Keep the bar usable on a phone: icons stay, text labels drop out. */
|
||||
@media (max-width: 599px) {
|
||||
.brand-text,
|
||||
.label-md {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class Toolbar {
|
||||
private readonly auth = inject(AuthService);
|
||||
private readonly router = inject(Router);
|
||||
|
||||
readonly user = this.auth.user;
|
||||
|
||||
logout(): void {
|
||||
this.auth.logout();
|
||||
void this.router.navigate(['/login']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>LudosData</title>
|
||||
<base href="/" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="description" content="Personal video game library and collection tracker." />
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<link rel="icon" type="image/x-icon" href="favicon.ico" />
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { bootstrapApplication } from '@angular/platform-browser';
|
||||
import { appConfig } from './app/app.config';
|
||||
import { App } from './app/app';
|
||||
|
||||
bootstrapApplication(App, appConfig)
|
||||
.catch((err) => console.error(err));
|
||||
@@ -0,0 +1,52 @@
|
||||
@use '@angular/material' as mat;
|
||||
|
||||
|
||||
// Material 3 theming. `color-scheme: light dark` lets a single theme definition
|
||||
// follow the OS preference, so there is no separate dark stylesheet to maintain.
|
||||
html {
|
||||
color-scheme: light dark;
|
||||
|
||||
@include mat.theme(
|
||||
(
|
||||
color: (
|
||||
primary: mat.$violet-palette,
|
||||
tertiary: mat.$magenta-palette,
|
||||
),
|
||||
typography: Roboto,
|
||||
density: 0,
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--mat-sys-surface);
|
||||
color: var(--mat-sys-on-surface);
|
||||
font-family: var(--mat-sys-body-medium-font, Roboto, sans-serif);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--mat-sys-primary);
|
||||
}
|
||||
|
||||
// Respect users who have asked for less motion.
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": []
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
"noImplicitReturns": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"experimentalDecorators": true,
|
||||
"importHelpers": true,
|
||||
"target": "ES2022",
|
||||
"module": "preserve"
|
||||
},
|
||||
"angularCompilerOptions": {
|
||||
"enableI18nLegacyMessageIdFormat": false,
|
||||
"strictInjectionParameters": true,
|
||||
"strictInputAccessModifiers": true
|
||||
},
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.spec.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/* To learn more about Typescript configuration file: https://www.typescriptlang.org/docs/handbook/tsconfig-json.html. */
|
||||
/* To learn more about Angular compiler options: https://angular.dev/reference/configs/angular-compiler-options. */
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": [
|
||||
"vitest/globals"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.spec.ts"
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,17 +0,0 @@
|
||||
<?php
|
||||
|
||||
define('dbhost', 'localhost');
|
||||
define('dbuser', 'lazyp_workadmin');
|
||||
define('dbpass', 'GH5fZF0iCtLnHLrz');
|
||||
define('dbname', 'LudosData');
|
||||
|
||||
|
||||
try {
|
||||
$connect = new PDO("mysql:host=".dbhost."; dbname=".dbname, dbuser, dbpass);
|
||||
$connect->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
|
||||
}
|
||||
catch(PDOException $e) {
|
||||
echo $e->getMessage();
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -1,36 +0,0 @@
|
||||
<?php
|
||||
|
||||
/* development only */
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
|
||||
require '../vendor/autoload.php';
|
||||
|
||||
use Lcobucci\JWT\Builder;
|
||||
use Lcobucci\JWT\Signer\Hmac\Sha256;
|
||||
|
||||
$username = $_POST["userName"];
|
||||
$password = $_POST["password"];
|
||||
|
||||
if( $username == "admin" && $password == "admin" ){
|
||||
|
||||
$signer = new Sha256();
|
||||
$token = (new Builder())
|
||||
->setIssuer("http://pugludos.com")
|
||||
->setIssuedAt(time())
|
||||
->set("userName", "ckoch")
|
||||
->sign($signer, "testing")
|
||||
->getToken();
|
||||
|
||||
$userData = array();
|
||||
$userDatap["id"] = "12345";
|
||||
$userDatap["username"] = "admin";
|
||||
$userDatap["firstName"] = "TestFirst";
|
||||
$userDatap["lastName"] = "TestLast";
|
||||
$userDatap["token"] = (string)$token;
|
||||
|
||||
echo( json_encode( $userDatap ) );
|
||||
}else{
|
||||
http_response_code(400);
|
||||
}
|
||||
|
||||
?>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,79 +0,0 @@
|
||||
<?php
|
||||
|
||||
include_once( "class.upload.php" );
|
||||
|
||||
$user = "ckoch";
|
||||
$target_dir = "community/uploads/" . $user . "/";
|
||||
$target_file = $target_dir . basename($_FILES["fileToUpload"]["name"]);
|
||||
$uploadOk = 1;
|
||||
$imageFileType = strtolower(pathinfo($target_file,PATHINFO_EXTENSION));
|
||||
//$_FILES["fileToUpload"]
|
||||
|
||||
$handle = new upload( $_FILES["fileToUpload"] );
|
||||
|
||||
$newFileName = hash("sha256", $user . time() );
|
||||
|
||||
if( $handle->uploaded ){
|
||||
$handle->file_new_name_body = $newFileName;
|
||||
$handle->image_resize = true;
|
||||
$handle->image_x = 250;
|
||||
$handle->image_ratio_y = true;
|
||||
$handle->image_convert = 'png';
|
||||
$handle->process( $target_dir );
|
||||
|
||||
if( $handle->processed ){
|
||||
$handle->clean();
|
||||
echo $newFileName;
|
||||
}else{
|
||||
echo 'error : ' . $handle->error;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
/*
|
||||
// Check if image file is a actual image or fake image
|
||||
//if(isset($_POST["submit"])) {
|
||||
$check = getimagesize($_FILES["fileToUpload"]["tmp_name"]);
|
||||
if($check !== false) {
|
||||
//echo "File is an image - " . $check["mime"] . ".";
|
||||
$uploadOk = 1;
|
||||
} else {
|
||||
//echo "File is not an image.";
|
||||
$uploadOk = 0;
|
||||
}
|
||||
//}
|
||||
// Check if file already exists
|
||||
if (file_exists($target_file)) {
|
||||
//echo "Sorry, file already exists.";
|
||||
$uploadOk = 0;
|
||||
}
|
||||
// Check file size
|
||||
if ($_FILES["fileToUpload"]["size"] > 5000000) {
|
||||
//echo "Sorry, your file is too large.";
|
||||
$uploadOk = 0;
|
||||
}
|
||||
// Allow certain file formats
|
||||
//echo("|" . $imageFileType . "|<br />");
|
||||
if($imageFileType != "jpg" && $imageFileType != "png" && $imageFileType != "jpeg"
|
||||
&& $imageFileType != "gif" ) {
|
||||
//echo "Sorry, only JPG, JPEG, PNG & GIF files are allowed.";
|
||||
$uploadOk = 0;
|
||||
}
|
||||
// Check if $uploadOk is set to 0 by an error
|
||||
if ($uploadOk == 0) {
|
||||
echo( 0 );
|
||||
//echo "Sorry, your file was not uploaded.";
|
||||
// if everything is ok, try to upload file
|
||||
} else {
|
||||
//echo( $target_file );
|
||||
if (move_uploaded_file($_FILES["fileToUpload"]["tmp_name"], $target_file)) {
|
||||
//echo "The file ". basename( $_FILES["fileToUpload"]["name"]). " has been uploaded.";
|
||||
echo( 1 );
|
||||
} else {
|
||||
//echo "Sorry, there was an error uploading your file.";
|
||||
echo( 0 );
|
||||
}
|
||||
}
|
||||
*/
|
||||
?>
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
require '../vendor/autoload.php';
|
||||
|
||||
use Lcobucci\JWT\Builder;
|
||||
use Lcobucci\JWT\Signer\Hmac\Sha256;
|
||||
|
||||
$signer = new Sha256();
|
||||
$token = (new Builder())->setIssuer("http://pugludos.com")
|
||||
->setIssuedAt(time())
|
||||
->setExpiration(time() + 3600)
|
||||
->set("userName", "ckoch")
|
||||
->sign($signer, "testing")
|
||||
->getToken();
|
||||
|
||||
echo( $token );
|
||||
|
||||
/* used to verify token */
|
||||
var_dump($token->verify($signer, 'testing'));
|
||||
|
||||
?>
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
/* development only */
|
||||
header("Access-Control-Allow-Origin: *");
|
||||
|
||||
require '../vendor/autoload.php';
|
||||
require 'dbConfig.php';
|
||||
|
||||
use Lcobucci\JWT\Builder;
|
||||
use Lcobucci\JWT\Signer\Hmac\Sha256;
|
||||
|
||||
|
||||
$passwordSalt = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
|
||||
|
||||
$userName = $_POST['userName'];
|
||||
$password = $_POST['password'];
|
||||
|
||||
$hashedPassword = crypt( $password, $passwordSalt );
|
||||
|
||||
|
||||
$stmt = $connect->prepare('SELECT * FROM users WHERE userName = :userName');
|
||||
$stmt->execute(array(
|
||||
':userName' => $userName
|
||||
));
|
||||
|
||||
$data = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if( $data == false ){
|
||||
http_response_code(400);
|
||||
die();
|
||||
}else {
|
||||
if( hash_equals($hashedPassword,$data['password'] ) ) {
|
||||
if( $data['userName'] == "ckoch" ){
|
||||
$signer = new Sha256();
|
||||
$token = (new Builder())
|
||||
->setIssuer("http://pugludos.com")
|
||||
->setIssuedAt(time())
|
||||
->set("userName", $data['userId'])
|
||||
->sign($signer, "testing")
|
||||
->getToken();
|
||||
|
||||
$userData = array();
|
||||
$userDatap["id"] = $data['userId'];
|
||||
$userDatap["username"] = $data['userName'];
|
||||
$userDatap["firstName"] = $data['firstName'];
|
||||
$userDatap["lastName"] = $data['lastName'];
|
||||
$userDatap["email"] = $data['email'];
|
||||
$userDatap["art"] = $data['art'];
|
||||
$userDatap["token"] = (string)$token;
|
||||
|
||||
echo( json_encode( $userDatap ) );
|
||||
die();
|
||||
}else{
|
||||
http_response_code(400);
|
||||
die();
|
||||
}
|
||||
}else{
|
||||
http_response_code(400);
|
||||
die();
|
||||
}
|
||||
}
|
||||
|
||||
?>
|
||||
@@ -1,3 +0,0 @@
|
||||
<?php
|
||||
phpinfo();
|
||||
?>
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
$firstName = $_POST["firstName"];
|
||||
$lastName = $_POST["lastName"];
|
||||
$email = $_POST["email"];
|
||||
$userName = $_POST["userName"];
|
||||
$password = $_POST["password"];
|
||||
|
||||
$newUser = $_POST["newUser"];
|
||||
|
||||
$returnData = array();
|
||||
$date = new DateTime();
|
||||
$id = $date->getTimestamp() . $userName;
|
||||
|
||||
$passwordSalt = "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824";
|
||||
|
||||
$hashedPassword = crypt( $password, $passwordSalt );
|
||||
$hashedId = crypt( $id, $passwordSalt );
|
||||
|
||||
/*
|
||||
For login:
|
||||
if (hash_equals($hashed_password, crypt($user_input, $hashed_password))) {
|
||||
echo "Password verified!";
|
||||
}
|
||||
*/
|
||||
|
||||
$returnData["password"] = $hashedPassword;
|
||||
$returnData["id"] = $hashedId;
|
||||
|
||||
//echo( json_encode( $returnData ) );
|
||||
|
||||
$url = 'http://192.241.155.78/api.php/users/';
|
||||
$fields = array(
|
||||
'firstName' => urlencode( $firstName ),
|
||||
'lastName' => urlencode( $lastName ),
|
||||
'email' => urlencode( $email ),
|
||||
'userName' => urlencode( $userName ),
|
||||
'password' => urlencode( $hashedPassword ),
|
||||
'userId' => urlencode( $hashedId ),
|
||||
'id' => urlencode( $newUser )
|
||||
);
|
||||
|
||||
$ch = curl_init();
|
||||
|
||||
curl_setopt($ch,CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, 1);
|
||||
curl_setopt($ch,CURLOPT_POSTFIELDS, json_encode($fields));
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1);
|
||||
|
||||
$result = curl_exec( $ch );
|
||||
|
||||
curl_close( $ch );
|
||||
|
||||
echo( $result );
|
||||
|
||||
?>
|
||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user