created new role

This commit is contained in:
boxswapper
2026-11-13 12:26:24 -07:00
parent 632aee934f
commit 6d90e131b2
47 changed files with 424 additions and 136 deletions

View File

@@ -0,0 +1 @@
"""Star Citizen Inventory backend package (scunpacked data)."""

View File

@@ -0,0 +1,127 @@
"""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 config
from app.auth import router as auth_router
from app.available import router as available_router
from app.catalog import store
from app.db import db_healthy, init_db
from app.inventory import router as inventory_router
from app.orders import router as orders_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 catalog built from scunpacked game data, plus "
"per-user accounts and personal inventories.",
version="1.1.0",
lifespan=lifespan,
)
# 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"],
)
app.include_router(auth_router)
app.include_router(inventory_router)
app.include_router(available_router)
app.include_router(orders_router)
VALID_CATEGORIES = {"ships", "ship-items", "fps-items", "commodities"}
def _filter_items(category, search, manufacturer, item_type, size):
result = store.items
if category is not None:
if category not in VALID_CATEGORIES:
raise HTTPException(
status_code=422,
detail=f"Invalid category '{category}'. Valid: {sorted(VALID_CATEGORIES)}",
)
result = [i for i in result if i["category"] == category]
if search is not None and search.strip():
needle = search.strip().lower()
result = [
i
for i in result
if needle in i["name"].lower() or needle in i["class_name"].lower()
]
if manufacturer is not None and manufacturer.strip():
code = manufacturer.strip().lower()
result = [i for i in result if i["manufacturer"]["code"].lower() == code]
if item_type is not None and item_type.strip():
wanted = item_type.strip().lower()
result = [i for i in result if (i["type"] or "").lower() == wanted]
if size is not None:
result = [i for i in result if i["size"] == size]
return result
@app.get("/healthz")
def healthz():
return {"status": "ok", "counts": store.counts, "db": db_healthy()}
@app.get("/api/v1/stats")
def stats():
return {"categories": store.counts, "manufacturers": len(store.manufacturers)}
@app.get("/api/v1/manufacturers")
def manufacturers():
return store.manufacturers
@app.get("/api/v1/items")
def list_items(
category: str | None = None,
search: str | None = None,
manufacturer: str | None = None,
item_type: str | None = Query(default=None, alias="type"),
size: int | None = None,
page: int = Query(default=1, ge=1),
page_size: int = Query(default=25, ge=1),
):
page_size = min(page_size, 100) # cap, don't reject
filtered = _filter_items(category, search, manufacturer, item_type, size)
total = len(filtered)
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": filtered[start : start + page_size],
}
@app.get("/api/v1/items/{class_name}")
def item_detail(class_name: str):
summary = store.by_class_name.get(class_name)
if summary is None:
raise HTTPException(status_code=404, detail=f"Item '{class_name}' not found")
return {**summary, "details": store.details.get(class_name, {})}

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,69 @@
"""Public listing of inventory items owners marked as `available`.
Read-only and unauthenticated, like the catalog. Notes are never included: the
listing only reveals the owner's username, quantity and catalog details.
"""
import math
from fastapi import APIRouter, Depends, Query
from sqlalchemy import select
from sqlalchemy.orm import Session as DbSession
from app.db import get_db
from app.inventory import SORT_KEYS, enrich_entry
from app.models import InventoryEntry, User
from app.schemas import AvailablePage
router = APIRouter(prefix="/api/v1/available", tags=["available"])
MAX_PAGE_SIZE = 100
@router.get("", response_model=AvailablePage)
def list_available(
category: str | None = None,
search: str | None = None,
sort: str = Query(default="updated", pattern="^(name|category|quantity|updated)$"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=25, ge=1),
db: DbSession = Depends(get_db),
):
page_size = min(page_size, MAX_PAGE_SIZE)
rows = db.execute(
select(InventoryEntry, User.username)
.join(User, User.id == InventoryEntry.user_id)
.where(InventoryEntry.visibility == "available")
).all()
items = []
for entry, username in rows:
enriched = enrich_entry(entry)
if not enriched["available"]:
continue
enriched["username"] = username
items.append(enriched)
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()
or needle in i["username"].lower()
]
items.sort(key=SORT_KEYS[sort])
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],
}

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

@@ -0,0 +1,58 @@
"""Configuration for the inventory backend.
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
_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
MAX_QUALITY = 1000

