Add multi-user support: Authelia OIDC, SQLite, Garage S3

- Auth via Authelia OIDC (public client + PKCE) with signed session cookies
- SQLite DB for users, rides, and insights (SQLAlchemy)
- Garage S3 for GPX file storage (boto3)
- All API endpoints scoped to authenticated user
- Dockerfile + docker-compose.yml with Garage service
- Frontend auth check: redirects to /auth/login on 401, shows user badge

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 22:12:23 -04:00
parent 65a1bb010d
commit c6d9a3b8b6
12 changed files with 853 additions and 134 deletions
+186 -104
View File
@@ -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 34 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)