created new role
This commit is contained in:
1
roles/trade_hub/files/trade-hub/backend/app/__init__.py
Normal file
1
roles/trade_hub/files/trade-hub/backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Star Citizen Inventory backend package (scunpacked data)."""
|
||||
127
roles/trade_hub/files/trade-hub/backend/app/api.py
Normal file
127
roles/trade_hub/files/trade-hub/backend/app/api.py
Normal 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, {})}
|
||||
160
roles/trade_hub/files/trade-hub/backend/app/auth.py
Normal file
160
roles/trade_hub/files/trade-hub/backend/app/auth.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Authentication routes: register, login, logout and current-user lookup.
|
||||
|
||||
Identity is a self-hosted username/password pair. Passwords are hashed with
|
||||
Argon2; sessions are opaque random tokens whose SHA-256 is persisted, delivered
|
||||
in an httpOnly cookie.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from app import config, security
|
||||
from app.db import get_db
|
||||
from app.deps import get_current_user, require_same_origin
|
||||
from app.models import User, UserSession, utcnow
|
||||
from app.schemas import AuthResponse, LoginRequest, RegisterRequest, UserOut
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
throttle = security.LoginThrottle(
|
||||
config.LOGIN_MAX_ATTEMPTS, config.LOGIN_WINDOW_SECONDS
|
||||
)
|
||||
|
||||
_COOKIE_MAX_AGE = config.SESSION_TTL_DAYS * 24 * 60 * 60
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=config.COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=_COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=config.COOKIE_SECURE,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def _issue_session(db: DbSession, user: User) -> str:
|
||||
token = security.new_session_token()
|
||||
now = utcnow()
|
||||
# Drop this user's expired sessions while we are here.
|
||||
db.execute(
|
||||
delete(UserSession).where(
|
||||
UserSession.user_id == user.id, UserSession.expires_at <= now
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
UserSession(
|
||||
token_hash=security.hash_token(token),
|
||||
user_id=user.id,
|
||||
created_at=now,
|
||||
expires_at=now + timedelta(days=config.SESSION_TTL_DAYS),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
|
||||
@router.post(
|
||||
"/register",
|
||||
status_code=201,
|
||||
response_model=AuthResponse,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def register(payload: RegisterRequest, response: Response, db: DbSession = Depends(get_db)):
|
||||
username = payload.username.strip()
|
||||
if not security.valid_username(username):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Username must be 3-32 characters: letters, digits, '.', '_' or '-'",
|
||||
)
|
||||
|
||||
username_key = security.normalize_username(username)
|
||||
existing = db.execute(
|
||||
select(User).where(User.username_key == username_key)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=409, detail="Username is already taken")
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
username_key=username_key,
|
||||
password_hash=security.hash_password(payload.password),
|
||||
)
|
||||
db.add(user)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="Username is already taken")
|
||||
|
||||
db.refresh(user)
|
||||
_set_session_cookie(response, _issue_session(db, user))
|
||||
return {"user": UserOut.model_validate(user)}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=AuthResponse,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def login(payload: LoginRequest, request: Request, response: Response, db: DbSession = Depends(get_db)):
|
||||
username_key = security.normalize_username(payload.username)
|
||||
ip = security.client_ip(request)
|
||||
|
||||
wait = throttle.retry_after(username_key, ip)
|
||||
if wait:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many failed login attempts. Try again later.",
|
||||
headers={"Retry-After": str(wait)},
|
||||
)
|
||||
|
||||
user = db.execute(
|
||||
select(User).where(User.username_key == username_key)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
security.burn_password_check(payload.password)
|
||||
throttle.record_failure(username_key, ip)
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
if not security.verify_password(payload.password, user.password_hash):
|
||||
throttle.record_failure(username_key, ip)
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
throttle.reset(username_key, ip)
|
||||
if security.needs_rehash(user.password_hash):
|
||||
user.password_hash = security.hash_password(payload.password)
|
||||
db.commit()
|
||||
|
||||
_set_session_cookie(response, _issue_session(db, user))
|
||||
return {"user": UserOut.model_validate(user)}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/logout",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def logout(request: Request, db: DbSession = Depends(get_db)):
|
||||
token = request.cookies.get(config.COOKIE_NAME)
|
||||
if token:
|
||||
db.execute(
|
||||
delete(UserSession).where(
|
||||
UserSession.token_hash == security.hash_token(token)
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
response = Response(status_code=204)
|
||||
response.delete_cookie(config.COOKIE_NAME, path="/")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/me", response_model=AuthResponse)
|
||||
def me(user: User = Depends(get_current_user)):
|
||||
return {"user": UserOut.model_validate(user)}
|
||||
69
roles/trade_hub/files/trade-hub/backend/app/available.py
Normal file
69
roles/trade_hub/files/trade-hub/backend/app/available.py
Normal 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],
|
||||
}
|
||||
8
roles/trade_hub/files/trade-hub/backend/app/catalog.py
Normal file
8
roles/trade_hub/files/trade-hub/backend/app/catalog.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Single owner of the immutable, read-only scunpacked catalog.
|
||||
|
||||
Keeping the store here (instead of in app/api.py) lets the auth and inventory
|
||||
routers import it without importing the FastAPI app itself.
|
||||
"""
|
||||
from app import loader
|
||||
|
||||
store = loader.load_store()
|
||||
58
roles/trade_hub/files/trade-hub/backend/app/config.py
Normal file
58
roles/trade_hub/files/trade-hub/backend/app/config.py
Normal 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
|
||||
147
roles/trade_hub/files/trade-hub/backend/app/db.py
Normal file
147
roles/trade_hub/files/trade-hub/backend/app/db.py
Normal 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
|
||||
39
roles/trade_hub/files/trade-hub/backend/app/deps.py
Normal file
39
roles/trade_hub/files/trade-hub/backend/app/deps.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Shared FastAPI dependencies: origin check and current-user resolution."""
|
||||
from datetime import timezone
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from app import config, security
|
||||
from app.db import get_db
|
||||
from app.models import User, UserSession, utcnow
|
||||
|
||||
|
||||
def require_same_origin(request: Request) -> None:
|
||||
if not security.same_origin(request):
|
||||
raise HTTPException(status_code=403, detail="Cross-origin request rejected")
|
||||
|
||||
|
||||
def _as_utc(value):
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
request: Request, db: DbSession = Depends(get_db)
|
||||
) -> User:
|
||||
token = request.cookies.get(config.COOKIE_NAME)
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
session = db.get(UserSession, security.hash_token(token))
|
||||
if session is None:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
if _as_utc(session.expires_at) <= utcnow():
|
||||
db.delete(session)
|
||||
db.commit()
|
||||
raise HTTPException(status_code=401, detail="Session expired")
|
||||
|
||||
return session.user
|
||||
286
roles/trade_hub/files/trade-hub/backend/app/inventory.py
Normal file
286
roles/trade_hub/files/trade-hub/backend/app/inventory.py
Normal 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)
|
||||
254
roles/trade_hub/files/trade-hub/backend/app/loader.py
Normal file
254
roles/trade_hub/files/trade-hub/backend/app/loader.py
Normal 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)
|
||||
157
roles/trade_hub/files/trade-hub/backend/app/models.py
Normal file
157
roles/trade_hub/files/trade-hub/backend/app/models.py
Normal 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")
|
||||
209
roles/trade_hub/files/trade-hub/backend/app/orders.py
Normal file
209
roles/trade_hub/files/trade-hub/backend/app/orders.py
Normal 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)
|
||||
156
roles/trade_hub/files/trade-hub/backend/app/schemas.py
Normal file
156
roles/trade_hub/files/trade-hub/backend/app/schemas.py
Normal 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]
|
||||
140
roles/trade_hub/files/trade-hub/backend/app/security.py
Normal file
140
roles/trade_hub/files/trade-hub/backend/app/security.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Password hashing, session tokens, origin checks and login throttling."""
|
||||
import hashlib
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
# Hash of a throwaway secret, verified when a username does not exist so that
|
||||
# "unknown user" and "wrong password" take comparable time.
|
||||
_DUMMY_HASH = _hasher.hash(secrets.token_urlsafe(16))
|
||||
|
||||
USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{3,32}$")
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
MAX_PASSWORD_LENGTH = 128
|
||||
|
||||
|
||||
def normalize_username(username: str) -> str:
|
||||
return username.strip().lower()
|
||||
|
||||
|
||||
def valid_username(username: str) -> bool:
|
||||
return bool(USERNAME_RE.match(username.strip()))
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return _hasher.verify(password_hash, password)
|
||||
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
||||
return False
|
||||
|
||||
|
||||
def burn_password_check(password: str) -> None:
|
||||
"""Constant-ish work for unknown users, to blunt username enumeration."""
|
||||
try:
|
||||
_hasher.verify(_DUMMY_HASH, password)
|
||||
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
||||
pass
|
||||
|
||||
|
||||
def needs_rehash(password_hash: str) -> bool:
|
||||
return _hasher.check_needs_rehash(password_hash)
|
||||
|
||||
|
||||
def new_session_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def client_ip(request) -> str:
|
||||
"""Real client address as seen by the reverse proxy.
|
||||
|
||||
nginx overwrites X-Real-IP with the actual peer address, so it is safe to
|
||||
trust here. X-Forwarded-For is client-appendable and is never used.
|
||||
"""
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def same_origin(request) -> bool:
|
||||
"""Reject cross-origin state-changing requests (CSRF defense in depth)."""
|
||||
origin = request.headers.get("origin")
|
||||
if not origin:
|
||||
return True # non-browser client; the session cookie is still required
|
||||
host = request.headers.get("x-forwarded-host") or request.headers.get("host") or ""
|
||||
return urlsplit(origin).netloc.lower() == host.lower()
|
||||
|
||||
class LoginThrottle:
|
||||
"""In-process sliding-window limiter for failed logins.
|
||||
|
||||
Tracks failures per (username, ip) and, with a higher ceiling, per ip so a
|
||||
single address cannot spray passwords across many usernames.
|
||||
"""
|
||||
|
||||
def __init__(self, max_attempts: int, window_seconds: int):
|
||||
self.max_attempts = max_attempts
|
||||
self.window = window_seconds
|
||||
self._events: dict[tuple[str, str], deque[float]] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _keys(self, username_key: str, ip: str) -> list[tuple[tuple[str, str], int]]:
|
||||
return [
|
||||
((username_key, ip), self.max_attempts),
|
||||
(("*", ip), self.max_attempts * 4),
|
||||
]
|
||||
|
||||
def _recent(self, key: tuple[str, str], now: float) -> deque[float] | None:
|
||||
events = self._events.get(key)
|
||||
if not events:
|
||||
return None
|
||||
cutoff = now - self.window
|
||||
while events and events[0] < cutoff:
|
||||
events.popleft()
|
||||
if not events:
|
||||
self._events.pop(key, None)
|
||||
return None
|
||||
return events
|
||||
|
||||
def retry_after(self, username_key: str, ip: str) -> int:
|
||||
"""Seconds to wait before another attempt is allowed (0 if allowed)."""
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
waits = []
|
||||
for key, limit in self._keys(username_key, ip):
|
||||
events = self._recent(key, now)
|
||||
if events and len(events) >= limit:
|
||||
waits.append(int(self.window - (now - events[0])) + 1)
|
||||
return max(waits) if waits else 0
|
||||
|
||||
def record_failure(self, username_key: str, ip: str) -> None:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
if len(self._events) > 5000: # opportunistic cleanup
|
||||
for key in list(self._events):
|
||||
self._recent(key, now)
|
||||
for key, _limit in self._keys(username_key, ip):
|
||||
self._events.setdefault(key, deque()).append(now)
|
||||
|
||||
def reset(self, username_key: str, ip: str) -> None:
|
||||
with self._lock:
|
||||
for key, _limit in self._keys(username_key, ip):
|
||||
self._events.pop(key, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._events.clear()
|
||||
Reference in New Issue
Block a user