working with login and building inventory

This commit is contained in:
2026-11-12 11:40:31 -07:00
parent d11e5e70c7
commit 0a93925e11
45 changed files with 2389 additions and 77 deletions

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

135
README.md
View File

@@ -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` |
| -------- | -------------------------------------- | ---------------------------------------------------------- |
| 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` |
| deploy | Ansible + Podman (containers.podman) | Builds images and runs the two containers |
| 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,19 +135,43 @@ 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
```
@@ -93,7 +180,35 @@ 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.

View File

@@ -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

126
teardown.yml Normal file
View File

@@ -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

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

@@ -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(
# 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=["*"],
allow_methods=["GET"],
)
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")

View File

@@ -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)}

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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")

View File

@@ -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

View File

@@ -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()

View File

@@ -1,2 +1,5 @@
fastapi>=0.115
uvicorn[standard]>=0.30
SQLAlchemy>=2.0
psycopg[binary]>=3.2
argon2-cffi>=23.1

View File

Binary file not shown.

View File

@@ -1,4 +1,31 @@
"""Point the backend at the tiny fixture dataset before the app is imported."""
"""Test bootstrap.
Runs the suite against a throwaway in-memory SQLite database and the tiny
fixture catalog, so no PostgreSQL server is needed. Environment variables must
be set before any app module is imported, because config reads them at import
time.
"""
import os
os.environ["DATA_DIR"] = os.path.join(os.path.dirname(__file__), "fixtures")
os.environ["DATABASE_URL"] = "sqlite://"
os.environ["AUTO_INIT_DB"] = "false"
os.environ["COOKIE_SECURE"] = "false"
import pytest # noqa: E402
from app import db # noqa: E402
from app.models import Base # noqa: E402
@pytest.fixture(autouse=True)
def fresh_db():
"""Give every test an empty schema and a clean login throttle."""
engine = db.get_engine()
Base.metadata.drop_all(bind=engine)
Base.metadata.create_all(bind=engine)
from app.auth import throttle
throttle.clear()
yield

View File

@@ -0,0 +1,151 @@
"""Authentication tests: registration, login, sessions, throttling, CSRF."""
from fastapi.testclient import TestClient
from app.api import app
PASSWORD = "correct horse battery staple"
def new_client() -> TestClient:
return TestClient(app)
def register(client: TestClient, username: str = "alice", password: str = PASSWORD):
return client.post(
"/api/v1/auth/register", json={"username": username, "password": password}
)
def login(client: TestClient, username: str = "alice", password: str = PASSWORD):
return client.post(
"/api/v1/auth/login", json={"username": username, "password": password}
)
def test_register_returns_user_and_authenticates():
client = new_client()
resp = register(client)
assert resp.status_code == 201
body = resp.json()
assert body["user"]["username"] == "alice"
assert "password" not in body["user"]
assert client.cookies.get("sc_session")
me = client.get("/api/v1/auth/me")
assert me.status_code == 200
assert me.json()["user"]["username"] == "alice"
def test_register_duplicate_username_is_conflict_case_insensitive():
client = new_client()
assert register(client, "Alice").status_code == 201
assert register(new_client(), "alice").status_code == 409
def test_register_rejects_weak_password():
resp = register(new_client(), password="short")
assert resp.status_code == 422
def test_register_rejects_invalid_username():
for username in ["ab", "has space", "bad!", "x" * 33]:
assert register(new_client(), username).status_code == 422
def test_me_requires_authentication():
resp = new_client().get("/api/v1/auth/me")
assert resp.status_code == 401
def test_login_is_case_insensitive_and_works_after_logout():
client = new_client()
assert register(client, "alice").status_code == 201
assert client.post("/api/v1/auth/logout").status_code == 204
assert client.get("/api/v1/auth/me").status_code == 401
resp = login(client, "ALICE")
assert resp.status_code == 200
assert resp.json()["user"]["username"] == "alice"
assert client.get("/api/v1/auth/me").status_code == 200
def test_wrong_password_and_unknown_user_are_indistinguishable():
client = new_client()
assert register(client, "alice").status_code == 201
wrong = login(new_client(), "alice", "definitely wrong")
unknown = login(new_client(), "nobody", "definitely wrong")
assert wrong.status_code == unknown.status_code == 401
assert wrong.json()["detail"] == unknown.json()["detail"]
def test_logout_invalidates_session_token():
client = new_client()
assert register(client).status_code == 201
token = client.cookies.get("sc_session")
assert client.post("/api/v1/auth/logout").status_code == 204
# Replay the old cookie on a fresh client: it must be rejected.
replay = new_client()
replay.cookies.set("sc_session", token)
assert replay.get("/api/v1/auth/me").status_code == 401
def test_login_is_rate_limited():
client = new_client()
assert register(client, "alice").status_code == 201
for _ in range(5):
assert login(new_client(), "alice", "wrong password").status_code == 401
blocked = login(new_client(), "alice", PASSWORD)
assert blocked.status_code == 429
assert int(blocked.headers["Retry-After"]) > 0
def test_cross_origin_mutation_is_rejected():
client = new_client()
resp = client.post(
"/api/v1/auth/register",
json={"username": "alice", "password": PASSWORD},
headers={"Origin": "http://evil.example"},
)
assert resp.status_code == 403
def test_same_origin_mutation_is_allowed():
client = new_client()
resp = client.post(
"/api/v1/auth/register",
json={"username": "alice", "password": PASSWORD},
headers={"Origin": "http://testserver"},
)
assert resp.status_code == 201
def test_same_origin_with_forwarded_port_is_allowed():
client = new_client()
resp = client.post(
"/api/v1/auth/register",
json={"username": "alice", "password": PASSWORD},
headers={
"Origin": "http://testserver:8080",
"X-Forwarded-Host": "testserver:8080",
},
)
assert resp.status_code == 201
def test_forwarded_host_without_port_is_rejected():
client = new_client()
resp = client.post(
"/api/v1/auth/register",
json={"username": "alice", "password": PASSWORD},
headers={
"Origin": "http://testserver:8080",
"X-Forwarded-Host": "testserver",
},
)
assert resp.status_code == 403

View File

@@ -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

View File

@@ -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 / {

View File

@@ -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("'", "&#039;");
}
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) {
<div class="item-name">${escapeHtml(item.name)}</div>
<div class="item-manufacturer">${escapeHtml(manufacturer)}</div>
<div class="item-classification">${escapeHtml(item.classification || item.type || "")}</div>
<div class="item-meta">${categoryBadge(item.category)}${size}${grade}</div>
<div class="item-meta">${categoryBadge(item.category)}${size}${grade}
<button type="button" class="card-add" aria-label="Add to my inventory" title="Add to my inventory">+</button>
</div>
`;
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) {
<h3>Specifications</h3>
<table class="spec-table">${rows}</table>
${item.category === "ships" ? "" : renderPorts(item.details?.ports)}
<div class="add-row">
<label for="add-qty">Qty</label>
<input type="number" id="add-qty" min="1" max="1000000" value="1">
<button type="button" id="add-to-inventory" class="btn-primary">
${state.user ? "Add to my inventory" : "Log in to add"}
</button>
</div>
`;
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 = `<p class="modal-desc">Failed to load item: ${escapeHtml(err.message)}</p>`;
}
@@ -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 =
`<div class="inv-item-name">${escapeHtml(entry.name)}</div>` +
`<div class="inv-item-sub">${escapeHtml(entry.class_name)}</div>` +
(entry.available ? "" : '<div class="unavailable-note">Not in current catalog</div>');
const categoryCell = document.createElement("td");
categoryCell.innerHTML = entry.category
? categoryBadge(entry.category)
: '<span class="badge size">—</span>';
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();
});

View File

@@ -13,20 +13,25 @@
<h1>STAR CITIZEN <span>INVENTORY</span></h1>
<p class="subtitle">Item catalog from scunpacked game data</p>
</div>
<div class="header-right">
<div class="header-status">
<div id="api-status" class="status-pill checking">CHECKING API…</div>
<div id="stats-line" class="stats-line">Loading counts…</div>
</div>
<div class="account-bar" id="account-bar"></div>
</div>
</div>
</header>
<nav class="tabs" id="category-tabs">
<button class="tab active" data-category="">All Items</button>
<button class="tab" data-category="ships">Ships</button>
<button class="tab" data-category="ship-items">Ship Equipment</button>
<button class="tab" data-category="fps-items">FPS Items</button>
<button class="tab active" data-view="catalog" data-category="">All Items</button>
<button class="tab" data-view="catalog" data-category="ships">Ships</button>
<button class="tab" data-view="catalog" data-category="ship-items">Ship Equipment</button>
<button class="tab" data-view="catalog" data-category="fps-items">FPS Items</button>
<button class="tab tab-mine" id="tab-mine" data-view="mine">My Inventory</button>
</nav>
<div id="catalog-view">
<section class="controls">
<input type="search" id="search-input" placeholder="Search name or class name…" autocomplete="off">
<select id="manufacturer-select">
@@ -52,6 +57,38 @@
<button id="next-page" class="btn-secondary">Next &rarr;</button>
</div>
</main>
</div>
<section id="inventory-view" class="hidden">
<div class="inventory-controls">
<input type="search" id="inv-search" placeholder="Search my inventory…" autocomplete="off">
<select id="inv-category">
<option value="">All categories</option>
<option value="ships">Ships</option>
<option value="ship-items">Ship Equipment</option>
<option value="fps-items">FPS Items</option>
</select>
</div>
<div id="inv-summary" class="inv-summary"></div>
<div id="inv-empty" class="empty-state hidden">
<p>Your inventory is empty.</p>
<p class="hint">Browse the catalog and use “Add to my inventory” on any item.</p>
</div>
<div id="inv-table-wrap" class="inv-table-wrap hidden">
<table class="inv-table">
<thead>
<tr>
<th>Item</th>
<th>Category</th>
<th>Qty</th>
<th>Notes</th>
<th></th>
</tr>
</thead>
<tbody id="inv-tbody"></tbody>
</table>
</div>
</section>
<footer class="site-footer">
<p>Data source:
@@ -68,6 +105,30 @@
</div>
</div>
<div id="auth-backdrop" class="modal-backdrop hidden">
<div class="modal modal-narrow" role="dialog" aria-modal="true" aria-labelledby="auth-title">
<button class="modal-close" id="auth-close" aria-label="Close">&times;</button>
<h2 id="auth-title">Log in</h2>
<form id="auth-form" class="auth-form" novalidate>
<label>Username
<input type="text" id="auth-username" name="username" autocomplete="username"
minlength="3" maxlength="32" required>
</label>
<label>Password
<input type="password" id="auth-password" name="password"
autocomplete="current-password" minlength="8" maxlength="128" required>
</label>
<p id="auth-error" class="auth-error" role="alert"></p>
<button type="submit" id="auth-submit" class="btn-primary">Log in</button>
</form>
<p class="auth-switch">
<button type="button" id="auth-toggle" class="btn-link">Need an account? Register</button>
</p>
</div>
</div>
<div id="toast" class="toast hidden" role="status"></div>
<script src="app.js" defer></script>
</body>
</html>

View File

@@ -84,6 +84,7 @@ body {
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.tab-mine { margin-left: auto; }
.tab:hover { color: var(--text); }
.tab.active {
color: var(--accent);
@@ -269,8 +270,225 @@ main {
.spec-table th { color: var(--text-dim); font-weight: 600; width: 40%; }
.spec-table td { font-family: ui-monospace, monospace; }
/* Account controls */
.header-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.6rem;
}
.account-bar {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.85rem;
}
.account-bar .who {
color: var(--accent);
font-weight: 700;
letter-spacing: 0.04em;
}
.btn-primary {
background: var(--accent);
color: #04121c;
border: none;
border-radius: 6px;
padding: 0.55rem 1rem;
font-size: 0.9rem;
font-weight: 700;
cursor: pointer;
}
.btn-primary:hover:not(:disabled) { filter: brightness(1.1); }
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-link {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
font-size: 0.85rem;
text-decoration: underline;
padding: 0;
}
.btn-link:hover { color: var(--accent); }
/* Card add button */
.card-add {
margin-left: auto;
width: 1.6rem;
height: 1.6rem;
line-height: 1;
font-size: 1rem;
font-weight: 700;
background: var(--bg-panel);
color: var(--accent);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
}
.card-add:hover { border-color: var(--accent); }
/* Personal inventory view */
.inventory-controls {
max-width: 1200px;
margin: 1rem auto 0;
padding: 0 1.25rem;
display: flex;
gap: 0.6rem;
flex-wrap: wrap;
}
.inventory-controls input,
.inventory-controls select {
background: var(--bg-panel);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.55rem 0.75rem;
font-size: 0.9rem;
}
#inv-search { flex: 2 1 280px; }
#inv-category { flex: 1 1 180px; }
.inventory-controls input:focus,
.inventory-controls select:focus { outline: none; border-color: var(--accent); }
#inventory-view {
max-width: 1200px;
margin: 0 auto;
padding: 1rem 1.25rem 0;
}
.inv-summary {
color: var(--text-dim);
font-size: 0.85rem;
margin: 0.75rem 0 1rem;
}
.inv-table-wrap {
overflow-x: auto;
border: 1px solid var(--border);
border-radius: 10px;
}
.inv-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
.inv-table th,
.inv-table td {
text-align: left;
padding: 0.65rem 0.8rem;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.inv-table th {
color: var(--text-dim);
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.08em;
}
.inv-table tr:last-child td { border-bottom: none; }
.inv-item-name { font-weight: 600; }
.inv-item-sub {
color: var(--text-dim);
font-size: 0.75rem;
font-family: ui-monospace, monospace;
}
.inv-qty {
width: 5rem;
background: var(--bg-panel);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.35rem 0.5rem;
}
.inv-notes {
width: 100%;
min-width: 12rem;
background: var(--bg-panel);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.35rem 0.5rem;
}
.inv-qty:focus, .inv-notes:focus { outline: none; border-color: var(--accent); }
.inv-remove {
background: none;
border: 1px solid var(--border);
color: var(--danger);
border-radius: 6px;
padding: 0.3rem 0.6rem;
font-size: 0.8rem;
cursor: pointer;
}
.inv-remove:hover { border-color: var(--danger); }
.unavailable-note { color: var(--accent-warm); font-size: 0.75rem; }
/* Auth modal */
.modal-narrow { max-width: 400px; }
.auth-form {
display: flex;
flex-direction: column;
gap: 0.8rem;
margin-top: 1rem;
}
.auth-form label {
display: flex;
flex-direction: column;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--text-dim);
}
.auth-form input {
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.55rem 0.75rem;
font-size: 0.95rem;
}
.auth-form input:focus { outline: none; border-color: var(--accent); }
.auth-error {
color: #fca5a5;
font-size: 0.85rem;
min-height: 1.1rem;
margin: 0;
}
.auth-switch { margin-top: 0.9rem; font-size: 0.85rem; color: var(--text-dim); }
/* Detail modal add row */
.add-row {
display: flex;
align-items: center;
gap: 0.6rem;
margin-top: 1.2rem;
padding-top: 1rem;
border-top: 1px solid var(--border);
font-size: 0.85rem;
color: var(--text-dim);
}
.add-row input {
width: 5rem;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 0.5rem 0.6rem;
}
.add-row input:focus { outline: none; border-color: var(--accent); }
/* Toast */
.toast {
position: fixed;
bottom: 1.2rem;
left: 50%;
transform: translateX(-50%);
background: var(--bg-panel);
border: 1px solid var(--accent);
color: var(--text);
padding: 0.6rem 1.1rem;
border-radius: 8px;
font-size: 0.9rem;
z-index: 100;
max-width: calc(100vw - 2rem);
}
.toast.error { border-color: var(--danger); color: #fca5a5; }
@media (max-width: 640px) {
.header-inner { flex-direction: column; align-items: flex-start; }
.header-status { text-align: left; }
.header-right { align-items: flex-start; }
.controls input, .controls select { flex: 1 1 100%; }
.inventory-controls input, .inventory-controls select { flex: 1 1 100%; }
}

View File

@@ -1,7 +1,28 @@
services:
db:
image: docker.io/library/postgres:16-alpine
environment:
POSTGRES_USER: sc_inventory
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-sc_inventory_dev}
POSTGRES_DB: sc_inventory
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U sc_inventory -d sc_inventory"]
interval: 5s
timeout: 3s
retries: 20
restart: unless-stopped
backend:
build: ./backend
image: localhost/sc-inventory-backend:latest
environment:
DATABASE_URL: postgresql+psycopg://sc_inventory:${POSTGRES_PASSWORD:-sc_inventory_dev}@db:5432/sc_inventory
DB_WAIT_SECONDS: "60"
COOKIE_SECURE: ${COOKIE_SECURE:-false}
depends_on:
- db
restart: unless-stopped
frontend:
@@ -12,3 +33,6 @@ services:
depends_on:
- backend
restart: unless-stopped
volumes:
pgdata: