Files
bincio-activity/bincio/serve/routers/strava.py
T
Davide Scaini 8380b1d2cc 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.
2026-05-13 23:47:19 +02:00

193 lines
7.6 KiB
Python

"""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)