cbac82a2ba
After registration creates the user's directories, it now calls _write_root_manifest(dd). This rewrites index.json to include the new handle's shard immediately. Since Astro dev re-evaluates getStaticPaths() on every request (reading that file), /u/pres/, /u/pres/stats/, and /u/pres/athlete/ will resolve correctly as soon as the new user navigates there. Fix 2 — invites link (athlete/index.astro:33): Added an Invites button (top-right, same style as "Edit profile") that starts hidden. When bincio:me fires and me === handle (you're on your own page), the subnav tabs are removed as before AND the invites button is revealed. Other visitors see neither.
512 lines
18 KiB
Python
512 lines
18 KiB
Python
"""bincio serve — multi-user FastAPI application server.
|
|
|
|
Handles auth, user management, and auth-gated write operations.
|
|
nginx serves static files; this server only handles /api/* routes.
|
|
|
|
Run via `bincio serve` CLI command.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any, Optional
|
|
|
|
from fastapi import Cookie, FastAPI, File, HTTPException, Request, Response, UploadFile
|
|
from fastapi.middleware.cors import CORSMiddleware
|
|
from fastapi.middleware.gzip import GZipMiddleware
|
|
from fastapi.responses import JSONResponse
|
|
|
|
from bincio.serve.db import (
|
|
User,
|
|
authenticate,
|
|
create_invite,
|
|
create_session,
|
|
create_user,
|
|
delete_session,
|
|
get_invite,
|
|
get_session,
|
|
get_user,
|
|
list_invites,
|
|
list_users,
|
|
open_db,
|
|
use_invite,
|
|
)
|
|
|
|
# ── Globals (set by CLI before uvicorn starts) ────────────────────────────────
|
|
|
|
data_dir: Path | None = None
|
|
site_dir: Path | None = None # for post-write rebuild trigger
|
|
strava_client_id: str = ""
|
|
strava_client_secret: str = ""
|
|
_db = None # sqlite3.Connection, opened lazily
|
|
|
|
|
|
def _get_db():
|
|
global _db
|
|
if _db is None:
|
|
_db = open_db(_get_data_dir())
|
|
return _db
|
|
|
|
|
|
def _get_data_dir() -> Path:
|
|
if data_dir is None:
|
|
raise HTTPException(500, "Server not configured")
|
|
return data_dir
|
|
|
|
|
|
# ── App ───────────────────────────────────────────────────────────────────────
|
|
|
|
app = FastAPI(title="BincioActivity Serve", docs_url=None, redoc_url=None)
|
|
|
|
app.add_middleware(GZipMiddleware, minimum_size=1024)
|
|
app.add_middleware(
|
|
CORSMiddleware,
|
|
allow_origin_regex=r"https?://localhost(:\d+)?",
|
|
allow_credentials=True,
|
|
allow_methods=["GET", "POST", "DELETE"],
|
|
allow_headers=["Content-Type"],
|
|
)
|
|
|
|
_VALID_HANDLE = re.compile(r'^[a-z0-9][a-z0-9_-]{0,29}$')
|
|
from bincio.edit.ops import VALID_ACTIVITY_ID as _VALID_ACTIVITY_ID
|
|
_SESSION_COOKIE = "bincio_session"
|
|
_COOKIE_MAX_AGE = 30 * 86400 # 30 days
|
|
|
|
|
|
def _check_id(activity_id: str) -> str:
|
|
if not _VALID_ACTIVITY_ID.match(activity_id):
|
|
raise HTTPException(400, "Invalid activity ID")
|
|
return activity_id
|
|
|
|
# ── Rate limiting (simple in-memory, per IP) ──────────────────────────────────
|
|
|
|
_login_attempts: dict[str, list[float]] = {}
|
|
_register_attempts: dict[str, list[float]] = {}
|
|
_RATE_WINDOW = 900 # 15 minutes
|
|
_LOGIN_RATE_LIMIT = 10
|
|
_REGISTER_RATE_LIMIT = 5
|
|
|
|
|
|
def _check_rate_limit(
|
|
ip: str,
|
|
store: dict[str, list[float]],
|
|
limit: int,
|
|
msg: str = "Too many attempts. Try again later.",
|
|
) -> None:
|
|
now = time.time()
|
|
attempts = [t for t in store.get(ip, []) if now - t < _RATE_WINDOW]
|
|
store[ip] = attempts
|
|
if len(attempts) >= limit:
|
|
raise HTTPException(429, msg)
|
|
attempts.append(now)
|
|
store[ip] = attempts
|
|
|
|
|
|
# ── Auth helpers ──────────────────────────────────────────────────────────────
|
|
|
|
def _current_user(bincio_session: Optional[str] = Cookie(default=None)) -> Optional[User]:
|
|
if not bincio_session:
|
|
return None
|
|
return get_session(_get_db(), bincio_session)
|
|
|
|
|
|
def _require_user(bincio_session: Optional[str] = Cookie(default=None)) -> User:
|
|
user = _current_user(bincio_session)
|
|
if not user:
|
|
raise HTTPException(401, "Not authenticated")
|
|
return user
|
|
|
|
|
|
def _require_admin(bincio_session: Optional[str] = Cookie(default=None)) -> User:
|
|
user = _require_user(bincio_session)
|
|
if not user.is_admin:
|
|
raise HTTPException(403, "Admin required")
|
|
return user
|
|
|
|
|
|
def _set_session_cookie(response: Response, token: str) -> None:
|
|
response.set_cookie(
|
|
key=_SESSION_COOKIE,
|
|
value=token,
|
|
max_age=_COOKIE_MAX_AGE,
|
|
httponly=True,
|
|
samesite="lax",
|
|
secure=False, # nginx/caddy handles TLS termination
|
|
)
|
|
|
|
|
|
# ── Post-write rebuild ────────────────────────────────────────────────────────
|
|
|
|
def _trigger_rebuild(handle: str) -> None:
|
|
"""Asynchronously re-merge one user's shard and rewrite the root manifest."""
|
|
if site_dir is None:
|
|
return
|
|
subprocess.Popen(
|
|
["uv", "run", "bincio", "render",
|
|
"--data-dir", str(data_dir),
|
|
"--site-dir", str(site_dir),
|
|
"--handle", handle],
|
|
stdout=subprocess.DEVNULL,
|
|
stderr=subprocess.DEVNULL,
|
|
)
|
|
|
|
|
|
# ── Auth endpoints ────────────────────────────────────────────────────────────
|
|
|
|
@app.get("/api/me")
|
|
async def me(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
|
user = _current_user(bincio_session)
|
|
if not user:
|
|
raise HTTPException(404, "Not authenticated")
|
|
return JSONResponse({
|
|
"handle": user.handle,
|
|
"display_name": user.display_name,
|
|
"is_admin": user.is_admin,
|
|
})
|
|
|
|
|
|
@app.post("/api/auth/login")
|
|
async def login(request: Request) -> JSONResponse:
|
|
ip = request.client.host if request.client else "unknown"
|
|
_check_rate_limit(ip, _login_attempts, _LOGIN_RATE_LIMIT, "Too many login attempts. Try again later.")
|
|
|
|
body = await request.json()
|
|
handle = body.get("handle", "").strip().lower()
|
|
password = body.get("password", "")
|
|
|
|
user = authenticate(_get_db(), handle, password)
|
|
if not user:
|
|
raise HTTPException(401, "Invalid credentials")
|
|
|
|
token = create_session(_get_db(), handle)
|
|
resp = JSONResponse({"ok": True, "handle": user.handle, "display_name": user.display_name})
|
|
_set_session_cookie(resp, token)
|
|
return resp
|
|
|
|
|
|
@app.post("/api/auth/logout")
|
|
async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
|
if bincio_session:
|
|
delete_session(_get_db(), bincio_session)
|
|
resp = JSONResponse({"ok": True})
|
|
resp.delete_cookie(_SESSION_COOKIE)
|
|
return resp
|
|
|
|
|
|
# ── Registration ──────────────────────────────────────────────────────────────
|
|
|
|
@app.post("/api/register")
|
|
async def register(request: Request) -> JSONResponse:
|
|
ip = request.client.host if request.client else "unknown"
|
|
_check_rate_limit(ip, _register_attempts, _REGISTER_RATE_LIMIT, "Too many registration attempts. Try again later.")
|
|
|
|
body = await request.json()
|
|
code = body.get("code", "").strip().upper()
|
|
handle = body.get("handle", "").strip().lower()
|
|
password = body.get("password", "")
|
|
display = body.get("display_name", "").strip() or handle
|
|
|
|
if not _VALID_HANDLE.match(handle):
|
|
raise HTTPException(400, "Invalid handle (lowercase letters, numbers, _ - only; max 30 chars)")
|
|
if len(password) < 8:
|
|
raise HTTPException(400, "Password must be at least 8 characters")
|
|
|
|
invite = get_invite(_get_db(), code)
|
|
if not invite or invite.used:
|
|
raise HTTPException(400, "Invalid or already-used invite code")
|
|
if get_user(_get_db(), handle):
|
|
raise HTTPException(409, "Handle already taken")
|
|
|
|
create_user(_get_db(), handle, display, password, is_admin=False)
|
|
use_invite(_get_db(), code, handle)
|
|
|
|
# Create per-user directories
|
|
dd = _get_data_dir()
|
|
(dd / handle / "activities").mkdir(parents=True, exist_ok=True)
|
|
(dd / handle / "edits").mkdir(parents=True, exist_ok=True)
|
|
|
|
# Update root manifest so the new user's shard is discoverable immediately
|
|
# (Astro dev re-evaluates getStaticPaths() on each request from this file)
|
|
from bincio.render.cli import _write_root_manifest
|
|
_write_root_manifest(dd)
|
|
|
|
token = create_session(_get_db(), handle)
|
|
resp = JSONResponse({"ok": True, "handle": handle})
|
|
_set_session_cookie(resp, token)
|
|
return resp
|
|
|
|
|
|
# ── Invites ───────────────────────────────────────────────────────────────────
|
|
|
|
@app.get("/api/invites")
|
|
async def get_invites(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
|
user = _require_user(bincio_session)
|
|
invites = list_invites(_get_db(), user.handle)
|
|
return JSONResponse([{
|
|
"code": i.code,
|
|
"used": i.used,
|
|
"used_by": i.used_by,
|
|
"created_at": i.created_at,
|
|
"used_at": i.used_at,
|
|
} for i in invites])
|
|
|
|
|
|
@app.post("/api/invites")
|
|
async def post_invite(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
|
user = _require_user(bincio_session)
|
|
try:
|
|
code = create_invite(_get_db(), user.handle)
|
|
except ValueError as e:
|
|
raise HTTPException(400, str(e))
|
|
return JSONResponse({"ok": True, "code": code})
|
|
|
|
|
|
# ── Admin ─────────────────────────────────────────────────────────────────────
|
|
|
|
@app.get("/api/admin/users")
|
|
async def admin_users(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
|
_require_admin(bincio_session)
|
|
users = list_users(_get_db())
|
|
return JSONResponse([{
|
|
"handle": u.handle,
|
|
"display_name": u.display_name,
|
|
"is_admin": u.is_admin,
|
|
"created_at": u.created_at,
|
|
} for u in users])
|
|
|
|
|
|
# ── Write API (ported from bincio edit, auth-gated) ───────────────────────────
|
|
|
|
def _user_data_dir(handle: str) -> Path:
|
|
"""Return the merged data dir for a user, for reading activity files."""
|
|
dd = _get_data_dir()
|
|
merged = dd / handle / "_merged"
|
|
return merged if merged.exists() else dd / handle
|
|
|
|
|
|
def _require_owns(activity_id: str, user: User) -> Path:
|
|
"""Verify the user owns this activity (it lives in their data dir)."""
|
|
activity_path = _user_data_dir(user.handle) / "activities" / f"{activity_id}.json"
|
|
if not activity_path.exists():
|
|
raise HTTPException(404, "Activity not found")
|
|
return activity_path
|
|
|
|
|
|
@app.get("/api/activity/{activity_id}")
|
|
async def get_activity(
|
|
activity_id: str,
|
|
bincio_session: Optional[str] = Cookie(default=None),
|
|
) -> JSONResponse:
|
|
user = _require_user(bincio_session)
|
|
_check_id(activity_id)
|
|
path = _require_owns(activity_id, user)
|
|
return JSONResponse(json.loads(path.read_text()))
|
|
|
|
|
|
@app.post("/api/activity/{activity_id}")
|
|
async def post_activity(
|
|
activity_id: str,
|
|
request: Request,
|
|
bincio_session: Optional[str] = Cookie(default=None),
|
|
) -> JSONResponse:
|
|
user = _require_user(bincio_session)
|
|
_check_id(activity_id)
|
|
dd = _get_data_dir() / user.handle
|
|
# Verify the activity belongs to this user before writing
|
|
if not (dd / "activities" / f"{activity_id}.json").exists():
|
|
raise HTTPException(404, "Activity not found")
|
|
|
|
from bincio.edit.ops import apply_sidecar_edit
|
|
body = await request.json()
|
|
apply_sidecar_edit(activity_id, body, dd)
|
|
_trigger_rebuild(user.handle)
|
|
return JSONResponse({"ok": True})
|
|
|
|
|
|
@app.get("/api/activity/{activity_id}/images")
|
|
async def list_images(
|
|
activity_id: str,
|
|
bincio_session: Optional[str] = Cookie(default=None),
|
|
) -> JSONResponse:
|
|
user = _require_user(bincio_session)
|
|
_check_id(activity_id)
|
|
dd = _get_data_dir() / user.handle
|
|
images_dir = dd / "edits" / "images" / activity_id
|
|
images = sorted(p.name for p in images_dir.iterdir() if p.is_file()) if images_dir.exists() else []
|
|
return JSONResponse({"images": images})
|
|
|
|
|
|
@app.post("/api/activity/{activity_id}/images")
|
|
async def upload_image(
|
|
activity_id: str,
|
|
file: UploadFile = File(...),
|
|
bincio_session: Optional[str] = Cookie(default=None),
|
|
) -> JSONResponse:
|
|
user = _require_user(bincio_session)
|
|
_check_id(activity_id)
|
|
dd = _get_data_dir() / user.handle
|
|
if not (dd / "activities" / f"{activity_id}.json").exists():
|
|
raise HTTPException(404, "Activity not found")
|
|
if not file.filename:
|
|
raise HTTPException(400, "No filename")
|
|
ct = file.content_type or ""
|
|
if not ct.startswith("image/"):
|
|
raise HTTPException(400, f"Only image files accepted (got {ct})")
|
|
images_dir = dd / "edits" / "images" / activity_id
|
|
images_dir.mkdir(parents=True, exist_ok=True)
|
|
safe_name = Path(file.filename).name
|
|
(images_dir / safe_name).write_bytes(await file.read())
|
|
_trigger_rebuild(user.handle)
|
|
return JSONResponse({"ok": True, "filename": safe_name})
|
|
|
|
|
|
@app.delete("/api/activity/{activity_id}/images/{filename}")
|
|
async def delete_image(
|
|
activity_id: str,
|
|
filename: str,
|
|
bincio_session: Optional[str] = Cookie(default=None),
|
|
) -> JSONResponse:
|
|
user = _require_user(bincio_session)
|
|
_check_id(activity_id)
|
|
dd = _get_data_dir() / user.handle
|
|
import shutil
|
|
safe_name = Path(filename).name
|
|
target = dd / "edits" / "images" / activity_id / safe_name
|
|
if target.exists() and target.is_file():
|
|
target.unlink()
|
|
if target.parent.exists() and not any(target.parent.iterdir()):
|
|
shutil.rmtree(target.parent)
|
|
_trigger_rebuild(user.handle)
|
|
return JSONResponse({"ok": True})
|
|
|
|
|
|
@app.get("/api/athlete")
|
|
async def get_athlete(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
|
user = _require_user(bincio_session)
|
|
dd = _get_data_dir() / user.handle
|
|
athlete_path = dd / "athlete.json"
|
|
if not athlete_path.exists():
|
|
raise HTTPException(404, "athlete.json not found — run bincio extract first")
|
|
data = json.loads(athlete_path.read_text(encoding="utf-8"))
|
|
# Layer edits/athlete.yaml on top
|
|
edits_path = dd / "edits" / "athlete.yaml"
|
|
if edits_path.exists():
|
|
try:
|
|
import yaml
|
|
edits = yaml.safe_load(edits_path.read_text(encoding="utf-8")) or {}
|
|
for k in ("max_hr", "ftp_w", "hr_zones", "power_zones", "seasons", "gear"):
|
|
if k in edits:
|
|
data[k] = edits[k]
|
|
except Exception:
|
|
pass
|
|
return JSONResponse({
|
|
"max_hr": data.get("max_hr"),
|
|
"ftp_w": data.get("ftp_w"),
|
|
"hr_zones": data.get("hr_zones"),
|
|
"power_zones": data.get("power_zones"),
|
|
"seasons": data.get("seasons", []),
|
|
"gear": data.get("gear", {}),
|
|
})
|
|
|
|
|
|
@app.post("/api/athlete")
|
|
async def save_athlete(
|
|
request: Request,
|
|
bincio_session: Optional[str] = Cookie(default=None),
|
|
) -> JSONResponse:
|
|
user = _require_user(bincio_session)
|
|
dd = _get_data_dir() / user.handle
|
|
if not (dd / "athlete.json").exists():
|
|
raise HTTPException(404, "athlete.json not found — run bincio extract first")
|
|
payload = await request.json()
|
|
edits_dir = dd / "edits"
|
|
edits_dir.mkdir(exist_ok=True)
|
|
overrides: dict[str, Any] = {}
|
|
if payload.get("max_hr") is not None:
|
|
overrides["max_hr"] = int(payload["max_hr"])
|
|
if payload.get("ftp_w") is not None:
|
|
overrides["ftp_w"] = int(payload["ftp_w"])
|
|
if payload.get("hr_zones") is not None:
|
|
overrides["hr_zones"] = [[int(lo), int(hi)] for lo, hi in payload["hr_zones"]]
|
|
if payload.get("power_zones") is not None:
|
|
overrides["power_zones"] = [[int(lo), int(hi)] for lo, hi in payload["power_zones"]]
|
|
if payload.get("seasons") is not None:
|
|
overrides["seasons"] = [
|
|
{"name": str(s["name"]), "start": str(s["start"]), "end": str(s["end"])}
|
|
for s in payload["seasons"]
|
|
]
|
|
if payload.get("gear") is not None:
|
|
overrides["gear"] = payload["gear"]
|
|
import yaml
|
|
(edits_dir / "athlete.yaml").write_text(
|
|
yaml.dump(overrides, allow_unicode=True, default_flow_style=False),
|
|
encoding="utf-8",
|
|
)
|
|
from bincio.render.merge import merge_all
|
|
merge_all(dd)
|
|
_trigger_rebuild(user.handle)
|
|
return JSONResponse({"ok": True})
|
|
|
|
|
|
_SUPPORTED_SUFFIXES = {".fit", ".gpx", ".tcx", ".fit.gz", ".gpx.gz", ".tcx.gz"}
|
|
|
|
|
|
@app.post("/api/upload")
|
|
async def upload_activity(
|
|
file: UploadFile = File(...),
|
|
bincio_session: Optional[str] = Cookie(default=None),
|
|
) -> JSONResponse:
|
|
user = _require_user(bincio_session)
|
|
dd = _get_data_dir() / user.handle
|
|
name = Path(file.filename or "upload.fit").name
|
|
p = Path(name.lower())
|
|
suffix = (p.stem.rsplit(".", 1)[-1].join([".", ".gz"]) if "." in p.stem else ".gz") if p.suffix == ".gz" else p.suffix
|
|
if suffix not in _SUPPORTED_SUFFIXES:
|
|
raise HTTPException(400, f"Unsupported file type '{suffix}'")
|
|
contents = await file.read()
|
|
if len(contents) > 50 * 1024 * 1024:
|
|
raise HTTPException(413, "File too large (max 50 MB)")
|
|
staging = dd / "_uploads"
|
|
staging.mkdir(exist_ok=True)
|
|
staged = staging / name
|
|
staged.write_bytes(contents)
|
|
try:
|
|
from bincio.extract.ingest import ingest_parsed
|
|
from bincio.extract.parsers.factory import parse_file
|
|
activity = parse_file(staged)
|
|
activity_id_check = dd / "activities" / f"{activity.source_file}.json"
|
|
from bincio.extract.writer import make_activity_id
|
|
activity_id = make_activity_id(activity)
|
|
if (dd / "activities" / f"{activity_id}.json").exists():
|
|
raise HTTPException(409, f"Activity already exists: {activity_id}")
|
|
ingest_parsed(activity, dd, privacy="public")
|
|
from bincio.render.merge import merge_all
|
|
merge_all(dd)
|
|
_trigger_rebuild(user.handle)
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
raise HTTPException(422, f"Failed to process activity: {type(exc).__name__}: {exc}")
|
|
finally:
|
|
staged.unlink(missing_ok=True)
|
|
return JSONResponse({"ok": True, "id": activity_id})
|
|
|
|
|
|
@app.post("/api/strava/sync")
|
|
async def serve_strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
|
|
user = _require_user(bincio_session)
|
|
if not strava_client_id or not strava_client_secret:
|
|
raise HTTPException(400, "Strava not configured on this server")
|
|
dd = _get_data_dir() / user.handle
|
|
from bincio.edit.ops import run_strava_sync
|
|
try:
|
|
result = run_strava_sync(dd, strava_client_id, strava_client_secret)
|
|
except RuntimeError as e:
|
|
raise HTTPException(502, str(e))
|
|
_trigger_rebuild(user.handle)
|
|
return JSONResponse(result)
|