(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>.
This commit is contained in:
Davide Scaini
2026-04-09 09:19:48 +02:00
parent 98c42dc443
commit cf7c71b8a3
6 changed files with 87 additions and 117 deletions
+8 -1
View File
@@ -16,7 +16,10 @@ console = Console()
@click.option("--site-dir", default=None, type=click.Path(), help="Astro site dir for post-write rebuilds")
@click.option("--host", default="127.0.0.1", help="Bind host (default: 127.0.0.1 — proxy via nginx)")
@click.option("--port", default=4041, help="Bind port (default: 4041)")
def serve(data_dir: str, site_dir: Optional[str], host: str, port: int) -> None:
@click.option("--strava-client-id", default=None, envvar="STRAVA_CLIENT_ID", help="Strava OAuth client ID (enables per-user Strava sync)")
@click.option("--strava-client-secret", default=None, envvar="STRAVA_CLIENT_SECRET", help="Strava OAuth client secret")
def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
strava_client_id: Optional[str], strava_client_secret: Optional[str]) -> None:
"""Start the bincio multi-user application server.
Handles auth, user management, and write operations.
@@ -36,6 +39,10 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int) -> None:
srv.data_dir = dd
if site_dir:
srv.site_dir = Path(site_dir).expanduser().resolve()
if strava_client_id:
srv.strava_client_id = strava_client_id
if strava_client_secret:
srv.strava_client_secret = strava_client_secret
console.print(f"[bold]bincio serve[/bold]")
console.print(f" Data: [cyan]{dd}[/cyan]")
+42 -21
View File
@@ -39,6 +39,8 @@ from bincio.serve.db import (
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
@@ -68,24 +70,38 @@ app.add_middleware(
)
_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
_RATE_LIMIT = 10
_LOGIN_RATE_LIMIT = 10
_REGISTER_RATE_LIMIT = 5
def _check_rate_limit(ip: str) -> None:
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 _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 = [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)
_login_attempts[ip] = attempts
store[ip] = attempts
# ── Auth helpers ──────────────────────────────────────────────────────────────
@@ -154,7 +170,7 @@ async def me(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRespon
@app.post("/api/auth/login")
async def login(request: Request) -> JSONResponse:
ip = request.client.host if request.client else "unknown"
_check_rate_limit(ip)
_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()
@@ -183,6 +199,9 @@ async def logout(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRe
@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()
@@ -276,6 +295,7 @@ async def get_activity(
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()))
@@ -287,28 +307,29 @@ async def post_activity(
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.server import _apply_sidecar_edit # type: ignore[attr-defined]
from bincio.edit.ops import apply_sidecar_edit
body = await request.json()
_apply_sidecar_edit(activity_id, body, dd)
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:
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
# 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
from bincio.edit.ops import run_strava_sync
try:
result = await _sync()
finally:
edit_srv.data_dir = old
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 result
return JSONResponse(result)