(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:
+42
-21
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user