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:
@@ -13,26 +13,51 @@ uv add <package> # 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=<key>
|
||||
S3_SECRET_KEY=<secret>
|
||||
|
||||
# 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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user