Files
bincio-activity/bincio/serve/routers/strava.py
T
Davide Scaini 27f6d141f7 Refactor step 4: narrow broad except Exception catches
Replaced 28 bare `except Exception` catches across 8 files with specific
exception types reflecting the actual failure modes:

- JSON file reads → (OSError, json.JSONDecodeError)
- datetime parsing → ValueError
- base64 decoding → ValueError
- YAML parsing → (OSError, yaml.YAMLError); import moved above try
- GeoJSON coord extraction → (TypeError, IndexError, AttributeError)
- Startup temp-file cleanup → OSError
- Single JSON line parsing (SSE batch) → json.JSONDecodeError

Kept broad catches only where intentional:
- Background thread top-level guards (tasks.py, admin.py) with log.exception
- SSE stream generator tops (strava.py, garmin.py, uploads.py)
- Per-item batch loops that must not abort the whole operation
- Explicitly non-fatal post-upload merge steps with log.warning
2026-05-13 23:58:14 +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 (OSError, json.JSONDecodeError, ValueError):
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)