working with login and building inventory
This commit is contained in:
Binary file not shown.
Binary file not shown.
BIN
trade-hub/backend/app/__pycache__/auth.cpython-314.pyc
Normal file
BIN
trade-hub/backend/app/__pycache__/auth.cpython-314.pyc
Normal file
Binary file not shown.
BIN
trade-hub/backend/app/__pycache__/catalog.cpython-314.pyc
Normal file
BIN
trade-hub/backend/app/__pycache__/catalog.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
trade-hub/backend/app/__pycache__/db.cpython-314.pyc
Normal file
BIN
trade-hub/backend/app/__pycache__/db.cpython-314.pyc
Normal file
Binary file not shown.
BIN
trade-hub/backend/app/__pycache__/deps.cpython-314.pyc
Normal file
BIN
trade-hub/backend/app/__pycache__/deps.cpython-314.pyc
Normal file
Binary file not shown.
BIN
trade-hub/backend/app/__pycache__/inventory.cpython-314.pyc
Normal file
BIN
trade-hub/backend/app/__pycache__/inventory.cpython-314.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
trade-hub/backend/app/__pycache__/models.cpython-314.pyc
Normal file
BIN
trade-hub/backend/app/__pycache__/models.cpython-314.pyc
Normal file
Binary file not shown.
BIN
trade-hub/backend/app/__pycache__/schemas.cpython-314.pyc
Normal file
BIN
trade-hub/backend/app/__pycache__/schemas.cpython-314.pyc
Normal file
Binary file not shown.
BIN
trade-hub/backend/app/__pycache__/security.cpython-314.pyc
Normal file
BIN
trade-hub/backend/app/__pycache__/security.cpython-314.pyc
Normal file
Binary file not shown.
@@ -1,25 +1,50 @@
|
||||
"""FastAPI routes for the Star Citizen inventory (scunpacked data)."""
|
||||
"""FastAPI routes for the Star Citizen inventory (scunpacked data).
|
||||
|
||||
The catalog endpoints below are read-only. Accounts and personal inventories
|
||||
are served by the auth and inventory routers and persisted in PostgreSQL.
|
||||
"""
|
||||
import math
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Query
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app import loader
|
||||
from app import config
|
||||
from app.auth import router as auth_router
|
||||
from app.catalog import store
|
||||
from app.db import db_healthy, init_db
|
||||
from app.inventory import router as inventory_router
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_app: FastAPI):
|
||||
if config.AUTO_INIT_DB:
|
||||
init_db(wait_seconds=config.DB_WAIT_SECONDS)
|
||||
yield
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
title="Star Citizen Inventory API",
|
||||
description="Read-only item inventory built from scunpacked game data.",
|
||||
version="1.0.0",
|
||||
description="Read-only item catalog built from scunpacked game data, plus "
|
||||
"per-user accounts and personal inventories.",
|
||||
version="1.1.0",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Read-only public GET API; permissive CORS keeps local development easy.
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_methods=["GET"],
|
||||
)
|
||||
# Same-origin deployment by default. Cross-origin access must be opted into
|
||||
# explicitly, because credentialed cookies and a wildcard origin are
|
||||
# incompatible (and unsafe).
|
||||
if config.CORS_ORIGINS:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=config.CORS_ORIGINS,
|
||||
allow_credentials=True,
|
||||
allow_methods=["GET", "POST", "PATCH", "DELETE"],
|
||||
allow_headers=["Content-Type"],
|
||||
)
|
||||
|
||||
store = loader.load_store()
|
||||
app.include_router(auth_router)
|
||||
app.include_router(inventory_router)
|
||||
|
||||
VALID_CATEGORIES = {"ships", "ship-items", "fps-items"}
|
||||
|
||||
@@ -53,7 +78,7 @@ def _filter_items(category, search, manufacturer, item_type, size):
|
||||
|
||||
@app.get("/healthz")
|
||||
def healthz():
|
||||
return {"status": "ok", "counts": store.counts}
|
||||
return {"status": "ok", "counts": store.counts, "db": db_healthy()}
|
||||
|
||||
|
||||
@app.get("/api/v1/stats")
|
||||
|
||||
160
trade-hub/backend/app/auth.py
Normal file
160
trade-hub/backend/app/auth.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""Authentication routes: register, login, logout and current-user lookup.
|
||||
|
||||
Identity is a self-hosted username/password pair. Passwords are hashed with
|
||||
Argon2; sessions are opaque random tokens whose SHA-256 is persisted, delivered
|
||||
in an httpOnly cookie.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from sqlalchemy import delete, select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from app import config, security
|
||||
from app.db import get_db
|
||||
from app.deps import get_current_user, require_same_origin
|
||||
from app.models import User, UserSession, utcnow
|
||||
from app.schemas import AuthResponse, LoginRequest, RegisterRequest, UserOut
|
||||
|
||||
router = APIRouter(prefix="/api/v1/auth", tags=["auth"])
|
||||
|
||||
throttle = security.LoginThrottle(
|
||||
config.LOGIN_MAX_ATTEMPTS, config.LOGIN_WINDOW_SECONDS
|
||||
)
|
||||
|
||||
_COOKIE_MAX_AGE = config.SESSION_TTL_DAYS * 24 * 60 * 60
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
response.set_cookie(
|
||||
key=config.COOKIE_NAME,
|
||||
value=token,
|
||||
max_age=_COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=config.COOKIE_SECURE,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def _issue_session(db: DbSession, user: User) -> str:
|
||||
token = security.new_session_token()
|
||||
now = utcnow()
|
||||
# Drop this user's expired sessions while we are here.
|
||||
db.execute(
|
||||
delete(UserSession).where(
|
||||
UserSession.user_id == user.id, UserSession.expires_at <= now
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
UserSession(
|
||||
token_hash=security.hash_token(token),
|
||||
user_id=user.id,
|
||||
created_at=now,
|
||||
expires_at=now + timedelta(days=config.SESSION_TTL_DAYS),
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
return token
|
||||
|
||||
|
||||
@router.post(
|
||||
"/register",
|
||||
status_code=201,
|
||||
response_model=AuthResponse,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def register(payload: RegisterRequest, response: Response, db: DbSession = Depends(get_db)):
|
||||
username = payload.username.strip()
|
||||
if not security.valid_username(username):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Username must be 3-32 characters: letters, digits, '.', '_' or '-'",
|
||||
)
|
||||
|
||||
username_key = security.normalize_username(username)
|
||||
existing = db.execute(
|
||||
select(User).where(User.username_key == username_key)
|
||||
).scalar_one_or_none()
|
||||
if existing is not None:
|
||||
raise HTTPException(status_code=409, detail="Username is already taken")
|
||||
|
||||
user = User(
|
||||
username=username,
|
||||
username_key=username_key,
|
||||
password_hash=security.hash_password(payload.password),
|
||||
)
|
||||
db.add(user)
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="Username is already taken")
|
||||
|
||||
db.refresh(user)
|
||||
_set_session_cookie(response, _issue_session(db, user))
|
||||
return {"user": UserOut.model_validate(user)}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/login",
|
||||
response_model=AuthResponse,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def login(payload: LoginRequest, request: Request, response: Response, db: DbSession = Depends(get_db)):
|
||||
username_key = security.normalize_username(payload.username)
|
||||
ip = security.client_ip(request)
|
||||
|
||||
wait = throttle.retry_after(username_key, ip)
|
||||
if wait:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many failed login attempts. Try again later.",
|
||||
headers={"Retry-After": str(wait)},
|
||||
)
|
||||
|
||||
user = db.execute(
|
||||
select(User).where(User.username_key == username_key)
|
||||
).scalar_one_or_none()
|
||||
|
||||
if user is None:
|
||||
security.burn_password_check(payload.password)
|
||||
throttle.record_failure(username_key, ip)
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
if not security.verify_password(payload.password, user.password_hash):
|
||||
throttle.record_failure(username_key, ip)
|
||||
raise HTTPException(status_code=401, detail="Invalid username or password")
|
||||
|
||||
throttle.reset(username_key, ip)
|
||||
if security.needs_rehash(user.password_hash):
|
||||
user.password_hash = security.hash_password(payload.password)
|
||||
db.commit()
|
||||
|
||||
_set_session_cookie(response, _issue_session(db, user))
|
||||
return {"user": UserOut.model_validate(user)}
|
||||
|
||||
|
||||
@router.post(
|
||||
"/logout",
|
||||
status_code=204,
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
def logout(request: Request, db: DbSession = Depends(get_db)):
|
||||
token = request.cookies.get(config.COOKIE_NAME)
|
||||
if token:
|
||||
db.execute(
|
||||
delete(UserSession).where(
|
||||
UserSession.token_hash == security.hash_token(token)
|
||||
)
|
||||
)
|
||||
db.commit()
|
||||
response = Response(status_code=204)
|
||||
response.delete_cookie(config.COOKIE_NAME, path="/")
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/me", response_model=AuthResponse)
|
||||
def me(user: User = Depends(get_current_user)):
|
||||
return {"user": UserOut.model_validate(user)}
|
||||
8
trade-hub/backend/app/catalog.py
Normal file
8
trade-hub/backend/app/catalog.py
Normal file
@@ -0,0 +1,8 @@
|
||||
"""Single owner of the immutable, read-only scunpacked catalog.
|
||||
|
||||
Keeping the store here (instead of in app/api.py) lets the auth and inventory
|
||||
routers import it without importing the FastAPI app itself.
|
||||
"""
|
||||
from app import loader
|
||||
|
||||
store = loader.load_store()
|
||||
@@ -3,6 +3,9 @@
|
||||
DATA_DIR selects which dataset the API serves:
|
||||
- local dev / tests: small real-schema samples (default) or a fixture dir
|
||||
- container image: the full scunpacked dataset downloaded at build time
|
||||
|
||||
The catalog is read-only. Everything mutable (accounts, sessions, per-user
|
||||
inventories) lives in the database configured by DATABASE_URL.
|
||||
"""
|
||||
import os
|
||||
|
||||
@@ -10,3 +13,45 @@ _APP_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_BACKEND_DIR = os.path.dirname(_APP_DIR)
|
||||
|
||||
DATA_DIR = os.environ.get("DATA_DIR", os.path.join(_BACKEND_DIR, "data", "samples"))
|
||||
|
||||
|
||||
def _env_bool(name: str, default: bool) -> bool:
|
||||
raw = os.environ.get(name)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
# --- Persistence ----------------------------------------------------------
|
||||
DATABASE_URL = os.environ.get(
|
||||
"DATABASE_URL", "postgresql+psycopg://sc_inventory:sc_inventory_dev@db:5432/sc_inventory"
|
||||
)
|
||||
# Create missing tables on startup. Disabled by the test suite, which builds
|
||||
# its own schema against an in-memory SQLite database.
|
||||
AUTO_INIT_DB = _env_bool("AUTO_INIT_DB", True)
|
||||
# How long startup waits for PostgreSQL to accept connections (container boot).
|
||||
DB_WAIT_SECONDS = int(os.environ.get("DB_WAIT_SECONDS", "60"))
|
||||
|
||||
# --- Session cookies ------------------------------------------------------
|
||||
# Enable COOKIE_SECURE once the site is served over TLS. Defaults to false so
|
||||
# plain http://localhost:8080 keeps working out of the box.
|
||||
COOKIE_SECURE = _env_bool("COOKIE_SECURE", False)
|
||||
COOKIE_NAME = os.environ.get("COOKIE_NAME", "sc_session")
|
||||
SESSION_TTL_DAYS = int(os.environ.get("SESSION_TTL_DAYS", "30"))
|
||||
|
||||
# --- CORS -----------------------------------------------------------------
|
||||
# Empty by default: the frontend is served same-origin through nginx, so no
|
||||
# cross-origin access is needed. Comma-separated origins opt in explicitly.
|
||||
CORS_ORIGINS = [
|
||||
origin.strip()
|
||||
for origin in os.environ.get("CORS_ORIGINS", "").split(",")
|
||||
if origin.strip()
|
||||
]
|
||||
|
||||
# --- Login hardening ------------------------------------------------------
|
||||
LOGIN_MAX_ATTEMPTS = int(os.environ.get("LOGIN_MAX_ATTEMPTS", "5"))
|
||||
LOGIN_WINDOW_SECONDS = int(os.environ.get("LOGIN_WINDOW_SECONDS", "900"))
|
||||
|
||||
# --- Input limits ---------------------------------------------------------
|
||||
MAX_NOTES_LENGTH = 1000
|
||||
MAX_QUANTITY = 1_000_000
|
||||
|
||||
89
trade-hub/backend/app/db.py
Normal file
89
trade-hub/backend/app/db.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""Database engine, session factory and schema bootstrap.
|
||||
|
||||
The engine is created lazily so importing the app (and loading the read-only
|
||||
catalog) never requires a reachable database.
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
|
||||
from sqlalchemy import create_engine, text
|
||||
from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app import config
|
||||
from app.models import Base
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_engine = None
|
||||
_session_factory = None
|
||||
|
||||
|
||||
def get_engine():
|
||||
"""Return the process-wide engine, creating it on first use."""
|
||||
global _engine, _session_factory
|
||||
if _engine is None:
|
||||
url = config.DATABASE_URL
|
||||
kwargs = {"pool_pre_ping": True, "future": True}
|
||||
if url.startswith("sqlite"):
|
||||
kwargs["connect_args"] = {"check_same_thread": False}
|
||||
if ":memory:" in url or url in {"sqlite://", "sqlite+pysqlite://"}:
|
||||
kwargs["poolclass"] = StaticPool
|
||||
_engine = create_engine(url, **kwargs)
|
||||
_session_factory = sessionmaker(
|
||||
bind=_engine, autoflush=False, autocommit=False, expire_on_commit=False
|
||||
)
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_factory():
|
||||
get_engine()
|
||||
return _session_factory
|
||||
|
||||
|
||||
def get_db():
|
||||
"""FastAPI dependency yielding a request-scoped SQLAlchemy session."""
|
||||
db = get_session_factory()()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db(wait_seconds: int = 0, interval: float = 1.0) -> None:
|
||||
"""Create missing tables, retrying while PostgreSQL finishes booting.
|
||||
|
||||
`create_all(checkfirst=True)` is idempotent and safe to run on every start.
|
||||
It does not migrate changed columns; introduce Alembic when the schema
|
||||
starts evolving in place.
|
||||
"""
|
||||
deadline = time.monotonic() + max(wait_seconds, 0)
|
||||
while True:
|
||||
try:
|
||||
engine = get_engine()
|
||||
with engine.connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
Base.metadata.create_all(bind=engine, checkfirst=True)
|
||||
return
|
||||
except (OperationalError, IntegrityError, ProgrammingError) as exc:
|
||||
# A concurrent startup may have created the tables first.
|
||||
if "already exists" in str(exc).lower():
|
||||
return
|
||||
if time.monotonic() >= deadline:
|
||||
raise
|
||||
log.warning(
|
||||
"database not ready (%s); retrying in %.1fs",
|
||||
exc.__class__.__name__,
|
||||
interval,
|
||||
)
|
||||
time.sleep(interval)
|
||||
|
||||
|
||||
def db_healthy() -> bool:
|
||||
try:
|
||||
with get_engine().connect() as conn:
|
||||
conn.execute(text("SELECT 1"))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
39
trade-hub/backend/app/deps.py
Normal file
39
trade-hub/backend/app/deps.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""Shared FastAPI dependencies: origin check and current-user resolution."""
|
||||
from datetime import timezone
|
||||
|
||||
from fastapi import Depends, HTTPException, Request
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from app import config, security
|
||||
from app.db import get_db
|
||||
from app.models import User, UserSession, utcnow
|
||||
|
||||
|
||||
def require_same_origin(request: Request) -> None:
|
||||
if not security.same_origin(request):
|
||||
raise HTTPException(status_code=403, detail="Cross-origin request rejected")
|
||||
|
||||
|
||||
def _as_utc(value):
|
||||
if value.tzinfo is None:
|
||||
return value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
request: Request, db: DbSession = Depends(get_db)
|
||||
) -> User:
|
||||
token = request.cookies.get(config.COOKIE_NAME)
|
||||
if not token:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
session = db.get(UserSession, security.hash_token(token))
|
||||
if session is None:
|
||||
raise HTTPException(status_code=401, detail="Not authenticated")
|
||||
|
||||
if _as_utc(session.expires_at) <= utcnow():
|
||||
db.delete(session)
|
||||
db.commit()
|
||||
raise HTTPException(status_code=401, detail="Session expired")
|
||||
|
||||
return session.user
|
||||
227
trade-hub/backend/app/inventory.py
Normal file
227
trade-hub/backend/app/inventory.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""Per-user inventory routes.
|
||||
|
||||
Rows reference immutable catalog items by `class_name`; responses are enriched
|
||||
live from the in-memory catalog. A missing catalog item is reported as
|
||||
`available: false` instead of deleting the user's data.
|
||||
"""
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from app import catalog
|
||||
from app.db import get_db
|
||||
from app.deps import get_current_user, require_same_origin
|
||||
from app.models import InventoryEntry, User
|
||||
from app.schemas import (
|
||||
InventoryAddRequest,
|
||||
InventoryAddResponse,
|
||||
InventoryEntryOut,
|
||||
InventoryPage,
|
||||
InventoryPatchRequest,
|
||||
InventorySummary,
|
||||
)
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/api/v1/me/inventory",
|
||||
tags=["inventory"],
|
||||
dependencies=[Depends(require_same_origin)],
|
||||
)
|
||||
|
||||
MAX_PAGE_SIZE = 100
|
||||
|
||||
|
||||
def _enrich(entry: InventoryEntry) -> dict:
|
||||
item = catalog.store.by_class_name.get(entry.class_name)
|
||||
if item is None:
|
||||
return {
|
||||
"class_name": entry.class_name,
|
||||
"name": entry.class_name.replace("_", " "),
|
||||
"category": None,
|
||||
"type": None,
|
||||
"size": None,
|
||||
"grade": None,
|
||||
"manufacturer": None,
|
||||
"quantity": entry.quantity,
|
||||
"notes": entry.notes,
|
||||
"available": False,
|
||||
"created_at": entry.created_at,
|
||||
"updated_at": entry.updated_at,
|
||||
}
|
||||
return {
|
||||
"class_name": entry.class_name,
|
||||
"name": item["name"],
|
||||
"category": item["category"],
|
||||
"type": item["type"],
|
||||
"size": item["size"],
|
||||
"grade": item["grade"],
|
||||
"manufacturer": item["manufacturer"],
|
||||
"quantity": entry.quantity,
|
||||
"notes": entry.notes,
|
||||
"available": True,
|
||||
"created_at": entry.created_at,
|
||||
"updated_at": entry.updated_at,
|
||||
}
|
||||
|
||||
|
||||
def _find(db: DbSession, user: User, class_name: str) -> InventoryEntry | None:
|
||||
return db.execute(
|
||||
select(InventoryEntry).where(
|
||||
InventoryEntry.user_id == user.id,
|
||||
InventoryEntry.class_name == class_name,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
@router.get("", response_model=InventoryPage)
|
||||
def list_inventory(
|
||||
category: str | None = None,
|
||||
search: str | None = None,
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=25, ge=1),
|
||||
user: User = Depends(get_current_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
page_size = min(page_size, MAX_PAGE_SIZE)
|
||||
rows = db.execute(
|
||||
select(InventoryEntry).where(InventoryEntry.user_id == user.id)
|
||||
).scalars().all()
|
||||
|
||||
items = [_enrich(row) for row in rows]
|
||||
|
||||
if category is not None and category.strip():
|
||||
wanted = category.strip()
|
||||
items = [i for i in items if i["category"] == wanted]
|
||||
if search is not None and search.strip():
|
||||
needle = search.strip().lower()
|
||||
items = [
|
||||
i
|
||||
for i in items
|
||||
if needle in i["name"].lower() or needle in i["class_name"].lower()
|
||||
]
|
||||
|
||||
items.sort(key=lambda i: (i["name"].lower(), i["class_name"]))
|
||||
total = len(items)
|
||||
total_pages = max(1, math.ceil(total / page_size))
|
||||
start = (page - 1) * page_size
|
||||
return {
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
"items": items[start : start + page_size],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/summary", response_model=InventorySummary)
|
||||
def inventory_summary(
|
||||
user: User = Depends(get_current_user), db: DbSession = Depends(get_db)
|
||||
):
|
||||
rows = db.execute(
|
||||
select(InventoryEntry).where(InventoryEntry.user_id == user.id)
|
||||
).scalars().all()
|
||||
|
||||
categories: dict[str, int] = {}
|
||||
unavailable = 0
|
||||
total_quantity = 0
|
||||
for row in rows:
|
||||
item = catalog.store.by_class_name.get(row.class_name)
|
||||
if item is None:
|
||||
unavailable += 1
|
||||
else:
|
||||
key = item["category"]
|
||||
categories[key] = categories.get(key, 0) + row.quantity
|
||||
total_quantity += row.quantity
|
||||
|
||||
return {
|
||||
"items": len(rows),
|
||||
"quantity": total_quantity,
|
||||
"categories": categories,
|
||||
"unavailable": unavailable,
|
||||
}
|
||||
|
||||
|
||||
@router.post("", response_model=InventoryAddResponse)
|
||||
def add_to_inventory(
|
||||
payload: InventoryAddRequest,
|
||||
response: Response,
|
||||
user: User = Depends(get_current_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
class_name = payload.class_name.strip()
|
||||
if class_name not in catalog.store.by_class_name:
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"Catalog item '{class_name}' not found"
|
||||
)
|
||||
|
||||
entry = _find(db, user, class_name)
|
||||
created = entry is None
|
||||
|
||||
if created:
|
||||
entry = InventoryEntry(
|
||||
user_id=user.id,
|
||||
class_name=class_name,
|
||||
quantity=payload.quantity,
|
||||
notes=payload.notes,
|
||||
)
|
||||
db.add(entry)
|
||||
else:
|
||||
entry.quantity += payload.quantity
|
||||
if payload.notes:
|
||||
entry.notes = payload.notes
|
||||
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
# Lost a race against a concurrent add: re-read and increment instead.
|
||||
db.rollback()
|
||||
entry = _find(db, user, class_name)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=409, detail="Could not add item; retry")
|
||||
entry.quantity += payload.quantity
|
||||
if payload.notes:
|
||||
entry.notes = payload.notes
|
||||
db.commit()
|
||||
created = False
|
||||
|
||||
db.refresh(entry)
|
||||
response.status_code = 201 if created else 200
|
||||
return {"entry": InventoryEntryOut.model_validate(_enrich(entry)), "created": created}
|
||||
|
||||
|
||||
@router.patch("/{class_name}", response_model=InventoryEntryOut)
|
||||
def update_inventory_entry(
|
||||
class_name: str,
|
||||
payload: InventoryPatchRequest,
|
||||
user: User = Depends(get_current_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
entry = _find(db, user, class_name)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="Item is not in your inventory")
|
||||
|
||||
if payload.quantity is not None:
|
||||
entry.quantity = payload.quantity
|
||||
if payload.notes is not None:
|
||||
entry.notes = payload.notes
|
||||
|
||||
db.commit()
|
||||
db.refresh(entry)
|
||||
return InventoryEntryOut.model_validate(_enrich(entry))
|
||||
|
||||
|
||||
@router.delete("/{class_name}", status_code=204)
|
||||
def remove_inventory_entry(
|
||||
class_name: str,
|
||||
user: User = Depends(get_current_user),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
entry = _find(db, user, class_name)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail="Item is not in your inventory")
|
||||
|
||||
db.delete(entry)
|
||||
db.commit()
|
||||
return Response(status_code=204)
|
||||
93
trade-hub/backend/app/models.py
Normal file
93
trade-hub/backend/app/models.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""SQLAlchemy models for mutable user data (accounts + personal inventories).
|
||||
|
||||
The scunpacked catalog is deliberately NOT modeled here. It stays read-only in
|
||||
memory (see app/catalog.py); inventory rows reference catalog items only by
|
||||
`class_name`, so the catalog can be replaced wholesale without touching user
|
||||
data.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
CheckConstraint,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
UniqueConstraint,
|
||||
)
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
"""Timezone-aware current time (portable across SQLite and PostgreSQL)."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
# Display name as typed at registration.
|
||||
username: Mapped[str] = mapped_column(String(32), nullable=False)
|
||||
# Lowercased form, unique: gives case-insensitive uniqueness without citext.
|
||||
username_key: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, unique=True, index=True
|
||||
)
|
||||
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=utcnow
|
||||
)
|
||||
|
||||
sessions: Mapped[list["UserSession"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
inventory: Mapped[list["InventoryEntry"]] = relationship(
|
||||
back_populates="user", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class UserSession(Base):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
# Only the SHA-256 of the cookie token is persisted.
|
||||
token_hash: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=utcnow
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="sessions")
|
||||
|
||||
|
||||
class InventoryEntry(Base):
|
||||
__tablename__ = "inventory_entries"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "class_name", name="uq_inventory_user_item"),
|
||||
CheckConstraint("quantity >= 1", name="ck_inventory_quantity_positive"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
class_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
quantity: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
notes: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=utcnow
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False, default=utcnow, onupdate=utcnow
|
||||
)
|
||||
|
||||
user: Mapped[User] = relationship(back_populates="inventory")
|
||||
77
trade-hub/backend/app/schemas.py
Normal file
77
trade-hub/backend/app/schemas.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Pydantic request/response models for auth and personal inventory."""
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app import config
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=32)
|
||||
password: str = Field(min_length=8, max_length=128)
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=32)
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
username: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
user: UserOut
|
||||
|
||||
|
||||
class InventoryAddRequest(BaseModel):
|
||||
class_name: str = Field(min_length=1, max_length=255)
|
||||
quantity: int = Field(default=1, ge=1, le=config.MAX_QUANTITY)
|
||||
notes: str = Field(default="", max_length=config.MAX_NOTES_LENGTH)
|
||||
|
||||
|
||||
class InventoryPatchRequest(BaseModel):
|
||||
quantity: int | None = Field(default=None, ge=1, le=config.MAX_QUANTITY)
|
||||
notes: str | None = Field(default=None, max_length=config.MAX_NOTES_LENGTH)
|
||||
|
||||
|
||||
class InventoryEntryOut(BaseModel):
|
||||
class_name: str
|
||||
name: str
|
||||
category: str | None
|
||||
type: str | None
|
||||
size: int | None
|
||||
grade: Any | None = None
|
||||
manufacturer: dict | None
|
||||
quantity: int
|
||||
notes: str
|
||||
# False when the catalog no longer contains the referenced item (e.g. after
|
||||
# a dataset refresh). The row is preserved rather than silently deleted.
|
||||
available: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class InventoryPage(BaseModel):
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
items: list[InventoryEntryOut]
|
||||
|
||||
|
||||
class InventorySummary(BaseModel):
|
||||
items: int
|
||||
quantity: int
|
||||
categories: dict[str, int]
|
||||
unavailable: int
|
||||
|
||||
|
||||
class InventoryAddResponse(BaseModel):
|
||||
entry: InventoryEntryOut
|
||||
created: bool
|
||||
140
trade-hub/backend/app/security.py
Normal file
140
trade-hub/backend/app/security.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Password hashing, session tokens, origin checks and login throttling."""
|
||||
import hashlib
|
||||
import re
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
|
||||
|
||||
_hasher = PasswordHasher()
|
||||
# Hash of a throwaway secret, verified when a username does not exist so that
|
||||
# "unknown user" and "wrong password" take comparable time.
|
||||
_DUMMY_HASH = _hasher.hash(secrets.token_urlsafe(16))
|
||||
|
||||
USERNAME_RE = re.compile(r"^[A-Za-z0-9_.-]{3,32}$")
|
||||
MIN_PASSWORD_LENGTH = 8
|
||||
MAX_PASSWORD_LENGTH = 128
|
||||
|
||||
|
||||
def normalize_username(username: str) -> str:
|
||||
return username.strip().lower()
|
||||
|
||||
|
||||
def valid_username(username: str) -> bool:
|
||||
return bool(USERNAME_RE.match(username.strip()))
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return _hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
try:
|
||||
return _hasher.verify(password_hash, password)
|
||||
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
||||
return False
|
||||
|
||||
|
||||
def burn_password_check(password: str) -> None:
|
||||
"""Constant-ish work for unknown users, to blunt username enumeration."""
|
||||
try:
|
||||
_hasher.verify(_DUMMY_HASH, password)
|
||||
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
||||
pass
|
||||
|
||||
|
||||
def needs_rehash(password_hash: str) -> bool:
|
||||
return _hasher.check_needs_rehash(password_hash)
|
||||
|
||||
|
||||
def new_session_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def hash_token(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def client_ip(request) -> str:
|
||||
"""Real client address as seen by the reverse proxy.
|
||||
|
||||
nginx overwrites X-Real-IP with the actual peer address, so it is safe to
|
||||
trust here. X-Forwarded-For is client-appendable and is never used.
|
||||
"""
|
||||
real_ip = request.headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
return real_ip.strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def same_origin(request) -> bool:
|
||||
"""Reject cross-origin state-changing requests (CSRF defense in depth)."""
|
||||
origin = request.headers.get("origin")
|
||||
if not origin:
|
||||
return True # non-browser client; the session cookie is still required
|
||||
host = request.headers.get("x-forwarded-host") or request.headers.get("host") or ""
|
||||
return urlsplit(origin).netloc.lower() == host.lower()
|
||||
|
||||
class LoginThrottle:
|
||||
"""In-process sliding-window limiter for failed logins.
|
||||
|
||||
Tracks failures per (username, ip) and, with a higher ceiling, per ip so a
|
||||
single address cannot spray passwords across many usernames.
|
||||
"""
|
||||
|
||||
def __init__(self, max_attempts: int, window_seconds: int):
|
||||
self.max_attempts = max_attempts
|
||||
self.window = window_seconds
|
||||
self._events: dict[tuple[str, str], deque[float]] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _keys(self, username_key: str, ip: str) -> list[tuple[tuple[str, str], int]]:
|
||||
return [
|
||||
((username_key, ip), self.max_attempts),
|
||||
(("*", ip), self.max_attempts * 4),
|
||||
]
|
||||
|
||||
def _recent(self, key: tuple[str, str], now: float) -> deque[float] | None:
|
||||
events = self._events.get(key)
|
||||
if not events:
|
||||
return None
|
||||
cutoff = now - self.window
|
||||
while events and events[0] < cutoff:
|
||||
events.popleft()
|
||||
if not events:
|
||||
self._events.pop(key, None)
|
||||
return None
|
||||
return events
|
||||
|
||||
def retry_after(self, username_key: str, ip: str) -> int:
|
||||
"""Seconds to wait before another attempt is allowed (0 if allowed)."""
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
waits = []
|
||||
for key, limit in self._keys(username_key, ip):
|
||||
events = self._recent(key, now)
|
||||
if events and len(events) >= limit:
|
||||
waits.append(int(self.window - (now - events[0])) + 1)
|
||||
return max(waits) if waits else 0
|
||||
|
||||
def record_failure(self, username_key: str, ip: str) -> None:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
if len(self._events) > 5000: # opportunistic cleanup
|
||||
for key in list(self._events):
|
||||
self._recent(key, now)
|
||||
for key, _limit in self._keys(username_key, ip):
|
||||
self._events.setdefault(key, deque()).append(now)
|
||||
|
||||
def reset(self, username_key: str, ip: str) -> None:
|
||||
with self._lock:
|
||||
for key, _limit in self._keys(username_key, ip):
|
||||
self._events.pop(key, None)
|
||||
|
||||
def clear(self) -> None:
|
||||
with self._lock:
|
||||
self._events.clear()
|
||||
Reference in New Issue
Block a user