diff --git a/bincio/edit/server.py b/bincio/edit/server.py index ec28198..ea2f7f3 100644 --- a/bincio/edit/server.py +++ b/bincio/edit/server.py @@ -5,7 +5,6 @@ from __future__ import annotations import json import re import shutil -import time from pathlib import Path 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(): raise HTTPException(404, f"Activity {activity_id!r} not found") - edits_dir = dd / "edits" - edits_dir.mkdir(exist_ok=True) - 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) + from bincio.edit.ops import apply_sidecar_edit + apply_sidecar_edit(activity_id, payload, dd) + sidecar_path = dd / "edits" / f"{activity_id}.md" 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: raise HTTPException(400, "Strava not configured. Pass --strava-client-id and --strava-client-secret to bincio edit.") dd = _get_data_dir() - - from bincio.extract.strava_api import ( - StravaError, ensure_fresh, fetch_activities, fetch_streams, - save_token, strava_to_parsed, - ) + from bincio.edit.ops import run_strava_sync try: - token = ensure_fresh(dd, strava_client_id, strava_client_secret) - except StravaError as e: + result = run_strava_sync(dd, strava_client_id, strava_client_secret) + except RuntimeError as e: raise HTTPException(502, str(e)) - - 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]}) + return JSONResponse(result) @app.post("/api/strava/reset") diff --git a/bincio/render/cli.py b/bincio/render/cli.py index f7d18ad..e045f7e 100644 --- a/bincio/render/cli.py +++ b/bincio/render/cli.py @@ -107,9 +107,17 @@ def _write_root_manifest(data: Path) -> None: except Exception: 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 = { "bas_version": "1.0", - "instance": existing.get("instance", {"name": "BincioActivity", "private": True}), + "instance": existing_instance, "generated_at": datetime.now(timezone.utc).isoformat(), "shards": [ { diff --git a/bincio/serve/cli.py b/bincio/serve/cli.py index 4abfb54..207a97b 100644 --- a/bincio/serve/cli.py +++ b/bincio/serve/cli.py @@ -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]") diff --git a/bincio/serve/server.py b/bincio/serve/server.py index 58364a2..3a8427f 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -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) diff --git a/site/src/components/ActivityFeed.svelte b/site/src/components/ActivityFeed.svelte index a04dbf7..e941b8a 100644 --- a/site/src/components/ActivityFeed.svelte +++ b/site/src/components/ActivityFeed.svelte @@ -66,7 +66,13 @@ : `${base}data/index.json`; const index = await loadIndex(base, indexUrl); 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; } catch (e: any) { error = e.message; @@ -130,7 +136,7 @@

- {formatDate(a.started_at)}{#if a.handle} · @{a.handle}{/if} + {formatDate(a.started_at)}{#if a.handle} · {/if}

{a.title} diff --git a/site/src/layouts/Base.astro b/site/src/layouts/Base.astro index a80ffa1..d5e3889 100644 --- a/site/src/layouts/Base.astro +++ b/site/src/layouts/Base.astro @@ -52,12 +52,21 @@ try { }); - + {instancePrivate && !isPublicPage && ( + )} @@ -132,6 +141,7 @@ try {