cf7c71b8a3
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>.
336 lines
12 KiB
Python
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)
|