diff --git a/.omo/run-continuation/ses_e2f302d08ffewav8qUiPZ0aI2j.json b/.omo/run-continuation/ses_e2f302d08ffewav8qUiPZ0aI2j.json new file mode 100644 index 0000000..4f67382 --- /dev/null +++ b/.omo/run-continuation/ses_e2f302d08ffewav8qUiPZ0aI2j.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_e2f302d08ffewav8qUiPZ0aI2j", + "updatedAt": "2026-11-12T17:30:43.353Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-11-12T17:30:43.353Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_e2f304d0bffewgGWmy7An2rOeJ.json b/.omo/run-continuation/ses_e2f304d0bffewgGWmy7An2rOeJ.json new file mode 100644 index 0000000..e1f344e --- /dev/null +++ b/.omo/run-continuation/ses_e2f304d0bffewgGWmy7An2rOeJ.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_e2f304d0bffewgGWmy7An2rOeJ", + "updatedAt": "2026-11-12T17:30:35.146Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-11-12T17:30:35.146Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_e2f306c90ffe63BtszteHo8OAl.json b/.omo/run-continuation/ses_e2f306c90ffe63BtszteHo8OAl.json new file mode 100644 index 0000000..1304561 --- /dev/null +++ b/.omo/run-continuation/ses_e2f306c90ffe63BtszteHo8OAl.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_e2f306c90ffe63BtszteHo8OAl", + "updatedAt": "2026-11-12T17:30:27.126Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-11-12T17:30:27.126Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_e2f306cc1ffemzh3awaA137wZh.json b/.omo/run-continuation/ses_e2f306cc1ffemzh3awaA137wZh.json new file mode 100644 index 0000000..2fa2a9d --- /dev/null +++ b/.omo/run-continuation/ses_e2f306cc1ffemzh3awaA137wZh.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_e2f306cc1ffemzh3awaA137wZh", + "updatedAt": "2026-11-12T17:30:27.053Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-11-12T17:30:27.053Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_e2f306cedffevymy11Qu04FoB0.json b/.omo/run-continuation/ses_e2f306cedffevymy11Qu04FoB0.json new file mode 100644 index 0000000..e3864ca --- /dev/null +++ b/.omo/run-continuation/ses_e2f306cedffevymy11Qu04FoB0.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_e2f306cedffevymy11Qu04FoB0", + "updatedAt": "2026-11-12T17:30:27.004Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-11-12T17:30:27.004Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_e2f393836ffeUi9lBqb1kfxxoz.json b/.omo/run-continuation/ses_e2f393836ffeUi9lBqb1kfxxoz.json new file mode 100644 index 0000000..adea47c --- /dev/null +++ b/.omo/run-continuation/ses_e2f393836ffeUi9lBqb1kfxxoz.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_e2f393836ffeUi9lBqb1kfxxoz", + "updatedAt": "2026-11-12T18:38:36.743Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-11-12T18:38:36.743Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_e2f45eaccffeiis1QONAKg7gzm.json b/.omo/run-continuation/ses_e2f45eaccffeiis1QONAKg7gzm.json new file mode 100644 index 0000000..55678e3 --- /dev/null +++ b/.omo/run-continuation/ses_e2f45eaccffeiis1QONAKg7gzm.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_e2f45eaccffeiis1QONAKg7gzm", + "updatedAt": "2026-11-12T17:10:58.656Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-11-12T17:10:58.656Z" + } + } +} \ No newline at end of file diff --git a/README.md b/README.md index a188daf..4d44bf7 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,29 @@ # Star Citizen Inventory -A read-only item inventory website for Star Citizen players, built from the +A Star Citizen item catalog built from the [scunpacked](https://github.com/richardthombs/scunpacked) game data (mirrored at [StarCitizenWiki/scunpacked-data](https://github.com/StarCitizenWiki/scunpacked-data)). Ships, ship equipment, and FPS items are browsable with search, manufacturer / type / size filters, pagination, and a detail view per item. +Users can optionally create an account and build a **personal inventory**: items +from the catalog are added to their own list with a quantity and notes. The +catalog itself stays immutable read-only reference data; personal inventories +live in PostgreSQL and reference catalog items by `class_name` only, so the +dataset can be replaced wholesale without touching user data. + ## Stack -| Piece | Tech | Role | -| -------- | -------------------------------------- | ----------------------------------------------- | -| backend | FastAPI (python:3.12-slim container) | Serves normalized scunpacked JSON over `/api/v1` | -| frontend | nginx:1.27-alpine + vanilla JS/CSS | Inventory UI; proxies `/api/` and `/healthz` | -| deploy | Ansible + Podman (containers.podman) | Builds images and runs the two containers | +| Piece | Tech | Role | +| -------- | -------------------------------------- | ---------------------------------------------------------- | +| backend | FastAPI (python:3.12-slim container) | Serves the read-only catalog and the account/inventory API | +| frontend | nginx:1.27-alpine + vanilla JS/CSS | Inventory UI; proxies `/api/` and `/healthz` | +| database | PostgreSQL 16 | Accounts, sessions, and per-user inventory rows | +| deploy | Ansible + Podman (containers.podman) | Builds images and runs the three containers | The backend image downloads the full scunpacked dataset (~83 MB) at build time, -so no database is needed — the inventory is read-only game data. +so the catalog needs no database — only user data does. ## Layout @@ -24,7 +31,7 @@ so no database is needed — the inventory is read-only game data. playbook.yml Ansible deployment (hosts: webservers) inventory.ini Inventory (localhost + commented remote example) trade-hub/ - podman-compose.yml Local two-service stack (backend + frontend) + podman-compose.yml Local three-service stack (db + backend + frontend) backend/ FastAPI app, tests, data fetch script, Dockerfile frontend/ nginx config + static inventory UI ``` @@ -39,12 +46,19 @@ podman-compose up -d --build Then open http://localhost:8080 +PostgreSQL data is kept in the `pgdata` volume. Set a non-default database +password before deploying anywhere real: + +```bash +POSTGRES_PASSWORD='choose-something-strong' podman-compose up -d --build +``` + ## Deploy with Ansible Deploys to every host in the `webservers` group (defaults to localhost): ```bash -ansible-playbook playbook.yml +POSTGRES_PASSWORD='choose-something-strong' ansible-playbook playbook.yml ``` To deploy to a remote server, edit `inventory.ini`: @@ -60,10 +74,59 @@ Then run the same command from this directory. The playbook: 2. removes the legacy `my-apache-server` container if present, 3. builds `sc-inventory-backend` / `sc-inventory-frontend` images (skipped when they already exist; set `-e force_rebuild=true` to rebuild), -4. creates the `sc-inventory` podman network, -5. runs both containers (only the frontend publishes a port: 8080), +4. creates the `sc-inventory` podman network and the `sc-inventory-data` volume, +5. runs PostgreSQL, the backend, and the frontend containers (only the frontend + publishes a port: 8080), 6. verifies `http://localhost:8080/healthz` returns 200. +## Reset for testing (remove everything) + +```bash +# preview only, changes nothing +ansible-playbook teardown.yml -e confirm_destroy=true --check + +# remove containers, all project images, the network and the database volume +ansible-playbook teardown.yml -e confirm_destroy=true +``` + +The teardown is scoped by name (`sc-inventory*`, `sc-inv-verify*`, +`trade-hub*`) and never runs `podman system prune`, so unrelated containers, +images and volumes on the host are left alone. It removes every tag of the +project images (so stale builds cannot resurface) and deletes the +`sc-inventory-data` volume — **all accounts and inventories are destroyed**. +Run `playbook.yml` afterwards to deploy a clean install; the images rebuild +from scratch. + +## Accounts and personal inventory + +- **Registration is open**: anyone who can reach the site can create an account. +- A new account starts with an **empty inventory**; nothing is seeded or shared + between accounts. +- Passwords are hashed with Argon2 and never returned by the API. +- Sessions are opaque random tokens delivered in an `httpOnly`, `SameSite=Lax` + cookie; only the SHA-256 of the token is stored server-side. +- Failed logins are throttled per username and per client IP, keyed on the + `X-Real-IP` header that nginx sets from the actual peer address. +- Mutating requests are rejected when the `Origin` header does not match the + request host. +- Catalog browsing works without an account; the catalog endpoints remain + read-only. + +Inventory rows store `(user_id, class_name, quantity, notes)`. Adding an item +that is already present increments its quantity; quantity and notes can then be +edited or the row removed. Responses are enriched live from the catalog. If a +catalog item disappears after a dataset refresh, the row is reported with +`available: false` instead of being silently deleted. + +### Security: read before exposing this publicly + +The stack publishes plain HTTP on port 8080. Session cookies therefore travel +in cleartext, and `COOKIE_SECURE` defaults to `false` so local development works. +Before putting this on the open internet, terminate TLS in front of the +frontend (Caddy, nginx with certificates, or a load balancer) and set +`COOKIE_SECURE=true` (compose env var) or `-e cookie_secure=true` (Ansible). +Also change `POSTGRES_PASSWORD` from its development default. + ## Verify ```bash @@ -72,28 +135,80 @@ curl -fsS http://localhost:8080/api/v1/stats curl -fsS "http://localhost:8080/api/v1/items?search=carrack" ``` +Register, then use the cookie jar for authenticated calls: + +```bash +curl -fsS -c cookies.txt -X POST http://localhost:8080/api/v1/auth/register \ + -H 'Content-Type: application/json' \ + -d '{"username":"pilot","password":"correct horse battery"}' + +curl -fsS -b cookies.txt http://localhost:8080/api/v1/auth/me + +curl -fsS -b cookies.txt -X POST http://localhost:8080/api/v1/me/inventory \ + -H 'Content-Type: application/json' \ + -d '{"class_name":"ANVL_Carrack","quantity":1,"notes":"pledged"}' + +curl -fsS -b cookies.txt http://localhost:8080/api/v1/me/inventory +``` + ## Backend development ```bash cd trade-hub/backend python3 -m venv .venv .venv/bin/pip install -r requirements.txt -r requirements-dev.txt -.venv/bin/python -m pytest tests/ -q # 19 tests +.venv/bin/python -m pytest tests/ -q # 45 tests, no database needed DATA_DIR=./data/samples .venv/bin/uvicorn main:app --port 8000 ``` +Tests run against an in-memory SQLite database configured in `tests/conftest.py`, +so no PostgreSQL server is required for the suite. Production uses PostgreSQL. + - `data/samples/` — small real-schema subset for local dev/tests - `scripts/fetch_data.py` — downloads the full dataset (used by the Dockerfile) +Tables are created on startup with an idempotent `create_all` and a connection +retry loop, so a fresh volume bootstraps itself and a slow PostgreSQL start does +not crash the backend. This does not migrate changed columns — introduce Alembic +when the schema starts evolving in place. + ## API ``` -GET /healthz -GET /api/v1/stats -GET /api/v1/manufacturers -GET /api/v1/items?category=&search=&manufacturer=&type=&size=&page=&page_size= -GET /api/v1/items/{class_name} +GET /healthz +GET /api/v1/stats +GET /api/v1/manufacturers +GET /api/v1/items?category=&search=&manufacturer=&type=&size=&page=&page_size= +GET /api/v1/items/{class_name} + +POST /api/v1/auth/register {username, password} -> 201 + session cookie +POST /api/v1/auth/login {username, password} -> 200 + session cookie +POST /api/v1/auth/logout -> 204 +GET /api/v1/auth/me -> {user} + +GET /api/v1/me/inventory?category=&search=&page=&page_size= +GET /api/v1/me/inventory/summary +POST /api/v1/me/inventory {class_name, quantity?, notes?} +PATCH /api/v1/me/inventory/{class_name} {quantity?, notes?} +DELETE /api/v1/me/inventory/{class_name} ``` +`/api/v1/me/*` requires a valid session cookie and returns `401` otherwise. + +### Configuration + +| Variable | Default | Purpose | +| ---------------------- | ------------------------------------------------------------------------ | ----------------------------------------------- | +| `DATA_DIR` | `backend/data/samples` | Catalog JSON location | +| `DATABASE_URL` | `postgresql+psycopg://sc_inventory:sc_inventory_dev@db:5432/sc_inventory` | SQLAlchemy database URL | +| `AUTO_INIT_DB` | `true` | Create missing tables on startup | +| `DB_WAIT_SECONDS` | `60` | Startup wait for PostgreSQL | +| `COOKIE_SECURE` | `false` | Mark the session cookie `Secure` (use with TLS) | +| `COOKIE_NAME` | `sc_session` | Session cookie name | +| `SESSION_TTL_DAYS` | `30` | Absolute session lifetime | +| `CORS_ORIGINS` | *(empty)* | Comma-separated cross-origin opt-in | +| `LOGIN_MAX_ATTEMPTS` | `5` | Failed logins before throttling | +| `LOGIN_WINDOW_SECONDS` | `900` | Throttle window | + Data source: scunpacked — unofficial Star Citizen fansite content, not affiliated with Cloud Imperium Games. diff --git a/playbook.yml b/playbook.yml index f87049b..b52731e 100644 --- a/playbook.yml +++ b/playbook.yml @@ -5,9 +5,15 @@ vars: backend_image: sc-inventory-backend frontend_image: sc-inventory-frontend + postgres_image: docker.io/library/postgres:16-alpine image_tag: latest network_name: sc-inventory frontend_port: "8080:80" + postgres_volume: sc-inventory-data + postgres_password: "{{ lookup('env', 'POSTGRES_PASSWORD') | default('sc_inventory_dev', true) }}" + database_url: "postgresql+psycopg://sc_inventory:{{ postgres_password }}@db:5432/sc_inventory" + # Set to true once the site is served over TLS. + cookie_secure: "false" # Set to true to force image rebuilds even when the images already exist force_rebuild: false @@ -70,6 +76,27 @@ name: "{{ network_name }}" state: present + - name: Create PostgreSQL data volume + containers.podman.podman_volume: + name: "{{ postgres_volume }}" + state: present + + - name: Run PostgreSQL container (internal only) + containers.podman.podman_container: + name: sc-inventory-db + image: "{{ postgres_image }}" + state: started + network: "{{ network_name }}" + network_aliases: + - db + env: + POSTGRES_USER: sc_inventory + POSTGRES_PASSWORD: "{{ postgres_password }}" + POSTGRES_DB: sc_inventory + volumes: + - "{{ postgres_volume }}:/var/lib/postgresql/data:Z" + restart_policy: unless-stopped + - name: Run backend container (internal only) containers.podman.podman_container: name: sc-inventory-backend @@ -80,6 +107,10 @@ # nginx upstream (http://backend:8000) resolves in both deployments. network_aliases: - backend + env: + DATABASE_URL: "{{ database_url }}" + DB_WAIT_SECONDS: "60" + COOKIE_SECURE: "{{ cookie_secure }}" restart_policy: unless-stopped - name: Run frontend container (published port) @@ -98,5 +129,5 @@ status_code: 200 register: health_result until: health_result.status == 200 - retries: 5 + retries: 8 delay: 10 diff --git a/teardown.yml b/teardown.yml new file mode 100644 index 0000000..5fbe662 --- /dev/null +++ b/teardown.yml @@ -0,0 +1,126 @@ +--- +# Removes only this project's resources, matched by name. It never calls +# `podman system prune`, so unrelated containers, images and volumes on the +# host are left untouched. +- name: Remove the Star Citizen Inventory installation and its data + hosts: webservers + + vars: + confirm_destroy: false + container_pattern: '^(sc-inventory|sc-inv-verify|trade-hub[-_])' + image_pattern: '^(localhost/)?(sc-inventory-|sc-inv-verify-|trade-hub[-_])' + volume_pattern: '^(sc-inventory-data|pgdata|trade-hub_pgdata)$' + network_pattern: '^(sc-inventory$|sc-inv-verify$)' + + tasks: + - name: Require explicit confirmation for a destructive run + ansible.builtin.assert: + that: + - confirm_destroy | bool + fail_msg: >- + Refusing to run. This deletes the containers, images, network and the + PostgreSQL volume that holds every account and inventory. Re-run with: + ansible-playbook teardown.yml -e confirm_destroy=true + quiet: true + when: not ansible_check_mode + + - name: Check whether podman is installed + ansible.builtin.command: podman --version + register: podman_check + failed_when: false + changed_when: false + check_mode: false + + - name: End play when podman is absent + ansible.builtin.meta: end_play + when: podman_check.rc != 0 + + - name: List containers + ansible.builtin.command: podman ps -a --format json + register: existing_containers + changed_when: false + check_mode: false + + - name: Remove project containers + containers.podman.podman_container: + name: "{{ item }}" + state: absent + loop: >- + {{ (existing_containers.stdout | from_json) + | map(attribute='Names') + | select('iterable') + | flatten + | unique + | select('match', container_pattern) + | list }} + + - name: List images + ansible.builtin.command: podman images --format json + register: existing_images + changed_when: false + check_mode: false + + - name: Remove project images (every tag, including stale builds) + ansible.builtin.command: + argv: + - podman + - rmi + - --force + - "{{ item }}" + loop: >- + {{ (existing_images.stdout | from_json) + | selectattr('Names', 'defined') + | selectattr('Names', 'iterable') + | map(attribute='Names') + | flatten + | unique + | select('match', image_pattern) + | list }} + failed_when: false + + - name: List volumes + ansible.builtin.command: podman volume ls --format json + register: existing_volumes + changed_when: false + check_mode: false + + - name: Remove the database volume (all accounts and inventories) + containers.podman.podman_volume: + name: "{{ item }}" + state: absent + loop: >- + {{ (existing_volumes.stdout | from_json) + | map(attribute='Name') + | unique + | select('match', volume_pattern) + | list }} + + - name: List networks + ansible.builtin.command: podman network ls --format json + register: existing_networks + changed_when: false + check_mode: false + + - name: Remove project networks + containers.podman.podman_network: + name: "{{ item }}" + state: absent + loop: >- + {{ (existing_networks.stdout | from_json) + | map(attribute='name') + | unique + | select('match', network_pattern) + | list }} + + - name: Verify nothing from this project remains + ansible.builtin.shell: + cmd: | + set -eu + c=$(podman ps -a --format '{% raw %}{{ .Names }}{% endraw %}' | grep -Ec '^(sc-inventory|sc-inv-verify|trade-hub[-_])' || true) + i=$(podman images --format '{% raw %}{{ .Names }}{% endraw %}' | grep -Ec '^(localhost/)?(sc-inventory-|sc-inv-verify-|trade-hub[-_])' || true) + v=$(podman volume ls --format '{% raw %}{{ .Name }}{% endraw %}' | grep -Ec '^(sc-inventory-data|pgdata|trade-hub_pgdata)$' || true) + n=$(podman network ls --format '{% raw %}{{ .Name }}{% endraw %}' | grep -Ec '^(sc-inventory|sc-inv-verify)$' || true) + echo "remaining: containers=$c images=$i volumes=$v networks=$n" + [ "$c" -eq 0 ] && [ "$i" -eq 0 ] && [ "$v" -eq 0 ] && [ "$n" -eq 0 ] + changed_when: false + when: not ansible_check_mode diff --git a/trade-hub/backend/__pycache__/main.cpython-314.pyc b/trade-hub/backend/__pycache__/main.cpython-314.pyc index 9a5bd28..dde91bb 100644 Binary files a/trade-hub/backend/__pycache__/main.cpython-314.pyc and b/trade-hub/backend/__pycache__/main.cpython-314.pyc differ diff --git a/trade-hub/backend/app/__pycache__/__init__.cpython-314.pyc b/trade-hub/backend/app/__pycache__/__init__.cpython-314.pyc index ba92cd2..e288fda 100644 Binary files a/trade-hub/backend/app/__pycache__/__init__.cpython-314.pyc and b/trade-hub/backend/app/__pycache__/__init__.cpython-314.pyc differ diff --git a/trade-hub/backend/app/__pycache__/api.cpython-314.pyc b/trade-hub/backend/app/__pycache__/api.cpython-314.pyc index a25ec7f..c6b43b9 100644 Binary files a/trade-hub/backend/app/__pycache__/api.cpython-314.pyc and b/trade-hub/backend/app/__pycache__/api.cpython-314.pyc differ diff --git a/trade-hub/backend/app/__pycache__/auth.cpython-314.pyc b/trade-hub/backend/app/__pycache__/auth.cpython-314.pyc new file mode 100644 index 0000000..b882813 Binary files /dev/null and b/trade-hub/backend/app/__pycache__/auth.cpython-314.pyc differ diff --git a/trade-hub/backend/app/__pycache__/catalog.cpython-314.pyc b/trade-hub/backend/app/__pycache__/catalog.cpython-314.pyc new file mode 100644 index 0000000..c19b00f Binary files /dev/null and b/trade-hub/backend/app/__pycache__/catalog.cpython-314.pyc differ diff --git a/trade-hub/backend/app/__pycache__/config.cpython-314.pyc b/trade-hub/backend/app/__pycache__/config.cpython-314.pyc index 93757db..5b575a7 100644 Binary files a/trade-hub/backend/app/__pycache__/config.cpython-314.pyc and b/trade-hub/backend/app/__pycache__/config.cpython-314.pyc differ diff --git a/trade-hub/backend/app/__pycache__/db.cpython-314.pyc b/trade-hub/backend/app/__pycache__/db.cpython-314.pyc new file mode 100644 index 0000000..33bc6d0 Binary files /dev/null and b/trade-hub/backend/app/__pycache__/db.cpython-314.pyc differ diff --git a/trade-hub/backend/app/__pycache__/deps.cpython-314.pyc b/trade-hub/backend/app/__pycache__/deps.cpython-314.pyc new file mode 100644 index 0000000..714bdb3 Binary files /dev/null and b/trade-hub/backend/app/__pycache__/deps.cpython-314.pyc differ diff --git a/trade-hub/backend/app/__pycache__/inventory.cpython-314.pyc b/trade-hub/backend/app/__pycache__/inventory.cpython-314.pyc new file mode 100644 index 0000000..321ce74 Binary files /dev/null and b/trade-hub/backend/app/__pycache__/inventory.cpython-314.pyc differ diff --git a/trade-hub/backend/app/__pycache__/loader.cpython-314.pyc b/trade-hub/backend/app/__pycache__/loader.cpython-314.pyc index 00d72e7..c234c02 100644 Binary files a/trade-hub/backend/app/__pycache__/loader.cpython-314.pyc and b/trade-hub/backend/app/__pycache__/loader.cpython-314.pyc differ diff --git a/trade-hub/backend/app/__pycache__/models.cpython-314.pyc b/trade-hub/backend/app/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..91e6e5f Binary files /dev/null and b/trade-hub/backend/app/__pycache__/models.cpython-314.pyc differ diff --git a/trade-hub/backend/app/__pycache__/schemas.cpython-314.pyc b/trade-hub/backend/app/__pycache__/schemas.cpython-314.pyc new file mode 100644 index 0000000..60f22b6 Binary files /dev/null and b/trade-hub/backend/app/__pycache__/schemas.cpython-314.pyc differ diff --git a/trade-hub/backend/app/__pycache__/security.cpython-314.pyc b/trade-hub/backend/app/__pycache__/security.cpython-314.pyc new file mode 100644 index 0000000..11bc5ee Binary files /dev/null and b/trade-hub/backend/app/__pycache__/security.cpython-314.pyc differ diff --git a/trade-hub/backend/app/api.py b/trade-hub/backend/app/api.py index a996ae7..53df03f 100644 --- a/trade-hub/backend/app/api.py +++ b/trade-hub/backend/app/api.py @@ -1,25 +1,50 @@ -"""FastAPI routes for the Star Citizen inventory (scunpacked data).""" +"""FastAPI routes for the Star Citizen inventory (scunpacked data). + +The catalog endpoints below are read-only. Accounts and personal inventories +are served by the auth and inventory routers and persisted in PostgreSQL. +""" import math +from contextlib import asynccontextmanager from fastapi import FastAPI, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware -from app import loader +from app import config +from app.auth import router as auth_router +from app.catalog import store +from app.db import db_healthy, init_db +from app.inventory import router as inventory_router + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + if config.AUTO_INIT_DB: + init_db(wait_seconds=config.DB_WAIT_SECONDS) + yield + app = FastAPI( title="Star Citizen Inventory API", - description="Read-only item inventory built from scunpacked game data.", - version="1.0.0", + description="Read-only item catalog built from scunpacked game data, plus " + "per-user accounts and personal inventories.", + version="1.1.0", + lifespan=lifespan, ) -# Read-only public GET API; permissive CORS keeps local development easy. -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["GET"], -) +# Same-origin deployment by default. Cross-origin access must be opted into +# explicitly, because credentialed cookies and a wildcard origin are +# incompatible (and unsafe). +if config.CORS_ORIGINS: + app.add_middleware( + CORSMiddleware, + allow_origins=config.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["GET", "POST", "PATCH", "DELETE"], + allow_headers=["Content-Type"], + ) -store = loader.load_store() +app.include_router(auth_router) +app.include_router(inventory_router) VALID_CATEGORIES = {"ships", "ship-items", "fps-items"} @@ -53,7 +78,7 @@ def _filter_items(category, search, manufacturer, item_type, size): @app.get("/healthz") def healthz(): - return {"status": "ok", "counts": store.counts} + return {"status": "ok", "counts": store.counts, "db": db_healthy()} @app.get("/api/v1/stats") diff --git a/trade-hub/backend/app/auth.py b/trade-hub/backend/app/auth.py new file mode 100644 index 0000000..ce57b79 --- /dev/null +++ b/trade-hub/backend/app/auth.py @@ -0,0 +1,160 @@ +"""Authentication routes: register, login, logout and current-user lookup. + +Identity is a self-hosted username/password pair. Passwords are hashed with +Argon2; sessions are opaque random tokens whose SHA-256 is persisted, delivered +in an httpOnly cookie. +""" +from datetime import timedelta + +from fastapi import APIRouter, Depends, HTTPException, Request, Response +from sqlalchemy import delete, select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session as DbSession + +from app import config, security +from app.db import get_db +from app.deps import get_current_user, require_same_origin +from app.models import User, UserSession, utcnow +from app.schemas import AuthResponse, LoginRequest, RegisterRequest, UserOut + +router = APIRouter(prefix="/api/v1/auth", tags=["auth"]) + +throttle = security.LoginThrottle( + config.LOGIN_MAX_ATTEMPTS, config.LOGIN_WINDOW_SECONDS +) + +_COOKIE_MAX_AGE = config.SESSION_TTL_DAYS * 24 * 60 * 60 + + +def _set_session_cookie(response: Response, token: str) -> None: + response.set_cookie( + key=config.COOKIE_NAME, + value=token, + max_age=_COOKIE_MAX_AGE, + httponly=True, + samesite="lax", + secure=config.COOKIE_SECURE, + path="/", + ) + + +def _issue_session(db: DbSession, user: User) -> str: + token = security.new_session_token() + now = utcnow() + # Drop this user's expired sessions while we are here. + db.execute( + delete(UserSession).where( + UserSession.user_id == user.id, UserSession.expires_at <= now + ) + ) + db.add( + UserSession( + token_hash=security.hash_token(token), + user_id=user.id, + created_at=now, + expires_at=now + timedelta(days=config.SESSION_TTL_DAYS), + ) + ) + db.commit() + return token + + +@router.post( + "/register", + status_code=201, + response_model=AuthResponse, + dependencies=[Depends(require_same_origin)], +) +def register(payload: RegisterRequest, response: Response, db: DbSession = Depends(get_db)): + username = payload.username.strip() + if not security.valid_username(username): + raise HTTPException( + status_code=422, + detail="Username must be 3-32 characters: letters, digits, '.', '_' or '-'", + ) + + username_key = security.normalize_username(username) + existing = db.execute( + select(User).where(User.username_key == username_key) + ).scalar_one_or_none() + if existing is not None: + raise HTTPException(status_code=409, detail="Username is already taken") + + user = User( + username=username, + username_key=username_key, + password_hash=security.hash_password(payload.password), + ) + db.add(user) + try: + db.commit() + except IntegrityError: + db.rollback() + raise HTTPException(status_code=409, detail="Username is already taken") + + db.refresh(user) + _set_session_cookie(response, _issue_session(db, user)) + return {"user": UserOut.model_validate(user)} + + +@router.post( + "/login", + response_model=AuthResponse, + dependencies=[Depends(require_same_origin)], +) +def login(payload: LoginRequest, request: Request, response: Response, db: DbSession = Depends(get_db)): + username_key = security.normalize_username(payload.username) + ip = security.client_ip(request) + + wait = throttle.retry_after(username_key, ip) + if wait: + raise HTTPException( + status_code=429, + detail="Too many failed login attempts. Try again later.", + headers={"Retry-After": str(wait)}, + ) + + user = db.execute( + select(User).where(User.username_key == username_key) + ).scalar_one_or_none() + + if user is None: + security.burn_password_check(payload.password) + throttle.record_failure(username_key, ip) + raise HTTPException(status_code=401, detail="Invalid username or password") + + if not security.verify_password(payload.password, user.password_hash): + throttle.record_failure(username_key, ip) + raise HTTPException(status_code=401, detail="Invalid username or password") + + throttle.reset(username_key, ip) + if security.needs_rehash(user.password_hash): + user.password_hash = security.hash_password(payload.password) + db.commit() + + _set_session_cookie(response, _issue_session(db, user)) + return {"user": UserOut.model_validate(user)} + + +@router.post( + "/logout", + status_code=204, + dependencies=[Depends(require_same_origin)], +) +def logout(request: Request, db: DbSession = Depends(get_db)): + token = request.cookies.get(config.COOKIE_NAME) + if token: + db.execute( + delete(UserSession).where( + UserSession.token_hash == security.hash_token(token) + ) + ) + db.commit() + response = Response(status_code=204) + response.delete_cookie(config.COOKIE_NAME, path="/") + return response + + +@router.get("/me", response_model=AuthResponse) +def me(user: User = Depends(get_current_user)): + return {"user": UserOut.model_validate(user)} diff --git a/trade-hub/backend/app/catalog.py b/trade-hub/backend/app/catalog.py new file mode 100644 index 0000000..cd7ce8d --- /dev/null +++ b/trade-hub/backend/app/catalog.py @@ -0,0 +1,8 @@ +"""Single owner of the immutable, read-only scunpacked catalog. + +Keeping the store here (instead of in app/api.py) lets the auth and inventory +routers import it without importing the FastAPI app itself. +""" +from app import loader + +store = loader.load_store() diff --git a/trade-hub/backend/app/config.py b/trade-hub/backend/app/config.py index 380bb29..80a7ffe 100644 --- a/trade-hub/backend/app/config.py +++ b/trade-hub/backend/app/config.py @@ -3,6 +3,9 @@ DATA_DIR selects which dataset the API serves: - local dev / tests: small real-schema samples (default) or a fixture dir - container image: the full scunpacked dataset downloaded at build time + +The catalog is read-only. Everything mutable (accounts, sessions, per-user +inventories) lives in the database configured by DATABASE_URL. """ import os @@ -10,3 +13,45 @@ _APP_DIR = os.path.dirname(os.path.abspath(__file__)) _BACKEND_DIR = os.path.dirname(_APP_DIR) DATA_DIR = os.environ.get("DATA_DIR", os.path.join(_BACKEND_DIR, "data", "samples")) + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.environ.get(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +# --- Persistence ---------------------------------------------------------- +DATABASE_URL = os.environ.get( + "DATABASE_URL", "postgresql+psycopg://sc_inventory:sc_inventory_dev@db:5432/sc_inventory" +) +# Create missing tables on startup. Disabled by the test suite, which builds +# its own schema against an in-memory SQLite database. +AUTO_INIT_DB = _env_bool("AUTO_INIT_DB", True) +# How long startup waits for PostgreSQL to accept connections (container boot). +DB_WAIT_SECONDS = int(os.environ.get("DB_WAIT_SECONDS", "60")) + +# --- Session cookies ------------------------------------------------------ +# Enable COOKIE_SECURE once the site is served over TLS. Defaults to false so +# plain http://localhost:8080 keeps working out of the box. +COOKIE_SECURE = _env_bool("COOKIE_SECURE", False) +COOKIE_NAME = os.environ.get("COOKIE_NAME", "sc_session") +SESSION_TTL_DAYS = int(os.environ.get("SESSION_TTL_DAYS", "30")) + +# --- CORS ----------------------------------------------------------------- +# Empty by default: the frontend is served same-origin through nginx, so no +# cross-origin access is needed. Comma-separated origins opt in explicitly. +CORS_ORIGINS = [ + origin.strip() + for origin in os.environ.get("CORS_ORIGINS", "").split(",") + if origin.strip() +] + +# --- Login hardening ------------------------------------------------------ +LOGIN_MAX_ATTEMPTS = int(os.environ.get("LOGIN_MAX_ATTEMPTS", "5")) +LOGIN_WINDOW_SECONDS = int(os.environ.get("LOGIN_WINDOW_SECONDS", "900")) + +# --- Input limits --------------------------------------------------------- +MAX_NOTES_LENGTH = 1000 +MAX_QUANTITY = 1_000_000 diff --git a/trade-hub/backend/app/db.py b/trade-hub/backend/app/db.py new file mode 100644 index 0000000..9c8baa4 --- /dev/null +++ b/trade-hub/backend/app/db.py @@ -0,0 +1,89 @@ +"""Database engine, session factory and schema bootstrap. + +The engine is created lazily so importing the app (and loading the read-only +catalog) never requires a reachable database. +""" +import logging +import time + +from sqlalchemy import create_engine, text +from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +from app import config +from app.models import Base + +log = logging.getLogger(__name__) + +_engine = None +_session_factory = None + + +def get_engine(): + """Return the process-wide engine, creating it on first use.""" + global _engine, _session_factory + if _engine is None: + url = config.DATABASE_URL + kwargs = {"pool_pre_ping": True, "future": True} + if url.startswith("sqlite"): + kwargs["connect_args"] = {"check_same_thread": False} + if ":memory:" in url or url in {"sqlite://", "sqlite+pysqlite://"}: + kwargs["poolclass"] = StaticPool + _engine = create_engine(url, **kwargs) + _session_factory = sessionmaker( + bind=_engine, autoflush=False, autocommit=False, expire_on_commit=False + ) + return _engine + + +def get_session_factory(): + get_engine() + return _session_factory + + +def get_db(): + """FastAPI dependency yielding a request-scoped SQLAlchemy session.""" + db = get_session_factory()() + try: + yield db + finally: + db.close() + + +def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None: + """Create missing tables, retrying while PostgreSQL finishes booting. + + `create_all(checkfirst=True)` is idempotent and safe to run on every start. + It does not migrate changed columns; introduce Alembic when the schema + starts evolving in place. + """ + deadline = time.monotonic() + max(wait_seconds, 0) + while True: + try: + engine = get_engine() + with engine.connect() as conn: + conn.execute(text("SELECT 1")) + Base.metadata.create_all(bind=engine, checkfirst=True) + return + except (OperationalError, IntegrityError, ProgrammingError) as exc: + # A concurrent startup may have created the tables first. + if "already exists" in str(exc).lower(): + return + if time.monotonic() >= deadline: + raise + log.warning( + "database not ready (%s); retrying in %.1fs", + exc.__class__.__name__, + interval, + ) + time.sleep(interval) + + +def db_healthy() -> bool: + try: + with get_engine().connect() as conn: + conn.execute(text("SELECT 1")) + return True + except Exception: + return False diff --git a/trade-hub/backend/app/deps.py b/trade-hub/backend/app/deps.py new file mode 100644 index 0000000..c342bd2 --- /dev/null +++ b/trade-hub/backend/app/deps.py @@ -0,0 +1,39 @@ +"""Shared FastAPI dependencies: origin check and current-user resolution.""" +from datetime import timezone + +from fastapi import Depends, HTTPException, Request +from sqlalchemy.orm import Session as DbSession + +from app import config, security +from app.db import get_db +from app.models import User, UserSession, utcnow + + +def require_same_origin(request: Request) -> None: + if not security.same_origin(request): + raise HTTPException(status_code=403, detail="Cross-origin request rejected") + + +def _as_utc(value): + if value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc) + + +def get_current_user( + request: Request, db: DbSession = Depends(get_db) +) -> User: + token = request.cookies.get(config.COOKIE_NAME) + if not token: + raise HTTPException(status_code=401, detail="Not authenticated") + + session = db.get(UserSession, security.hash_token(token)) + if session is None: + raise HTTPException(status_code=401, detail="Not authenticated") + + if _as_utc(session.expires_at) <= utcnow(): + db.delete(session) + db.commit() + raise HTTPException(status_code=401, detail="Session expired") + + return session.user diff --git a/trade-hub/backend/app/inventory.py b/trade-hub/backend/app/inventory.py new file mode 100644 index 0000000..e03716e --- /dev/null +++ b/trade-hub/backend/app/inventory.py @@ -0,0 +1,227 @@ +"""Per-user inventory routes. + +Rows reference immutable catalog items by `class_name`; responses are enriched +live from the in-memory catalog. A missing catalog item is reported as +`available: false` instead of deleting the user's data. +""" +import math + +from fastapi import APIRouter, Depends, HTTPException, Query, Response +from sqlalchemy import select +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import Session as DbSession + +from app import catalog +from app.db import get_db +from app.deps import get_current_user, require_same_origin +from app.models import InventoryEntry, User +from app.schemas import ( + InventoryAddRequest, + InventoryAddResponse, + InventoryEntryOut, + InventoryPage, + InventoryPatchRequest, + InventorySummary, +) + +router = APIRouter( + prefix="/api/v1/me/inventory", + tags=["inventory"], + dependencies=[Depends(require_same_origin)], +) + +MAX_PAGE_SIZE = 100 + + +def _enrich(entry: InventoryEntry) -> dict: + item = catalog.store.by_class_name.get(entry.class_name) + if item is None: + return { + "class_name": entry.class_name, + "name": entry.class_name.replace("_", " "), + "category": None, + "type": None, + "size": None, + "grade": None, + "manufacturer": None, + "quantity": entry.quantity, + "notes": entry.notes, + "available": False, + "created_at": entry.created_at, + "updated_at": entry.updated_at, + } + return { + "class_name": entry.class_name, + "name": item["name"], + "category": item["category"], + "type": item["type"], + "size": item["size"], + "grade": item["grade"], + "manufacturer": item["manufacturer"], + "quantity": entry.quantity, + "notes": entry.notes, + "available": True, + "created_at": entry.created_at, + "updated_at": entry.updated_at, + } + + +def _find(db: DbSession, user: User, class_name: str) -> InventoryEntry | None: + return db.execute( + select(InventoryEntry).where( + InventoryEntry.user_id == user.id, + InventoryEntry.class_name == class_name, + ) + ).scalar_one_or_none() + + +@router.get("", response_model=InventoryPage) +def list_inventory( + category: str | None = None, + search: str | None = None, + page: int = Query(default=1, ge=1), + page_size: int = Query(default=25, ge=1), + user: User = Depends(get_current_user), + db: DbSession = Depends(get_db), +): + page_size = min(page_size, MAX_PAGE_SIZE) + rows = db.execute( + select(InventoryEntry).where(InventoryEntry.user_id == user.id) + ).scalars().all() + + items = [_enrich(row) for row in rows] + + if category is not None and category.strip(): + wanted = category.strip() + items = [i for i in items if i["category"] == wanted] + if search is not None and search.strip(): + needle = search.strip().lower() + items = [ + i + for i in items + if needle in i["name"].lower() or needle in i["class_name"].lower() + ] + + items.sort(key=lambda i: (i["name"].lower(), i["class_name"])) + total = len(items) + total_pages = max(1, math.ceil(total / page_size)) + start = (page - 1) * page_size + return { + "total": total, + "page": page, + "page_size": page_size, + "total_pages": total_pages, + "items": items[start : start + page_size], + } + + +@router.get("/summary", response_model=InventorySummary) +def inventory_summary( + user: User = Depends(get_current_user), db: DbSession = Depends(get_db) +): + rows = db.execute( + select(InventoryEntry).where(InventoryEntry.user_id == user.id) + ).scalars().all() + + categories: dict[str, int] = {} + unavailable = 0 + total_quantity = 0 + for row in rows: + item = catalog.store.by_class_name.get(row.class_name) + if item is None: + unavailable += 1 + else: + key = item["category"] + categories[key] = categories.get(key, 0) + row.quantity + total_quantity += row.quantity + + return { + "items": len(rows), + "quantity": total_quantity, + "categories": categories, + "unavailable": unavailable, + } + + +@router.post("", response_model=InventoryAddResponse) +def add_to_inventory( + payload: InventoryAddRequest, + response: Response, + user: User = Depends(get_current_user), + db: DbSession = Depends(get_db), +): + class_name = payload.class_name.strip() + if class_name not in catalog.store.by_class_name: + raise HTTPException( + status_code=404, detail=f"Catalog item '{class_name}' not found" + ) + + entry = _find(db, user, class_name) + created = entry is None + + if created: + entry = InventoryEntry( + user_id=user.id, + class_name=class_name, + quantity=payload.quantity, + notes=payload.notes, + ) + db.add(entry) + else: + entry.quantity += payload.quantity + if payload.notes: + entry.notes = payload.notes + + try: + db.commit() + except IntegrityError: + # Lost a race against a concurrent add: re-read and increment instead. + db.rollback() + entry = _find(db, user, class_name) + if entry is None: + raise HTTPException(status_code=409, detail="Could not add item; retry") + entry.quantity += payload.quantity + if payload.notes: + entry.notes = payload.notes + db.commit() + created = False + + db.refresh(entry) + response.status_code = 201 if created else 200 + return {"entry": InventoryEntryOut.model_validate(_enrich(entry)), "created": created} + + +@router.patch("/{class_name}", response_model=InventoryEntryOut) +def update_inventory_entry( + class_name: str, + payload: InventoryPatchRequest, + user: User = Depends(get_current_user), + db: DbSession = Depends(get_db), +): + entry = _find(db, user, class_name) + if entry is None: + raise HTTPException(status_code=404, detail="Item is not in your inventory") + + if payload.quantity is not None: + entry.quantity = payload.quantity + if payload.notes is not None: + entry.notes = payload.notes + + db.commit() + db.refresh(entry) + return InventoryEntryOut.model_validate(_enrich(entry)) + + +@router.delete("/{class_name}", status_code=204) +def remove_inventory_entry( + class_name: str, + user: User = Depends(get_current_user), + db: DbSession = Depends(get_db), +): + entry = _find(db, user, class_name) + if entry is None: + raise HTTPException(status_code=404, detail="Item is not in your inventory") + + db.delete(entry) + db.commit() + return Response(status_code=204) diff --git a/trade-hub/backend/app/models.py b/trade-hub/backend/app/models.py new file mode 100644 index 0000000..d9b0297 --- /dev/null +++ b/trade-hub/backend/app/models.py @@ -0,0 +1,93 @@ +"""SQLAlchemy models for mutable user data (accounts + personal inventories). + +The scunpacked catalog is deliberately NOT modeled here. It stays read-only in +memory (see app/catalog.py); inventory rows reference catalog items only by +`class_name`, so the catalog can be replaced wholesale without touching user +data. +""" +from datetime import datetime, timezone + +from sqlalchemy import ( + CheckConstraint, + DateTime, + ForeignKey, + Integer, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship + + +def utcnow() -> datetime: + """Timezone-aware current time (portable across SQLite and PostgreSQL).""" + return datetime.now(timezone.utc) + + +class Base(DeclarativeBase): + pass + + +class User(Base): + __tablename__ = "users" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + # Display name as typed at registration. + username: Mapped[str] = mapped_column(String(32), nullable=False) + # Lowercased form, unique: gives case-insensitive uniqueness without citext. + username_key: Mapped[str] = mapped_column( + String(32), nullable=False, unique=True, index=True + ) + password_hash: Mapped[str] = mapped_column(String(255), nullable=False) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=utcnow + ) + + sessions: Mapped[list["UserSession"]] = relationship( + back_populates="user", cascade="all, delete-orphan" + ) + inventory: Mapped[list["InventoryEntry"]] = relationship( + back_populates="user", cascade="all, delete-orphan" + ) + + +class UserSession(Base): + __tablename__ = "sessions" + + # Only the SHA-256 of the cookie token is persisted. + token_hash: Mapped[str] = mapped_column(String(64), primary_key=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=utcnow + ) + expires_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False + ) + + user: Mapped[User] = relationship(back_populates="sessions") + + +class InventoryEntry(Base): + __tablename__ = "inventory_entries" + __table_args__ = ( + UniqueConstraint("user_id", "class_name", name="uq_inventory_user_item"), + CheckConstraint("quantity >= 1", name="ck_inventory_quantity_positive"), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + user_id: Mapped[int] = mapped_column( + ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True + ) + class_name: Mapped[str] = mapped_column(String(255), nullable=False) + quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + notes: Mapped[str] = mapped_column(Text, nullable=False, default="") + created_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=utcnow + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow + ) + + user: Mapped[User] = relationship(back_populates="inventory") diff --git a/trade-hub/backend/app/schemas.py b/trade-hub/backend/app/schemas.py new file mode 100644 index 0000000..7969c0e --- /dev/null +++ b/trade-hub/backend/app/schemas.py @@ -0,0 +1,77 @@ +"""Pydantic request/response models for auth and personal inventory.""" +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from app import config + + +class RegisterRequest(BaseModel): + username: str = Field(min_length=3, max_length=32) + password: str = Field(min_length=8, max_length=128) + + +class LoginRequest(BaseModel): + username: str = Field(min_length=1, max_length=32) + password: str = Field(min_length=1, max_length=128) + + +class UserOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + username: str + created_at: datetime + + +class AuthResponse(BaseModel): + user: UserOut + + +class InventoryAddRequest(BaseModel): + class_name: str = Field(min_length=1, max_length=255) + quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY) + notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH) + + +class InventoryPatchRequest(BaseModel): + quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY) + notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH) + + +class InventoryEntryOut(BaseModel): + class_name: str + name: str + category: str | None + type: str | None + size: int | None + grade: Any | None = None + manufacturer: dict | None + quantity: int + notes: str + # False when the catalog no longer contains the referenced item (e.g. after + # a dataset refresh). The row is preserved rather than silently deleted. + available: bool + created_at: datetime + updated_at: datetime + + +class InventoryPage(BaseModel): + total: int + page: int + page_size: int + total_pages: int + items: list[InventoryEntryOut] + + +class InventorySummary(BaseModel): + items: int + quantity: int + categories: dict[str, int] + unavailable: int + + +class InventoryAddResponse(BaseModel): + entry: InventoryEntryOut + created: bool diff --git a/trade-hub/backend/app/security.py b/trade-hub/backend/app/security.py new file mode 100644 index 0000000..ff7fd8b --- /dev/null +++ b/trade-hub/backend/app/security.py @@ -0,0 +1,140 @@ +"""Password hashing, session tokens, origin checks and login throttling.""" +import hashlib +import re +import secrets +import threading +import time +from collections import deque +from urllib.parse import urlsplit + +from argon2 import PasswordHasher +from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError + +_hasher = PasswordHasher() +# Hash of a throwaway secret, verified when a username does not exist so that +# "unknown user" and "wrong password" take comparable time. +_DUMMY_HASH = _hasher.hash(secrets.token_urlsafe(16)) + +USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{3,32}$") +MIN_PASSWORD_LENGTH = 8 +MAX_PASSWORD_LENGTH = 128 + + +def normalize_username(username: str) -> str: + return username.strip().lower() + + +def valid_username(username: str) -> bool: + return bool(USERNAME_RE.match(username.strip())) + + +def hash_password(password: str) -> str: + return _hasher.hash(password) + + +def verify_password(password: str, password_hash: str) -> bool: + try: + return _hasher.verify(password_hash, password) + except (VerifyMismatchError, VerificationError, InvalidHashError): + return False + + +def burn_password_check(password: str) -> None: + """Constant-ish work for unknown users, to blunt username enumeration.""" + try: + _hasher.verify(_DUMMY_HASH, password) + except (VerifyMismatchError, VerificationError, InvalidHashError): + pass + + +def needs_rehash(password_hash: str) -> bool: + return _hasher.check_needs_rehash(password_hash) + + +def new_session_token() -> str: + return secrets.token_urlsafe(32) + + +def hash_token(token: str) -> str: + return hashlib.sha256(token.encode("utf-8")).hexdigest() + + +def client_ip(request) -> str: + """Real client address as seen by the reverse proxy. + + nginx overwrites X-Real-IP with the actual peer address, so it is safe to + trust here. X-Forwarded-For is client-appendable and is never used. + """ + real_ip = request.headers.get("x-real-ip") + if real_ip: + return real_ip.strip() + return request.client.host if request.client else "unknown" + + +def same_origin(request) -> bool: + """Reject cross-origin state-changing requests (CSRF defense in depth).""" + origin = request.headers.get("origin") + if not origin: + return True # non-browser client; the session cookie is still required + host = request.headers.get("x-forwarded-host") or request.headers.get("host") or "" + return urlsplit(origin).netloc.lower() == host.lower() + +class LoginThrottle: + """In-process sliding-window limiter for failed logins. + + Tracks failures per (username, ip) and, with a higher ceiling, per ip so a + single address cannot spray passwords across many usernames. + """ + + def __init__(self, max_attempts: int, window_seconds: int): + self.max_attempts = max_attempts + self.window = window_seconds + self._events: dict[tuple[str, str], deque[float]] = {} + self._lock = threading.Lock() + + def _keys(self, username_key: str, ip: str) -> list[tuple[tuple[str, str], int]]: + return [ + ((username_key, ip), self.max_attempts), + (("*", ip), self.max_attempts * 4), + ] + + def _recent(self, key: tuple[str, str], now: float) -> deque[float] | None: + events = self._events.get(key) + if not events: + return None + cutoff = now - self.window + while events and events[0] < cutoff: + events.popleft() + if not events: + self._events.pop(key, None) + return None + return events + + def retry_after(self, username_key: str, ip: str) -> int: + """Seconds to wait before another attempt is allowed (0 if allowed).""" + now = time.monotonic() + with self._lock: + waits = [] + for key, limit in self._keys(username_key, ip): + events = self._recent(key, now) + if events and len(events) >= limit: + waits.append(int(self.window - (now - events[0])) + 1) + return max(waits) if waits else 0 + + def record_failure(self, username_key: str, ip: str) -> None: + now = time.monotonic() + with self._lock: + if len(self._events) > 5000: # opportunistic cleanup + for key in list(self._events): + self._recent(key, now) + for key, _limit in self._keys(username_key, ip): + self._events.setdefault(key, deque()).append(now) + + def reset(self, username_key: str, ip: str) -> None: + with self._lock: + for key, _limit in self._keys(username_key, ip): + self._events.pop(key, None) + + def clear(self) -> None: + with self._lock: + self._events.clear() diff --git a/trade-hub/backend/requirements.txt b/trade-hub/backend/requirements.txt index c60a450..30ab993 100644 --- a/trade-hub/backend/requirements.txt +++ b/trade-hub/backend/requirements.txt @@ -1,2 +1,5 @@ fastapi>=0.115 uvicorn[standard]>=0.30 +SQLAlchemy>=2.0 +psycopg[binary]>=3.2 +argon2-cffi>=23.1 diff --git a/trade-hub/backend/tests/__pycache__/conftest.cpython-314-pytest-9.2.0.pyc b/trade-hub/backend/tests/__pycache__/conftest.cpython-314-pytest-9.2.0.pyc index a426b4a..db58e51 100644 Binary files a/trade-hub/backend/tests/__pycache__/conftest.cpython-314-pytest-9.2.0.pyc and b/trade-hub/backend/tests/__pycache__/conftest.cpython-314-pytest-9.2.0.pyc differ diff --git a/trade-hub/backend/tests/__pycache__/test_auth.cpython-314-pytest-9.2.0.pyc b/trade-hub/backend/tests/__pycache__/test_auth.cpython-314-pytest-9.2.0.pyc new file mode 100644 index 0000000..925dbc2 Binary files /dev/null and b/trade-hub/backend/tests/__pycache__/test_auth.cpython-314-pytest-9.2.0.pyc differ diff --git a/trade-hub/backend/tests/__pycache__/test_inventory.cpython-314-pytest-9.2.0.pyc b/trade-hub/backend/tests/__pycache__/test_inventory.cpython-314-pytest-9.2.0.pyc new file mode 100644 index 0000000..ca6194e Binary files /dev/null and b/trade-hub/backend/tests/__pycache__/test_inventory.cpython-314-pytest-9.2.0.pyc differ diff --git a/trade-hub/backend/tests/conftest.py b/trade-hub/backend/tests/conftest.py index 8289c93..b72eaac 100644 --- a/trade-hub/backend/tests/conftest.py +++ b/trade-hub/backend/tests/conftest.py @@ -1,4 +1,31 @@ -"""Point the backend at the tiny fixture dataset before the app is imported.""" +"""Test bootstrap. + +Runs the suite against a throwaway in-memory SQLite database and the tiny +fixture catalog, so no PostgreSQL server is needed. Environment variables must +be set before any app module is imported, because config reads them at import +time. +""" import os os.environ["DATA_DIR"] = os.path.join(os.path.dirname(__file__), "fixtures") +os.environ["DATABASE_URL"] = "sqlite://" +os.environ["AUTO_INIT_DB"] = "false" +os.environ["COOKIE_SECURE"] = "false" + +import pytest # noqa: E402 + +from app import db # noqa: E402 +from app.models import Base # noqa: E402 + + +@pytest.fixture(autouse=True) +def fresh_db(): + """Give every test an empty schema and a clean login throttle.""" + engine = db.get_engine() + Base.metadata.drop_all(bind=engine) + Base.metadata.create_all(bind=engine) + + from app.auth import throttle + + throttle.clear() + yield diff --git a/trade-hub/backend/tests/test_auth.py b/trade-hub/backend/tests/test_auth.py new file mode 100644 index 0000000..76751c2 --- /dev/null +++ b/trade-hub/backend/tests/test_auth.py @@ -0,0 +1,151 @@ +"""Authentication tests: registration, login, sessions, throttling, CSRF.""" +from fastapi.testclient import TestClient + +from app.api import app + +PASSWORD = "correct horse battery staple" + + +def new_client() -> TestClient: + return TestClient(app) + + +def register(client: TestClient, username: str = "alice", password: str = PASSWORD): + return client.post( + "/api/v1/auth/register", json={"username": username, "password": password} + ) + + +def login(client: TestClient, username: str = "alice", password: str = PASSWORD): + return client.post( + "/api/v1/auth/login", json={"username": username, "password": password} + ) + + +def test_register_returns_user_and_authenticates(): + client = new_client() + resp = register(client) + assert resp.status_code == 201 + body = resp.json() + assert body["user"]["username"] == "alice" + assert "password" not in body["user"] + assert client.cookies.get("sc_session") + + me = client.get("/api/v1/auth/me") + assert me.status_code == 200 + assert me.json()["user"]["username"] == "alice" + + +def test_register_duplicate_username_is_conflict_case_insensitive(): + client = new_client() + assert register(client, "Alice").status_code == 201 + assert register(new_client(), "alice").status_code == 409 + + +def test_register_rejects_weak_password(): + resp = register(new_client(), password="short") + assert resp.status_code == 422 + + +def test_register_rejects_invalid_username(): + for username in ["ab", "has space", "bad!", "x" * 33]: + assert register(new_client(), username).status_code == 422 + + +def test_me_requires_authentication(): + resp = new_client().get("/api/v1/auth/me") + assert resp.status_code == 401 + + +def test_login_is_case_insensitive_and_works_after_logout(): + client = new_client() + assert register(client, "alice").status_code == 201 + assert client.post("/api/v1/auth/logout").status_code == 204 + assert client.get("/api/v1/auth/me").status_code == 401 + + resp = login(client, "ALICE") + assert resp.status_code == 200 + assert resp.json()["user"]["username"] == "alice" + assert client.get("/api/v1/auth/me").status_code == 200 + + +def test_wrong_password_and_unknown_user_are_indistinguishable(): + client = new_client() + assert register(client, "alice").status_code == 201 + + wrong = login(new_client(), "alice", "definitely wrong") + unknown = login(new_client(), "nobody", "definitely wrong") + + assert wrong.status_code == unknown.status_code == 401 + assert wrong.json()["detail"] == unknown.json()["detail"] + + +def test_logout_invalidates_session_token(): + client = new_client() + assert register(client).status_code == 201 + token = client.cookies.get("sc_session") + + assert client.post("/api/v1/auth/logout").status_code == 204 + + # Replay the old cookie on a fresh client: it must be rejected. + replay = new_client() + replay.cookies.set("sc_session", token) + assert replay.get("/api/v1/auth/me").status_code == 401 + + +def test_login_is_rate_limited(): + client = new_client() + assert register(client, "alice").status_code == 201 + + for _ in range(5): + assert login(new_client(), "alice", "wrong password").status_code == 401 + + blocked = login(new_client(), "alice", PASSWORD) + assert blocked.status_code == 429 + assert int(blocked.headers["Retry-After"]) > 0 + + +def test_cross_origin_mutation_is_rejected(): + client = new_client() + resp = client.post( + "/api/v1/auth/register", + json={"username": "alice", "password": PASSWORD}, + headers={"Origin": "http://evil.example"}, + ) + assert resp.status_code == 403 + + +def test_same_origin_mutation_is_allowed(): + client = new_client() + resp = client.post( + "/api/v1/auth/register", + json={"username": "alice", "password": PASSWORD}, + headers={"Origin": "http://testserver"}, + ) + assert resp.status_code == 201 + + +def test_same_origin_with_forwarded_port_is_allowed(): + client = new_client() + resp = client.post( + "/api/v1/auth/register", + json={"username": "alice", "password": PASSWORD}, + headers={ + "Origin": "http://testserver:8080", + "X-Forwarded-Host": "testserver:8080", + }, + ) + assert resp.status_code == 201 + + +def test_forwarded_host_without_port_is_rejected(): + client = new_client() + resp = client.post( + "/api/v1/auth/register", + json={"username": "alice", "password": PASSWORD}, + headers={ + "Origin": "http://testserver:8080", + "X-Forwarded-Host": "testserver", + }, + ) + assert resp.status_code == 403 diff --git a/trade-hub/backend/tests/test_inventory.py b/trade-hub/backend/tests/test_inventory.py new file mode 100644 index 0000000..56cfcc6 --- /dev/null +++ b/trade-hub/backend/tests/test_inventory.py @@ -0,0 +1,191 @@ +"""Personal inventory tests: auth gating, CRUD, isolation, catalog immutability.""" +from fastapi.testclient import TestClient + +from app.api import app + +PASSWORD = "correct horse battery staple" +CARROCK = "ANVL_Carrack" +RIFLE = "BEHR_Rifle_Ballistic_01" + + +def authed_client(username: str = "alice") -> TestClient: + client = TestClient(app) + resp = client.post( + "/api/v1/auth/register", json={"username": username, "password": PASSWORD} + ) + assert resp.status_code == 201, resp.text + return client + + +def add(client: TestClient, class_name: str, quantity: int = 1, notes: str = ""): + return client.post( + "/api/v1/me/inventory", + json={"class_name": class_name, "quantity": quantity, "notes": notes}, + ) + + +def test_new_account_starts_with_empty_inventory(): + client = authed_client("freshpilot") + listing = client.get("/api/v1/me/inventory").json() + assert listing["total"] == 0 + assert listing["items"] == [] + assert client.get("/api/v1/me/inventory/summary").json() == { + "items": 0, + "quantity": 0, + "categories": {}, + "unavailable": 0, + } + + +def test_inventory_requires_authentication(): + client = TestClient(app) + assert client.get("/api/v1/me/inventory").status_code == 401 + assert add(client, CARROCK).status_code == 401 + assert client.get("/api/v1/me/inventory/summary").status_code == 401 + + +def test_add_unknown_catalog_item_is_not_found(): + client = authed_client() + assert add(client, "NOPE_Does_Not_Exist").status_code == 404 + + +def test_add_creates_enriched_entry(): + client = authed_client() + resp = add(client, CARROCK) + assert resp.status_code == 201 + body = resp.json() + assert body["created"] is True + entry = body["entry"] + assert entry["class_name"] == CARROCK + assert entry["name"] == "Anvil Carrack" + assert entry["category"] == "ships" + assert entry["quantity"] == 1 + assert entry["available"] is True + assert entry["manufacturer"]["code"] == "ANVL" + + +def test_add_same_item_increments_quantity(): + client = authed_client() + assert add(client, CARROCK, quantity=1).status_code == 201 + resp = add(client, CARROCK, quantity=2, notes="pledged") + assert resp.status_code == 200 + body = resp.json() + assert body["created"] is False + assert body["entry"]["quantity"] == 3 + assert body["entry"]["notes"] == "pledged" + + listing = client.get("/api/v1/me/inventory").json() + assert listing["total"] == 1 + + +def test_inventories_are_isolated_between_users(): + alice = authed_client("alice") + bob = authed_client("bob") + + assert add(alice, CARROCK).status_code == 201 + assert add(bob, RIFLE).status_code == 201 + + alice_items = alice.get("/api/v1/me/inventory").json()["items"] + bob_items = bob.get("/api/v1/me/inventory").json()["items"] + + assert [i["class_name"] for i in alice_items] == [CARROCK] + assert [i["class_name"] for i in bob_items] == [RIFLE] + + +def test_patch_updates_quantity_and_notes(): + client = authed_client() + assert add(client, CARROCK).status_code == 201 + + resp = client.patch( + f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 4, "notes": "hangar 3"} + ) + assert resp.status_code == 200 + body = resp.json() + assert body["quantity"] == 4 + assert body["notes"] == "hangar 3" + + +def test_patch_unknown_entry_is_not_found(): + client = authed_client() + assert client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 2}).status_code == 404 + + +def test_patch_rejects_zero_quantity(): + client = authed_client() + assert add(client, CARROCK).status_code == 201 + resp = client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 0}) + assert resp.status_code == 422 + + +def test_delete_removes_entry(): + client = authed_client() + assert add(client, CARROCK).status_code == 201 + + assert client.delete(f"/api/v1/me/inventory/{CARROCK}").status_code == 204 + assert client.get("/api/v1/me/inventory").json()["total"] == 0 + assert client.delete(f"/api/v1/me/inventory/{CARROCK}").status_code == 404 + + +def test_list_filters_by_category_and_search(): + client = authed_client() + assert add(client, CARROCK).status_code == 201 + assert add(client, RIFLE).status_code == 201 + + ships = client.get("/api/v1/me/inventory", params={"category": "ships"}).json() + assert ships["total"] == 1 + assert ships["items"][0]["class_name"] == CARROCK + + found = client.get("/api/v1/me/inventory", params={"search": "carrack"}).json() + assert found["total"] == 1 + assert found["items"][0]["class_name"] == CARROCK + + +def test_list_pagination_envelope_and_cap(): + client = authed_client() + assert add(client, CARROCK).status_code == 201 + assert add(client, RIFLE).status_code == 201 + + body = client.get( + "/api/v1/me/inventory", params={"page_size": 1, "page": 2} + ).json() + assert body["total"] == 2 + assert body["page"] == 2 + assert body["page_size"] == 1 + assert body["total_pages"] == 2 + assert len(body["items"]) == 1 + + capped = client.get("/api/v1/me/inventory", params={"page_size": 500}).json() + assert capped["page_size"] == 100 + + +def test_summary_aggregates_quantity_and_categories(): + client = authed_client() + assert add(client, CARROCK, quantity=2).status_code == 201 + assert add(client, RIFLE, quantity=3).status_code == 201 + + summary = client.get("/api/v1/me/inventory/summary").json() + assert summary["items"] == 2 + assert summary["quantity"] == 5 + assert summary["unavailable"] == 0 + assert summary["categories"]["ships"] == 2 + + +def test_notes_length_is_bounded(): + client = authed_client() + resp = add(client, CARROCK, notes="x" * 1001) + assert resp.status_code == 422 + + +def test_catalog_is_not_mutated_by_inventory_operations(): + client = authed_client() + before = client.get("/api/v1/items", params={"page_size": 100}).json() + + assert add(client, CARROCK).status_code == 201 + client.patch(f"/api/v1/me/inventory/{CARROCK}", json={"quantity": 9}) + client.delete(f"/api/v1/me/inventory/{CARROCK}") + + after = client.get("/api/v1/items", params={"page_size": 100}).json() + assert after == before + carrack = client.get(f"/api/v1/items/{CARROCK}").json() + assert "quantity" not in carrack + assert "notes" not in carrack diff --git a/trade-hub/frontend/nginx.conf b/trade-hub/frontend/nginx.conf index 1acd565..7a1e01c 100644 --- a/trade-hub/frontend/nginx.conf +++ b/trade-hub/frontend/nginx.conf @@ -8,18 +8,27 @@ server { # API and health checks are proxied to the backend container on the # shared podman network (service DNS name: backend). + # + # X-Real-IP and X-Forwarded-Proto are set here (not passed through), so the + # backend can trust them for login throttling and redirect/scheme decisions. location /api/ { proxy_pass http://backend:8000; - proxy_set_header Host $host; + # $http_host keeps the client's port, which the backend's Origin check + # compares against; $host would drop ":8080" and reject real browsers. + proxy_set_header Host $http_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; + proxy_set_header X-Forwarded-Host $http_host; proxy_connect_timeout 5s; proxy_read_timeout 30s; } location = /healthz { proxy_pass http://backend:8000; - proxy_set_header Host $host; + proxy_set_header Host $http_host; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $http_host; } location / { diff --git a/trade-hub/frontend/src/app.js b/trade-hub/frontend/src/app.js index b8d8b65..d4a9244 100644 --- a/trade-hub/frontend/src/app.js +++ b/trade-hub/frontend/src/app.js @@ -5,6 +5,7 @@ "use strict"; const PAGE_SIZE = 25; +const INVENTORY_PAGE_SIZE = 100; const state = { category: "", @@ -14,17 +15,28 @@ const state = { size: "", page: 1, totalPages: 1, + view: "catalog", + user: null, + inventory: { search: "", category: "" }, }; +let authMode = "login"; +let pendingView = null; + const els = {}; function cacheElements() { const ids = [ - "api-status", "stats-line", "category-tabs", "search-input", + "api-status", "stats-line", "account-bar", "category-tabs", "search-input", "manufacturer-select", "type-input", "size-input", "reset-filters", "error-banner", "loading", "empty-state", "results-grid", "pagination", "prev-page", "next-page", "page-info", "modal-backdrop", "modal-close", "modal-title", "modal-body", + "catalog-view", "inventory-view", "inv-search", "inv-category", + "inv-summary", "inv-empty", "inv-table-wrap", "inv-tbody", "tab-mine", + "auth-backdrop", "auth-close", "auth-title", "auth-form", + "auth-username", "auth-password", "auth-error", "auth-submit", + "auth-toggle", "toast", ]; for (const id of ids) els[id] = document.getElementById(id); } @@ -39,13 +51,45 @@ function escapeHtml(value) { .replaceAll("'", "'"); } -async function fetchJson(url) { - const resp = await fetch(url); - if (!resp.ok) { - const detail = await resp.json().catch(() => ({})); - throw new Error(detail.detail || `Request failed: ${resp.status}`); +async function apiFetch(url, options = {}) { + const opts = { credentials: "same-origin", ...options }; + if (opts.body !== undefined && typeof opts.body !== "string") { + opts.body = JSON.stringify(opts.body); + opts.headers = { "Content-Type": "application/json", ...(opts.headers || {}) }; } - return resp.json(); + const resp = await fetch(url, opts); + let data = null; + if (resp.status !== 204) { + data = await resp.json().catch(() => null); + } + if (!resp.ok) { + const err = new Error((data && data.detail) || `Request failed: ${resp.status}`); + err.status = resp.status; + throw err; + } + return data; +} + +function fetchJson(url) { + return apiFetch(url); +} + +function showToast(message, isError = false) { + const toast = els["toast"]; + toast.textContent = message; + toast.classList.toggle("error", isError); + toast.classList.remove("hidden"); + clearTimeout(showToast._timer); + showToast._timer = setTimeout(() => toast.classList.add("hidden"), 2600); +} + +function handleUnauthorized(err) { + if (err.status !== 401) return false; + state.user = null; + resetInventoryView(); + renderAccount(); + openAuth("login", "Please log in to continue."); + return true; } /* ---------- header: status, stats, manufacturers ---------- */ @@ -78,7 +122,104 @@ async function initHeader() { } } -/* ---------- item list ---------- */ +/* ---------- auth ---------- */ + +async function initAuth() { + try { + const data = await apiFetch("/api/v1/auth/me"); + state.user = data.user; + } catch { + state.user = null; + } + renderAccount(); +} + +function renderAccount() { + const bar = els["account-bar"]; + bar.innerHTML = ""; + if (state.user) { + const who = document.createElement("span"); + who.className = "who"; + who.textContent = state.user.username; + const out = document.createElement("button"); + out.type = "button"; + out.className = "btn-secondary"; + out.textContent = "Log out"; + out.addEventListener("click", logout); + bar.append(who, out); + } else { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "btn-secondary"; + btn.textContent = "Log in / Register"; + btn.addEventListener("click", () => openAuth("login")); + bar.appendChild(btn); + } +} + +function openAuth(mode = "login", message = "") { + authMode = mode; + els["auth-error"].textContent = message; + els["auth-title"].textContent = mode === "register" ? "Create account" : "Welcome back"; + els["auth-submit"].textContent = mode === "register" ? "Create account" : "Log in"; + els["auth-toggle"].textContent = + mode === "register" ? "Already have an account? Log in" : "Need an account? Register"; + els["auth-password"].autocomplete = mode === "register" ? "new-password" : "current-password"; + els["auth-backdrop"].classList.remove("hidden"); + els["auth-username"].focus(); +} + +function closeAuth() { + els["auth-backdrop"].classList.add("hidden"); +} + +async function submitAuth(event) { + event.preventDefault(); + const username = els["auth-username"].value.trim(); + const password = els["auth-password"].value; + const url = authMode === "register" ? "/api/v1/auth/register" : "/api/v1/auth/login"; + + els["auth-submit"].disabled = true; + els["auth-error"].textContent = ""; + try { + const data = await apiFetch(url, { method: "POST", body: { username, password } }); + state.user = data.user; + els["auth-password"].value = ""; + closeAuth(); + resetInventoryView(); + renderAccount(); + showToast(`Signed in as ${state.user.username}`); + if (pendingView === "mine") { + pendingView = null; + switchToMine(); + } + } catch (err) { + els["auth-error"].textContent = err.message; + } finally { + els["auth-submit"].disabled = false; + } +} + +async function logout() { + try { + await apiFetch("/api/v1/auth/logout", { method: "POST" }); + } catch { + /* clearing local state below is what matters */ + } + state.user = null; + resetInventoryView(); + renderAccount(); + showToast("Logged out"); + if (state.view === "mine") { + showCatalogView(); + const allTab = document.querySelector('.tab[data-category=""]'); + setActiveTab(allTab); + state.category = ""; + loadItems(); + } +} + +/* ---------- catalog list ---------- */ function buildQuery() { const params = new URLSearchParams(); @@ -111,9 +252,15 @@ function renderItems(items) {
Failed to load item: ${escapeHtml(err.message)}
`; } @@ -211,6 +369,191 @@ function closeModal() { els["modal-backdrop"].classList.add("hidden"); } +/* ---------- personal inventory ---------- */ + +async function switchToMine() { + if (!state.user) { + pendingView = "mine"; + openAuth("login", "Log in to see your inventory."); + return; + } + setActiveTab(els["tab-mine"]); + state.view = "mine"; + els["catalog-view"].classList.add("hidden"); + els["inventory-view"].classList.remove("hidden"); + await loadInventory(); +} + +function showCatalogView() { + state.view = "catalog"; + els["inventory-view"].classList.add("hidden"); + els["catalog-view"].classList.remove("hidden"); +} + +/* A new session must never show the previous account's rows or filters. */ +function resetInventoryView() { + state.inventory.search = ""; + state.inventory.category = ""; + els["inv-search"].value = ""; + els["inv-category"].value = ""; + els["inv-summary"].textContent = ""; + renderInventory({ total: 0, items: [] }); +} + +async function loadInventory() { + const params = new URLSearchParams(); + if (state.inventory.search) params.set("search", state.inventory.search); + if (state.inventory.category) params.set("category", state.inventory.category); + params.set("page", "1"); + params.set("page_size", String(INVENTORY_PAGE_SIZE)); + + els["inv-summary"].textContent = "Loading…"; + try { + const listing = await apiFetch(`/api/v1/me/inventory?${params}`); + renderInventory(listing); + await refreshSummary(); + } catch (err) { + if (!handleUnauthorized(err)) showToast(err.message, true); + } +} + +async function refreshSummary() { + try { + const summary = await apiFetch("/api/v1/me/inventory/summary"); + renderInventorySummary(summary); + } catch { + /* summary is decorative; the table already reflects the data */ + } +} + +function renderInventorySummary(summary) { + const labels = { ships: "ships", "ship-items": "ship items", "fps-items": "FPS items" }; + const parts = Object.entries(summary.categories || {}) + .map(([key, qty]) => `${qty} ${labels[key] || key}`) + .join(" · "); + const unavailable = summary.unavailable + ? ` · ${summary.unavailable} unavailable catalog item(s)` + : ""; + els["inv-summary"].textContent = + `${summary.items} item${summary.items === 1 ? "" : "s"} · ` + + `${summary.quantity} unit${summary.quantity === 1 ? "" : "s"}` + + (parts ? ` · ${parts}` : "") + unavailable; +} + +function renderInventory(listing) { + const tbody = els["inv-tbody"]; + tbody.innerHTML = ""; + els["inv-empty"].classList.toggle("hidden", listing.total !== 0); + els["inv-table-wrap"].classList.toggle("hidden", listing.total === 0); + + for (const entry of listing.items) { + const row = document.createElement("tr"); + + const nameCell = document.createElement("td"); + nameCell.innerHTML = + `Item catalog from scunpacked game data
-