From 0a93925e11ad042c7b91c46f5614ba08decfc3f6 Mon Sep 17 00:00:00 2001 From: boxswapper Date: Thu, 12 Nov 2026 11:40:31 -0700 Subject: [PATCH] working with login and building inventory --- .../ses_e2f302d08ffewav8qUiPZ0aI2j.json | 10 + .../ses_e2f304d0bffewgGWmy7An2rOeJ.json | 10 + .../ses_e2f306c90ffe63BtszteHo8OAl.json | 10 + .../ses_e2f306cc1ffemzh3awaA137wZh.json | 10 + .../ses_e2f306cedffevymy11Qu04FoB0.json | 10 + .../ses_e2f393836ffeUi9lBqb1kfxxoz.json | 10 + .../ses_e2f45eaccffeiis1QONAKg7gzm.json | 10 + README.md | 149 ++++++- playbook.yml | 33 +- teardown.yml | 126 ++++++ .../backend/__pycache__/main.cpython-314.pyc | Bin 223 -> 223 bytes .../app/__pycache__/__init__.cpython-314.pyc | Bin 217 -> 223 bytes .../app/__pycache__/api.cpython-314.pyc | Bin 5434 -> 6681 bytes .../app/__pycache__/auth.cpython-314.pyc | Bin 0 -> 8951 bytes .../app/__pycache__/catalog.cpython-314.pyc | Bin 0 -> 471 bytes .../app/__pycache__/config.cpython-314.pyc | Bin 843 -> 2889 bytes .../app/__pycache__/db.cpython-314.pyc | Bin 0 -> 4558 bytes .../app/__pycache__/deps.cpython-314.pyc | Bin 0 -> 2545 bytes .../app/__pycache__/inventory.cpython-314.pyc | Bin 0 -> 11029 bytes .../app/__pycache__/loader.cpython-314.pyc | Bin 11107 -> 11113 bytes .../app/__pycache__/models.cpython-314.pyc | Bin 0 -> 5572 bytes .../app/__pycache__/schemas.cpython-314.pyc | Bin 0 -> 6052 bytes .../app/__pycache__/security.cpython-314.pyc | Bin 0 -> 11441 bytes trade-hub/backend/app/api.py | 49 ++- trade-hub/backend/app/auth.py | 160 +++++++ trade-hub/backend/app/catalog.py | 8 + trade-hub/backend/app/config.py | 45 ++ trade-hub/backend/app/db.py | 89 ++++ trade-hub/backend/app/deps.py | 39 ++ trade-hub/backend/app/inventory.py | 227 ++++++++++ trade-hub/backend/app/models.py | 93 ++++ trade-hub/backend/app/schemas.py | 77 ++++ trade-hub/backend/app/security.py | 140 ++++++ trade-hub/backend/requirements.txt | 3 + .../conftest.cpython-314-pytest-9.2.0.pyc | Bin 597 -> 1738 bytes .../test_auth.cpython-314-pytest-9.2.0.pyc | Bin 0 -> 31501 bytes ...est_inventory.cpython-314-pytest-9.2.0.pyc | Bin 0 -> 52525 bytes trade-hub/backend/tests/conftest.py | 29 +- trade-hub/backend/tests/test_auth.py | 151 +++++++ trade-hub/backend/tests/test_inventory.py | 191 ++++++++ trade-hub/frontend/nginx.conf | 13 +- trade-hub/frontend/src/app.js | 409 +++++++++++++++++- trade-hub/frontend/src/index.html | 123 ++++-- trade-hub/frontend/src/style.css | 218 ++++++++++ trade-hub/podman-compose.yml | 24 + 45 files changed, 2389 insertions(+), 77 deletions(-) create mode 100644 .omo/run-continuation/ses_e2f302d08ffewav8qUiPZ0aI2j.json create mode 100644 .omo/run-continuation/ses_e2f304d0bffewgGWmy7An2rOeJ.json create mode 100644 .omo/run-continuation/ses_e2f306c90ffe63BtszteHo8OAl.json create mode 100644 .omo/run-continuation/ses_e2f306cc1ffemzh3awaA137wZh.json create mode 100644 .omo/run-continuation/ses_e2f306cedffevymy11Qu04FoB0.json create mode 100644 .omo/run-continuation/ses_e2f393836ffeUi9lBqb1kfxxoz.json create mode 100644 .omo/run-continuation/ses_e2f45eaccffeiis1QONAKg7gzm.json create mode 100644 teardown.yml create mode 100644 trade-hub/backend/app/__pycache__/auth.cpython-314.pyc create mode 100644 trade-hub/backend/app/__pycache__/catalog.cpython-314.pyc create mode 100644 trade-hub/backend/app/__pycache__/db.cpython-314.pyc create mode 100644 trade-hub/backend/app/__pycache__/deps.cpython-314.pyc create mode 100644 trade-hub/backend/app/__pycache__/inventory.cpython-314.pyc create mode 100644 trade-hub/backend/app/__pycache__/models.cpython-314.pyc create mode 100644 trade-hub/backend/app/__pycache__/schemas.cpython-314.pyc create mode 100644 trade-hub/backend/app/__pycache__/security.cpython-314.pyc create mode 100644 trade-hub/backend/app/auth.py create mode 100644 trade-hub/backend/app/catalog.py create mode 100644 trade-hub/backend/app/db.py create mode 100644 trade-hub/backend/app/deps.py create mode 100644 trade-hub/backend/app/inventory.py create mode 100644 trade-hub/backend/app/models.py create mode 100644 trade-hub/backend/app/schemas.py create mode 100644 trade-hub/backend/app/security.py create mode 100644 trade-hub/backend/tests/__pycache__/test_auth.cpython-314-pytest-9.2.0.pyc create mode 100644 trade-hub/backend/tests/__pycache__/test_inventory.cpython-314-pytest-9.2.0.pyc create mode 100644 trade-hub/backend/tests/test_auth.py create mode 100644 trade-hub/backend/tests/test_inventory.py 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 9a5bd288084054ef9cf189083b53182509121407..dde91bbd187b802a9e918df13a9bdeb6f1d7f71e 100644 GIT binary patch delta 19 Zcmcc5c%PA5n~#@^0SNj&ex1mD1pqS21^@s6 delta 19 Zcmcc5c%PA5n~#@^0SFEs{ydTU3IH=21_b~B diff --git a/trade-hub/backend/app/__pycache__/__init__.cpython-314.pyc b/trade-hub/backend/app/__pycache__/__init__.cpython-314.pyc index ba92cd27f033a1372b3dbf0408cb1cf38a3f00f5..e288fdaa278dbcafff92835543028df8b04d7c6b 100644 GIT binary patch delta 42 wcmcb~c%PA5n~#@^0SNj&ex1l&C2XRfUr?f-R*+w&n_7{ak(igBI&qRa01$=_EdT%j delta 36 qcmcc5c$1M^n~#@^0SJ~J{ydSpidRoRzo0}vCABOyC%<6g40iy)*9+1B diff --git a/trade-hub/backend/app/__pycache__/api.cpython-314.pyc b/trade-hub/backend/app/__pycache__/api.cpython-314.pyc index a25ec7f121d54f1f860e6b5f5c67e0b4e65c189b..c6b43b901a7c342363bf7205d60df655d9e14974 100644 GIT binary patch delta 2849 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 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 0000000000000000000000000000000000000000..33bc6d0a0ce832723a60a0e91d27b2986cee6d22 GIT binary patch literal 4558 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 E8wI=nwg3PC literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..321ce74e9bedb63e868e1b10ed9d946735009dbb GIT binary patch literal 11029 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 MZSMcnq{GPn0vOQmKL7v# literal 0 HcmV?d00001 diff --git a/trade-hub/backend/app/__pycache__/loader.cpython-314.pyc b/trade-hub/backend/app/__pycache__/loader.cpython-314.pyc index 00d72e7a9056e0ac65cf3eba9492b64ff2d077d3..c234c02afeae75c9d8cf6bb0de79a0ceb9e97eee 100644 GIT binary patch delta 45 zcmaDH_A-oHn~#@^0SNj&e%;6&$|h{2pI=a-pH`4xrkh%koROH9p1L`g?VAb!R0a>g delta 39 tcmaDE_Bf1Nn~#@^0SI`Hf8NL)%EqgspI=a-pORXZnv-9!xtQ&n3IOa54AB4p 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 0000000000000000000000000000000000000000..91e6e5fac650a719c5677a7493809f99e102afda GIT binary patch literal 5572 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 J1h0Hq{ujGw!ma=S literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..60f22b61f53af2fd528635b9a1d15b394d1469f7 GIT binary patch literal 6052 zcmb_g&2JmW6`$em@=Fv&N|Y>Fl1)i%QpS$d#6e^?ZcsUvE%}3%*NWk^wYDpAB{8Lz z^z6`$a%iPCKyoplwg{l!e6)ZZe9XT=K!xD}awv+T=*_a&I=A+{*(J54SV;}K03T=H z%+Aid-|v0Qo{4ux30yz@_MadBCPc_z@uq%hUSsbRC1joGgp&oL(^)!0IX#W-z-(Yf z;fic4v+9h-wV5Cf&V+bK>eSirOoT^HlU_1HbnOJugZH&}6n9=8mD&(!!#-_?)J8xX z^=V^L+X32`PunTAouG~Tv~j8J0&T*l?ULFgXuEydgw*zcHs#YMrM4HeeLiis)Sdus zzfaqv4~&t_$xo-|*7By!3%NA29#kxcPcX|Vmu$yMua@#w(Md0rSlX=cm9%N+(`Ab} zCEF~f3-&|H<|Vc^o}nU~H@U?Ns}{7CDSIuWh{z?=v2NlFBKkqeUMehKF66k-t`w|d z9)<;TQkkoxhW`!Z=HbQ!?Cujz0l&aPlq-FtMDD92WIvS^UKFBqx3ooqubiupi z9MuCSNHNSqy28VE36JP1>{kLpwri2jMoledyH%=|G)){aK*8h zZLV4(Ts9r&S0$F;29r6QWdb4ymR>cv=roL65qk4rFvD>0!O?YbZlYA?6HDdN!#Ax@ zax132Y)x=x=B+nZDvJ|~X6~1kou4qv!&oXXhZhUBWtR+t1>td)K#>F?lD_vZRqPy0&e!x5Ky|7SZUSAn)ar|*B;Y-DY&w9UvT9B zv=!HtGb+PPia;SRsVt&7W`Td@;oT7Tl!DD!4-8;v=)*0a@|NFZbVSN|1BCR$-5`m? zHm>f5fUCP<($&3jb2ozRC`k@f)&7?qaN~m~6Jf*n@>3{Iqe!E`$SPtopc`)BWh1B` zMKOk=711-OL?PFM{^dGT&+wD{PS3gZ8?P`n)ZGUMHsOq$$7}B_h$fteT%0AURHCZt z0YsSxbp=pWMQjSPjqYWk_Anw8$AJ;%3%GEp7>|ya;=mtc!6Juf0EjXtHj4--{OL!oPh< z(M^-E7@KJSlk+lmO~ttGQk&L=Q`!01`H$FTR3%WAy){35$G9<_y*fX`rg8WY z?0XN#ph&~dK})-?-Z%L8!P7fCeHYfRVcr~hLZ2)>8=tCoHKb2!i_9`v+N>F$EHBE=V2Cl4huXwJ&WLYmoJ0R13k^W%t{0$ zUcfvgprqpAEF5jSi6$Omq;$qWxIBy}H!nZ>xT@Z8x!4ikOg;KhRlOz4yL}2~I{!NZ zaewS83gjG{L2(?KUBiJWx@e+opM!@!95gca43L70vryI45~;r>^KeCdkrdz zYa|s9cX(|L+A&|d6Wd^)xXhpoBacwAw9krg4l15yIQ{zp4EX6vE2tJ`&r{(7+m&Y?bfqpGIr71zE3tFGM`L~qx9hITtq z5hpz-wo+f7sj9=fU8qR71zQr^-7tFN+HMcF`$-}NOBlfRNw<*O9mJaxO4LKWZS zV3E^`Y8;LQ^_Y_E{r#OkjcwlhGG0|rHNpE5PjUM$o^6jMx(GudQ|&&Ms5&N=*N?<9 z)!3)3)!nau?eWB{eM~$X$7euD@!Su-7(&Vjc?V{&z6?(v!K8;HG16{@O=w()IUJ<9 zhsID<9dezl6HjSaJsfH5B0SDe;ACvy*r>{8QX*s+`4W7jlKDskA@LRLWj9-e%idR1 zrsA_2iXaG~zy~>5uDf5SUdIj$#qzW#i*8vGxrUIQD-v-rpytwrN>U@KB1@K+nv=-4K7z+Ww)(%#Lr8iBuB QJV3j*F8m7~=$4EA4=^mUi~s-t literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..11bc5eeb273e8b8b7bf1fb492dbdba2a9033b010 GIT binary patch literal 11441 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 DFv=*b literal 0 HcmV?d00001 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 a426b4a135d3c3f0676d6e9a5857305503fc3fc4..db58e516b1b5becdba17e808a82c018acdaa1b1f 100644 GIT binary patch literal 1738 zcmah}&2Jk;6rWvh?DZy26B-El^n{d9i^i5v0I8ssqzS|#O^oC8Lc(h7o!Cq5-DPH+ z#5pAeDF}qPQgLW{s(Rzh0r4j^Y9y)=l`5egkT|rY65@h*zslJ)X3^ndIN#?%MEmL&W0wGm2rx;qpl5~qK*8g z)>PVh-Z2q_HJa|8KO@f6Gq=(`^Sz=sj@DwCb0_0z;4zf#%yzxq$|2T|rkY0LY*rgb ziSF%xa+C|4_kF{`QKFbvT|qG^64wJ00y5@rl1);>t~Xhs6`$4c@<$8G z$RUE{2?uM>srw4ot6A&rmZ&m{3!p9g#S57G*z~ z8!q#`3iSluAj~ECGUd2Z<-*ECNEBkF>pmPxyc#A!fGfUJEmL)bTM2w7sB-~K3Zx3r z6nt;pEn-HAQ%|XaiD;Tx!mZGxwJ6PIlw6v#)46GTHl5v$^3TeU*2Ppxb}8Y^^zxj& zl3kD;>!i%7?3!N5E!pYCbk3fgk+CaFOCP4^?B%&DE7`e(CUqwdvoQk?k^>MSRpCQ8 z&vD#cRp}8D5g;P z8WxIv#KW`_h#KaF60MLriwG{1DJXzAu2<)t5M`QF51V&A2Ub%NQVACmQ5J!lrB$TD zrXDy@W-5VMp@JxKO4DI}V3TrLS_R;Ppml5lgOX6566;dt9qLM!&}F!Jh>~(u42Fh+UFcJfU9VbsSL zVEP>@{w$2Td%j)!YVB)#=lx#|bMNZz)nAOk17mPMe9ss>G+t{9hYyV5{fT?V@S%}- zY>YiJy7m@#7Y~iTheq^ou2^tm`id&>XxAsx$Jr`sM+n^fF0E8 z(Gd;}C|uu5GP=ot@iN*L$w*6PfwMBV36r|dr4bNOlE$aL>&b}YGLKZKG;Mp`EmOEP zLK`hC!wokXf=6!FM?E(*EG_d!eIm`~t3Sc7`oMev;Wrc&Eft0o#O4{9sjofL+Sp+<(~)d4=n8Hd)BV? z<;4T^(u0xn&E2H~H27fT+{xYOtIgfz12ps-iakazJVZlJ!bmswMsJLMXB=uHPxYu~ P9fgoS`lmwdJVf*_@@u01 delta 211 zcmX@bdzD2%n~#@^nSp`fZF29`?1ue&Cklu$ZD5$Fu|AzqnL(4K$}=E8Gp|IUBqLQJ zDKR-aH7`XW5yCCW%&SyL%d99VElO2LNi0b$PAySLN=?fLazV-y3kno6ixo0+3-XIf zQd9K&ZgD_W7vJJ=aSU;cckv9;D-s6EGXin3C6M^Q%*e=ilfnN!1H)u@Hf@f}3}QusKyd&}3_^GS 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 0000000000000000000000000000000000000000..925dbc2edfecb809c350340cfd0d47f11f9b86d1 GIT binary patch literal 31501 zcmeHQdu$uYd0&z%k>XM$^|oc%rk`h>Wzn)^>t#E4_Svy7j?Y=&QP+!NhL&iFu`E)% z)Y(>Zh#FiP8|UEMoZBi*(y9s2o`Wl3H2uS9iy}ya=7OXssfXndE_#2Y1r9|4$Co07 z|0w$VW_M?o6lIdOe5bp5A$|O2c4l^VX1*0-F!XvGnC(!E}g#iS&c zH3)KfgD~PxdSvGpq;|XPLQDn4jM$QudYl_^k{;)kJq<$ACszXcWiMcr>;tTp{eU%c z6=1Dg4Ol1F0M^U3fDLjT;3~NuaJAe3*eI_8TqD~y3C*h~4nI4d9FC4AV*`<7EIt}Y zM%AR+6HuZTV6iZp6VAUUkWlgW|T=uk`G;7R##>+$QhjQd68 zJvb6WY0b8bBa%o=G!4WRB|4A{4968U8aN$ECZo#5fSQaXMxp~YG$Q@eWye3Y?gyIz zCWVl2%HH+5APgd|N%)*?*e2WhaYws?RCoLPlqXZJM3dvns6qwdvE^st%DG5VrQpU) zXE2^f2G1noukMIm7#NO>4n>1_tHJ1w;qlYK(~*I*(b2&mduIm25wu2Y;$p@V4o5~u zK=GO$SJYmR+WrvT#YKDk#o_ED-luE^KoVHy7meg z2gXgaO>rW*)(h-KSQ|1xZ=r5r_4%#fu$BLsz_nNI($72X?B^#3StNU{0jj@oQK9U8 zb^@VMa)LLkkwgGh%C?YAwukINyX@%H8yw9n-meQr60zW`Z9(eipf<>(ic*ct3iT~U zvG^r5K1#odxSG7~P^#!2PoUD%w{G34b&iXduoltm#kK;O6mCmZZ}wj9z3o~*J#nM! z<}2^6N^O;&f za31n^NZQY$||aUBY`HWV^*7&X^8{4!CoMz!JDR^zu~(1^pA#9n_kG<;K;`> z>M=H)z6Y|J6ih1Z(32#De!9=DkYG@30>kTsXvCYp3Hzy^jqM6Kx{e6K*mF&n zQ&>NBF1t<;UFTdI1i3yWw%g=}zD@1+4!vv{gSelj0(PAss?WJL2u4l2ac2mlFzZR) zoa6{@5JEQNJ&YO-Hss`^;gfS&^L-Ze!USQyQU3IH<2kZeFs;K#8+5eras?q#CYQT&Q)qQL^!irNr55OW1pD%0cua-aYp++BD$|Ib~N14MhEt zE3Bb}OLmVP35l$~zh$)M(8ZkAT&lk-(BG^2UbpnOWO-( z%ayV>KP&0o3JNT2Y9d`qk> z?=JJoQui3fi_dtE62}YAhw)NZ(s)_9D~soh{x{JW>`(B74Q6(AJvMq73vstCc&f^)8!R+&X-lbH&8qu%sdylPee6LUMidY|YL^iOM336pthNrs<7Z=0)%{xQP&Da& zZ5x$?$2PlPV-g0{sq4Nkiz|k3Wj`0aZo6;ax9_@bqMfoVQ91VPcm1sg4h@VSDlxW6-+u_sWv5{nKRLzu+#F0YHtO~2c@$r#CmNYyx5?q32VZ9IC>#55*cO62xT=@$f-AH#(~ys_G?1g zW>!^tyw+)L8OaQESOe`^Kr4NZHPDq2Psax@S`+WI26kB!@3sayt;zH|T0QhT+S9s~ zHdIq3?kh~e$LM2ey@J9dS z;X6{z)x)#Wn#t#GOTGnZOv#L{Gu5A#nioX~Zu@ibOsMrm~FW|Q2Z&uuXX%CN>F+;b2?$>V2u`)P5HNw%FJ4`mpk9+z8adBnxFcn_Wu?mD8*-kMR}ME7Y>T)i@Q7J*d|p{_aehrMhqS%?Is?wp z8}0cJl7J|e739LXZbB|EnAX%UI18rb`UTllFs&&!muE==<#L7W4wdJ~&CU|!1EJqf#o0;)L$wCAOOGP!c>Yav&*UIG*_lrygKYbL6h z)Lq;6^2e3}yd_aUan#DE7^CkvB@{=ki}Hh7y(@wud<7|@%y?(nT`(=zPz9Y?)Z>)> za#hHgLp|;isK;&8!uVZGJ&7_<(3C7eR;bIAJxlfwWfNMSv9Ze2gwc9K+;+RNm4e$S zD1t-YdEaN@_1nN`v*=yN6`H+c--7;%am19jdm<|>n17bGL)nF-;JH6fJT-(6OND^S#6)L{lC<};QR-Pv!11lb-YEK5D zr^XYvLHy93iV6W=;;Cqh>yjxBD4o=BT?9z$tn?7*B|ugtWgmh41U?1OENRR+7GFU* z2w-KCb=F&J_ipQ*E=`Wmc;>dYtWdia(wThyo*vqnm2$T#;fWSDTVt{JWwY3W;|UBD zvh*27t#FKFsnIitPC~*_qhZK0(NQ&)jJ+CFPNPgBe)T&T6P)M1-30!&^GX*n-kX6f zNITQgP9Qf9GLV)!7ZA(-=ETmFw37!pa;1x-GgKB2aRfzOJ%@_@*dh2mBR2C0$1VvqYo`;ag2;CC*T|K7VKU0W)o%}joJJk)^+AmYc8&a@}I1ThV3vxmd1RnCK$u94Iw*QXxohip-6*|JLCYP zDfLxbmRZODKjtmR;3LW~xrD@=WumNvWr8nOmSv(sW3rT8as`$KPo@mo2nAjWOyTqX zU(npnmWj<6AIcU2Y;m9-Wh=ySHnS@5bCottZOWdel<1e>Nuh?%MEo}QW<~EDlsIQs2Gl@0W}dBh)#&7BZC_< z8!i;O*bHqj$9YusP_}CvFpwPHyAX2>{(foZ7;LoiJE(KBOM6qs=pLij{Vahm68Jno z7Gt_AE7YNqAa1a!+T_1A|KuMVeHC6_gPc=U639tXhk6eH^M7skCD)x*8^7&Ixei>p zFy)!`1%WO2degpMAU9P8(!K)=h-H6st^+AwFAs9$$_0+jP+2_05ft?uHxD6^5xV6% zFy-MlGz4+e!#oa&jSz06d_nDwwJyMKsyssul6D>Vz#-K2EM99ki$^Yn`Sgh=XeG+I zWIi`MfYq}-G^d zK_Pt_SUneJDM{Ih*LeRAO62g^6!t7~mL%&cXOLqN8@HZO^0_EXIb-87SS_t49N4-P z6}gC!RNnv~7Ow7%y}Hq+X~(S8x^nn9A1cWBxF4f|^3hfT&X*seuD*%!Z)>yowY)~>2TkN$@C)>eO~GTU!r-9CevnsvAf5Wf*v~^A zras{QqR`W04a@4U{CfH(<}tdi@9!-N1oYer<(x&ivf!%1b;m{ccJa2A3+u1B=2-R@ ztONH#k*f-(<^E;CY)erkyxwX;l{rd`NA)BlOA)PZq|9*RA7SNKeosP-z}n(Qpj`LJ zBXIqb5m<1fe1b<{KePhc6@nMLp|4relhJkR-LY-D%GC4+{%Mgj9{B?Y`o5Yq<25{n z8IOA;mbixUd{{#pN}BNs>qnO2qy6$K@X`L9*`}%lm1&i+P8q+tWsUr~zX^|2Qh4TE zTur{(>JcwDT0@CyaDx>g*pt}`S7o&2&_t2FZ9{Kwp!}BpF1+uc+IWjnR*@VZ`5JkR z>r#l;D1CcrIK z$`J}aPvA2EY8`-~z0CU6RGGV(0)%bbV}|t{dph%K#Rk%|P>h_x_TaESnl7SLawt%L z#*9X~YwO3{%&u8d?A^_2PF{+5vPy>P}1DKyJd*6Cf=;vw&FkH;436HxF`z z98mQnGbl_C-J){Mj14U&F72@3dVwhXCfq^Ir&)1Sh3edMxhEy|rt0Ax(0i!|zq8~P z+Pffiq@@lZHysS5B|KOEn?rgDe#B;shEpx|xEU0thi*~1+6~HB57R#-VuirTl;e7V zh`;I3q8CF{1d;%cT;YxPlJK2`~-5ANl-{xGLKI*Irh_UxeNc#^VDMw8K4z!4Iy*fl{inV?JS(1{1Zxxp7 z3${2{N#`gjd{8m13i)zc++RYA`;C@1esv`UoIjK`OhjDwup;7we=vF`HX2JtM=l1= zEAi2x3^pCo<~m*eV{uGo%s9tK&yL2=k18Q_YqoDSxmS=3nJFKPCL^&C&8LJ(h)l@j zVj@n^X-7H+n(Y%Wk_fkiFqqf3I*;=-oT9TmqP0Dk#iTTLTf#Y8lkF-WR<;Y!OEHPA zs%VMs%arFe0$jGsayZ$kFwrhYj3c=}OMIh%Cc^bF5svj3cRgfso8EEy_C$por3Z0v z6b^!3!s62urmmn$V z>1MzixvFOiBG^gEtYF8LR?0q3 zfbA4A4PGRtgTKig>mKy&KFsbo=QuwRJ6Vgo*Te= z+6Yb+Nl7p+nCFjZIBiqZ<8Zu)CB&mq6m7ON6B(gfROA}%j=eG+1D=eDQ% z&EuDkFL>6aJ?o~vJnLzhJoKJ+YA8%DA0Ok;Z^{MlDmruKw&?!)@!vbXAl9eF`m0}_ zehFuvd@>9awHRy6>p24j-wN=0Uds#=>z)`W&@cM+#6Up}@!=aNPH|;0+tK+W)WMa9 zWwgN$%#Jfm9Q|B%+gBZO7>10om-3466*$G@ddSU`vVUh0U!d43LWEx%k|4gU@B5Q1 z@#S%x48oaOLy2nCwaERFi}FKh?}Z3onqj0kb%RbX!eVnC7X7SN&Aac1JV&(4cvog9 zSpgz_5yl!mR*UfL<^~j;t6FF;6c_)3uB;PJ9-l$Pa$XRfctRfG%HLvt=09rtGfgU6 zOpCJ6-^lyWFeG|I-nI9|AUtrQb%P|TV|s^uoxAF9q7FU#SH z%*G4GOijYUOnsXk+SKP|mZ1PYl!6>Ql>h*lm!RH3^$A zeakmeYH>hH+yxGST{|lY_P-@%@iw@7YR81wGPeELjvl0{AX0lOkesK&M2W%z`;BvJ zahNGzbf2%*`Ix*b|kZRcrcxC)*Kd@QfJ_Htg zN7KHeK;AvcK-za=0kQ0F4mo^Bd5|Ml#yL7e#qkhFP}H?ocnFD%&@I=AtNq#yls?_b zQB=spefuo9UI8S3_oNoR7|y#+{MsRS>!CRC)zVot(@(wAHS6t|{Pb<9dO=#3me!@j zEz>w|3Lq_QSrGNVIdMx$TE~MNxr8&X^dvLl79(`ak{UUuaZZ$36vt=9Ew_F3Z+_+S zSEgQ@_rVKy*Om5^k2YNcy-@{_l3H(6r6nAqs{hUDaXf+}SD@_C6U|UTTId$i``~p; zceK15zfq;3i{WDHD7J)ZN{DrA=r3mM*ue!lLx;5dqI0a+=Q>0q3SY&+_THdPF7GrL zK>DBwXLF5oU;*s1bID`pISX)t`#qUC!3_A=_Edk?2TfpS>-QL`PIwK>{LTA86Z1)V zg%a~H0?q=!FAlf`LJ0zz>|k(vPD(3#R8Roh%hh%MW(RIrN~69oDk=gC^ud z&n`>Jd8{m`b-oXp&|xj?gC^`~mMVq(0?kv;wPVf8Ur=KBjMqfXa59y1}odmiFbQ9gCYuUrESOjPN_PfQk0l*eh7${!-WXmVHnm{LyxET+oMjB``* zQMi0WiSGo&&x0nvjl8c?{$gpeHRTVkaGKn>Olz`?197lAy9%$%k3g4xMweAtbV;k< z%A`wo5o@hUm!5(&yfj_XN3oWBT_zD*TbF%wWZIH+dB1)UUG{YC?Am$X|MHH*apinO z8H^6@ID$=#6F!r4S(4?WU+oK+R{A|3OAgaFIJD=76F4)I)>ng+amXT8Rgm)EFvRfe z%)7k4=c!HA!|J&%6==I#=BRS(B<*C&tbfHVvmZW5%M}mJYF9CmuFfK9BAk^;(pUfs zlBr43l?Br-P10TnA|ZkVBg+hhk>(0c-cve>?}9sBHf)N@HGOYwPKMdE4Ey;TMA<-i(c(!?jArhis^8wC) zlBJF7=-a(Bm0id3ovxPmNF80}Y~+hm(Ix&vm8+z5SE*WWWvc=Zq{u@yiMQ&mQm&*D zt-Oir{7>@xdS-eCgP{QEVQaOR8Z^G{>FMd|>F)2@U#|}ae3gPA$p37sQuC4Lcl!YI zgD%kV<=-F}7Zf2W^a_f2TufH5|H@<1Sj9@=SXI)qlCD%7uRi8YdKp}KyyloM>02o% zQc_l`l77XL3@FV?b)%qo8->BTK`|LrmMb-g`;2(4A~y=jI>nE$UI`#^E*U_5zo(}@l->pP9x4-Ljq7-E&tMB-9Q--xQl`jTzuMpP}7;NfFRM2Vjrj71Jc2M6O?GTs;2+}S6h$>~Rj zgdcs2FE=9?7s5hs#jfuQLO;@4gqOv0q9XR-ihd{+JryP#s=!-UenIi9cT9_^!_lEw z)}zLfW9o26MYd8)swzDNRrj17QHP>QjiRfvdqX3MWaw;S`Pn~*n1N|M-6`Ly$A6I<3f+w9)k6T+l;h?7#6##*j+h+g8mvt&+PG- z$)O6Ra;MQkiHdMVm)E>v{U<7We>!?7EbTfb2&3OwXIvQgpVZzxJ~Lkzy_n-@7Zf>M zwN+I7-P^WSY?JiMilkKa1kJ2nbtYu}w+q{h$Zq@(tZ+Q3!OCeu?LxT1e0#Ho{f&~b@sR?^RVov$hJw7kFk;Z2vs%GmWeVAJ!YAm7l zjSMBCDyAV^j+_~b4`Ra8vYx>aut}pPqD9Zfs05Uz zPZbgU?S{00tdxwsmCObYu-CcKSkv68}f~k{zy}GwlYpgP^Ae{Mf1hgX z?>}hln*O%DmbjCn1ez}CZM!&9K!S0wNncbjxhs|7%21^unG-&}+1HEBp*<7}akQm| zaI;DXR_&yK=4iYNp{x#xwSyyvM%7x2L+@{u(f)pQJCfK-t_LtK@XpmbA8ej{{+jRl zu^R(7$5MfpZdb7$R_XWL7AmVe`g`IYe$)eEUo{D&W=DOBU`|>IdV9*_I26&$;f4^Y z-RK~MsZ?yB_cM+Jh`P}&;Yx<|o-G?!vAQn%u!+^#;pEH zBiwP|9r?I4>u3;T)_v~%n%@UvEU47ldaHYWPP~*dWs)yI?{YqWIyfr*;eZjmw zp0}Z3UfIkWs?HhX0cDZ0I2>?{@n8vKJZP3|{u{lQ#yQBogcrXW{6Ge(|52$1o@mPY z&VryjmxvFJBvtAV*{UR1nn9xCIw{!pbx@~S7}d5zr|)*0$X3OZu_5j27B@5#(#D2{ zqUuG4X@FV5Aq|YE@tBsC$A+Wlqw&G$nZcM!ol-|S)Y<4^yRs4<3nCNY)~{=A5AFn6 z$db3KApZprmJLKtc6Mkc-M9^Oqj>3UmI~rhr#CwR83gApGYx=|gtyVN0Wgvlpi~E9 zN#EN~QrYwv-`ksrAVvy6^ZA`yaYa^%2Fq3=ze?Cm1&<@BtrV=HU=0OpDZt1U)HVv* z5rE=lR7f@BjfV&9B;jWDDde!HY_ZdY+wGB^Dp4NxyvAj{DR8%iWET0FFR z8pRMlqix2tO5|$u3ThrGDPqaOJ?f zckz`o^WG&_MsL?t9S}i1p(F$Ve<@V?Jzr7$|4{J1eP0N_lIc0}UY7c$nfeu_{$De8 zn_?3?SQZx0jrK%-pPsmfWx~VsN3cY^fLp4(-8I#<1f!C<B2*f}9^mUm-G^=X8dQ7_7qxV`(r`3A(_zEc){{7{*Yi+#7kq8q z=Smq^cpmr~oQ3U;-1Bp493r4XWpjZJ@O7cQoTh?Mrchoklu^6|^H!NXA>_%y!Jy?7 zU%1+VgS|aV%vQCP>@MKp6o&Z(;9wazSHusrHp9ab@NcYVnOSC7=;0j7HiN<(UFF*E zD$(!$FkF+%y`bM+F0$Vd|FXDycTT_am(=fot#W#}YrpH^LN2ov%rVlB2|wq4*Z!1$ zKl-6x2|UVU1oU<(Bj zt1e;OagM?4amKha#+-T_as>AozrH-<+c8CX#s^RTP-c8Z2FJC8aar*XFrFO@kh%v5 zLETHi0R)WO>C9z(zjfTay!+!b!zz!**NVZ>&+7QY+Uf|iCrJxZ@g zN)Q{jmCIf^nJ*Gys=q~zBI^w3laxSgmiiI}ClKWDLz~r;Jjq~+wx|jv*`)fra@eHJ z8j0df{s{j%i?o)pNL(Td9mMEZ94vT*iSi+J2Dc2|g8Djcrdnyo4d%!u%LjLnlAcX1 z-GGodiwzlhBfvR%cUs;J;KnHyq~&Mlkjl;(d3{=558xWq3<%QlmN}%db4J>dlGpPn z2Y$GfgVWL$TZ|{8sEPADhD^2?Pe!Jz7kCVr%-Ah@?n?cNy$*n{L3v?Ski)oQ@Odfe z7+52OCXWYjPCk&94*TyQ- zac*h>8GC>!mV-HpW#WyvoX}k%ZPu3YMmlX}J;{}u%I69lw3T(gJB@2l>VjRi30?^t zP$_lQmEhL1T>ytyPRdnw*h*e(rdj`>aolJ`e=cvPo>u(8(zV$|Cr)-9jyyMlnfb&>GIIDW zsK&pz%CR=XAk^7(xgButR+X4UmAFFnAc9Ld;!EH&hS0a1Fp=?VgdL$T*HIw=0~JEh zsV4zg2s@IfaUtwTB2PmOsZ$B1U`8mesfVf0Q_z9Hj^B1LB&haMGSjRZm@rpZxRvRt zO}wWgy{B{?eSd#sZ1~OLkqg66)iYD02uYD6ap+yq#%Cch>{sI`p461u&k!)FAX8yC z60-Z@)`|1?j_U|-80ZkV&KBdzcm&}ME0Y-mLQFN@Cl<_~du#B9Oky>S8N+?vNMW*pFR{?tXw`4b24&F3leK9U(JElkU~B1g*!Eow>Jtv;(+5*zo~ ziO)8USTtC~ zL+gds@mN@TUKssjv-J!uw$&Q$-?uef11ZqT?(f^AK)!-)$mLp!TrjUSgXe0oeI&!l zCqc;VW|Kt6583 zPpMwU_gZBCUJ0+Vn0mS6TU{*STeUxi{oJoCakSFXaw}19HT2#3Y~WWKb6crbEVq(N zD>db|QW>wfw9ztq8!65980M(u<$mRCGiF_~>WiNuT^Dl_m!=Y1#@M7BqZ05wjBPrR zt?pA}Fc#@Q%|x5JK=f(4hoqv}Dl9*MQ3){zYCi=e!OJO$8Htc3@1?+j;q&pqHgg4n z*RVl>MW=Qyp3rp5lp1b}lJyTohsVxF`>j*HL5S z<({FUcqTi58}QWkcVFK9gQr;quGGBu3_ZDD56F<&IPv(2XY|;t1ya%dJdYiqqWyJO z8sB?q{P4V7cco=kZcRxYAFWHv9dnX#W~7dk-1^ZvJz6{o(27oK{ zn5FOSOG)b|PqF~w(mBaEGt&BreR^!x0%s^M&tnIsrS()^^Q_#GlGaUCvmh;_D#n?S z)}`bYJvwWHXDBz%We29Ebykt>QzEQpro=p}85GH_W(*isGgG47Scb#QYNqbW@>zLB zN?J3uSP$eibCPjpq%|pdg&v)?!84Sb=duIS(i*B|>#W>1wRBc~l4Q+CSYQ`8XGrk- zq%PH+wZSC#J;H^(b^urM8F_BVd~-oYz859{?#w)lZ^fO4cy%GQzuQV3h*bZ)C_;Dz z6REc5iBuoFx;jrJS|a2D78x6 zPJ=y8c*EWlG<>YYW%n`?h!g!uEX&izB&7cPXRSA+V`a8nKqJ&e4YT zCA49^*>>i?(S~S8^_OU)92Qc^VU`lX-b!jQHYQ4KvlXn(R{aLFSuRV7B@*Q}1D8Pp z9U7~8KNGshTHJ9c%gsJ{*OA;1Zctis*!CdALFIml*0i^=5`^+|^%o@k{aDl&3KA7$ znH%YhDC6T9FPm$#q=qSTHwA|ffT4#ofc}A4e@BI0SSMX#q5!5W9i%AJm<|CtqB35e zxK&$j;x^S?6p$4Y8113rt(c$+%`+?7&}Odg+(!i&>p5HvjXKGIitvMafdYt~1)Zf2 z-Zx9njFvhU>CO2R70eBf)Ec;dR!v&3+I+xF9F}o=e zmULp(^H9uC#SVk<9|EthMa*VWzlTETb87#?&hyWAJe896lScOHg=_UQWYV*LPTrZ8 zcLKPvl?7>e{~S`;IV0^)$vb(J1EhyFvP@HXJjMYOMS55(lP$)Rk%_E%tW0L?mbCv` zy?%jJ0gNd9D2qCj3fCmCl(T21l|ip^T! z3}xea?7+0N8lsac)$i?_gpA+`h&HZ)p+}G>-*`e7Q!wd<0nboIyYzy?OpKuy+c+z? zr=(3&udpC3Z<>>gGb3$E(PA5l&f4G^%FT1xfoW;e=ikuUAgmh7=kseZ3W!txGVI{< z?F*JI4<6Pc<%M{EQ;}aH-+#F%j-r9_{fmB$`F^1!-nuyMhx6|y{az&Z!|wz4BQRsC zUk|?DQ^e94ij+-Y|>d zFllE)&L%#D@y2CJC5|_KAL>TNi-D5rR-BxzNpA(K9e5qVY24#7rND3Ic*F7Bu$GfpkrLU}e>Wy>-HOd}9@xgt3Yvu*e_%3#HD^2cD>SAwV8iT7`t1 z@L2{lo5a!e;R+_T;0_szl=L)&$sNPu?tfj9q=%a}mC}D4lAZwMa^5`CoL~u(9{#<| znE~7(&8t|#sQyj3rlhvyqpRGOdOx?fC7BtMU%|18gfDJ`I+kRsqJ#0iShn)a$QfOC zz8$yg8gVzq`xUzSDh0%S!!v_&{K$)k*)k(_A0;z|Ib*GfgH~UofHcz|*WMQ!`!1Dp zlme!su2p}J;NPdfVra=&m@TJKBLopco0Zx7F@96a*5EA-P>j1VvI~0~Sf8_$2JR)` z1q^H~^JfT!^_f`mLM%2MA(w!f`a?YVW&E_ybSBonY;s-3ziRyO&wY)PRT*FF_@TeU zGL^Bb0~ylrcFxI9rDbrqo!9zUkd`~=kjl;(sWT-%#iJY`=GDkDEp^&rJQ+nzjPV#U z*)A0u1v$88#`wk4voJsU$^K-)gM%+nl@$XLUVQ7 z(skQ1bvv$Kn5}ze{P-`{Y|hA?AHFsvP7J<(a^mENuR(B-k~=>gxDEz&|MgewB8v>W zq{|CtZ7}2o*TJ9~vIaW<3C7>9*_4q(AHKq$fO7YwQAqg`Vi*_=T^V2zBlvo(Rx=c zm#cAk(TmUc0qHgpX1OFH);4 z1us(T&^p(Zz>CzGwJ`q;`Z<7B{DpIAMJ&A@a4c#R#dCrciZCjZ?QCIbzgZp4QHD(-pUz)URRU(?~JE!v>^-f)y z3+qw38cieKCQpN=>(ACO{)1H^GM4Cv;j*PPgKO}9TGSZR)GH`~m>+GU&UpkJCbKX~ zd~DXle-7VgdoS;u_pSV>dh%k*+i|`5#>+RWKWk2HK1~jXo7@}@e|u)az6b40j;Xv- z(%Q-ASpa5hPBPAnw3aM)DK=|?GnAL-u>-i0uYjW7$P`e2$NG|`YU1dF{N-}O6}gRr zJe%bb8Hox-?5zliqn+(S_iClWBs4~!b0ssXl*-Xq*uw;37fd7sq92Yna?m&wW6lF> zN^8T_KtpxiVH+BvmGTds(BL9%vyS(m-XSu&kTD!bdqto~i#UBq>J_E12_|22SA+>B zCu1ERO1;bk)9XO#H6@^QPIB2$IvMylyrU(mfa1?HW0ZRJpP)bK*oM|Ts>I^n-E$6G ze$Nb0)aNMkVFY~HeMt^N7w`tkrhLmE=!Q1(2Fk2S;O%DNh558<->-|@-luvoyj#-h ze4mcvo}{}i8QTepo}{3Q0+Mp+MkA*HX*3`$1m=Rdj1~E8eMjz6{6E00gH*wd>v9W3ez$KRmM4k z6uE~-IY7Qoj4abA&WPQjlJyI8V-uX&P$s}PYdM&byKJygHf4X^daRy-hq#(Q&vMbp zKdyR&L?>?k$4gzAUmVNNF^D-%`D_Z-C1<{OHImQwtx^`9U`{i%q~T-mBPT2@{(%I>aWWt>^#d#3uD6iA@Td>T?{PCpIDfhg(g*#kk-NSXt4%B_ zy$fOC16_76E`J`1PNqy)V36bT*O-zD5^oWW{NPtsF zOv3d(MTkjw+m@v73l^*cw?ZvK^uhH<4&9)K^l^F42Yn@oKKKeplax2)^yI$E6n~Io z_{jw<>G&GFyIRsU*lpVqGB&$fZfK55wPIVstk{~m|I*eM3u}%_v0~%j+o3t4jc^{G z7298hl8r}csHg^yNk{8csIwn`M=P3XN=9V2f)}Iz-O=)-{}T`yC{um-c>Ni8Xx9>7M)0{{x-~4Ig^_tW&I3hnW zu@C6u@U#0Ei$hpO`}`J7;j4e3VByKmuMP%QOlzVz;PV>&9g;n4?Ur zkf&|1Q6SPkgQ24JG&_u{n9jPHuk<;23!K1Y;jgPW9l&+vBvTjk9x)&v;va6^DBk80W$XlBs378%xf7 zcjvO@Ubr4}KIB(|K3HA-R29BjFdvE>g-zw-gScQ3XJFM0)g zEY}Uwef#m&Td{QkKi-AK99DZS{q-@qcYIIyM^>7`eup(rpahW?HxtffapA{%fyJQ8 zGNrlbaT5#&mGZa|K*?~ST5dZtbMg<+O676GFmt=j!qIFguDU7CZpGE!;+q>cOcb>8 z{zO6VqAhgU4{3f?6#+aKdlFGKI;822l$bndA6*Sm&`-e#1+>y7>(OFSweK99F-KK3 z+V`gJ0r@q$P9hxj5(N_oj7_TxD7C2qZtFVLe?%9^kx{ma?fkN^_~l6^exa?gBWL4- zNi64#oViF_$k4Xp7n!NGUS|~!QH6K#(=NbD_Ays@>uUV54EfC6JSTU+V-J99*l`R& zTHZW|RCdltn^WYi*h=r#2PJ8K! zy$*n{o#Hpx0bKdq-|+tPm!D5HZoGc!=FZfP7bc$1_+Om!znu2JobjL1y)ZYvw?8Gd zPQJ{7G%blW&WzMLfn}K#o3+3h%FFZEfoZ9gyac1X$#%F%p6r>WHNV?G8A!|6UClT% z(snHO{RB&LDPfky_(dJKMfdQu>6{o(27oKJ7kumHvd8TOXQb_SktDqiyUIsKlGMy3 zNz2QUB+Qq_QI;f`En)qaB}rymxbG@f^uq7*crzQ4|3AR-R+c0^(vsT+IpW%0tc$`r zEZb7=lBG!%_bg3nX40gV$5NV<58K-{0R{~EFBIo1IbmUbY|f1i!zUF4p(3m>5A?ZHrcnR zEG@}Avfc3{_k9#w&6=BUYSy&KeH8QVhbi#VBFrUuJC!1*R~ieNKypcKNZQ~r-%St_ zL8S>>0|Xs1;QQUnkz8FEZco&W3u8WIS<$lK+Hh^&Ox0h;dh{jjW01)YTOW}8Fk6#| z4#Xl_{1Uc8BT)SlJkF9JX657vHgU!RlmU1-AaP>0auC~vkw5oK!KeneYe}|0+U^c5 z{wpj%aR?G&g0|W4z^-q24Oi@r4XJ(83{YjZ_w7nzBW^5Ya9+w23YsAwec=!S-CcuIDh*G81@t{0IyMudHV;zh(-q7!jnMyWK&CO`G4b-R=yScDuuC8++o} z0bI!!yK-kCC*WqoUF_Ohv5;r_hrTOaBlAw*^lR*${t)bd%v|Sl*N?tkESkA!c=E+!%=t45+bzd>@~N6&iLQP(MaFAL6GS zv>TuOrtFh9zYcq>?`8V|j!liukTKfcIe9w_sQ}zq#DcU8xr1@eNPAOcWM!r3F!9|+ z+BB8NV;n$HWQS&Dvc-5ZGLapcmC20VlJ-uG>K9lQU>HZ4?9~B$V-dgC4&Y|K0o~3_ zaMxULUplxi6MSa;=)Bx;W#@zTC)#jj&jS{bpqBgCj(lsUU_OVy;iHu8$Y(eyhJ<;SsEg*!%E_Ga(@UKA>o3p^-YOsE7AUvC+l0xrHZ}% z(DQTu;$85l>pZykadvTCqxlk0+i6P3L`9X%A580P+ti5IzDY5_sGvYaU* zT5=Q-guA(ANEe|L$I&jBb>59D1G)g+dJ>@?#{mH)r#co8%vtx74&8CM-LKV{2zAVw;sZpF2u*KC@5Y{$I&QCBaV)D?O+ zizf0F%*$ya2rUbhYY8o#+vNI{K%UdF>K>ZV*kLQV*-W$kVO_kCa)ED?i&2aI;5;Cf z=553_B&zGJvD@mbq+JHMb(eLw^FH(Yu>AKwJ9xX_$#twZ@P(r<2d%o85OyDMT{Des zj!Nm7#f=)iF1W-|!|y{+Y`~~l^bqRCIe-%D#_vPj7CA({v{h1#i_hQUd#Kym)?lJ$ z0c^4)w_lq5>#jJ(4^PkkCG6iV!WXW!ebQeR)pJ_e_DL(de{K_^+tt4Ab0D|2gUw&# zL->;1O0dK)$?wCLY;=6dT37-ubJ?z`_%F$o9OhTig@)>XRhrA~F9?{8zpeHUf=_O{ z6bMBV@zD9sP&^sKN??*y>K1*BV`hXg(K0%vOGJTAC+{I>F9okq@G1pi1ScHcw6SgN zSuD-Xdd|eoj;OI53qV-*T?*zrKBTWY{-;zLUwW)_@pd0|4P+p=nd}1hF;hY^2o%5e zfTV7V55s_PDP8Zxbv=dUp=~w{Gr^-&Jek4h;^~s?ee~BPB zFW&!(E)bKDm7h&qjG!@wqRFl53zW>>_c%e%Q^1!%-))@?)YTgwPmS>tuxX#V}taruqO{j8G9wq}Cc=7PJ^!QGkQ z(`u#1%t^+Xk)D`<%RGwBS|GoCi}Lf>bWV&X1HcuCcT-aP7RWE(qWnBIofG590A{3iCgg?u?)T7FJ1W%Q!CzMBSedPkMEXbiB9W~3 z%ovvZ$A>kQ=*{f1Na7-mhfTVDF>Gfysj&+xrnGDgPs&ysX)4jS*@m-Gc%@9lH_?rK zgK=`)2x0(QAsFj!!UdHqN7UsMuqonQf;LjXX2sJ4y-q=#g10I79tA(7;0gtQK*1kT zFiF9mQ}AO7KBj=Ud^RFK7TEjh(gE+@p^^Tv!PtKF-{Cq{NFy`j+m)gy{;hE8zX`(k zg;}BF?}f(C<&JUhysv4zdfwYUUiFp7BQ|{1Ac>wkK_7x 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) {
${escapeHtml(item.name)}
${escapeHtml(manufacturer)}
${escapeHtml(item.classification || item.type || "")}
-
${categoryBadge(item.category)}${size}${grade}
+
${categoryBadge(item.category)}${size}${grade} + +
`; card.addEventListener("click", () => openModal(item.class_name)); + card.querySelector(".card-add").addEventListener("click", (event) => { + event.stopPropagation(); + addToInventory(item.class_name, 1); + }); grid.appendChild(card); } } @@ -201,7 +348,18 @@ async function openModal(class_name) {

Specifications

${rows}
${item.category === "ships" ? "" : renderPorts(item.details?.ports)} +
+ + + +
`; + document.getElementById("add-to-inventory").addEventListener("click", () => { + const qty = Math.max(1, Number(document.getElementById("add-qty").value) || 1); + addToInventory(item.class_name, qty); + }); } catch (err) { els["modal-body"].innerHTML = ``; } @@ -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 = + `
${escapeHtml(entry.name)}
` + + `
${escapeHtml(entry.class_name)}
` + + (entry.available ? "" : '
Not in current catalog
'); + + const categoryCell = document.createElement("td"); + categoryCell.innerHTML = entry.category + ? categoryBadge(entry.category) + : '—'; + + const qtyCell = document.createElement("td"); + const qty = document.createElement("input"); + qty.type = "number"; + qty.className = "inv-qty"; + qty.min = "1"; + qty.max = "1000000"; + qty.value = String(entry.quantity); + qty.setAttribute("aria-label", `Quantity of ${entry.name}`); + qty.addEventListener("change", () => { + const value = Number(qty.value); + if (!Number.isInteger(value) || value < 1) { + qty.value = String(entry.quantity); + showToast("Quantity must be at least 1", true); + return; + } + patchEntry(entry.class_name, { quantity: value }); + }); + qtyCell.appendChild(qty); + + const notesCell = document.createElement("td"); + const notes = document.createElement("input"); + notes.type = "text"; + notes.className = "inv-notes"; + notes.maxLength = 1000; + notes.value = entry.notes || ""; + notes.placeholder = "Notes…"; + notes.setAttribute("aria-label", `Notes for ${entry.name}`); + notes.addEventListener("change", () => { + patchEntry(entry.class_name, { notes: notes.value }); + }); + notesCell.appendChild(notes); + + const actionCell = document.createElement("td"); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "inv-remove"; + remove.textContent = "Remove"; + remove.addEventListener("click", () => deleteEntry(entry.class_name, entry.name)); + actionCell.appendChild(remove); + + row.append(nameCell, categoryCell, qtyCell, notesCell, actionCell); + tbody.appendChild(row); + } +} + +async function patchEntry(class_name, payload) { + try { + await apiFetch(`/api/v1/me/inventory/${encodeURIComponent(class_name)}`, { + method: "PATCH", + body: payload, + }); + showToast("Saved"); + refreshSummary(); + } catch (err) { + if (!handleUnauthorized(err)) showToast(err.message, true); + loadInventory(); + } +} + +async function deleteEntry(class_name, name) { + if (!window.confirm(`Remove "${name}" from your inventory?`)) return; + try { + await apiFetch(`/api/v1/me/inventory/${encodeURIComponent(class_name)}`, { + method: "DELETE", + }); + showToast("Removed from your inventory"); + loadInventory(); + } catch (err) { + if (!handleUnauthorized(err)) showToast(err.message, true); + } +} + +async function addToInventory(class_name, quantity = 1) { + if (!state.user) { + openAuth("login", "Log in to add items to your inventory."); + return; + } + try { + const data = await apiFetch("/api/v1/me/inventory", { + method: "POST", + body: { class_name, quantity }, + }); + showToast( + data.created + ? "Added to your inventory" + : `Quantity updated (${data.entry.quantity})` + ); + if (state.view === "mine") loadInventory(); + else refreshSummary(); + } catch (err) { + if (!handleUnauthorized(err)) showToast(err.message, true); + } +} + /* ---------- events ---------- */ function debounce(fn, wait) { @@ -225,12 +568,22 @@ function resetPage() { state.page = 1; } +function setActiveTab(tab) { + if (!tab) return; + document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active")); + tab.classList.add("active"); +} + function bindEvents() { els["category-tabs"].addEventListener("click", (event) => { const tab = event.target.closest(".tab"); if (!tab) return; - document.querySelectorAll(".tab").forEach((t) => t.classList.remove("active")); - tab.classList.add("active"); + if (tab.dataset.view === "mine") { + switchToMine(); + return; + } + setActiveTab(tab); + showCatalogView(); state.category = tab.dataset.category; resetPage(); loadItems(); @@ -292,15 +645,45 @@ function bindEvents() { if (event.target === els["modal-backdrop"]) closeModal(); }); document.addEventListener("keydown", (event) => { - if (event.key === "Escape") closeModal(); + if (event.key !== "Escape") return; + closeModal(); + closeAuth(); + }); +} + +function bindAuthEvents() { + els["auth-form"].addEventListener("submit", submitAuth); + els["auth-close"].addEventListener("click", closeAuth); + els["auth-backdrop"].addEventListener("click", (event) => { + if (event.target === els["auth-backdrop"]) closeAuth(); + }); + els["auth-toggle"].addEventListener("click", () => { + openAuth(authMode === "login" ? "register" : "login"); + }); +} + +function bindInventoryEvents() { + els["inv-search"].addEventListener( + "input", + debounce(() => { + state.inventory.search = els["inv-search"].value.trim(); + loadInventory(); + }, 300) + ); + els["inv-category"].addEventListener("change", () => { + state.inventory.category = els["inv-category"].value; + loadInventory(); }); } /* ---------- boot ---------- */ -document.addEventListener("DOMContentLoaded", () => { +document.addEventListener("DOMContentLoaded", async () => { cacheElements(); bindEvents(); + bindAuthEvents(); + bindInventoryEvents(); initHeader(); + await initAuth(); loadItems(); }); diff --git a/trade-hub/frontend/src/index.html b/trade-hub/frontend/src/index.html index 77bf31e..8d769b7 100644 --- a/trade-hub/frontend/src/index.html +++ b/trade-hub/frontend/src/index.html @@ -13,46 +13,83 @@

STAR CITIZEN INVENTORY

Item catalog from scunpacked game data

-
-
CHECKING API…
-
Loading counts…
+
+
+
CHECKING API…
+
Loading counts…
+
+
-
- - - - - +
+
+ + + + + +
+ +
+ +
Loading inventory…
+ +
+ + +
+
+ + -
- -
Loading inventory…
- -
- - -
-