working with login and building inventory
This commit is contained in:
149
README.md
149
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.
|
||||
|
||||
Reference in New Issue
Block a user