"""bincio serve — multi-user FastAPI application server. Handles auth, user management, and auth-gated write operations. nginx serves static files; this server only handles /api/* routes. Run via `bincio serve` CLI command. """ from __future__ import annotations import json import re import secrets import shutil import subprocess import time from pathlib import Path from typing import Any, Optional from fastapi import Cookie, FastAPI, File, Form, HTTPException, Request, Response, UploadFile from fastapi.responses import RedirectResponse, StreamingResponse from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import JSONResponse from bincio.serve.db import ( User, authenticate, create_invite, create_session, count_users, create_user, delete_session, get_invite, get_member_tree, get_session, get_setting, get_user, list_invites, list_users, open_db, use_invite, ) # ── Globals (set by CLI before uvicorn starts) ──────────────────────────────── data_dir: Path | None = None site_dir: Path | None = None # for post-write rebuild trigger strava_client_id: str = "" strava_client_secret: str = "" public_url: str = "" # e.g. "https://yourdomain.com" — used for OAuth redirect URIs _db = None # sqlite3.Connection, opened lazily def _get_db(): global _db if _db is None: _db = open_db(_get_data_dir()) return _db def _get_data_dir() -> Path: if data_dir is None: raise HTTPException(500, "Server not configured") return data_dir # ── App ─────────────────────────────────────────────────────────────────────── app = FastAPI(title="BincioActivity Serve", docs_url=None, redoc_url=None) app.add_middleware(GZipMiddleware, minimum_size=1024) app.add_middleware( CORSMiddleware, allow_origin_regex=r"https?://localhost(:\d+)?", allow_credentials=True, allow_methods=["GET", "POST", "DELETE"], allow_headers=["Content-Type"], ) _VALID_HANDLE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,29}$') from bincio.edit.ops import VALID_ACTIVITY_ID as _VALID_ACTIVITY_ID _SESSION_COOKIE = "bincio_session" _COOKIE_MAX_AGE = 30 * 86400 # 30 days 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 (simple in-memory, per IP) ────────────────────────────────── _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 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 helpers ────────────────────────────────────────────────────────────── 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 _set_session_cookie(response: Response, token: str) -> None: response.set_cookie( key=_SESSION_COOKIE, value=token, max_age=_COOKIE_MAX_AGE, httponly=True, samesite="lax", secure=False, # nginx/caddy handles TLS termination ) # ── Image upload constants ──────────────────────────────────────────────────── _ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"} _MAX_IMAGE_BYTES = 10 * 1024 * 1024 # 10 MB def _unique_image_name(directory: Path, filename: str) -> str: """Return a filename that does not collide with existing files in directory.""" stem, suffix = Path(filename).stem, Path(filename).suffix candidate = filename counter = 1 while (directory / candidate).exists(): candidate = f"{stem}_{counter}{suffix}" counter += 1 return candidate # ── Post-write rebuild ──────────────────────────────────────────────────────── def _trigger_rebuild(handle: str) -> None: """Asynchronously re-merge one user's shard and rewrite the root manifest.""" if site_dir is None: return if not _VALID_HANDLE.match(handle): return # safety: never pass untrusted strings to subprocess uv = shutil.which("uv") or str(Path.home() / ".local" / "bin" / "uv") try: subprocess.Popen( [uv, "run", "bincio", "render", "--data-dir", str(data_dir), "--site-dir", str(site_dir), "--handle", handle, "--no-build"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) except Exception: pass # rebuild failure must never 500 the calling endpoint # ── Auth endpoints ──────────────────────────────────────────────────────────── @app.get("/api/me") async def me(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: user = _current_user(bincio_session) if not user: raise HTTPException(404, "Not authenticated") store_orig = get_setting(_get_db(), "store_originals") return JSONResponse({ "handle": user.handle, "display_name": user.display_name, "is_admin": user.is_admin, "store_originals_default": store_orig != "false", }) @app.get("/api/stats") async def stats() -> JSONResponse: """Public endpoint: member count, join dates, and invitation tree.""" import time as _time now = int(_time.time()) members = get_member_tree(_get_db()) return JSONResponse({ "user_count": len(members), "members": [ { "handle": m["handle"], "display_name": m["display_name"], "member_since": m["created_at"], "member_for_days": (now - m["created_at"]) // 86400, "invited_by": m["invited_by"], } for m in members ], }) @app.post("/api/auth/login") async def login(request: Request) -> JSONResponse: ip = request.client.host if request.client else "unknown" _check_rate_limit(ip, _login_attempts, _LOGIN_RATE_LIMIT, "Too many login attempts. Try again later.") body = await request.json() handle = body.get("handle", "").strip().lower() password = body.get("password", "") user = authenticate(_get_db(), handle, password) if not user: raise HTTPException(401, "Invalid credentials") token = create_session(_get_db(), handle) resp = JSONResponse({"ok": True, "handle": user.handle, "display_name": user.display_name}) _set_session_cookie(resp, token) return resp @app.post("/api/auth/logout") async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: if bincio_session: delete_session(_get_db(), bincio_session) resp = JSONResponse({"ok": True}) resp.delete_cookie(_SESSION_COOKIE) return resp # ── Registration ────────────────────────────────────────────────────────────── @app.post("/api/register") async def register(request: Request) -> JSONResponse: ip = request.client.host if request.client else "unknown" _check_rate_limit(ip, _register_attempts, _REGISTER_RATE_LIMIT, "Too many registration attempts. Try again later.") body = await request.json() code = body.get("code", "").strip().upper() handle = body.get("handle", "").strip().lower() password = body.get("password", "") display = body.get("display_name", "").strip() or handle if not _VALID_HANDLE.match(handle): raise HTTPException(400, "Invalid handle (lowercase letters, numbers, _ - only; max 30 chars)") if len(password) < 8: raise HTTPException(400, "Password must be at least 8 characters") invite = get_invite(_get_db(), code) if not invite or invite.used: raise HTTPException(400, "Invalid or already-used invite code") if get_user(_get_db(), handle): raise HTTPException(409, "Handle already taken") max_users_val = get_setting(_get_db(), "max_users") if max_users_val is not None: limit = int(max_users_val) if limit > 0 and count_users(_get_db()) >= limit: raise HTTPException(403, f"This instance has reached its user limit ({limit})") create_user(_get_db(), handle, display, password, is_admin=False) use_invite(_get_db(), code, handle) # Create per-user directories dd = _get_data_dir() (dd / handle / "activities").mkdir(parents=True, exist_ok=True) (dd / handle / "edits").mkdir(parents=True, exist_ok=True) # Update root manifest so the new user's shard is discoverable immediately # (Astro dev re-evaluates getStaticPaths() on each request from this file) from bincio.render.cli import _write_root_manifest _write_root_manifest(dd) token = create_session(_get_db(), handle) resp = JSONResponse({"ok": True, "handle": handle}) _set_session_cookie(resp, token) return resp # ── Invites ─────────────────────────────────────────────────────────────────── @app.get("/api/invites") async def get_invites(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: user = _require_user(bincio_session) invites = list_invites(_get_db(), user.handle) return JSONResponse([{ "code": i.code, "used": i.used, "used_by": i.used_by, "created_at": i.created_at, "used_at": i.used_at, } for i in invites]) @app.post("/api/invites") async def post_invite(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: user = _require_user(bincio_session) try: code = create_invite(_get_db(), user.handle) except ValueError as e: raise HTTPException(400, str(e)) return JSONResponse({"ok": True, "code": code}) # ── Admin ───────────────────────────────────────────────────────────────────── @app.get("/api/admin/users") async def admin_users(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: _require_admin(bincio_session) users = list_users(_get_db()) return JSONResponse([{ "handle": u.handle, "display_name": u.display_name, "is_admin": u.is_admin, "created_at": u.created_at, } for u in users]) # ── Write API (ported from bincio edit, auth-gated) ─────────────────────────── def _user_data_dir(handle: str) -> Path: """Return the merged data dir for a user, for reading activity files.""" dd = _get_data_dir() merged = dd / handle / "_merged" return merged if merged.exists() else dd / handle def _require_owns(activity_id: str, user: User) -> Path: """Verify the user owns this activity (it lives in their data dir).""" activity_path = _user_data_dir(user.handle) / "activities" / f"{activity_id}.json" if not activity_path.exists(): raise HTTPException(404, "Activity not found") return activity_path @app.get("/api/activity/{activity_id}") async def get_activity( activity_id: str, bincio_session: Optional[str] = Cookie(default=None), ) -> JSONResponse: user = _require_user(bincio_session) _check_id(activity_id) path = _require_owns(activity_id, user) return JSONResponse(json.loads(path.read_text())) @app.post("/api/activity/{activity_id}") async def post_activity( activity_id: str, request: Request, bincio_session: Optional[str] = Cookie(default=None), ) -> JSONResponse: user = _require_user(bincio_session) _check_id(activity_id) dd = _get_data_dir() / user.handle # Verify the activity belongs to this user before writing if not (dd / "activities" / f"{activity_id}.json").exists(): raise HTTPException(404, "Activity not found") from bincio.edit.ops import apply_sidecar_edit body = await request.json() apply_sidecar_edit(activity_id, body, dd) _trigger_rebuild(user.handle) return JSONResponse({"ok": True}) @app.get("/api/activity/{activity_id}/images") async def list_images( activity_id: str, bincio_session: Optional[str] = Cookie(default=None), ) -> JSONResponse: user = _require_user(bincio_session) _check_id(activity_id) dd = _get_data_dir() / user.handle images_dir = dd / "edits" / "images" / activity_id images = sorted(p.name for p in images_dir.iterdir() if p.is_file()) if images_dir.exists() else [] return JSONResponse({"images": images}) @app.post("/api/activity/{activity_id}/images") async def upload_image( activity_id: str, file: UploadFile = File(...), bincio_session: Optional[str] = Cookie(default=None), ) -> JSONResponse: user = _require_user(bincio_session) _check_id(activity_id) dd = _get_data_dir() / user.handle if not (dd / "activities" / f"{activity_id}.json").exists(): raise HTTPException(404, "Activity not found") if not file.filename: raise HTTPException(400, "No filename") ct = file.content_type or "" if ct not in _ALLOWED_IMAGE_TYPES: raise HTTPException(400, "Only JPEG, PNG, WebP, or GIF images are accepted") contents = await file.read() if len(contents) > _MAX_IMAGE_BYTES: raise HTTPException(413, f"Image too large (max {_MAX_IMAGE_BYTES // (1024*1024)} MB)") images_dir = dd / "edits" / "images" / activity_id images_dir.mkdir(parents=True, exist_ok=True) safe_name = _unique_image_name(images_dir, Path(file.filename).name) (images_dir / safe_name).write_bytes(contents) _trigger_rebuild(user.handle) return JSONResponse({"ok": True, "filename": safe_name}) @app.delete("/api/activity/{activity_id}/images/{filename}") async def delete_image( activity_id: str, filename: str, bincio_session: Optional[str] = Cookie(default=None), ) -> JSONResponse: user = _require_user(bincio_session) _check_id(activity_id) dd = _get_data_dir() / user.handle import shutil safe_name = Path(filename).name target = dd / "edits" / "images" / activity_id / safe_name if target.exists() and target.is_file(): target.unlink() if target.parent.exists() and not any(target.parent.iterdir()): shutil.rmtree(target.parent) _trigger_rebuild(user.handle) return JSONResponse({"ok": True}) @app.get("/api/athlete") async def get_athlete(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: user = _require_user(bincio_session) dd = _get_data_dir() / user.handle athlete_path = dd / "athlete.json" data: dict = {} if athlete_path.exists(): data = json.loads(athlete_path.read_text(encoding="utf-8")) # Layer edits/athlete.yaml on top edits_path = dd / "edits" / "athlete.yaml" if edits_path.exists(): try: import yaml edits = yaml.safe_load(edits_path.read_text(encoding="utf-8")) or {} for k in ("max_hr", "ftp_w", "hr_zones", "power_zones", "seasons", "gear"): if k in edits: data[k] = edits[k] except Exception: pass return JSONResponse(data) @app.post("/api/athlete") async def save_athlete( request: Request, bincio_session: Optional[str] = Cookie(default=None), ) -> JSONResponse: user = _require_user(bincio_session) dd = _get_data_dir() / user.handle athlete_path = dd / "athlete.json" if not athlete_path.exists(): from datetime import datetime, timezone athlete_path.write_text(json.dumps({ "bas_version": "1.0", "generated_at": datetime.now(timezone.utc).isoformat(), "power_curve": {}, }), encoding="utf-8") payload = await request.json() edits_dir = dd / "edits" edits_dir.mkdir(exist_ok=True) overrides: dict[str, Any] = {} if payload.get("max_hr") is not None: overrides["max_hr"] = int(payload["max_hr"]) if payload.get("ftp_w") is not None: overrides["ftp_w"] = int(payload["ftp_w"]) if payload.get("hr_zones") is not None: overrides["hr_zones"] = [[int(lo), int(hi)] for lo, hi in payload["hr_zones"]] if payload.get("power_zones") is not None: overrides["power_zones"] = [[int(lo), int(hi)] for lo, hi in payload["power_zones"]] if payload.get("seasons") is not None: overrides["seasons"] = [ {"name": str(s["name"]), "start": str(s["start"]), "end": str(s["end"])} for s in payload["seasons"] ] if payload.get("gear") is not None: overrides["gear"] = payload["gear"] import yaml (edits_dir / "athlete.yaml").write_text( yaml.dump(overrides, allow_unicode=True, default_flow_style=False), encoding="utf-8", ) from bincio.render.merge import merge_all merge_all(dd) _trigger_rebuild(user.handle) return JSONResponse({"ok": True}) _SUPPORTED_SUFFIXES = {".fit", ".gpx", ".tcx", ".fit.gz", ".gpx.gz", ".tcx.gz"} @app.post("/api/upload") async def upload_activity( files: list[UploadFile] = File(...), store_original: bool = Form(False), bincio_session: Optional[str] = Cookie(default=None), ) -> JSONResponse: from bincio.extract.ingest import ingest_parsed from bincio.extract.parsers.factory import parse_file from bincio.extract.writer import make_activity_id from bincio.render.merge import merge_all user = _require_user(bincio_session) dd = _get_data_dir() / user.handle staging = dd / "_uploads" staging.mkdir(exist_ok=True) results = [] any_added = False for file in files: name = Path(file.filename or "upload.fit").name p = Path(name.lower()) suffix = (p.stem.rsplit(".", 1)[-1].join([".", ".gz"]) if "." in p.stem else ".gz") if p.suffix == ".gz" else p.suffix if suffix not in _SUPPORTED_SUFFIXES: results.append({"name": name, "ok": False, "error": f"Unsupported file type '{suffix}'"}) continue contents = await file.read() if len(contents) > 50 * 1024 * 1024: results.append({"name": name, "ok": False, "error": "File too large (max 50 MB)"}) continue staged = staging / name staged.write_bytes(contents) kept = False try: activity = parse_file(staged) activity_id = make_activity_id(activity) if (dd / "activities" / f"{activity_id}.json").exists(): results.append({"name": name, "ok": False, "error": "duplicate"}) continue ingest_parsed(activity, dd, privacy="public") if store_original: originals_dir = dd / "originals" originals_dir.mkdir(exist_ok=True) staged.rename(originals_dir / name) kept = True results.append({"name": name, "ok": True, "id": activity_id}) any_added = True except Exception as exc: results.append({"name": name, "ok": False, "error": "Processing failed"}) finally: if not kept: staged.unlink(missing_ok=True) if any_added: merge_all(dd) _trigger_rebuild(user.handle) added = [r for r in results if r["ok"]] return JSONResponse({"ok": True, "added": len(added), "results": results}) # ── Feedback ────────────────────────────────────────────────────────────────── _FEEDBACK_IMAGE_SUFFIXES = {".jpg", ".jpeg", ".png", ".gif", ".webp", ".heic"} _FEEDBACK_MAX_IMAGES = 3 _FEEDBACK_MAX_IMAGE_BYTES = 2 * 1024 * 1024 # 2 MB @app.post("/api/feedback") async def submit_feedback( text: str = Form(""), images: list[UploadFile] = File(default=[]), bincio_session: Optional[str] = Cookie(default=None), ) -> JSONResponse: user = _require_user(bincio_session) text = text.strip() if not text and not any(f.filename for f in images): raise HTTPException(400, "Feedback must include text or at least one image") if len(images) > _FEEDBACK_MAX_IMAGES: raise HTTPException(400, f"Maximum {_FEEDBACK_MAX_IMAGES} images per submission") feedback_dir = _get_data_dir() / "_feedback" feedback_dir.mkdir(exist_ok=True) images_dir = feedback_dir / user.handle images_dir.mkdir(exist_ok=True) now = int(time.time()) submission_id = f"{now}_{secrets.token_hex(4)}" saved_images: list[str] = [] for img in images: if not img.filename: continue suffix = Path(img.filename).suffix.lower() if suffix not in _FEEDBACK_IMAGE_SUFFIXES: raise HTTPException(400, f"Unsupported image type '{suffix}'") contents = await img.read() if len(contents) > _FEEDBACK_MAX_IMAGE_BYTES: raise HTTPException(413, f"Image '{img.filename}' exceeds 2 MB limit") safe_name = f"{submission_id}_{Path(img.filename).name}" (images_dir / safe_name).write_bytes(contents) saved_images.append(safe_name) from datetime import datetime, timezone entry = { "id": submission_id, "handle": user.handle, "submitted_at": datetime.now(timezone.utc).isoformat(), "text": text, "images": saved_images, } log_file = feedback_dir / f"{user.handle}.json" existing: list[dict] = [] if log_file.exists(): try: existing = json.loads(log_file.read_text()) except Exception: existing = [] existing.append(entry) log_file.write_text(json.dumps(existing, indent=2)) return JSONResponse({"ok": True, "id": submission_id}) # ── Strava ──────────────────────────────────────────────────────────────────── _strava_oauth_states: set[str] = set() @app.get("/api/strava/status") async def strava_status(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: user = _require_user(bincio_session) if not strava_client_id: return JSONResponse({"configured": False, "connected": False, "last_sync": None}) dd = _get_data_dir() / user.handle token_path = dd / "strava_token.json" connected = token_path.exists() last_sync = None if connected: try: token = json.loads(token_path.read_text()) last_sync = token.get("last_sync_at") except Exception: pass return JSONResponse({"configured": True, "connected": connected, "last_sync": last_sync}) @app.get("/api/strava/auth-url") async def strava_auth_url(request: Request, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: _require_user(bincio_session) if not strava_client_id: raise HTTPException(400, "Strava client ID not configured on this server") state = secrets.token_urlsafe(16) _strava_oauth_states.add(state) if public_url: redirect_uri = public_url.rstrip("/") + "/api/strava/callback" else: redirect_uri = str(request.url_for("strava_callback")) from bincio.extract.strava_api import auth_url return JSONResponse({"url": auth_url(strava_client_id, redirect_uri, state=state)}) @app.get("/api/strava/callback", name="strava_callback") async def strava_callback( request: Request, code: str = "", error: str = "", state: str = "", bincio_session: Optional[str] = Cookie(default=None), ) -> RedirectResponse: site_origin = public_url.rstrip("/") if public_url else str(request.base_url).rstrip("/") if error or not code: return RedirectResponse(f"{site_origin}/?strava=error") if state not in _strava_oauth_states: return RedirectResponse(f"{site_origin}/?strava=error") _strava_oauth_states.discard(state) user = _current_user(bincio_session) if not user: return RedirectResponse(f"{site_origin}/?strava=error") if not strava_client_id or not strava_client_secret: return RedirectResponse(f"{site_origin}/?strava=error") dd = _get_data_dir() / user.handle from bincio.extract.strava_api import StravaError, exchange_code, save_token try: token = exchange_code(strava_client_id, strava_client_secret, code) except StravaError: return RedirectResponse(f"{site_origin}/?strava=error") save_token(dd, token) return RedirectResponse(f"{site_origin}/?strava=connected") @app.get("/api/strava/sync/stream") async def serve_strava_sync_stream(bincio_session: Optional[str] = Cookie(default=None)) -> StreamingResponse: """SSE endpoint — streams per-activity progress then a final summary event.""" user = _require_user(bincio_session) if not strava_client_id or not strava_client_secret: raise HTTPException(400, "Strava not configured on this server") dd = _get_data_dir() / user.handle store_orig_setting = get_setting(_get_db(), "store_originals") store_orig = store_orig_setting == "true" originals_dir = (dd / "originals" / "strava") if store_orig else None if originals_dir: originals_dir.mkdir(parents=True, exist_ok=True) from bincio.extract.ingest import strava_sync_iter def event_stream(): try: for event in strava_sync_iter(dd, strava_client_id, strava_client_secret, originals_dir): yield f"data: {json.dumps(event)}\n\n" if event["type"] == "done": _trigger_rebuild(user.handle) except Exception as exc: yield f"data: {json.dumps({'type': 'error', 'message': str(exc)})}\n\n" return StreamingResponse( event_stream(), media_type="text/event-stream", headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, ) @app.post("/api/strava/sync") async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: user = _require_user(bincio_session) if not strava_client_id or not strava_client_secret: raise HTTPException(400, "Strava not configured on this server") dd = _get_data_dir() / user.handle store_orig_setting = get_setting(_get_db(), "store_originals") store_orig = store_orig_setting == "true" originals_dir = (dd / "originals" / "strava") if store_orig else None if originals_dir: originals_dir.mkdir(parents=True, exist_ok=True) from bincio.edit.ops import run_strava_sync try: result = run_strava_sync(dd, strava_client_id, strava_client_secret, originals_dir=originals_dir) except RuntimeError as e: raise HTTPException(502, str(e)) _trigger_rebuild(user.handle) return JSONResponse(result)