Refactor: split serve/server.py (3220 lines) into focused modules
serve/server.py is now 69 lines — app factory, middleware, and router
registration only.
New modules:
deps.py (168 lines) — module-level globals + auth dependency functions
models.py (85 lines) — all Pydantic request/response models
tasks.py (136 lines) — background workers and job tracker
routers/ — one file per domain (10 routers, ~2750 lines total)
auth.py, me.py, admin.py, activities.py, uploads.py,
segments.py, strava.py, garmin.py, ideas.py, feed.py
cli.py updated to set globals on deps instead of server.
88 new regression tests in tests/serve/ cover auth guards and key
behaviours for every router; 294 total passing after the split.
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
"""Shared state and FastAPI dependency functions for bincio.serve.
|
||||
|
||||
All module-level globals live here so routers can import them without
|
||||
creating circular dependencies through server.py.
|
||||
The CLI sets these before uvicorn starts.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Cookie, HTTPException, Request, Response
|
||||
|
||||
from bincio.serve.db import (
|
||||
User,
|
||||
authenticate,
|
||||
create_session,
|
||||
delete_session,
|
||||
get_session,
|
||||
get_user,
|
||||
open_db,
|
||||
)
|
||||
from bincio.edit.ops import VALID_ACTIVITY_ID as _VALID_ACTIVITY_ID
|
||||
from bincio.shared.images import ALLOWED_IMAGE_TYPES as _ALLOWED_IMAGE_TYPES # noqa: F401
|
||||
from bincio.shared.images import MAX_IMAGE_BYTES as _MAX_IMAGE_BYTES # noqa: F401
|
||||
from bincio.shared.images import unique_image_name as _unique_image_name # noqa: F401
|
||||
|
||||
# ── Module-level state (set by CLI before uvicorn starts) ─────────────────────
|
||||
|
||||
data_dir: Path | None = None
|
||||
site_dir: Path | None = None
|
||||
webroot: Path | None = None
|
||||
strava_client_id: str = ""
|
||||
strava_client_secret: str = ""
|
||||
public_url: str = ""
|
||||
dem_url: str = "https://api.open-elevation.com"
|
||||
sync_secret: str = ""
|
||||
_db = None
|
||||
_strava_sync_running = False
|
||||
_strava_sync_lock = threading.Lock()
|
||||
|
||||
# ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
_VALID_HANDLE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,29}$')
|
||||
_SESSION_COOKIE = "bincio_session"
|
||||
_COOKIE_MAX_AGE = 30 * 86400 # 30 days
|
||||
_SESSION_DOMAIN = os.environ.get("SESSION_DOMAIN") or None
|
||||
|
||||
_STRAVA_CREDS_FILE = "strava_credentials.json"
|
||||
|
||||
_login_attempts: dict[str, list[float]] = {}
|
||||
_register_attempts: dict[str, list[float]] = {}
|
||||
_RATE_WINDOW = 900 # 15 minutes
|
||||
_LOGIN_RATE_LIMIT = 10
|
||||
_REGISTER_RATE_LIMIT = 5
|
||||
|
||||
# ── Core helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
def _get_data_dir() -> Path:
|
||||
if data_dir is None:
|
||||
raise HTTPException(500, "Server not configured")
|
||||
return data_dir
|
||||
|
||||
|
||||
def _get_db():
|
||||
global _db
|
||||
if _db is None:
|
||||
_db = open_db(_get_data_dir())
|
||||
return _db
|
||||
|
||||
|
||||
def _strava_creds(handle: str) -> tuple[str, str]:
|
||||
"""Return (client_id, client_secret) for a user.
|
||||
|
||||
Per-user credentials take precedence over the instance-level globals.
|
||||
Returns ("", "") when neither is configured.
|
||||
"""
|
||||
creds_path = _get_data_dir() / handle / _STRAVA_CREDS_FILE
|
||||
if creds_path.exists():
|
||||
try:
|
||||
d = json.loads(creds_path.read_text(encoding="utf-8"))
|
||||
cid = str(d.get("client_id", "")).strip()
|
||||
csec = str(d.get("client_secret", "")).strip()
|
||||
if cid and csec:
|
||||
return cid, csec
|
||||
except (OSError, json.JSONDecodeError, KeyError, ValueError):
|
||||
pass
|
||||
return strava_client_id, strava_client_secret
|
||||
|
||||
|
||||
def _check_id(activity_id: str) -> str:
|
||||
if not _VALID_ACTIVITY_ID.match(activity_id):
|
||||
raise HTTPException(400, "Invalid activity ID")
|
||||
return activity_id
|
||||
|
||||
# ── Rate limiting ─────────────────────────────────────────────────────────────
|
||||
|
||||
def _check_rate_limit(
|
||||
ip: str,
|
||||
store: dict[str, list[float]],
|
||||
limit: int,
|
||||
msg: str = "Too many attempts. Try again later.",
|
||||
) -> None:
|
||||
now = time.time()
|
||||
attempts = [t for t in store.get(ip, []) if now - t < _RATE_WINDOW]
|
||||
store[ip] = attempts
|
||||
if len(attempts) >= limit:
|
||||
raise HTTPException(429, msg)
|
||||
attempts.append(now)
|
||||
store[ip] = attempts
|
||||
|
||||
# ── Auth dependency functions ─────────────────────────────────────────────────
|
||||
|
||||
def _current_user(bincio_session: Optional[str] = Cookie(default=None)) -> Optional[User]:
|
||||
if not bincio_session:
|
||||
return None
|
||||
return get_session(_get_db(), bincio_session)
|
||||
|
||||
|
||||
def _require_user(bincio_session: Optional[str] = Cookie(default=None)) -> User:
|
||||
user = _current_user(bincio_session)
|
||||
if not user:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
return user
|
||||
|
||||
|
||||
def _require_admin(bincio_session: Optional[str] = Cookie(default=None)) -> User:
|
||||
user = _require_user(bincio_session)
|
||||
if not user.is_admin:
|
||||
raise HTTPException(403, "Admin required")
|
||||
return user
|
||||
|
||||
|
||||
def _require_auth(
|
||||
request: Request,
|
||||
bincio_session: Optional[str] = Cookie(default=None),
|
||||
) -> User:
|
||||
"""Accept session cookie (web) OR Authorization: Bearer token (mobile)."""
|
||||
token = bincio_session
|
||||
if not token:
|
||||
auth = request.headers.get("Authorization", "")
|
||||
if auth.startswith("Bearer "):
|
||||
token = auth[7:]
|
||||
if not token:
|
||||
raise HTTPException(401, "Not authenticated")
|
||||
user = get_session(_get_db(), token)
|
||||
if not user:
|
||||
raise HTTPException(401, "Invalid or expired session")
|
||||
return user
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, token: str) -> None:
|
||||
kwargs: dict = dict(
|
||||
key=_SESSION_COOKIE,
|
||||
value=token,
|
||||
max_age=_COOKIE_MAX_AGE,
|
||||
httponly=True,
|
||||
samesite="lax",
|
||||
secure=False,
|
||||
)
|
||||
if _SESSION_DOMAIN:
|
||||
kwargs["domain"] = _SESSION_DOMAIN
|
||||
response.set_cookie(**kwargs)
|
||||
Reference in New Issue
Block a user