towards multi-user

This commit is contained in:
Davide Scaini
2026-04-08 19:37:10 +02:00
parent 36a91362d9
commit f76cc0ce7e
18 changed files with 1248 additions and 30 deletions
+311
View File
@@ -0,0 +1,311 @@
"""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
_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}$')
_SESSION_COOKIE = "bincio_session"
_COOKIE_MAX_AGE = 30 * 86400 # 30 days
# ── Rate limiting (simple in-memory, per IP) ──────────────────────────────────
_login_attempts: dict[str, list[float]] = {}
_RATE_WINDOW = 900 # 15 minutes
_RATE_LIMIT = 10
def _check_rate_limit(ip: str) -> None:
now = time.time()
attempts = [t for t in _login_attempts.get(ip, []) if now - t < _RATE_WINDOW]
_login_attempts[ip] = attempts
if len(attempts) >= _RATE_LIMIT:
raise HTTPException(429, "Too many login attempts. Try again later.")
attempts.append(now)
_login_attempts[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, response: Response) -> JSONResponse:
ip = request.client.host if request.client else "unknown"
_check_rate_limit(ip)
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)
_set_session_cookie(response, token)
return JSONResponse({"ok": True, "handle": user.handle, "display_name": user.display_name})
@app.post("/api/auth/logout")
async def logout(response: Response, bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
if bincio_session:
delete_session(_get_db(), bincio_session)
response.delete_cookie(_SESSION_COOKIE)
return JSONResponse({"ok": True})
# ── Registration ──────────────────────────────────────────────────────────────
@app.post("/api/register")
async def register(request: Request, response: Response) -> JSONResponse:
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)
_set_session_cookie(response, token)
return JSONResponse({"ok": True, "handle": handle})
# ── 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)
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)
dd = _get_data_dir() / user.handle
from bincio.edit.server import _apply_sidecar_edit # type: ignore[attr-defined]
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 strava_sync(bincio_session: Optional[str] = Cookie(default=None)) -> JSONResponse:
user = _require_user(bincio_session)
dd = _get_data_dir() / user.handle
# Delegate to edit server logic but using user's data dir
from bincio.edit.server import strava_sync as _sync # type: ignore[attr-defined]
# Temporarily override the global data_dir used by edit server
import bincio.edit.server as edit_srv
old = edit_srv.data_dir
edit_srv.data_dir = dd
try:
result = await _sync()
finally:
edit_srv.data_dir = old
_trigger_rebuild(user.handle)
return result