diff --git a/bincio/serve/server.py b/bincio/serve/server.py index c861b1a..0b23d83 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -18,6 +18,7 @@ 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 from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.responses import JSONResponse @@ -643,6 +644,70 @@ async def submit_feedback( 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) + 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 = 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.post("/api/strava/sync") async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse: user = _require_user(bincio_session) diff --git a/tests/test_server_imports.py b/tests/test_server_imports.py index 524e52b..76c55ac 100644 --- a/tests/test_server_imports.py +++ b/tests/test_server_imports.py @@ -14,6 +14,9 @@ def test_serve_app_has_routes(): paths = {r.path for r in app.routes} assert "/api/me" in paths assert "/api/upload" in paths + assert "/api/strava/status" in paths + assert "/api/strava/auth-url" in paths + assert "/api/strava/callback" in paths assert "/api/strava/sync" in paths assert "/api/register" in paths