"""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() _garmin_sync_running = False _garmin_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)