diff --git a/bincio/cli.py b/bincio/cli.py index 017833a..5b22e19 100644 --- a/bincio/cli.py +++ b/bincio/cli.py @@ -19,6 +19,7 @@ from bincio.serve.init_cmd import init # noqa: E402 from bincio.serve.cli import serve # noqa: E402 from bincio.dev import dev # noqa: E402 from bincio.reextract_cmd import reextract_originals # noqa: E402 +from bincio.sync_strava import sync_strava_cmd # noqa: E402 main.add_command(extract) main.add_command(render) @@ -28,3 +29,4 @@ main.add_command(init) main.add_command(serve) main.add_command(dev) main.add_command(reextract_originals) +main.add_command(sync_strava_cmd) diff --git a/bincio/serve/cli.py b/bincio/serve/cli.py index 478cc1b..7594721 100644 --- a/bincio/serve/cli.py +++ b/bincio/serve/cli.py @@ -22,10 +22,12 @@ console = Console() @click.option("--public-url", default=None, envvar="PUBLIC_URL", help="Public base URL (e.g. https://yourdomain.com). Required for Strava OAuth to work behind a reverse proxy.") @click.option("--webroot", default=None, type=click.Path(), help="Nginx webroot (e.g. /var/www/bincio). When set, uploads trigger a full Astro build + rsync so new activity pages are immediately accessible without a git push.") @click.option("--dem-url", default=None, envvar="DEM_URL", help="Base URL of an Open-Elevation-compatible API (default: https://api.open-elevation.com).") +@click.option("--sync-secret", default=None, envvar="BINCIO_SYNC_SECRET", help="Shared secret for POST /api/internal/rebuild (used by the sync-strava systemd timer).") def serve(data_dir: str, site_dir: Optional[str], host: str, port: int, strava_client_id: Optional[str], strava_client_secret: Optional[str], max_users: Optional[int], public_url: Optional[str], - webroot: Optional[str], dem_url: Optional[str]) -> None: + webroot: Optional[str], dem_url: Optional[str], + sync_secret: Optional[str]) -> None: """Start the bincio multi-user application server. Handles auth, user management, and write operations. @@ -61,6 +63,8 @@ def serve(data_dir: str, site_dir: Optional[str], host: str, port: int, srv.webroot = Path(webroot).expanduser().resolve() if dem_url: srv.dem_url = dem_url + if sync_secret: + srv.sync_secret = sync_secret db = open_db(dd) current_limit = get_setting(db, "max_users") diff --git a/bincio/serve/db.py b/bincio/serve/db.py index 051e3b2..db47133 100644 --- a/bincio/serve/db.py +++ b/bincio/serve/db.py @@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS users ( is_admin INTEGER NOT NULL DEFAULT 0, wiki_access INTEGER NOT NULL DEFAULT 1, activity_access INTEGER NOT NULL DEFAULT 0, + suspended INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL ); @@ -89,6 +90,7 @@ class User: is_admin: bool wiki_access: bool activity_access: bool + suspended: bool created_at: int @@ -115,6 +117,10 @@ def open_db(data_dir: Path) -> sqlite3.Connection: db.execute("PRAGMA journal_mode=WAL") db.execute("PRAGMA foreign_keys=ON") db.executescript(_SCHEMA) + # Migration: add suspended column to pre-existing databases + cols = {r[1] for r in db.execute("PRAGMA table_info(users)")} + if "suspended" not in cols: + db.execute("ALTER TABLE users ADD COLUMN suspended INTEGER NOT NULL DEFAULT 0") db.commit() return db @@ -140,7 +146,8 @@ def create_user( ) db.commit() return User(handle=handle, display_name=display_name, is_admin=is_admin, - wiki_access=wiki_access, activity_access=activity_access, created_at=now) + wiki_access=wiki_access, activity_access=activity_access, + suspended=False, created_at=now) def get_user(db: sqlite3.Connection, handle: str) -> Optional[User]: @@ -153,12 +160,13 @@ def get_user(db: sqlite3.Connection, handle: str) -> Optional[User]: is_admin=bool(row["is_admin"]), wiki_access=bool(row["wiki_access"]), activity_access=bool(row["activity_access"]), + suspended=bool(row["suspended"]), created_at=row["created_at"], ) def authenticate(db: sqlite3.Connection, handle: str, password: str) -> Optional[User]: - """Return the User if credentials are valid, else None.""" + """Return the User if credentials are valid and account is not suspended, else None.""" row = db.execute( "SELECT * FROM users WHERE handle = ?", (handle,) ).fetchone() @@ -166,12 +174,15 @@ def authenticate(db: sqlite3.Connection, handle: str, password: str) -> Optional return None if not bcrypt.checkpw(password.encode(), row["password_hash"].encode()): return None + if row["suspended"]: + return None return User( handle=row["handle"], display_name=row["display_name"], is_admin=bool(row["is_admin"]), wiki_access=bool(row["wiki_access"]), activity_access=bool(row["activity_access"]), + suspended=False, created_at=row["created_at"], ) @@ -188,6 +199,7 @@ def list_users(db: sqlite3.Connection) -> list[User]: return [User(handle=r["handle"], display_name=r["display_name"], is_admin=bool(r["is_admin"]), wiki_access=bool(r["wiki_access"]), activity_access=bool(r["activity_access"]), + suspended=bool(r["suspended"]), created_at=r["created_at"]) for r in rows] @@ -196,6 +208,11 @@ def delete_user(db: sqlite3.Connection, handle: str) -> None: db.commit() +def set_suspended(db: sqlite3.Connection, handle: str, suspended: bool) -> None: + db.execute("UPDATE users SET suspended = ? WHERE handle = ?", (int(suspended), handle)) + db.commit() + + def get_member_tree(db: sqlite3.Connection) -> list[dict]: """Return users with their inviter handle and join timestamp. @@ -271,10 +288,10 @@ def create_session(db: sqlite3.Connection, handle: str) -> str: def get_session(db: sqlite3.Connection, token: str) -> Optional[User]: - """Return the User owning this session, or None if expired/invalid.""" + """Return the User owning this session, or None if expired/invalid/suspended.""" row = db.execute( "SELECT s.handle, s.expires_at, u.display_name, u.is_admin, " - "u.wiki_access, u.activity_access, u.created_at " + "u.wiki_access, u.activity_access, u.suspended, u.created_at " "FROM sessions s JOIN users u ON s.handle = u.handle " "WHERE s.token = ?", (token,), @@ -284,12 +301,15 @@ def get_session(db: sqlite3.Connection, token: str) -> Optional[User]: if row["expires_at"] < int(time.time()): delete_session(db, token) return None + if row["suspended"]: + return None return User( handle=row["handle"], display_name=row["display_name"], is_admin=bool(row["is_admin"]), wiki_access=bool(row["wiki_access"]), activity_access=bool(row["activity_access"]), + suspended=False, created_at=row["created_at"], ) diff --git a/bincio/serve/server.py b/bincio/serve/server.py index c8c6739..fe3befe 100644 --- a/bincio/serve/server.py +++ b/bincio/serve/server.py @@ -162,6 +162,7 @@ strava_client_id: str = "" strava_client_secret: str = "" public_url: str = "" # e.g. "https://yourdomain.com" — used for OAuth redirect URIs dem_url: str = "https://api.open-elevation.com" # Open-Elevation-compatible API base URL +sync_secret: str = "" # shared secret for /api/internal/rebuild (set via --sync-secret) _db = None # sqlite3.Connection, opened lazily @@ -473,6 +474,24 @@ async def stats() -> JSONResponse: }) +@app.post("/api/internal/rebuild") +async def internal_rebuild(request: Request) -> JSONResponse: + """Trigger a site rebuild. Authenticated via X-Sync-Secret header. + + Called by the bincio sync-strava systemd timer after syncing new activities. + Returns 503 if webroot is not configured (rebuild not possible). + Returns 403 if the secret is missing or wrong. + """ + if not sync_secret: + raise HTTPException(503, "Rebuild endpoint not configured (no sync secret set)") + if request.headers.get("X-Sync-Secret") != sync_secret: + raise HTTPException(403, "Forbidden") + if site_dir is None: + raise HTTPException(503, "No site dir configured") + _site_rebuild_event.set() + return JSONResponse({"status": "rebuild queued"}) + + @app.get("/api/activity/{activity_id}/geojson") async def get_activity_geojson( activity_id: str, @@ -988,6 +1007,7 @@ async def admin_users(bincio_session: Optional[str] = Cookie(default=None)) -> J "handle": u.handle, "display_name": u.display_name, "is_admin": u.is_admin, + "suspended": u.suspended, "created_at": u.created_at, } for u in users]) @@ -1030,9 +1050,11 @@ async def admin_disk(bincio_session: Optional[str] = Cookie(default=None)) -> JS continue # leaked tmp zips leaked = [f for f in user_dir.glob("tmp*.zip") if f.is_file()] + db_user = _get_user(db, user_dir.name) users.append({ "handle": user_dir.name, - "in_db": _get_user(db, user_dir.name) is not None, + "in_db": db_user is not None, + "suspended": db_user.suspended if db_user else False, "total_mb": _mb(user_dir), "activities_mb": _mb(user_dir / "activities"), "activities_count": _count(user_dir / "activities", "*.json"), @@ -1071,6 +1093,57 @@ async def admin_reset_password_code( return JSONResponse({"ok": True, "code": code, "expires_in_hours": 24}) +@app.post("/api/admin/users/{handle}/suspend") +async def admin_suspend( + handle: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Suspend a user account. Blocks login and invalidates existing sessions. Admin only.""" + from bincio.serve.db import set_suspended, purge_expired_sessions + admin = _require_admin(bincio_session) + if handle == admin.handle: + raise HTTPException(400, "Cannot suspend yourself") + db = _get_db() + if not get_user(db, handle): + raise HTTPException(404, "User not found") + set_suspended(db, handle, True) + db.execute("DELETE FROM sessions WHERE handle = ?", (handle,)) + db.commit() + return JSONResponse({"status": "suspended", "handle": handle}) + + +@app.post("/api/admin/users/{handle}/unsuspend") +async def admin_unsuspend( + handle: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Re-enable a suspended user account. Admin only.""" + from bincio.serve.db import set_suspended + _require_admin(bincio_session) + db = _get_db() + if not get_user(db, handle): + raise HTTPException(404, "User not found") + set_suspended(db, handle, False) + return JSONResponse({"status": "unsuspended", "handle": handle}) + + +@app.delete("/api/admin/users/{handle}/account") +async def admin_delete_account( + handle: str, + bincio_session: Optional[str] = Cookie(default=None), +) -> JSONResponse: + """Delete a user account from the database. Data directory is NOT removed. Admin only.""" + from bincio.serve.db import delete_user as _delete_user + admin = _require_admin(bincio_session) + if handle == admin.handle: + raise HTTPException(400, "Cannot delete your own account") + db = _get_db() + if not get_user(db, handle): + raise HTTPException(404, "User not found") + _delete_user(db, handle) + return JSONResponse({"status": "deleted", "handle": handle}) + + @app.post("/api/admin/users/{handle}/rebuild") async def admin_rebuild( handle: str, diff --git a/bincio/sync_strava.py b/bincio/sync_strava.py new file mode 100644 index 0000000..804eb49 --- /dev/null +++ b/bincio/sync_strava.py @@ -0,0 +1,245 @@ +"""Headless multi-user Strava sync — designed to run as a systemd timer. + +For each user directory that contains both strava_token.json and +strava_credentials.json, refreshes the token, fetches new activities, +writes them to the user's data dir, merges sidecars, and updates the +_strava_sync.json checkpoint. + +After all users are synced, optionally POSTs to a server endpoint +to trigger an Astro rebuild + rsync. +""" + +from __future__ import annotations + +import json +import logging +import urllib.error +import urllib.request +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import click + +_TOKEN_FILE = "strava_token.json" +_CREDS_FILE = "strava_credentials.json" +_SYNC_FILE = "_strava_sync.json" + +log = logging.getLogger("bincio.sync_strava") + + +def _load_creds(user_dir: Path) -> tuple[str, str] | None: + """Return (client_id, client_secret) from strava_credentials.json, or None.""" + p = user_dir / _CREDS_FILE + if not p.exists(): + return None + try: + d = json.loads(p.read_text(encoding="utf-8")) + cid = str(d.get("client_id", "")).strip() + csec = str(d.get("client_secret", "")).strip() + if cid and csec: + return cid, csec + except Exception: + pass + return None + + +def sync_user(user_dir: Path) -> tuple[int, int]: + """Sync one user's Strava activities. + + Returns (new_count, error_count). Skips silently if no credentials. + """ + from bincio.extract.strava_api import ensure_fresh, fetch_activities, fetch_streams, StravaError + from bincio.extract.metrics import compute + from bincio.extract.writer import build_summary, make_activity_id, write_activity, write_index + from bincio.import_.strava import _strava_to_parsed, _patch_from_summary + from bincio.render.merge import merge_all + + handle = user_dir.name + + creds = _load_creds(user_dir) + if creds is None: + log.debug("sync[%s]: no strava_credentials.json — skipped", handle) + return 0, 0 + + client_id, client_secret = creds + + try: + token = ensure_fresh(user_dir, client_id, client_secret) + except StravaError as exc: + log.error("sync[%s]: token refresh failed: %s", handle, exc) + return 0, 1 + + access_token = token["access_token"] + + # Load incremental sync state + sync_path = user_dir / _SYNC_FILE + sync_state: dict = ( + json.loads(sync_path.read_text(encoding="utf-8")) + if sync_path.exists() else {} + ) + imported_ids: set[str] = set(sync_state.get("imported_ids", [])) + + after_ts: int | None = None + if sync_state.get("last_sync"): + last = datetime.fromisoformat(sync_state["last_sync"]) + # 1-hour overlap to catch activities saved late to Strava + after_ts = int((last - timedelta(hours=1)).timestamp()) + + try: + all_acts = fetch_activities(access_token, after=after_ts) + except StravaError as exc: + log.error("sync[%s]: fetch_activities failed: %s", handle, exc) + return 0, 1 + + new_acts = [a for a in all_acts if str(a["id"]) not in imported_ids] + log.info( + "sync[%s]: %d new, %d already imported", + handle, len(new_acts), len(all_acts) - len(new_acts), + ) + if not new_acts: + return 0, 0 + + # Load existing index so we can update it in place + index_path = user_dir / "index.json" + if index_path.exists(): + index_data = json.loads(index_path.read_text(encoding="utf-8")) + else: + index_data = {"owner": {"handle": handle}, "activities": []} + owner = index_data.get("owner", {}) + summaries: dict[str, dict] = {s["id"]: s for s in index_data.get("activities", [])} + + imported = 0 + errors = 0 + + for act in new_acts: + strava_id = str(act["id"]) + try: + try: + streams = fetch_streams(access_token, int(strava_id)) + except StravaError as exc: + if "404" in str(exc): + # Activity exists in list but has no accessible streams (old/deleted GPS). + # Still import it using summary-only stats via _patch_from_summary. + streams = {} + else: + raise + + # strava_api.fetch_streams returns {type: {"data": [...], ...}}; + # _strava_to_parsed (from import_/strava.py) expects {type: [...]} + flat_streams = { + k: v["data"] for k, v in streams.items() + if isinstance(v, dict) and "data" in v + } + + parsed = _strava_to_parsed(act, flat_streams) + metrics = compute(parsed) + metrics = _patch_from_summary(metrics, act) + act_id = make_activity_id(parsed) + + # Respect Strava visibility: only_me → unlisted + visibility = act.get("visibility") or "" + privacy = "unlisted" if (act.get("private") or visibility == "only_me") else "public" + + write_activity(parsed, metrics, user_dir, privacy=privacy) + summaries[act_id] = build_summary(parsed, metrics, act_id, privacy) + imported_ids.add(strava_id) + imported += 1 + except Exception as exc: + log.error("sync[%s]: activity %s failed: %s", handle, strava_id, exc) + errors += 1 + + # Persist index and sync checkpoint + write_index(list(summaries.values()), user_dir, owner) + sync_state["imported_ids"] = sorted(imported_ids) + sync_state["last_sync"] = datetime.now(timezone.utc).isoformat() + sync_path.write_text(json.dumps(sync_state, indent=2), encoding="utf-8") + + # Merge sidecars so _merged/ reflects any edits + merge_all(user_dir) + + log.info("sync[%s]: done — %d imported, %d errors", handle, imported, errors) + return imported, errors + + +def sync_all(root_data_dir: Path) -> dict[str, tuple[int, int]]: + """Sync all users that have a strava_token.json. Returns {handle: (new, errors)}.""" + results: dict[str, tuple[int, int]] = {} + token_files = sorted(root_data_dir.glob("*/strava_token.json")) + if not token_files: + log.info("sync_all: no users with strava_token.json found in %s", root_data_dir) + return results + log.info("sync_all: %d user(s) with Strava token", len(token_files)) + for tf in token_files: + user_dir = tf.parent + handle = user_dir.name + try: + results[handle] = sync_user(user_dir) + except Exception as exc: + log.exception("sync_all[%s]: unexpected error: %s", handle, exc) + results[handle] = (0, -1) + return results + + +def _post_rebuild(url: str, secret: str | None) -> None: + headers: dict[str, str] = {"Content-Type": "application/json"} + if secret: + headers["X-Sync-Secret"] = secret + req = urllib.request.Request(url, data=b"{}", headers=headers, method="POST") + try: + with urllib.request.urlopen(req, timeout=10) as resp: + log.info("rebuild triggered: HTTP %d", resp.status) + except urllib.error.HTTPError as exc: + log.error("rebuild trigger failed: HTTP %d %s", exc.code, exc.read().decode()[:100]) + except Exception as exc: + log.error("rebuild trigger failed: %s", exc) + + +@click.command("sync-strava") +@click.option("--data-dir", "data_dir_str", required=True, + help="Root data dir (parent of all user dirs, e.g. /var/bincio/data).") +@click.option("--user", "only_user", default=None, + help="Sync only this handle instead of all users.") +@click.option("--rebuild-url", default=None, envvar="BINCIO_REBUILD_URL", + help="POST here after a successful sync to trigger a site rebuild.") +@click.option("--rebuild-secret", default=None, envvar="BINCIO_SYNC_SECRET", + help="Value sent as X-Sync-Secret header to the rebuild endpoint.") +def sync_strava_cmd( + data_dir_str: str, + only_user: str | None, + rebuild_url: str | None, + rebuild_secret: str | None, +) -> None: + """Headless Strava sync for all users (designed for systemd timer). + + Discovers every user directory that has both strava_token.json and + strava_credentials.json, syncs new activities, and optionally triggers + a site rebuild via an HTTP POST. + """ + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s — %(message)s", + datefmt="%Y-%m-%dT%H:%M:%S", + ) + + root = Path(data_dir_str).expanduser().resolve() + if not root.is_dir(): + raise click.ClickException(f"Data dir not found: {root}") + + if only_user: + user_dir = root / only_user + if not user_dir.is_dir(): + raise click.ClickException(f"User dir not found: {user_dir}") + new_count, err_count = sync_user(user_dir) + click.echo(f"{only_user}: {new_count} imported, {err_count} errors") + total_new = new_count + else: + results = sync_all(root) + total_new = sum(n for n, _ in results.values()) + total_err = sum(e for _, e in results.values()) + click.echo( + f"Sync complete: {len(results)} users, " + f"{total_new} new activities, {total_err} errors" + ) + + if total_new > 0 and rebuild_url: + _post_rebuild(rebuild_url, rebuild_secret) diff --git a/deploy/systemd/bincio-sync.service b/deploy/systemd/bincio-sync.service new file mode 100644 index 0000000..59edfa2 --- /dev/null +++ b/deploy/systemd/bincio-sync.service @@ -0,0 +1,25 @@ +[Unit] +Description=BincioActivity Strava sync +Documentation=https://github.com/bincio/bincio-activity +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=root +WorkingDirectory=/opt/bincio + +# Secrets: BINCIO_SYNC_SECRET (must match --sync-secret passed to bincio serve) +# Copy /opt/bincio/deploy/systemd/sync.env.example → /etc/bincio/sync.env and fill it in. +EnvironmentFile=/etc/bincio/sync.env + +ExecStart=/root/.local/bin/uv run --project /opt/bincio bincio sync-strava \ + --data-dir /var/bincio/data \ + --rebuild-url http://localhost:4041/api/internal/rebuild + +StandardOutput=journal +StandardError=journal +SyslogIdentifier=bincio-sync + +# Don't restart on failure — the timer will retry in 3 hours. +Restart=no diff --git a/deploy/systemd/bincio-sync.timer b/deploy/systemd/bincio-sync.timer new file mode 100644 index 0000000..838554e --- /dev/null +++ b/deploy/systemd/bincio-sync.timer @@ -0,0 +1,14 @@ +[Unit] +Description=BincioActivity Strava sync — every 3 hours +Documentation=https://github.com/bincio/bincio-activity + +[Timer] +# Fire at 00:00, 03:00, 06:00, 09:00, 12:00, 15:00, 18:00, 21:00 UTC +OnCalendar=*-*-* 00,03,06,09,12,15,18,21:00:00 +# Catch up if the VPS was offline during a scheduled run +Persistent=true +# Spread load within a 2-minute window to avoid exact midnight spikes +RandomizedDelaySec=120 + +[Install] +WantedBy=timers.target diff --git a/deploy/systemd/sync.env.example b/deploy/systemd/sync.env.example new file mode 100644 index 0000000..beeb6fd --- /dev/null +++ b/deploy/systemd/sync.env.example @@ -0,0 +1,7 @@ +# /etc/bincio/sync.env — secrets for bincio-sync.service +# Copy this file to /etc/bincio/sync.env and fill in the values. +# chmod 600 /etc/bincio/sync.env + +# Must match the --sync-secret / BINCIO_SYNC_SECRET value passed to `bincio serve`. +# Generate with: openssl rand -hex 32 +BINCIO_SYNC_SECRET=your-secret-here diff --git a/site/src/pages/admin/index.astro b/site/src/pages/admin/index.astro index 25124ff..2d7cdfc 100644 --- a/site/src/pages/admin/index.astro +++ b/site/src/pages/admin/index.astro @@ -129,6 +129,9 @@ import Base from '../../layouts/Base.astro'; const stravaNote = u.originals_strava_mb > 0 ? `(${fmt(u.originals_strava_mb)} Strava)` : ''; + const suspendBtn = u.suspended + ? `` + : ``; const actionButtons = u.in_db ? ` + ${suspendBtn} ` + >Reset data + ` : `