27f6d141f7
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
193 lines
7.6 KiB
Python
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)
|