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

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

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,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">&larr; Prev</button>
<span id="page-info">Page 1 of 1</span>
<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>
<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">&larr; Prev</button>
<span id="page-info">Page 1 of 1</span>
<button id="next-page" class="btn-secondary">Next &rarr;</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">&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: