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,192 @@
|
||||
"""Strava integration endpoints (/api/strava/*)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import secrets
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Cookie, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse, RedirectResponse, StreamingResponse
|
||||
|
||||
from bincio.serve import deps, tasks
|
||||
from bincio.serve.db import get_setting
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_strava_oauth_states: set[str] = set()
|
||||
|
||||
|
||||
@router.get("/api/strava/status")
|
||||
async def strava_status(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
cid, _ = deps._strava_creds(user.handle)
|
||||
if not cid:
|
||||
return JSONResponse({"configured": False, "connected": False, "last_sync": None})
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
from bincio.extract.strava_api import load_token
|
||||
token = load_token(dd)
|
||||
return JSONResponse({
|
||||
"configured": True,
|
||||
"connected": token is not None,
|
||||
"last_sync": token.get("last_sync_at") if token else None,
|
||||
})
|
||||
|
||||
|
||||
@router.post("/api/strava/disconnect")
|
||||
async def strava_disconnect(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||
"""Remove the stored Strava token, forcing a fresh OAuth on next connect."""
|
||||
user = deps._require_user(bincio_session)
|
||||
token_path = deps._get_data_dir() / user.handle / "strava_token.json"
|
||||
token_path.unlink(missing_ok=True)
|
||||
return JSONResponse({"ok": True})
|
||||
|
||||
|
||||
@router.post("/api/strava/reset")
|
||||
async def strava_reset(request: Request, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||
"""Reset last_sync_at so the next sync re-fetches from a chosen point.
|
||||
|
||||
mode=soft — set to the started_at of the most recent activity on disk
|
||||
(next sync only fetches activities newer than the last known one)
|
||||
mode=hard — clear last_sync_at entirely
|
||||
(next sync re-downloads full Strava history, skipping existing files)
|
||||
"""
|
||||
user = deps._require_user(bincio_session)
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
from bincio.extract.strava_api import load_token, save_token
|
||||
token = load_token(dd)
|
||||
if token is None:
|
||||
raise HTTPException(400, "Not connected to Strava")
|
||||
|
||||
body = await request.json()
|
||||
mode = body.get("mode", "soft")
|
||||
|
||||
if mode == "hard":
|
||||
token.pop("last_sync_at", None)
|
||||
save_token(dd, token)
|
||||
return JSONResponse({"ok": True, "mode": "hard", "last_sync_at": None})
|
||||
|
||||
# soft: find the most recent started_at across the user's merged index
|
||||
from datetime import datetime, timezone
|
||||
last_ts: int | None = None
|
||||
for index_path in [dd / "_merged" / "index.json", dd / "index.json"]:
|
||||
if not index_path.exists():
|
||||
continue
|
||||
try:
|
||||
index_data = json.loads(index_path.read_text(encoding="utf-8"))
|
||||
started_ats = [
|
||||
a.get("started_at") for a in index_data.get("activities", [])
|
||||
if a.get("started_at")
|
||||
]
|
||||
if started_ats:
|
||||
latest = max(started_ats)
|
||||
dt = datetime.fromisoformat(latest.replace("Z", "+00:00"))
|
||||
last_ts = int(dt.astimezone(timezone.utc).timestamp())
|
||||
break
|
||||
except Exception:
|
||||
continue
|
||||
|
||||
if last_ts is None:
|
||||
token.pop("last_sync_at", None)
|
||||
else:
|
||||
token["last_sync_at"] = last_ts
|
||||
save_token(dd, token)
|
||||
return JSONResponse({"ok": True, "mode": "soft", "last_sync_at": last_ts})
|
||||
|
||||
|
||||
@router.get("/api/strava/auth-url")
|
||||
async def strava_auth_url(request: Request, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
cid, _ = deps._strava_creds(user.handle)
|
||||
if not cid:
|
||||
raise HTTPException(400, "Strava client ID not configured on this server")
|
||||
state = secrets.token_urlsafe(16)
|
||||
_strava_oauth_states.add(state)
|
||||
if deps.public_url:
|
||||
redirect_uri = deps.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(cid, redirect_uri, state=state)})
|
||||
|
||||
|
||||
@router.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 = deps.public_url.rstrip("/") if deps.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 = deps._current_user(bincio_session)
|
||||
if not user:
|
||||
return RedirectResponse(f"{site_origin}/?strava=error")
|
||||
cid, csec = deps._strava_creds(user.handle)
|
||||
if not cid or not csec:
|
||||
return RedirectResponse(f"{site_origin}/?strava=error")
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
from bincio.extract.strava_api import StravaError, exchange_code, save_token
|
||||
try:
|
||||
token = exchange_code(cid, csec, code)
|
||||
except StravaError:
|
||||
return RedirectResponse(f"{site_origin}/?strava=error")
|
||||
save_token(dd, token)
|
||||
return RedirectResponse(f"{site_origin}/?strava=connected")
|
||||
|
||||
|
||||
@router.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 = deps._require_user(bincio_session)
|
||||
cid, csec = deps._strava_creds(user.handle)
|
||||
if not cid or not csec:
|
||||
raise HTTPException(400, "Strava not configured on this server")
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
store_orig_setting = get_setting(deps._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, cid, csec, originals_dir):
|
||||
if event["type"] == "done":
|
||||
tasks._trigger_rebuild(user.handle) # start before client closes connection
|
||||
yield f"data: {json.dumps(event)}\n\n"
|
||||
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"},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/strava/sync")
|
||||
async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
||||
user = deps._require_user(bincio_session)
|
||||
cid, csec = deps._strava_creds(user.handle)
|
||||
if not cid or not csec:
|
||||
raise HTTPException(400, "Strava not configured on this server")
|
||||
dd = deps._get_data_dir() / user.handle
|
||||
store_orig_setting = get_setting(deps._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, cid, csec, originals_dir=originals_dir)
|
||||
except RuntimeError as e:
|
||||
raise HTTPException(502, str(e))
|
||||
tasks._trigger_rebuild(user.handle)
|
||||
return JSONResponse(result)
|
||||
Reference in New Issue
Block a user