Files
bincio-activity/bincio/serve/server.py
T
Davide Scaini cf7c71b8a3 (opus assessment) Fix auth wall flash, broken multi-user write API, and single-user redirect loop
Auth wall (Base.astro): set data-auth-pending on <body> at SSG time and hide
  it with inline CSS before any JS runs; remove the attribute after /api/me
  resolves. Eliminates the flash of protected content on private instances.

  Multi-user write API (serve/server.py): the previous _apply_sidecar_edit and
  strava_sync imports from bincio.edit.server were broken (those names don't
  exist as module-level exports) and the Strava sync mutated a global data_dir,
  making concurrent requests from different users racy. Fix: extract both
  operations into bincio/edit/ops.py as pure functions that take data_dir
  explicitly. Both edit/server.py and serve/server.py now import from there.

  Security: add rate limiting to POST /api/register (5 attempts / 15 min / IP,
  separate bucket from login). Add _check_id() activity ID validation to both
  GET and POST /api/activity/{id} in serve/server.py.

  Single-user mode: _write_root_manifest now forces instance.private=false when
  no instance.db exists, even if a previous run wrote true. Prevents the auth
  wall from firing and redirecting to /login/ when bincio serve isn't running.

  ActivityFeed: skip filterHandle when profileIndexUrl is set (per-user profile
  pages load the right shard directly; activities have no handle tag at that
  point, so the filter was producing an empty feed). Fix handle links to point
  to /u/{handle}/ instead of /{handle}/. Fix <a>-inside-<a> Svelte warning by
  converting the inner handle link to a <button>.
2026-04-09 09:19:48 +02:00

336 lines
12 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, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware
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(
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}$')
_VALID_ACTIVITY_ID = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9\-]{0,250}$')
_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)
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.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)