working with login and building inventory
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
trade-hub/backend/app/__pycache__/auth.cpython-314.pyc
Normal file
BIN
trade-hub/backend/app/__pycache__/auth.cpython-314.pyc
Normal file
Binary file not shown.
BIN
trade-hub/backend/app/__pycache__/catalog.cpython-314.pyc
Normal file
BIN
trade-hub/backend/app/__pycache__/catalog.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
trade-hub/backend/app/__pycache__/db.cpython-314.pyc
Normal file
BIN
trade-hub/backend/app/__pycache__/db.cpython-314.pyc
Normal file
Binary file not shown.
BIN
trade-hub/backend/app/__pycache__/deps.cpython-314.pyc
Normal file
BIN
trade-hub/backend/app/__pycache__/deps.cpython-314.pyc
Normal file
Binary file not shown.
BIN
trade-hub/backend/app/__pycache__/inventory.cpython-314.pyc
Normal file
BIN
trade-hub/backend/app/__pycache__/inventory.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
trade-hub/backend/app/__pycache__/models.cpython-314.pyc
Normal file
BIN
trade-hub/backend/app/__pycache__/models.cpython-314.pyc
Normal file
Binary file not shown.
BIN
trade-hub/backend/app/__pycache__/schemas.cpython-314.pyc
Normal file
BIN
trade-hub/backend/app/__pycache__/schemas.cpython-314.pyc
Normal file
Binary file not shown.
BIN
trade-hub/backend/app/__pycache__/security.cpython-314.pyc
Normal file
BIN
trade-hub/backend/app/__pycache__/security.cpython-314.pyc
Normal file
Binary file not shown.
@@ -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")
|
||||
|
||||
160
trade-hub/backend/app/auth.py
Normal file
160
trade-hub/backend/app/auth.py
Normal 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)}
|
||||
8
trade-hub/backend/app/catalog.py
Normal file
8
trade-hub/backend/app/catalog.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
89
trade-hub/backend/app/db.py
Normal file
89
trade-hub/backend/app/db.py
Normal 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
|
||||
39
trade-hub/backend/app/deps.py
Normal file
39
trade-hub/backend/app/deps.py
Normal 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
|
||||
227
trade-hub/backend/app/inventory.py
Normal file
227
trade-hub/backend/app/inventory.py
Normal 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)
|
||||
93
trade-hub/backend/app/models.py
Normal file
93
trade-hub/backend/app/models.py
Normal 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")
|
||||
77
trade-hub/backend/app/schemas.py
Normal file
77
trade-hub/backend/app/schemas.py
Normal 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
|
||||
140
trade-hub/backend/app/security.py
Normal file
140
trade-hub/backend/app/security.py
Normal 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()
|
||||
@@ -1,2 +1,5 @@
|
||||
fastapi>=0.115
|
||||
uvicorn[standard]>=0.30
|
||||
SQLAlchemy>=2.0
|
||||
psycopg[binary]>=3.2
|
||||
argon2-cffi>=23.1
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
151
trade-hub/backend/tests/test_auth.py
Normal file
151
trade-hub/backend/tests/test_auth.py
Normal 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
|
||||
191
trade-hub/backend/tests/test_inventory.py
Normal file
191
trade-hub/backend/tests/test_inventory.py
Normal 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
|
||||
@@ -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 / {
|
||||
|
||||
@@ -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) {
|
||||
<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();
|
||||
});
|
||||
|
||||
@@ -13,46 +13,83 @@
|
||||
<h1>STAR CITIZEN <span>INVENTORY</span></h1>
|
||||
<p class="subtitle">Item catalog from scunpacked game data</p>
|
||||
</div>
|
||||
<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 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>
|
||||
|
||||
<section class="controls">
|
||||
<input type="search" id="search-input" placeholder="Search name or class name…" autocomplete="off">
|
||||
<select id="manufacturer-select">
|
||||
<option value="">All manufacturers</option>
|
||||
</select>
|
||||
<input type="text" id="type-input" placeholder="Type (e.g. WeaponPersonal)">
|
||||
<input type="number" id="size-input" placeholder="Size" min="0" max="12">
|
||||
<button id="reset-filters" class="btn-secondary">Reset</button>
|
||||
<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">
|
||||
<option value="">All manufacturers</option>
|
||||
</select>
|
||||
<input type="text" id="type-input" placeholder="Type (e.g. WeaponPersonal)">
|
||||
<input type="number" id="size-input" placeholder="Size" min="0" max="12">
|
||||
<button id="reset-filters" class="btn-secondary">Reset</button>
|
||||
</section>
|
||||
|
||||
<main>
|
||||
<div id="error-banner" class="error-banner hidden"></div>
|
||||
<div id="loading" class="loading">Loading inventory…</div>
|
||||
<div id="empty-state" class="empty-state hidden">
|
||||
<p>No items match your filters.</p>
|
||||
<p class="hint">Try clearing the search or resetting the filters.</p>
|
||||
</div>
|
||||
<div id="results-grid" class="results-grid"></div>
|
||||
|
||||
<div class="pagination" id="pagination">
|
||||
<button id="prev-page" class="btn-secondary">← Prev</button>
|
||||
<span id="page-info">Page 1 of 1</span>
|
||||
<button id="next-page" class="btn-secondary">Next →</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>
|
||||
|
||||
<main>
|
||||
<div id="error-banner" class="error-banner hidden"></div>
|
||||
<div id="loading" class="loading">Loading inventory…</div>
|
||||
<div id="empty-state" class="empty-state hidden">
|
||||
<p>No items match your filters.</p>
|
||||
<p class="hint">Try clearing the search or resetting the filters.</p>
|
||||
</div>
|
||||
<div id="results-grid" class="results-grid"></div>
|
||||
|
||||
<div class="pagination" id="pagination">
|
||||
<button id="prev-page" class="btn-secondary">← Prev</button>
|
||||
<span id="page-info">Page 1 of 1</span>
|
||||
<button id="next-page" class="btn-secondary">Next →</button>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<p>Data source:
|
||||
<a href="https://github.com/richardthombs/scunpacked" target="_blank" rel="noopener noreferrer">scunpacked</a>
|
||||
@@ -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">×</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>
|
||||
|
||||
@@ -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%; }
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user