View File

@@ -0,0 +1,147 @@
"""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 _upgrade_schema(engine) -> None:
"""Add columns that `create_all` cannot add to an existing table.
PostgreSQL only: the SQLite database used by the tests is created fresh
from the models on every run, so it already has every column.
"""
if engine.dialect.name != "postgresql":
return
with engine.begin() as conn:
conn.execute(
text(
"ALTER TABLE inventory_entries ADD COLUMN IF NOT EXISTS "
"visibility VARCHAR(16) NOT NULL DEFAULT 'private'"
)
)
conn.execute(
text(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_inventory_visibility'
) THEN
ALTER TABLE inventory_entries
ADD CONSTRAINT ck_inventory_visibility
CHECK (visibility IN ('private', 'available'));
END IF;
END $$
"""
)
)
conn.execute(
text(
"ALTER TABLE inventory_entries ADD COLUMN IF NOT EXISTS "
"quality INTEGER"
)
)
conn.execute(
text(
"""
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'ck_inventory_quality_range'
) THEN
ALTER TABLE inventory_entries
ADD CONSTRAINT ck_inventory_quality_range
CHECK (quality IS NULL OR (quality >= 1 AND quality <= 1000));
END IF;
END $$
"""
)
)
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.
Additive columns for existing deployments are applied afterwards; changing
or dropping an existing column still requires Alembic.
"""
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)
_upgrade_schema(engine)
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,286 @@
"""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(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,
"quality": entry.quality,
"quality_applicable": False,
"visibility": entry.visibility,
"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,
"quality": entry.quality,
"quality_applicable": item.get("quality_applicable", False),
"visibility": entry.visibility,
"available": True,
"created_at": entry.created_at,
"updated_at": entry.updated_at,
}
def _validate_quality(item: dict, quality: int | None) -> None:
if quality is not None and not item.get("quality_applicable", False):
raise HTTPException(
status_code=422,
detail=f"Quality does not apply to '{item['name']}'",
)
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()
def _sort_key_name(item):
return (item["name"].lower(), item["class_name"])
def _sort_key_category(item):
return ((item["category"] or "").lower(), item["name"].lower(), item["class_name"])
def _sort_key_quantity(item):
return (-item["quantity"], item["name"].lower(), item["class_name"])
def _sort_key_updated(item):
return (-item["updated_at"].timestamp(), item["name"].lower())
SORT_KEYS = {
"name": _sort_key_name,
"category": _sort_key_category,
"quantity": _sort_key_quantity,
"updated": _sort_key_updated,
}
@router.get("", response_model=InventoryPage)
def list_inventory(
category: str | None = None,
search: str | None = None,
sort: str = Query(default="name", pattern="^(name|category|quantity|updated)$"),
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_entry(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=SORT_KEYS[sort])
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()
item = catalog.store.by_class_name.get(class_name)
if item is None:
raise HTTPException(
status_code=404, detail=f"Catalog item '{class_name}' not found"
)
_validate_quality(item, payload.quality)
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,
quality=payload.quality,
)
db.add(entry)
else:
entry.quantity += payload.quantity
if payload.notes:
entry.notes = payload.notes
if payload.quality is not None:
entry.quality = payload.quality
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
if payload.quality is not None:
entry.quality = payload.quality
db.commit()
created = False
db.refresh(entry)
response.status_code = 201 if created else 200
return {"entry": InventoryEntryOut.model_validate(enrich_entry(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
if payload.visibility is not None:
entry.visibility = payload.visibility
if "quality" in payload.model_fields_set:
# Explicit null clears the value; omitted keeps the existing one.
if payload.quality is not None:
item = catalog.store.by_class_name.get(entry.class_name)
if item is None:
raise HTTPException(
status_code=422,
detail=f"Quality does not apply to '{entry.class_name}'",
)
_validate_quality(item, payload.quality)
entry.quality = payload.quality
db.commit()
db.refresh(entry)
return InventoryEntryOut.model_validate(enrich_entry(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,254 @@
"""Load and normalize scunpacked JSON data into the inventory store.
Source data: https://github.com/richardthombs/scunpacked
(mirrored at https://github.com/StarCitizenWiki/scunpacked-data)
Real schemas this loader consumes:
- ships.json: {"UUID","ClassName","Name","Description","Career","Role",
"Manufacturer":{"UUID","Code","Name"},"Size","Length","Width",
"Height","Crew","Mass","IsVehicle","IsGravlev","IsSpaceship",...}
- ship-items.json / fps-items.json:
{"className","reference","itemName","type","subType","size",
"grade","name","tags","classification",
"stdItem":{"UUID","ClassName","Size","Grade","Width","Height",
"Length","Mass","Type","Name","Description","DescriptionText",
"Manufacturer":{"Name","Code","UUID"},"Tags":[...],"Ports":[...]}}
- manufacturers.json:{"Code","Name","Reference","Description"}
"""
import json
import os
import re
from app import config
PLACEHOLDER = "<= PLACEHOLDER =>"
CATEGORY_SHIPS = "ships"
CATEGORY_SHIP_ITEMS = "ship-items"
CATEGORY_FPS_ITEMS = "fps-items"
CATEGORY_COMMODITIES = "commodities"
# Commodities that can carry a mining/refining quality value (1-1000). These are
# the ores, minerals and gems that players actually mine (and their refined
# counterparts); crafted alloys, gases, organics, processed goods and junk
# by-products are deliberately excluded. Keep in sync with the Ore quality table
# at https://starcitizen.tools/Ore_quality.
QUALITY_MATERIALS = frozenset(
name.lower()
for name in (
"Agricium",
"Aluminum",
"Aphorite",
"Aslarite",
"Beradom",
"Beryl",
"Bexalite",
"Borase",
"Carinite",
"Copper",
"Corundum",
"Diamond",
"Dolivine",
"Feynmaline",
"Glacosite",
"Gold",
"Hadanite",
"Hephaestanite",
"Iron",
"Jaclium",
"Janalite",
"Laranite",
"Lindinium",
"Ouratite",
"Pressurized Ice",
"Quantainium",
"Quartz",
"Riccite",
"Sadaryx",
"Saldynium",
"Savrilium",
"Silicon",
"Stileron",
"Taranite",
"Tin",
"Titanium",
"Torite",
"Tungsten",
)
)
# Variant suffixes are presentation labels, not a different material: "Iron
# (Ore)" and "Iron" share a quality value, as do "(Raw)", "(Pure)" and "(R)".
_QUALITY_VARIANT = re.compile(r"\s*\((?:Ore|Raw|Pure|R|\d+\s*SCU)\)\s*$", re.IGNORECASE)
_RAW_PREFIX = re.compile(r"^Raw\s+", re.IGNORECASE)
def _quality_applicable(name, category):
if category != CATEGORY_COMMODITIES:
return False
base_name = _QUALITY_VARIANT.sub("", name or "").strip()
base_name = _RAW_PREFIX.sub("", base_name).strip().lower()
return base_name in QUALITY_MATERIALS
def _load_json(data_dir, filename):
path = os.path.join(data_dir, filename)
if not os.path.exists(path):
return []
with open(path, encoding="utf-8") as f:
return json.load(f)
def _resolve_name(*candidates, class_name=None):
"""Pick the first real display name; fall back to a prettified ClassName.
Many scunpacked entries have names literally set to "<= PLACEHOLDER =>"
(untranslated labels), so the fallback rule is mandatory.
"""
for candidate in candidates:
if candidate and str(candidate).strip() and str(candidate).strip() != PLACEHOLDER:
return str(candidate).strip()
if class_name:
return str(class_name).replace("_", " ")
return "Unknown"
def _manufacturer(raw):
if not raw:
return {"code": "UNKN", "name": "Unknown Manufacturer"}
code = raw.get("Code") or raw.get("code") or "UNKN"
name = _resolve_name(raw.get("Name"), raw.get("name"), class_name=code)
return {"code": code, "name": name}
def _normalize_ship(ship):
class_name = ship.get("ClassName") or ""
manufacturer = ship.get("Manufacturer") or {}
flags = {
"is_spaceship": bool(ship.get("IsSpaceship")),
"is_vehicle": bool(ship.get("IsVehicle")),
"is_gravlev": bool(ship.get("IsGravlev")),
}
tags = [flag for flag, value in flags.items() if value]
return {
"uuid": ship.get("UUID") or "",
"class_name": class_name,
"name": _resolve_name(ship.get("Name"), class_name=class_name),
"category": CATEGORY_SHIPS,
"type": ship.get("Career") or "Ship",
"sub_type": ship.get("Role") or "",
"size": ship.get("Size") or 0,
"grade": None,
"manufacturer": _manufacturer(manufacturer),
"classification": "Ship",
"description": ship.get("Description") or ship.get("DescriptionText") or "",
"tags": tags,
"quality_applicable": False,
}, {
"career": ship.get("Career") or "",
"role": ship.get("Role") or "",
"length": ship.get("Length"),
"width": ship.get("Width"),
"height": ship.get("Height"),
"crew": ship.get("Crew"),
"mass": ship.get("Mass"),
**flags,
}
def _normalize_item(item, category):
class_name = item.get("className") or ""
std = item.get("stdItem") or {}
manufacturer = std.get("Manufacturer") or {}
raw_tags = std.get("Tags")
tags = list(raw_tags) if isinstance(raw_tags, list) else []
summary = {
"uuid": std.get("UUID") or item.get("reference") or "",
"class_name": class_name,
"name": _resolve_name(std.get("Name"), item.get("name"), class_name=class_name),
"category": category,
"type": item.get("type") or std.get("Type") or "",
"sub_type": item.get("subType") or "",
"size": item.get("size") if item.get("size") is not None else std.get("Size"),
"grade": item.get("grade") if item.get("grade") is not None else std.get("Grade"),
"manufacturer": _manufacturer(manufacturer),
"classification": item.get("classification") or "",
"description": std.get("Description") or std.get("DescriptionText") or "",
"tags": tags,
"quality_applicable": _quality_applicable(
_resolve_name(std.get("Name"), item.get("name"), class_name=class_name),
category,
),
}
if category == CATEGORY_COMMODITIES:
summary["size"] = None
details = {
"ports": std.get("Ports") or [],
"mass": std.get("Mass"),
"width": std.get("Width"),
"height": std.get("Height"),
"length": std.get("Length"),
}
return summary, details
class Store:
"""In-memory, read-only inventory store built from the scunpacked JSON."""
def __init__(self, items, details, manufacturers):
self.items = items
self.details = details
self.manufacturers = manufacturers
self.by_class_name = {item["class_name"]: item for item in items}
self.counts = {
CATEGORY_SHIPS: sum(1 for i in items if i["category"] == CATEGORY_SHIPS),
CATEGORY_SHIP_ITEMS: sum(1 for i in items if i["category"] == CATEGORY_SHIP_ITEMS),
CATEGORY_FPS_ITEMS: sum(1 for i in items if i["category"] == CATEGORY_FPS_ITEMS),
CATEGORY_COMMODITIES: sum(
1 for i in items if i["category"] == CATEGORY_COMMODITIES
),
}
def load_store(data_dir=None):
data_dir = data_dir or config.DATA_DIR
items = []
details = {}
for ship in _load_json(data_dir, "ships.json"):
summary, detail = _normalize_ship(ship)
items.append(summary)
details[summary["class_name"]] = detail
for entry in _load_json(data_dir, "ship-items.json"):
summary, detail = _normalize_item(entry, CATEGORY_SHIP_ITEMS)
items.append(summary)
details[summary["class_name"]] = detail
for entry in _load_json(data_dir, "fps-items.json"):
summary, detail = _normalize_item(entry, CATEGORY_FPS_ITEMS)
items.append(summary)
details[summary["class_name"]] = detail
for entry in _load_json(data_dir, "commodities.json"):
summary, detail = _normalize_item(entry, CATEGORY_COMMODITIES)
items.append(summary)
details[summary["class_name"]] = detail
# Deterministic order so pagination is stable.
items.sort(key=lambda i: (i["category"], i["name"].lower(), i["class_name"]))
manufacturers = sorted(
(
{
"code": m.get("Code") or "",
"name": _resolve_name(m.get("Name"), class_name=m.get("Code") or ""),
"description": m.get("Description") or "",
}
for m in _load_json(data_dir, "manufacturers.json")
),
key=lambda m: m["name"].lower(),
)
return Store(items, details, manufacturers)

View File

@@ -0,0 +1,157 @@
"""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"
)
orders: Mapped[list["Order"]] = 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"),
CheckConstraint(
"visibility IN ('private', 'available')",
name="ck_inventory_visibility",
),
CheckConstraint(
"quality IS NULL OR (quality >= 1 AND quality <= 1000)",
name="ck_inventory_quality_range",
),
)
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="")
# Mining/refining quality (1-1000) for commodities where it applies. NULL
# means "not specified"; non-quality commodities must keep this NULL. The
# applicability itself is catalog data, not stored here.
quality: Mapped[int | None] = mapped_column(Integer, nullable=True)
# "private" (default): only the owner sees the row. "available": the owner
# is offering the item. Unrelated to the response flag `available`, which
# only reports whether the catalog still contains the referenced item.
visibility: Mapped[str] = mapped_column(
String(16), nullable=False, default="private", server_default="private"
)
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")
class Order(Base):
"""A public request for a catalog item placed by a logged-in user.
Orders are intentionally visible to everyone (see app/orders.py). They
reference catalog items by `class_name` only, exactly like inventory rows,
so a catalog refresh cannot break them.
"""
__tablename__ = "orders"
__table_args__ = (
CheckConstraint("quantity >= 1", name="ck_orders_quantity_positive"),
CheckConstraint(
"quality IS NULL OR (quality >= 1 AND quality <= 1000)",
name="ck_orders_quality_range",
),
CheckConstraint(
"status IN ('open', 'fulfilled')",
name="ck_orders_status",
),
)
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)
# Mining/refining quality (1-1000); only valid for quality-applicable ores.
quality: Mapped[int | None] = mapped_column(Integer, nullable=True)
notes: Mapped[str] = mapped_column(Text, nullable=False, default="")
status: Mapped[str] = mapped_column(
String(16), nullable=False, default="open", server_default="open"
)
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="orders")

View File

@@ -0,0 +1,209 @@
"""Public order board.
Any logged-in user can place an order (a request) for a catalog item. Every
order is visible to everyone, with the requester's display name, quantity,
notes, and — for quality-applicable ores — the requested quality. Orders are
read-only for the public; only the owner can edit or remove their own order.
Like inventory rows, orders reference catalog items by `class_name` only, so a
dataset refresh cannot break them.
"""
import math
from fastapi import APIRouter, Depends, HTTPException, Query, Response
from sqlalchemy import select
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.inventory import SORT_KEYS
from app.models import Order, User
from app.schemas import OrderCreateRequest, OrderOut, OrderPage, OrderPatchRequest
router = APIRouter(prefix="/api/v1/orders", tags=["orders"])
MAX_PAGE_SIZE = 100
def enrich_order(order: Order, username: str) -> dict:
item = catalog.store.by_class_name.get(order.class_name)
if item is None:
item_data = {
"name": order.class_name.replace("_", " "),
"category": None,
"type": None,
"size": None,
"grade": None,
"manufacturer": None,
"quality_applicable": False,
"available": False,
}
else:
item_data = {
"name": item["name"],
"category": item["category"],
"type": item["type"],
"size": item["size"],
"grade": item["grade"],
"manufacturer": item["manufacturer"],
"quality_applicable": item.get("quality_applicable", False),
"available": True,
}
return {
"id": order.id,
"class_name": order.class_name,
**item_data,
"quantity": order.quantity,
"notes": order.notes,
"quality": order.quality,
"username": username,
"status": order.status,
"created_at": order.created_at,
"updated_at": order.updated_at,
}
def _validate_quality(item: dict, quality: int | None) -> None:
if quality is not None and not item.get("quality_applicable", False):
raise HTTPException(
status_code=422,
detail=f"Quality does not apply to '{item['name']}'",
)
@router.get("", response_model=OrderPage)
def list_orders(
category: str | None = None,
search: str | None = None,
status: str | None = Query(default=None, pattern="^(open|fulfilled)$"),
sort: str = Query(default="updated", pattern="^(name|category|quantity|updated)$"),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=25, ge=1),
db: DbSession = Depends(get_db),
):
page_size = min(page_size, MAX_PAGE_SIZE)
rows = db.execute(
select(Order, User.username).join(User, User.id == Order.user_id)
).all()
items = [enrich_order(order, username) for order, username in rows]
if status is not None:
items = [i for i in items if i["status"] == status]
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()
or needle in i["username"].lower()
]
items.sort(key=SORT_KEYS[sort])
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.post(
"",
response_model=OrderOut,
status_code=201,
dependencies=[Depends(require_same_origin)],
)
def create_order(
payload: OrderCreateRequest,
user: User = Depends(get_current_user),
db: DbSession = Depends(get_db),
):
class_name = payload.class_name.strip()
item = catalog.store.by_class_name.get(class_name)
if item is None:
raise HTTPException(
status_code=404, detail=f"Catalog item '{class_name}' not found"
)
_validate_quality(item, payload.quality)
order = Order(
user_id=user.id,
class_name=class_name,
quantity=payload.quantity,
notes=payload.notes,
quality=payload.quality,
)
db.add(order)
db.commit()
db.refresh(order)
return enrich_order(order, user.username)
def _own_order(db: DbSession, user: User, order_id: int) -> Order:
order = db.get(Order, order_id)
if order is None or order.user_id != user.id:
# Do not reveal whether somebody else's order exists.
raise HTTPException(status_code=404, detail="Order not found")
return order
@router.patch(
"/{order_id}",
response_model=OrderOut,
dependencies=[Depends(require_same_origin)],
)
def update_order(
order_id: int,
payload: OrderPatchRequest,
user: User = Depends(get_current_user),
db: DbSession = Depends(get_db),
):
order = _own_order(db, user, order_id)
if payload.quantity is not None:
order.quantity = payload.quantity
if payload.notes is not None:
order.notes = payload.notes
if payload.status is not None:
order.status = payload.status
if "quality" in payload.model_fields_set:
# Explicit null clears the value; omitted keeps the existing one.
if payload.quality is not None:
item = catalog.store.by_class_name.get(order.class_name)
if item is None:
raise HTTPException(
status_code=422,
detail=f"Quality does not apply to '{order.class_name}'",
)
_validate_quality(item, payload.quality)
order.quality = payload.quality
db.commit()
db.refresh(order)
return enrich_order(order, user.username)
@router.delete(
"/{order_id}",
status_code=204,
dependencies=[Depends(require_same_origin)],
)
def delete_order(
order_id: int,
user: User = Depends(get_current_user),
db: DbSession = Depends(get_db),
):
order = _own_order(db, user, order_id)
db.delete(order)
db.commit()
return Response(status_code=204)

View File

@@ -0,0 +1,156 @@
"""Pydantic request/response models for auth and personal inventory."""
from datetime import datetime
from typing import Any, Literal
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)
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
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)
visibility: Literal["private", "available"] | None = None
# Explicit null clears the quality; omitting the field leaves it unchanged.
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
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
# Mining/refining quality (1-1000); only meaningful when
# `quality_applicable` is true. None means the owner has not set it.
quality: int | None = None
quality_applicable: bool = False
# Owner-chosen classification. Unrelated to `available` below.
visibility: Literal["private", "available"]
# 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 AvailableItemOut(BaseModel):
class_name: str
name: str
category: str | None
type: str | None
size: int | None
grade: Any | None = None
manufacturer: dict | None
quantity: int
quality: int | None = None
quality_applicable: bool = False
# Public listing: only the owner's display name, never notes.
username: str
updated_at: datetime
class AvailablePage(BaseModel):
total: int
page: int
page_size: int
total_pages: int
items: list[AvailableItemOut]
class InventoryAddResponse(BaseModel):
entry: InventoryEntryOut
created: bool
class OrderCreateRequest(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)
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
class OrderPatchRequest(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)
status: Literal["open", "fulfilled"] | None = None
# Explicit null clears the quality; omitting the field leaves it unchanged.
quality: int | None = Field(default=None, ge=1, le=config.MAX_QUALITY)
class OrderOut(BaseModel):
id: int
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
quality: int | None = None
quality_applicable: bool = False
# Public listing: the requesting user's display name.
username: str
status: Literal["open", "fulfilled"]
# False when the catalog no longer contains the referenced item.
available: bool
created_at: datetime
updated_at: datetime
class OrderPage(BaseModel):
total: int
page: int
page_size: int
total_pages: int
items: list[OrderOut]

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