diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ef01c85 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +.venv +.env +bikeslop.db +garmin_gpx_exports/*.gpx +insights.json +__pycache__ +*.pyc +*.pyo +.git diff --git a/.gitignore b/.gitignore index da46274..af7fef2 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,8 @@ insights.json # Local environment .env + +# Runtime data +data/ +bikeslop.db +*.swp diff --git a/CLAUDE.md b/CLAUDE.md index 9712e5f..97b4b93 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,26 +13,51 @@ uv add # add a new dependency Set `LLAMACPP_BASE_URL=http://host:port/v1` to point at a local LLM (inline, exported, or via `.env`). +## Environment variables + +``` +# Auth (Authelia OIDC — public client with PKCE, no secret) +OIDC_ISSUER=https://auth.example.com +OIDC_CLIENT_ID=bikeslop +APP_BASE_URL=https://bikeslop.example.com +SECRET_KEY=<64-char random hex> # signs session cookies + +# S3 (Garage or any S3-compatible) +S3_ENDPOINT=https://s3.example.com +S3_BUCKET=bikeslop-gpx +S3_ACCESS_KEY= +S3_SECRET_KEY= + +# Optional +DATABASE_URL=sqlite:///bikeslop.db # default +LLAMACPP_BASE_URL=http://localhost:8080/v1 +``` + ## Architecture -Two-file app: a FastAPI backend (`app.py`) and a single-page frontend (`static/index.html`). +Four-file app: `app.py` (FastAPI backend), `db.py` (SQLAlchemy models), `auth.py` (OIDC + session), `storage.py` (S3 wrapper), and `static/index.html` (SPA frontend). -**`app.py`** parses all GPX files in `garmin_gpx_exports/` at startup into an in-memory dict (`_rides`), keyed by filename stem. Stats (haversine distance, elevation gain/loss, HR zones) are computed once at load time. API routes: -- `GET /api/rides` — summary list (no track points) -- `GET /api/rides/{id}` — full ride metadata + cached insight -- `GET /api/rides/{id}/points` — full lat/lon/ele/hr array -- `POST /api/rides/{id}/insights` — streams AI coaching response -- `POST /api/insights/generate-all` — SSE stream, generates insights for all rides in chronological order -- `POST /api/fetch-rides` — SSE stream, fetches new rides from Garmin Connect (supports MFA) +**`db.py`** defines SQLAlchemy models (`User`, `Ride`, `Insight`) backed by SQLite (or any SQLAlchemy-compatible DB). Tables are created at startup via `create_tables()`. Ride IDs are UUIDs generated at insert time. + +**`auth.py`** handles Authelia OIDC login via `authlib`. Session is a signed cookie (itsdangerous) containing the user UUID — no server-side session storage. Routes: `GET /auth/login`, `GET /auth/callback`, `GET /auth/logout`. The `require_user` FastAPI dependency reads the cookie and raises 401 if not authenticated. + +**`storage.py`** wraps boto3 for Garage S3. GPX files are stored at `users/{user_id}/{filename}.gpx`. Raises `RuntimeError` on first use if env vars are not configured. + +**`app.py`** — all API endpoints require auth (`Depends(require_user)`) and are scoped to the current user. GPX metadata is stored in the DB at upload time; raw GPX bytes live in S3 and are fetched on demand for the `/points` endpoint and insight generation. In-memory caches: `_user_rides_cache` (ride metadata per user) and `_points_cache` (LRU, 50 entries) to avoid repeated S3 fetches. API routes: +- `GET /api/me` — current user info +- `GET /api/rides` — summary list for current user +- `GET /api/rides/{id}` — ride metadata + insight from DB +- `GET /api/rides/{id}/points` — downloads GPX from S3, parses, returns lat/lon/ele/hr array +- `POST /api/rides/{id}/insights` — streams AI coaching, saves to DB +- `POST /api/insights/generate-all` — SSE stream, generates insights for all rides without one +- `POST /api/fetch-rides` — SSE stream, fetches from Garmin → uploads to S3 → inserts into DB - `POST /api/mfa` — submits MFA code for an in-progress Garmin session -**AI integration** uses the OpenAI Python SDK pointed at a local LLM endpoint (`LLAMACPP_BASE_URL` env var, defaults to `http://localhost:8080/v1`). The model name is auto-discovered from `GET /v1/models` at startup (falls back to `"local-model"`). If the endpoint is unreachable at startup a warning is logged and the app continues — only insight generation is affected. The chunk reader handles both `content` and `reasoning_content` delta fields for reasoning models. +**AI integration** uses the OpenAI Python SDK pointed at a local LLM endpoint (`LLAMACPP_BASE_URL` env var, defaults to `http://localhost:8080/v1`). The model name is auto-discovered from `GET /v1/models` at startup (falls back to `"local-model"`). The chunk reader handles both `content` and `reasoning_content` delta fields for reasoning models. -**Insights cache** is persisted to `insights.json` (gitignored) and loaded at startup. The prompt includes the previous ride's cached insight so the coach can reference its own prior advice. +**`static/index.html`** is a self-contained SPA (no build step). On load it calls `GET /api/me`; a 401 redirects to `/auth/login`. Leaflet.js draws the route as per-segment polylines colored by HR intensity zone. Chart.js renders elevation and HR profiles. AI insights stream via `fetch` + `ReadableStream`. Units (km/mi) toggle and resting HR are persisted in `localStorage`. -**`static/index.html`** is a self-contained SPA (no build step). Leaflet.js draws the route as per-segment polylines colored by HR intensity zone. Chart.js renders elevation and HR profiles against cumulative distance. AI insights stream via `fetch` + `ReadableStream`. Units (km/mi) toggle and resting HR are persisted in `localStorage`. - -**`main.py`** is a standalone CLI script for bulk-downloading Garmin Connect GPX files without running the web server. +**`main.py`** is a standalone CLI script for bulk-downloading Garmin Connect GPX files (single-user, pre-auth era — largely superseded by the in-app Garmin sync). ## GPX data notes diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..3769a0f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM ghcr.io/astral-sh/uv:python3.14-bookworm-slim + +WORKDIR /app + +ENV UV_COMPILE_BYTECODE=1 +ENV UV_LINK_MODE=copy + +# Install dependencies (cached layer — only reruns when lock file changes) +RUN --mount=type=cache,target=/root/.cache/uv \ + --mount=type=bind,source=uv.lock,target=uv.lock \ + --mount=type=bind,source=pyproject.toml,target=pyproject.toml \ + uv sync --frozen --no-install-project --no-dev + +COPY . . + +RUN --mount=type=cache,target=/root/.cache/uv \ + uv sync --frozen --no-dev + +ENV PATH="/app/.venv/bin:$PATH" + +EXPOSE 8000 +CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app.py b/app.py index 1b7bdf1..8eb9de6 100644 --- a/app.py +++ b/app.py @@ -9,11 +9,18 @@ from datetime import datetime, timezone from pathlib import Path from xml.etree import ElementTree as ET -from fastapi import FastAPI, HTTPException, Query, Request +from fastapi import Depends, FastAPI, HTTPException, Query, Request from fastapi.responses import StreamingResponse from fastapi.staticfiles import StaticFiles from garminconnect import Garmin, GarminConnectAuthenticationError from openai import OpenAI +from sqlalchemy import and_, select +from sqlalchemy.orm import Session +from starlette.middleware.sessions import SessionMiddleware + +from auth import require_user, router as auth_router +from db import Insight, Ride, User, create_tables, engine +from storage import download_gpx, upload_gpx logging.basicConfig( level=logging.DEBUG, @@ -24,19 +31,18 @@ for _noisy in ("httpx", "httpcore", "openai"): logging.getLogger(_noisy).setLevel(logging.WARNING) log = logging.getLogger("bikeslop") -GPX_DIR = Path(__file__).parent / "garmin_gpx_exports" -INSIGHTS_FILE = Path(__file__).parent / "insights.json" LLAMACPP_BASE_URL = os.getenv("LLAMACPP_BASE_URL", "http://localhost:8080/v1") -MODEL_NAME = "local-model" # overwritten at startup by auto-discovery +SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-change-me") +MODEL_NAME = "local-model" app = FastAPI() +app.add_middleware(SessionMiddleware, secret_key=SECRET_KEY) +app.include_router(auth_router) -_insights_cache: dict[str, str] = {} _mfa_sessions: dict[str, dict] = {} - - -def _save_insights() -> None: - INSIGHTS_FILE.write_text(json.dumps(_insights_cache, indent=2, ensure_ascii=False)) +_user_rides_cache: dict[str, dict[str, dict]] = {} +_points_cache: dict[str, list[dict]] = {} +_POINTS_CACHE_MAX = 50 # --------------------------------------------------------------------------- # GPX parsing helpers @@ -49,7 +55,6 @@ NS = { def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: - """Return distance in metres between two GPS coordinates.""" R = 6_371_000 phi1, phi2 = math.radians(lat1), math.radians(lat2) dphi = math.radians(lat2 - lat1) @@ -58,12 +63,13 @@ def haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: return 2 * R * math.asin(math.sqrt(a)) -def parse_gpx(path: Path) -> dict: - tree = ET.parse(path) - root = tree.getroot() +def parse_gpx_bytes(data: bytes, filename: str) -> dict: + """Parse GPX bytes into a ride dict including 'points'. No 'id' field.""" + root = ET.fromstring(data) name_el = root.find(".//gpx:trk/gpx:name", NS) - name = name_el.text if name_el is not None else path.stem + stem = Path(filename).stem + name = name_el.text if name_el is not None else stem type_el = root.find(".//gpx:trk/gpx:type", NS) activity_type = type_el.text if type_el is not None else "cycling" @@ -81,7 +87,6 @@ def parse_gpx(path: Path) -> dict: hr = int(hr_el.text) if hr_el is not None else None points.append({"lat": lat, "lon": lon, "ele": ele, "time": ts, "hr": hr}) - # Compute stats distance_m = 0.0 ele_gain = 0.0 ele_loss = 0.0 @@ -105,10 +110,8 @@ def parse_gpx(path: Path) -> dict: hrs = [p["hr"] for p in points if p["hr"] is not None] avg_hr = round(sum(hrs) / len(hrs)) if hrs else None max_hr = max(hrs) if hrs else None - avg_speed_kmh = (distance_m / 1000) / (duration_s / 3600) if duration_s > 0 else 0 - # HR zone distribution (% of points) zone_counts = {"z1": 0, "z2": 0, "z3": 0, "z4": 0, "z5": 0} for h in hrs: if h < 120: @@ -127,13 +130,10 @@ def parse_gpx(path: Path) -> dict: for k, v in zone_counts.items() } - ride_id = path.stem # use filename without extension as ID - return { - "id": ride_id, "name": name, "activity_type": activity_type, - "filename": path.name, + "filename": filename, "start_time": start_time.isoformat() if start_time else None, "end_time": end_time.isoformat() if end_time else None, "duration_s": duration_s, @@ -160,16 +160,64 @@ def parse_gpx(path: Path) -> dict: # --------------------------------------------------------------------------- -# Load all rides at startup +# Per-user ride + points cache # --------------------------------------------------------------------------- -_rides: dict[str, dict] = {} + +def _load_user_rides(user_id: str) -> dict[str, dict]: + with Session(engine) as s: + rows = s.query(Ride).filter_by(user_id=user_id).all() + return {r.id: r.to_dict() for r in rows} + + +def get_user_rides_cached(user_id: str) -> dict[str, dict]: + if user_id not in _user_rides_cache: + _user_rides_cache[user_id] = _load_user_rides(user_id) + return _user_rides_cache[user_id] + + +def invalidate_user_cache(user_id: str) -> None: + _user_rides_cache.pop(user_id, None) + + +def _fetch_ride_points(ride_id: str, s3_key: str) -> list[dict]: + if ride_id not in _points_cache: + if len(_points_cache) >= _POINTS_CACHE_MAX: + del _points_cache[next(iter(_points_cache))] + gpx_data = download_gpx(s3_key) + parsed = parse_gpx_bytes(gpx_data, s3_key.split("/")[-1]) + _points_cache[ride_id] = parsed["points"] + return _points_cache[ride_id] + + +def _get_ride_insight(ride_id: str) -> str | None: + with Session(engine) as s: + row = s.query(Insight).filter_by(ride_id=ride_id).first() + return row.text if row else None + + +def _save_ride_insight(ride_id: str, user_id: str, text: str) -> None: + with Session(engine) as s: + row = s.query(Insight).filter_by(ride_id=ride_id).first() + if row: + row.text = text + else: + row = Insight(ride_id=ride_id, user_id=user_id, text=text) + s.add(row) + s.commit() + + +# --------------------------------------------------------------------------- +# Startup +# --------------------------------------------------------------------------- @app.on_event("startup") -def load_rides(): +def startup(): global MODEL_NAME - # Auto-discover model name from llamacpp + create_tables() + log.info("Database tables ready.") + try: client = OpenAI(base_url=LLAMACPP_BASE_URL, api_key="not-needed") models = client.models.list() @@ -178,62 +226,50 @@ def load_rides(): MODEL_NAME = first.id log.info("Discovered model: %s", MODEL_NAME) else: - log.warning("No models returned from %s — using fallback '%s'", LLAMACPP_BASE_URL, MODEL_NAME) + log.warning("No models from %s — using fallback '%s'", LLAMACPP_BASE_URL, MODEL_NAME) except Exception as exc: log.warning("Could not reach llamacpp at %s (%s) — using fallback '%s'", LLAMACPP_BASE_URL, exc, MODEL_NAME) - for gpx_file in sorted(GPX_DIR.glob("*.gpx"), reverse=True): - try: - ride = parse_gpx(gpx_file) - _rides[ride["id"]] = ride - except Exception as exc: - log.error("Failed to parse %s: %s", gpx_file.name, exc) - log.info("Loaded %d rides.", len(_rides)) - - if INSIGHTS_FILE.exists(): - try: - _insights_cache.update(json.loads(INSIGHTS_FILE.read_text())) - cached = sum(1 for rid in _insights_cache if rid in _rides) - log.info("Loaded %d cached insights from %s", cached, INSIGHTS_FILE) - except Exception as exc: - log.warning("Could not load insights cache: %s", exc) - # --------------------------------------------------------------------------- # API endpoints # --------------------------------------------------------------------------- +@app.get("/api/me") +def get_me(user: User = Depends(require_user)): + return {"display_name": user.display_name, "email": user.email} + + @app.get("/api/rides") -def list_rides(): +def list_rides(user: User = Depends(require_user)): summary_keys = [ "id", "name", "activity_type", "start_time", "duration_s", "distance_km", "avg_speed_kmh", "elevation_gain_m", "avg_hr", "max_hr", "has_gps", ] - rides = [] - for r in _rides.values(): - rides.append({k: r[k] for k in summary_keys}) - # Sort by start_time descending + rides_map = get_user_rides_cached(user.id) + rides = [{k: r[k] for k in summary_keys} for r in rides_map.values()] rides.sort(key=lambda x: x["start_time"] or "", reverse=True) return rides @app.get("/api/rides/{ride_id}") -def get_ride(ride_id: str): - if ride_id not in _rides: +def get_ride(ride_id: str, user: User = Depends(require_user)): + rides_map = get_user_rides_cached(user.id) + if ride_id not in rides_map: raise HTTPException(status_code=404, detail="Ride not found") - r = _rides[ride_id] - result = {k: v for k, v in r.items() if k != "points"} - result["cached_insight"] = _insights_cache.get(ride_id) - return result + r = {k: v for k, v in rides_map[ride_id].items() if k != "points"} + r["cached_insight"] = _get_ride_insight(ride_id) + return r @app.get("/api/rides/{ride_id}/points") -def get_ride_points(ride_id: str): - if ride_id not in _rides: +def get_ride_points(ride_id: str, user: User = Depends(require_user)): + rides_map = get_user_rides_cached(user.id) + if ride_id not in rides_map: raise HTTPException(status_code=404, detail="Ride not found") - return _rides[ride_id]["points"] + return _fetch_ride_points(ride_id, rides_map[ride_id]["s3_key"]) def _karvonen_zones(rhr: int, max_hr: int, points: list[dict]) -> dict: @@ -248,7 +284,7 @@ def _karvonen_zones(rhr: int, max_hr: int, points: list[dict]) -> dict: ] hrs = [p["hr"] for p in points if p.get("hr") is not None] if not hrs: - return {l: 0 for l in labels} + return {label: 0 for label in labels} counts = [0] * 5 for h in hrs: if h < bounds[0]: counts[0] += 1 @@ -263,27 +299,33 @@ def _llm_client() -> OpenAI: return OpenAI(base_url=LLAMACPP_BASE_URL, api_key="not-needed") -def _get_previous_insight(ride: dict) -> str | None: - """Return the cached insight for the most recent ride before this one, or None.""" - ride_time = ride["start_time"] +def _get_previous_insight_for_user(ride: dict, user_id: str) -> str | None: + ride_time = ride.get("start_time") if not ride_time: return None - gps_rides = [r for r in _rides.values() if r["has_gps"] and r["distance_km"] > 0 and r["start_time"]] - prior = sorted( - [r for r in gps_rides if r["start_time"] < ride_time and r["id"] in _insights_cache], - key=lambda r: r["start_time"], - ) - if not prior: - return None - return _insights_cache[prior[-1]["id"]] + with Session(engine) as s: + row = s.execute( + select(Insight.text) + .join(Ride, Insight.ride_id == Ride.id) + .where(and_( + Ride.user_id == user_id, + Ride.start_time < ride_time, + Ride.has_gps == True, + Ride.distance_km > 0, + )) + .order_by(Ride.start_time.desc()) + .limit(1) + ).first() + return row[0] if row else None -def _build_prompt(ride: dict, rhr: int, global_max_hr: int) -> str: +def _build_prompt(ride: dict, user_id: str, rhr: int, global_max_hr: int, points: list[dict]) -> str: ride_time = ride["start_time"] - gps_rides = [r for r in _rides.values() if r["has_gps"] and r["distance_km"] > 0 and r["start_time"]] + rides_map = get_user_rides_cached(user_id) + gps_rides = [r for r in rides_map.values() if r["has_gps"] and r["distance_km"] > 0 and r["start_time"]] past_rides = [r for r in gps_rides if r["start_time"] < ride_time] - zones = _karvonen_zones(rhr, global_max_hr, ride["points"]) + zones = _karvonen_zones(rhr, global_max_hr, points) zone_str = ", ".join(f"{k}={v}%" for k, v in zones.items()) duration_min = ride["duration_s"] // 60 ride_date = ride_time[:10] if ride_time else "unknown" @@ -291,12 +333,12 @@ def _build_prompt(ride: dict, rhr: int, global_max_hr: int) -> str: if past_rides: avg_dist = sum(r["distance_km"] for r in past_rides) / len(past_rides) hr_list = [r["avg_hr"] for r in past_rides if r["avg_hr"]] - avg_hr = sum(hr_list) / len(hr_list) if hr_list else 0 - history_str = f"Before today they've completed {len(past_rides)} rides, averaging {avg_dist:.1f} km and {avg_hr:.0f} bpm." + avg_hr_val = sum(hr_list) / len(hr_list) if hr_list else 0 + history_str = f"Before today they've completed {len(past_rides)} rides, averaging {avg_dist:.1f} km and {avg_hr_val:.0f} bpm." else: history_str = "This is one of their earliest recorded rides — no prior history to compare against." - previous_insight = _get_previous_insight(ride) + previous_insight = _get_previous_insight_for_user(ride, user_id) if previous_insight: prior_section = f""" Your feedback from their last ride: @@ -321,17 +363,19 @@ Then one blank line, then write exactly 3–4 short paragraphs in a warm, direct @app.post("/api/rides/{ride_id}/insights") -def get_insights(ride_id: str, rhr: int = Query(60, ge=30, le=100)): - if ride_id not in _rides: +def post_insights(ride_id: str, user: User = Depends(require_user), rhr: int = Query(60, ge=30, le=100)): + rides_map = get_user_rides_cached(user.id) + if ride_id not in rides_map: raise HTTPException(status_code=404, detail="Ride not found") - ride = _rides[ride_id] + ride = rides_map[ride_id] - gps_rides = [r for r in _rides.values() if r["has_gps"] and r["distance_km"] > 0] + gps_rides = [r for r in rides_map.values() if r["has_gps"] and r["distance_km"] > 0] global_max_hr = max((r["max_hr"] or 0 for r in gps_rides), default=200) - prompt = _build_prompt(ride, rhr, global_max_hr) + points = _fetch_ride_points(ride_id, ride["s3_key"]) + prompt = _build_prompt(ride, user.id, rhr, global_max_hr, points) client = _llm_client() - log.info("POST /insights ride_id=%s rhr=%d", ride_id, rhr) + log.info("POST /insights ride_id=%s rhr=%d user=%s", ride_id, rhr, user.id) log.debug("Prompt:\n%s", prompt) def stream_response(): @@ -356,8 +400,7 @@ def get_insights(ride_id: str, rhr: int = Query(60, ge=30, le=100)): accumulated.append(delta) yield delta log.info("Stream complete — %d chunks, %d chars", chunks_received, sum(len(c) for c in accumulated)) - _insights_cache[ride_id] = "".join(accumulated) - _save_insights() + _save_ride_insight(ride_id, user.id, "".join(accumulated)) log.info("Saved insight for %s", ride_id) except Exception as exc: log.exception("Error streaming insights: %s", exc) @@ -367,24 +410,32 @@ def get_insights(ride_id: str, rhr: int = Query(60, ge=30, le=100)): @app.post("/api/insights/generate-all") -def generate_all_insights(rhr: int = Query(60, ge=30, le=100)): - gps_rides = [r for r in _rides.values() if r["has_gps"] and r["distance_km"] > 0 and r["start_time"]] +def generate_all_insights(user: User = Depends(require_user), rhr: int = Query(60, ge=30, le=100)): + rides_map = get_user_rides_cached(user.id) + gps_rides = [r for r in rides_map.values() if r["has_gps"] and r["distance_km"] > 0 and r["start_time"]] global_max_hr = max((r["max_hr"] or 0 for r in gps_rides), default=200) - # Chronological order so time-aware context is always correct ordered = sorted(gps_rides, key=lambda r: r["start_time"]) - to_generate = [r for r in ordered if r["id"] not in _insights_cache] + + with Session(engine) as s: + insighted_ids = { + row[0] for row in s.execute( + select(Insight.ride_id).where(Insight.user_id == user.id) + ) + } + to_generate = [r for r in ordered if r["id"] not in insighted_ids] total = len(to_generate) - log.info("generate-all: %d rides to generate (rhr=%d)", total, rhr) + log.info("generate-all: %d rides to generate (rhr=%d user=%s)", total, rhr, user.id) def sse_stream(): - client = _llm_client() + llm = _llm_client() for idx, ride in enumerate(to_generate, 1): ride_id = ride["id"] yield f"data: {json.dumps({'index': idx, 'total': total, 'ride_id': ride_id, 'status': 'generating'})}\n\n" log.info("Generating insight %d/%d: %s", idx, total, ride_id) try: - prompt = _build_prompt(ride, rhr, global_max_hr) - response = client.chat.completions.create( + points = _fetch_ride_points(ride_id, ride["s3_key"]) + prompt = _build_prompt(ride, user.id, rhr, global_max_hr, points) + response = llm.chat.completions.create( model=MODEL_NAME, messages=[{"role": "user", "content": prompt}], stream=False, @@ -393,8 +444,7 @@ def generate_all_insights(rhr: int = Query(60, ge=30, le=100)): ) msg = response.choices[0].message.model_dump() text = msg.get("content") or msg.get("reasoning_content") or "" - _insights_cache[ride_id] = text - _save_insights() + _save_ride_insight(ride_id, user.id, text) yield f"data: {json.dumps({'index': idx, 'total': total, 'ride_id': ride_id, 'status': 'done'})}\n\n" except Exception as exc: log.exception("Failed insight for %s: %s", ride_id, exc) @@ -410,10 +460,11 @@ def generate_all_insights(rhr: int = Query(60, ge=30, le=100)): @app.post("/api/fetch-rides") -async def fetch_rides_from_garmin(request: Request): +async def fetch_rides_from_garmin(request: Request, user: User = Depends(require_user)): body = await request.json() email = body.get("email", "") password = body.get("password", "") + user_id = user.id session_id = uuid.uuid4().hex queue: asyncio.Queue = asyncio.Queue() @@ -432,16 +483,23 @@ async def fetch_rides_from_garmin(request: Request): def garmin_worker(): try: emit({"status": "auth"}) - client = Garmin(email=email, password=password, prompt_mfa=prompt_mfa) - client.login() + garmin = Garmin(email=email, password=password, prompt_mfa=prompt_mfa) + garmin.login() emit({"status": "fetching"}) - activities = client.get_activities(0, 50) + activities = garmin.get_activities(0, 50) cycling = [ a for a in activities if "cycling" in a.get("activityType", {}).get("typeKey", "") ] + with Session(engine) as s: + existing_filenames = { + row[0] for row in s.execute( + select(Ride.filename).where(Ride.user_id == user_id) + ) + } + to_download = [] for act in cycling: act_id = act["activityId"] @@ -450,18 +508,42 @@ async def fetch_rides_from_garmin(request: Request): safe_time = start.replace(":", "-").replace(" ", "_") safe_name = "".join(c for c in name if c.isalnum() or c in (" ", "_", "-")).strip() filename = f"{safe_time}_{safe_name}_{act_id}.gpx" - if not (GPX_DIR / filename).exists(): - to_download.append((act_id, name, GPX_DIR / filename)) + if filename not in existing_filenames: + to_download.append((act_id, name, filename)) skipped = len(cycling) - len(to_download) added = 0 - for idx, (act_id, name, filepath) in enumerate(to_download, 1): + for idx, (act_id, name, filename) in enumerate(to_download, 1): emit({"status": "downloading", "index": idx, "total": len(to_download), "name": name}) try: - gpx_data = client.download_activity(act_id, dl_fmt=client.ActivityDownloadFormat.GPX) - filepath.write_bytes(gpx_data) - ride = parse_gpx(filepath) - _rides[ride["id"]] = ride + gpx_data = garmin.download_activity(act_id, dl_fmt=garmin.ActivityDownloadFormat.GPX) + s3_key = upload_gpx(user_id, filename, gpx_data) + parsed = parse_gpx_bytes(gpx_data, filename) + ride_id = uuid.uuid4().hex + with Session(engine) as s: + s.add(Ride( + id=ride_id, + user_id=user_id, + s3_key=s3_key, + filename=filename, + name=parsed["name"], + activity_type=parsed["activity_type"], + start_time=parsed["start_time"], + end_time=parsed["end_time"], + duration_s=parsed["duration_s"], + distance_m=parsed["distance_m"], + distance_km=parsed["distance_km"], + avg_speed_kmh=parsed["avg_speed_kmh"], + elevation_gain_m=parsed["elevation_gain_m"], + elevation_loss_m=parsed["elevation_loss_m"], + avg_hr=parsed["avg_hr"], + max_hr=parsed["max_hr"], + hr_zones_json=json.dumps(parsed["hr_zones"]), + has_gps=parsed["has_gps"], + )) + s.commit() + _points_cache[ride_id] = parsed["points"] + invalidate_user_cache(user_id) added += 1 except Exception as exc: log.error("Failed to download activity %s: %s", act_id, exc) @@ -474,7 +556,7 @@ async def fetch_rides_from_garmin(request: Request): emit({"status": "error", "message": str(exc)}) finally: _mfa_sessions.pop(session_id, None) - emit(None) # sentinel to end the SSE stream + emit(None) async def generate(): loop.run_in_executor(None, garmin_worker) diff --git a/auth.py b/auth.py new file mode 100644 index 0000000..a46f57d --- /dev/null +++ b/auth.py @@ -0,0 +1,105 @@ +import os +import logging + +from authlib.integrations.starlette_client import OAuth +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import RedirectResponse +from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer + +from db import User, get_user_by_id, upsert_user + +log = logging.getLogger("bikeslop") + +OIDC_ISSUER = os.getenv("OIDC_ISSUER", "") +OIDC_CLIENT_ID = os.getenv("OIDC_CLIENT_ID", "") +APP_BASE_URL = os.getenv("APP_BASE_URL", "http://localhost:8000") +SECRET_KEY = os.getenv("SECRET_KEY", "dev-secret-change-me") + +SESSION_COOKIE = "bs_session" +SESSION_MAX_AGE = 30 * 24 * 3600 # 30 days + +_signer = URLSafeTimedSerializer(SECRET_KEY) + +oauth = OAuth() +oauth.register( + name="authelia", + client_id=OIDC_CLIENT_ID, + client_secret=None, # public client — no secret + server_metadata_url=f"{OIDC_ISSUER}/.well-known/openid-configuration" if OIDC_ISSUER else None, + client_kwargs={ + "scope": "openid email profile", + "code_challenge_method": "S256", # PKCE required by Authelia config + "token_endpoint_auth_method": "none", # public client + }, +) + + +def _sign_session(user_id: str) -> str: + return _signer.dumps({"uid": user_id}) + + +def _unsign_session(token: str) -> str | None: + try: + data = _signer.loads(token, max_age=SESSION_MAX_AGE) + return data.get("uid") + except (BadSignature, SignatureExpired): + return None + + +def get_session_user(request: Request) -> User | None: + token = request.cookies.get(SESSION_COOKIE) + if not token: + return None + user_id = _unsign_session(token) + if not user_id: + return None + return get_user_by_id(user_id) + + +def require_user(request: Request) -> User: + user = get_session_user(request) + if user is None: + raise HTTPException(status_code=401, detail="Not authenticated") + return user + + +router = APIRouter() + + +@router.get("/auth/login") +async def login(request: Request): + if not OIDC_ISSUER: + raise HTTPException(status_code=503, detail="OIDC not configured (set OIDC_ISSUER)") + redirect_uri = f"{APP_BASE_URL}/auth/callback" + return await oauth.authelia.authorize_redirect(request, redirect_uri) + + +@router.get("/auth/callback") +async def callback(request: Request): + token = await oauth.authelia.authorize_access_token(request) + userinfo = token.get("userinfo") or await oauth.authelia.userinfo(token=token) + sub = userinfo.get("sub") + if not sub: + raise HTTPException(status_code=400, detail="OIDC userinfo missing 'sub'") + email = userinfo.get("email") + display_name = userinfo.get("name") or userinfo.get("preferred_username") or email + user = upsert_user(oidc_sub=sub, email=email, display_name=display_name) + log.info("User logged in: %s (%s)", display_name, user.id) + session_token = _sign_session(user.id) + response = RedirectResponse(url="/", status_code=302) + response.set_cookie( + SESSION_COOKIE, + session_token, + max_age=SESSION_MAX_AGE, + httponly=True, + samesite="lax", + secure=APP_BASE_URL.startswith("https"), + ) + return response + + +@router.get("/auth/logout") +def logout(): + response = RedirectResponse(url="/", status_code=302) + response.delete_cookie(SESSION_COOKIE) + return response diff --git a/db.py b/db.py new file mode 100644 index 0000000..e0d6072 --- /dev/null +++ b/db.py @@ -0,0 +1,145 @@ +import json +import os +import uuid +from datetime import datetime, timezone + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Float, + ForeignKey, + Integer, + String, + Text, + create_engine, +) +from sqlalchemy.orm import DeclarativeBase, Session, relationship + +DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///bikeslop.db") + +engine = create_engine( + DATABASE_URL, + connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {}, +) + + +class Base(DeclarativeBase): + pass + + +class User(Base): + __tablename__ = "users" + + id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex) + oidc_sub = Column(String, unique=True, nullable=False) + email = Column(String, nullable=True) + display_name = Column(String, nullable=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + rides = relationship("Ride", back_populates="user", cascade="all, delete-orphan") + insights = relationship("Insight", back_populates="user", cascade="all, delete-orphan") + + +class Ride(Base): + __tablename__ = "rides" + + id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex) + user_id = Column(String, ForeignKey("users.id"), nullable=False) + s3_key = Column(String, nullable=False) + filename = Column(String, nullable=False) + name = Column(String, nullable=True) + activity_type = Column(String, nullable=True) + start_time = Column(String, nullable=True) + end_time = Column(String, nullable=True) + duration_s = Column(Integer, default=0) + distance_m = Column(Integer, default=0) + distance_km = Column(Float, default=0.0) + avg_speed_kmh = Column(Float, default=0.0) + elevation_gain_m = Column(Integer, default=0) + elevation_loss_m = Column(Integer, default=0) + avg_hr = Column(Integer, nullable=True) + max_hr = Column(Integer, nullable=True) + hr_zones_json = Column(Text, default="{}") + has_gps = Column(Boolean, default=True) + + user = relationship("User", back_populates="rides") + insight = relationship("Insight", back_populates="ride", uselist=False, cascade="all, delete-orphan") + + def to_dict(self) -> dict: + return { + "id": self.id, + "s3_key": self.s3_key, + "filename": self.filename, + "name": self.name, + "activity_type": self.activity_type, + "start_time": self.start_time, + "end_time": self.end_time, + "duration_s": self.duration_s, + "distance_m": self.distance_m, + "distance_km": self.distance_km, + "avg_speed_kmh": self.avg_speed_kmh, + "elevation_gain_m": self.elevation_gain_m, + "elevation_loss_m": self.elevation_loss_m, + "avg_hr": self.avg_hr, + "max_hr": self.max_hr, + "hr_zones": json.loads(self.hr_zones_json or "{}"), + "has_gps": self.has_gps, + } + + +class Insight(Base): + __tablename__ = "insights" + + id = Column(String, primary_key=True, default=lambda: uuid.uuid4().hex) + ride_id = Column(String, ForeignKey("rides.id"), nullable=False, unique=True) + user_id = Column(String, ForeignKey("users.id"), nullable=False) + text = Column(Text, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + ride = relationship("Ride", back_populates="insight") + user = relationship("User", back_populates="insights") + + +def create_tables() -> None: + Base.metadata.create_all(engine) + + +def get_session() -> Session: + return Session(engine) + + +def upsert_user(oidc_sub: str, email: str | None, display_name: str | None) -> User: + with get_session() as s: + user = s.query(User).filter_by(oidc_sub=oidc_sub).first() + if user is None: + user = User(oidc_sub=oidc_sub, email=email, display_name=display_name) + s.add(user) + else: + if email: + user.email = email + if display_name: + user.display_name = display_name + s.commit() + s.refresh(user) + return User( + id=user.id, + oidc_sub=user.oidc_sub, + email=user.email, + display_name=user.display_name, + created_at=user.created_at, + ) + + +def get_user_by_id(user_id: str) -> User | None: + with get_session() as s: + user = s.query(User).filter_by(id=user_id).first() + if user is None: + return None + return User( + id=user.id, + oidc_sub=user.oidc_sub, + email=user.email, + display_name=user.display_name, + created_at=user.created_at, + ) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1b761ca --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,30 @@ +services: + bikeslop: + build: . + restart: unless-stopped + ports: + - "8000:8000" + env_file: .env + environment: + DATABASE_URL: sqlite:////data/bikeslop.db + volumes: + - ./data:/data + depends_on: + - garage + + garage: + image: dxflrs/garage:v2.2.0 + restart: unless-stopped + ports: + - "3900:3900" + - "3901:3901" + - "3902:3902" + - "3903:3903" + volumes: + - ./garage.toml:/etc/garage.toml:ro + - garage-meta:/var/lib/garage/meta + - garage-data:/var/lib/garage/data + +volumes: + garage-meta: + garage-data: diff --git a/pyproject.toml b/pyproject.toml index ae8a950..8ef19c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,4 +9,8 @@ dependencies = [ "fastapi>=0.115.0", "uvicorn[standard]>=0.30.0", "openai>=1.50.0", + "sqlalchemy>=2.0.51", + "boto3>=1.43.66", + "authlib>=1.7.2", + "itsdangerous>=2.2.0", ] diff --git a/static/index.html b/static/index.html index 779d97d..3c36fc4 100644 --- a/static/index.html +++ b/static/index.html @@ -33,6 +33,10 @@ #sidebar-header { padding: 16px; border-bottom: 1px solid var(--border); } #sidebar-header h1 { font-size: 15px; font-weight: 600; color: var(--text); } #sidebar-header p { color: var(--muted); font-size: 11px; margin-top: 2px; } + #user-row { display: none; align-items: center; justify-content: space-between; margin-top: 4px; } + #user-name { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + #logout-link { font-size: 11px; color: var(--muted); text-decoration: none; flex-shrink: 0; margin-left: 6px; } + #logout-link:hover { color: var(--accent); } #ride-list { flex: 1; overflow-y: auto; } #ride-list::-webkit-scrollbar { width: 4px; } #ride-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; } @@ -147,6 +151,10 @@