(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
+7 -89
View File
@@ -5,7 +5,6 @@ from __future__ import annotations
import json import json
import re import re
import shutil import shutil
import time
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -405,37 +404,10 @@ async def save_activity(activity_id: str, payload: dict[str, Any]) -> JSONRespon
if not (dd / "activities" / f"{activity_id}.json").exists(): if not (dd / "activities" / f"{activity_id}.json").exists():
raise HTTPException(404, f"Activity {activity_id!r} not found") raise HTTPException(404, f"Activity {activity_id!r} not found")
edits_dir = dd / "edits" from bincio.edit.ops import apply_sidecar_edit
edits_dir.mkdir(exist_ok=True) apply_sidecar_edit(activity_id, payload, dd)
sidecar_path = edits_dir / f"{activity_id}.md"
lines: list[str] = []
if payload.get("title"):
lines.append(f"title: {json.dumps(payload['title'])}")
if payload.get("sport") and payload["sport"] in SPORTS and payload["sport"] != "other":
lines.append(f"sport: {payload['sport']}")
if payload.get("gear"):
lines.append(f"gear: {json.dumps(payload['gear'])}")
if payload.get("highlight"):
lines.append("highlight: true")
if payload.get("private"):
lines.append("private: true")
hide = [s for s in (payload.get("hide_stats") or []) if s in STAT_PANELS]
if hide:
lines.append(f"hide_stats: [{', '.join(hide)}]")
description = (payload.get("description") or "").strip()
content = "---\n" + "\n".join(lines) + "\n---\n"
if description:
content += "\n" + description + "\n"
sidecar_path.write_text(content, encoding="utf-8")
# Re-merge so the Astro dev server immediately serves updated data
from bincio.render.merge import merge_all
merge_all(dd)
sidecar_path = dd / "edits" / f"{activity_id}.md"
return JSONResponse({"ok": True, "sidecar": str(sidecar_path)}) return JSONResponse({"ok": True, "sidecar": str(sidecar_path)})
@@ -726,66 +698,12 @@ async def strava_sync() -> JSONResponse:
if not strava_client_id or not strava_client_secret: if not strava_client_id or not strava_client_secret:
raise HTTPException(400, "Strava not configured. Pass --strava-client-id and --strava-client-secret to bincio edit.") raise HTTPException(400, "Strava not configured. Pass --strava-client-id and --strava-client-secret to bincio edit.")
dd = _get_data_dir() dd = _get_data_dir()
from bincio.edit.ops import run_strava_sync
from bincio.extract.strava_api import (
StravaError, ensure_fresh, fetch_activities, fetch_streams,
save_token, strava_to_parsed,
)
try: try:
token = ensure_fresh(dd, strava_client_id, strava_client_secret) result = run_strava_sync(dd, strava_client_id, strava_client_secret)
except StravaError as e: except RuntimeError as e:
raise HTTPException(502, str(e)) raise HTTPException(502, str(e))
return JSONResponse(result)
after: int | None = token.get("last_sync_at")
try:
activities = fetch_activities(token["access_token"], after=after)
except StravaError as e:
raise HTTPException(502, str(e))
from bincio.extract.metrics import compute
from bincio.extract.writer import build_summary, make_activity_id, write_activity, write_index
from bincio.extract.strava_api import strava_meta_to_partial
from bincio.render.merge import merge_all
# Load existing index once
index_path = dd / "index.json"
if index_path.exists():
index_data = json.loads(index_path.read_text(encoding="utf-8"))
else:
index_data = {"owner": {"handle": "unknown"}, "activities": []}
owner = index_data.get("owner", {})
summaries: dict[str, dict] = {s["id"]: s for s in index_data.get("activities", [])}
imported = 0
skipped = 0
errors: list[str] = []
for meta in activities:
try:
# Compute ID from meta alone (no API call) to skip already-known activities
activity_id = make_activity_id(strava_meta_to_partial(meta))
if (dd / "activities" / f"{activity_id}.json").exists():
skipped += 1
continue
# Only fetch streams for genuinely new activities
streams = fetch_streams(token["access_token"], meta["id"])
parsed = strava_to_parsed(meta, streams)
metrics = compute(parsed)
write_activity(parsed, metrics, dd, privacy="public", rdp_epsilon=0.0001)
summaries[activity_id] = build_summary(parsed, metrics, activity_id, "public")
imported += 1
except Exception as exc:
errors.append(f"{meta.get('id')}: {type(exc).__name__}")
if imported:
write_index(list(summaries.values()), dd, owner)
merge_all(dd)
token["last_sync_at"] = int(time.time())
save_token(dd, token)
return JSONResponse({"ok": True, "imported": imported, "skipped": skipped, "error_count": len(errors), "errors": errors[:5]})
@app.post("/api/strava/reset") @app.post("/api/strava/reset")
+9 -1
View File
@@ -107,9 +107,17 @@ def _write_root_manifest(data: Path) -> None:
except Exception: except Exception:
pass pass
has_auth = (data / "instance.db").exists()
existing_instance = existing.get("instance", {"name": "BincioActivity"})
if not has_auth:
# Single-user: no auth server, force private off regardless of what was written before.
existing_instance = {**existing_instance, "private": False}
elif "private" not in existing_instance:
# Multi-user first run: default to private.
existing_instance = {**existing_instance, "private": True}
manifest = { manifest = {
"bas_version": "1.0", "bas_version": "1.0",
"instance": existing.get("instance", {"name": "BincioActivity", "private": True}), "instance": existing_instance,
"generated_at": datetime.now(timezone.utc).isoformat(), "generated_at": datetime.now(timezone.utc).isoformat(),
"shards": [ "shards": [
{ {
+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("--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("--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)") @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. """Start the bincio multi-user application server.
Handles auth, user management, and write operations. 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 srv.data_dir = dd
if site_dir: if site_dir:
srv.site_dir = Path(site_dir).expanduser().resolve() 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"[bold]bincio serve[/bold]")
console.print(f" Data: [cyan]{dd}[/cyan]") 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 data_dir: Path | None = None
site_dir: Path | None = None # for post-write rebuild trigger site_dir: Path | None = None # for post-write rebuild trigger
strava_client_id: str = ""
strava_client_secret: str = ""
_db = None # sqlite3.Connection, opened lazily _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_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" _SESSION_COOKIE = "bincio_session"
_COOKIE_MAX_AGE = 30 * 86400 # 30 days _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) ────────────────────────────────── # ── Rate limiting (simple in-memory, per IP) ──────────────────────────────────
_login_attempts: dict[str, list[float]] = {} _login_attempts: dict[str, list[float]] = {}
_register_attempts: dict[str, list[float]] = {}
_RATE_WINDOW = 900 # 15 minutes _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() now = time.time()
attempts = [t for t in _login_attempts.get(ip, []) if now - t < _RATE_WINDOW] attempts = [t for t in store.get(ip, []) if now - t < _RATE_WINDOW]
_login_attempts[ip] = attempts store[ip] = attempts
if len(attempts) >= _RATE_LIMIT: if len(attempts) >= limit:
raise HTTPException(429, "Too many login attempts. Try again later.") raise HTTPException(429, msg)
attempts.append(now) attempts.append(now)
_login_attempts[ip] = attempts store[ip] = attempts
# ── Auth helpers ────────────────────────────────────────────────────────────── # ── Auth helpers ──────────────────────────────────────────────────────────────
@@ -154,7 +170,7 @@ async def me(bincio_session: Optional[str] = Cookie(default=None)) -> JSONRespon
@app.post("/api/auth/login") @app.post("/api/auth/login")
async def login(request: Request) -> JSONResponse: async def login(request: Request) -> JSONResponse:
ip = request.client.host if request.client else "unknown" 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() body = await request.json()
handle = body.get("handle", "").strip().lower() 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") @app.post("/api/register")
async def register(request: Request) -> JSONResponse: 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() body = await request.json()
code = body.get("code", "").strip().upper() code = body.get("code", "").strip().upper()
handle = body.get("handle", "").strip().lower() handle = body.get("handle", "").strip().lower()
@@ -276,6 +295,7 @@ async def get_activity(
bincio_session: Optional[str] = Cookie(default=None), bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse: ) -> JSONResponse:
user = _require_user(bincio_session) user = _require_user(bincio_session)
_check_id(activity_id)
path = _require_owns(activity_id, user) path = _require_owns(activity_id, user)
return JSONResponse(json.loads(path.read_text())) return JSONResponse(json.loads(path.read_text()))
@@ -287,28 +307,29 @@ async def post_activity(
bincio_session: Optional[str] = Cookie(default=None), bincio_session: Optional[str] = Cookie(default=None),
) -> JSONResponse: ) -> JSONResponse:
user = _require_user(bincio_session) user = _require_user(bincio_session)
_check_id(activity_id)
dd = _get_data_dir() / user.handle 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() body = await request.json()
_apply_sidecar_edit(activity_id, body, dd) apply_sidecar_edit(activity_id, body, dd)
_trigger_rebuild(user.handle) _trigger_rebuild(user.handle)
return JSONResponse({"ok": True}) return JSONResponse({"ok": True})
@app.post("/api/strava/sync") @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) 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 dd = _get_data_dir() / user.handle
# Delegate to edit server logic but using user's data dir from bincio.edit.ops import run_strava_sync
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: try:
result = await _sync() result = run_strava_sync(dd, strava_client_id, strava_client_secret)
finally: except RuntimeError as e:
edit_srv.data_dir = old raise HTTPException(502, str(e))
_trigger_rebuild(user.handle) _trigger_rebuild(user.handle)
return result return JSONResponse(result)
+8 -2
View File
@@ -66,7 +66,13 @@
: `${base}data/index.json`; : `${base}data/index.json`;
const index = await loadIndex(base, indexUrl); const index = await loadIndex(base, indexUrl);
let activities = index.activities.filter(a => a.privacy !== 'private'); let activities = index.activities.filter(a => a.privacy !== 'private');
if (filterHandle) activities = activities.filter(a => a.handle === filterHandle); // filterHandle only applies when loading the root manifest (multi-user feed).
// When profileIndexUrl is set we already loaded the right user's shard directly —
// activities from a direct shard fetch have no handle tag, so the filter would
// remove everything.
if (filterHandle && !profileIndexUrl) {
activities = activities.filter(a => a.handle === filterHandle);
}
all = activities; all = activities;
} catch (e: any) { } catch (e: any) {
error = e.message; error = e.message;
@@ -130,7 +136,7 @@
<div class="flex items-start justify-between gap-2 mb-3"> <div class="flex items-start justify-between gap-2 mb-3">
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<p class="text-xs text-zinc-500 mb-0.5"> <p class="text-xs text-zinc-500 mb-0.5">
{formatDate(a.started_at)}{#if a.handle} · <a href={`${import.meta.env.BASE_URL}${a.handle}/`} class="hover:text-zinc-300 transition-colors" on:click|stopPropagation>@{a.handle}</a>{/if} {formatDate(a.started_at)}{#if a.handle} · <button class="hover:text-zinc-300 transition-colors" on:click|stopPropagation={() => window.location.href = `${import.meta.env.BASE_URL}u/${a.handle}/`}>@{a.handle}</button>{/if}
</p> </p>
<h3 class="font-semibold text-white truncate group-hover:text-[--accent] transition-colors"> <h3 class="font-semibold text-white truncate group-hover:text-[--accent] transition-colors">
{a.title} {a.title}
+13 -3
View File
@@ -52,12 +52,21 @@ try {
}); });
</script> </script>
<!-- Auth wall: redirect to /login/ on private instances when not authenticated --> <!-- Auth wall: redirect to /login/ on private instances when not authenticated.
body[data-auth-pending] is hidden by CSS (below) before the check resolves,
eliminating the flash of protected content. -->
{instancePrivate && !isPublicPage && ( {instancePrivate && !isPublicPage && (
<style is:inline>[data-auth-pending]{visibility:hidden}</style>
<script is:inline> <script is:inline>
fetch('/api/me', { credentials: 'include' }) fetch('/api/me', { credentials: 'include' })
.then(r => { if (r.status === 401 || r.status === 404) window.location.replace('/login/'); }) .then(r => {
.catch(() => {}); if (r.status === 401 || r.status === 404) {
window.location.replace('/login/');
} else {
document.body.removeAttribute('data-auth-pending');
}
})
.catch(() => { document.body.removeAttribute('data-auth-pending'); });
</script> </script>
)} )}
@@ -132,6 +141,7 @@ try {
<body <body
class="font-sans antialiased min-h-screen" class="font-sans antialiased min-h-screen"
style="background-color: var(--bg-base); color: var(--text-primary)" style="background-color: var(--bg-base); color: var(--text-primary)"
data-auth-pending={instancePrivate && !isPublicPage ? '' : undefined}
> >
<nav <nav
class="border-b border-zinc-800 sticky top-0 z-50 bg-zinc-950/90 backdrop-blur" class="border-b border-zinc-800 sticky top-0 z-50 bg-zinc-950/90 backdrop-blur"