feat: scheduled Strava sync + admin suspend/delete account
- Add bincio sync-strava command: headless multi-user Strava sync
designed for systemd timer. Discovers users via strava_token.json,
skips users without their own strava_credentials.json, respects
Strava visibility (only_me → unlisted). Treats 404 stream errors as
no-GPS activities rather than retrying every run.
- Add deploy/systemd/bincio-sync.{service,timer}: runs every 3 hours,
Persistent=true to catch up after downtime.
- Add POST /api/internal/rebuild: webhook for sync timer to trigger
site rebuild, authenticated via X-Sync-Secret header.
- Add suspended column to users table with auto-migration on open_db.
Suspended users are blocked at login and session lookup (covers both
activity site and wiki, which share instance.db).
- Add POST /api/admin/users/{handle}/suspend|unsuspend and
DELETE /api/admin/users/{handle}/account endpoints.
- Admin panel: Suspend/Unsuspend toggle, Del account button, suspended
badge on user row.
This commit is contained in:
@@ -19,6 +19,7 @@ from bincio.serve.init_cmd import init # noqa: E402
|
|||||||
from bincio.serve.cli import serve # noqa: E402
|
from bincio.serve.cli import serve # noqa: E402
|
||||||
from bincio.dev import dev # noqa: E402
|
from bincio.dev import dev # noqa: E402
|
||||||
from bincio.reextract_cmd import reextract_originals # 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(extract)
|
||||||
main.add_command(render)
|
main.add_command(render)
|
||||||
@@ -28,3 +29,4 @@ main.add_command(init)
|
|||||||
main.add_command(serve)
|
main.add_command(serve)
|
||||||
main.add_command(dev)
|
main.add_command(dev)
|
||||||
main.add_command(reextract_originals)
|
main.add_command(reextract_originals)
|
||||||
|
main.add_command(sync_strava_cmd)
|
||||||
|
|||||||
+5
-1
@@ -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("--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("--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("--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,
|
def serve(data_dir: str, site_dir: Optional[str], host: str, port: int,
|
||||||
strava_client_id: Optional[str], strava_client_secret: Optional[str],
|
strava_client_id: Optional[str], strava_client_secret: Optional[str],
|
||||||
max_users: Optional[int], public_url: 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.
|
"""Start the bincio multi-user application server.
|
||||||
|
|
||||||
Handles auth, user management, and write operations.
|
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()
|
srv.webroot = Path(webroot).expanduser().resolve()
|
||||||
if dem_url:
|
if dem_url:
|
||||||
srv.dem_url = dem_url
|
srv.dem_url = dem_url
|
||||||
|
if sync_secret:
|
||||||
|
srv.sync_secret = sync_secret
|
||||||
|
|
||||||
db = open_db(dd)
|
db = open_db(dd)
|
||||||
current_limit = get_setting(db, "max_users")
|
current_limit = get_setting(db, "max_users")
|
||||||
|
|||||||
+24
-4
@@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
wiki_access INTEGER NOT NULL DEFAULT 1,
|
wiki_access INTEGER NOT NULL DEFAULT 1,
|
||||||
activity_access INTEGER NOT NULL DEFAULT 0,
|
activity_access INTEGER NOT NULL DEFAULT 0,
|
||||||
|
suspended INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at INTEGER NOT NULL
|
created_at INTEGER NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -89,6 +90,7 @@ class User:
|
|||||||
is_admin: bool
|
is_admin: bool
|
||||||
wiki_access: bool
|
wiki_access: bool
|
||||||
activity_access: bool
|
activity_access: bool
|
||||||
|
suspended: bool
|
||||||
created_at: int
|
created_at: int
|
||||||
|
|
||||||
|
|
||||||
@@ -115,6 +117,10 @@ def open_db(data_dir: Path) -> sqlite3.Connection:
|
|||||||
db.execute("PRAGMA journal_mode=WAL")
|
db.execute("PRAGMA journal_mode=WAL")
|
||||||
db.execute("PRAGMA foreign_keys=ON")
|
db.execute("PRAGMA foreign_keys=ON")
|
||||||
db.executescript(_SCHEMA)
|
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()
|
db.commit()
|
||||||
return db
|
return db
|
||||||
|
|
||||||
@@ -140,7 +146,8 @@ def create_user(
|
|||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return User(handle=handle, display_name=display_name, is_admin=is_admin,
|
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]:
|
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"]),
|
is_admin=bool(row["is_admin"]),
|
||||||
wiki_access=bool(row["wiki_access"]),
|
wiki_access=bool(row["wiki_access"]),
|
||||||
activity_access=bool(row["activity_access"]),
|
activity_access=bool(row["activity_access"]),
|
||||||
|
suspended=bool(row["suspended"]),
|
||||||
created_at=row["created_at"],
|
created_at=row["created_at"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def authenticate(db: sqlite3.Connection, handle: str, password: str) -> Optional[User]:
|
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(
|
row = db.execute(
|
||||||
"SELECT * FROM users WHERE handle = ?", (handle,)
|
"SELECT * FROM users WHERE handle = ?", (handle,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
@@ -166,12 +174,15 @@ def authenticate(db: sqlite3.Connection, handle: str, password: str) -> Optional
|
|||||||
return None
|
return None
|
||||||
if not bcrypt.checkpw(password.encode(), row["password_hash"].encode()):
|
if not bcrypt.checkpw(password.encode(), row["password_hash"].encode()):
|
||||||
return None
|
return None
|
||||||
|
if row["suspended"]:
|
||||||
|
return None
|
||||||
return User(
|
return User(
|
||||||
handle=row["handle"],
|
handle=row["handle"],
|
||||||
display_name=row["display_name"],
|
display_name=row["display_name"],
|
||||||
is_admin=bool(row["is_admin"]),
|
is_admin=bool(row["is_admin"]),
|
||||||
wiki_access=bool(row["wiki_access"]),
|
wiki_access=bool(row["wiki_access"]),
|
||||||
activity_access=bool(row["activity_access"]),
|
activity_access=bool(row["activity_access"]),
|
||||||
|
suspended=False,
|
||||||
created_at=row["created_at"],
|
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"],
|
return [User(handle=r["handle"], display_name=r["display_name"],
|
||||||
is_admin=bool(r["is_admin"]), wiki_access=bool(r["wiki_access"]),
|
is_admin=bool(r["is_admin"]), wiki_access=bool(r["wiki_access"]),
|
||||||
activity_access=bool(r["activity_access"]),
|
activity_access=bool(r["activity_access"]),
|
||||||
|
suspended=bool(r["suspended"]),
|
||||||
created_at=r["created_at"]) for r in rows]
|
created_at=r["created_at"]) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
@@ -196,6 +208,11 @@ def delete_user(db: sqlite3.Connection, handle: str) -> None:
|
|||||||
db.commit()
|
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]:
|
def get_member_tree(db: sqlite3.Connection) -> list[dict]:
|
||||||
"""Return users with their inviter handle and join timestamp.
|
"""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]:
|
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(
|
row = db.execute(
|
||||||
"SELECT s.handle, s.expires_at, u.display_name, u.is_admin, "
|
"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 "
|
"FROM sessions s JOIN users u ON s.handle = u.handle "
|
||||||
"WHERE s.token = ?",
|
"WHERE s.token = ?",
|
||||||
(token,),
|
(token,),
|
||||||
@@ -284,12 +301,15 @@ def get_session(db: sqlite3.Connection, token: str) -> Optional[User]:
|
|||||||
if row["expires_at"] < int(time.time()):
|
if row["expires_at"] < int(time.time()):
|
||||||
delete_session(db, token)
|
delete_session(db, token)
|
||||||
return None
|
return None
|
||||||
|
if row["suspended"]:
|
||||||
|
return None
|
||||||
return User(
|
return User(
|
||||||
handle=row["handle"],
|
handle=row["handle"],
|
||||||
display_name=row["display_name"],
|
display_name=row["display_name"],
|
||||||
is_admin=bool(row["is_admin"]),
|
is_admin=bool(row["is_admin"]),
|
||||||
wiki_access=bool(row["wiki_access"]),
|
wiki_access=bool(row["wiki_access"]),
|
||||||
activity_access=bool(row["activity_access"]),
|
activity_access=bool(row["activity_access"]),
|
||||||
|
suspended=False,
|
||||||
created_at=row["created_at"],
|
created_at=row["created_at"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+74
-1
@@ -162,6 +162,7 @@ strava_client_id: str = ""
|
|||||||
strava_client_secret: str = ""
|
strava_client_secret: str = ""
|
||||||
public_url: str = "" # e.g. "https://yourdomain.com" — used for OAuth redirect URIs
|
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
|
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
|
_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")
|
@app.get("/api/activity/{activity_id}/geojson")
|
||||||
async def get_activity_geojson(
|
async def get_activity_geojson(
|
||||||
activity_id: str,
|
activity_id: str,
|
||||||
@@ -988,6 +1007,7 @@ async def admin_users(bincio_session: Optional[str] = Cookie(default=None)) -> J
|
|||||||
"handle": u.handle,
|
"handle": u.handle,
|
||||||
"display_name": u.display_name,
|
"display_name": u.display_name,
|
||||||
"is_admin": u.is_admin,
|
"is_admin": u.is_admin,
|
||||||
|
"suspended": u.suspended,
|
||||||
"created_at": u.created_at,
|
"created_at": u.created_at,
|
||||||
} for u in users])
|
} for u in users])
|
||||||
|
|
||||||
@@ -1030,9 +1050,11 @@ async def admin_disk(bincio_session: Optional[str] = Cookie(default=None)) -> JS
|
|||||||
continue
|
continue
|
||||||
# leaked tmp zips
|
# leaked tmp zips
|
||||||
leaked = [f for f in user_dir.glob("tmp*.zip") if f.is_file()]
|
leaked = [f for f in user_dir.glob("tmp*.zip") if f.is_file()]
|
||||||
|
db_user = _get_user(db, user_dir.name)
|
||||||
users.append({
|
users.append({
|
||||||
"handle": user_dir.name,
|
"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),
|
"total_mb": _mb(user_dir),
|
||||||
"activities_mb": _mb(user_dir / "activities"),
|
"activities_mb": _mb(user_dir / "activities"),
|
||||||
"activities_count": _count(user_dir / "activities", "*.json"),
|
"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})
|
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")
|
@app.post("/api/admin/users/{handle}/rebuild")
|
||||||
async def admin_rebuild(
|
async def admin_rebuild(
|
||||||
handle: str,
|
handle: str,
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -129,6 +129,9 @@ import Base from '../../layouts/Base.astro';
|
|||||||
const stravaNote = u.originals_strava_mb > 0
|
const stravaNote = u.originals_strava_mb > 0
|
||||||
? `<span class="text-zinc-600 text-xs ml-1">(${fmt(u.originals_strava_mb)} Strava)</span>`
|
? `<span class="text-zinc-600 text-xs ml-1">(${fmt(u.originals_strava_mb)} Strava)</span>`
|
||||||
: '';
|
: '';
|
||||||
|
const suspendBtn = u.suspended
|
||||||
|
? `<button class="unsuspend-btn text-xs px-3 py-1.5 rounded-lg bg-amber-900/60 hover:bg-amber-800 text-amber-300 transition-colors" data-handle="${u.handle}" title="Re-enable this account">Unsuspend</button>`
|
||||||
|
: `<button class="suspend-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-amber-900 hover:text-amber-300 text-zinc-400 transition-colors" data-handle="${u.handle}" title="Block login and invalidate all sessions">Suspend</button>`;
|
||||||
const actionButtons = u.in_db
|
const actionButtons = u.in_db
|
||||||
? `<button
|
? `<button
|
||||||
class="diag-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
|
class="diag-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||||
@@ -150,11 +153,17 @@ import Base from '../../layouts/Base.astro';
|
|||||||
data-handle="${u.handle}"
|
data-handle="${u.handle}"
|
||||||
title="Generate a one-time password reset code for this user"
|
title="Generate a one-time password reset code for this user"
|
||||||
>Reset pwd</button>
|
>Reset pwd</button>
|
||||||
|
${suspendBtn}
|
||||||
<button
|
<button
|
||||||
class="delete-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-red-900 hover:text-red-300 text-zinc-400 transition-colors"
|
class="delete-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-red-900 hover:text-red-300 text-zinc-400 transition-colors"
|
||||||
data-handle="${u.handle}"
|
data-handle="${u.handle}"
|
||||||
title="Wipe all activities, originals, edits and images — account is kept"
|
title="Wipe all activities, originals, edits and images — account is kept"
|
||||||
>Reset data</button>`
|
>Reset data</button>
|
||||||
|
<button
|
||||||
|
class="del-account-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-red-900 hover:text-red-300 text-zinc-400 transition-colors"
|
||||||
|
data-handle="${u.handle}"
|
||||||
|
title="Delete the database account — data directory is NOT removed"
|
||||||
|
>Del account</button>`
|
||||||
: `<button
|
: `<button
|
||||||
class="diag-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
|
class="diag-btn text-xs px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-zinc-200 transition-colors"
|
||||||
data-handle="${u.handle}"
|
data-handle="${u.handle}"
|
||||||
@@ -170,7 +179,9 @@ import Base from '../../layouts/Base.astro';
|
|||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex items-center gap-1">
|
<div class="flex items-center gap-1">
|
||||||
<a href="/u/${u.handle}/" class="text-white hover:text-zinc-300">@${u.handle}</a>
|
<a href="/u/${u.handle}/" class="text-white hover:text-zinc-300">@${u.handle}</a>
|
||||||
${ghostBadge}${leaked}
|
${ghostBadge}
|
||||||
|
${u.suspended ? '<span class="text-amber-400 text-xs ml-1 font-medium">suspended</span>' : ''}
|
||||||
|
${leaked}
|
||||||
</div>
|
</div>
|
||||||
${bar(rowPct)}
|
${bar(rowPct)}
|
||||||
</td>
|
</td>
|
||||||
@@ -336,6 +347,62 @@ import Base from '../../layouts/Base.astro';
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
tbodyEl.querySelectorAll<HTMLButtonElement>('.suspend-btn, .unsuspend-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const h = btn.dataset.handle!;
|
||||||
|
const isSuspend = btn.classList.contains('suspend-btn');
|
||||||
|
const action = isSuspend ? 'Suspend' : 'Unsuspend';
|
||||||
|
if (!confirm(`${action} @${h}?${isSuspend ? ' All active sessions will be invalidated.' : ''}`)) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '…';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/admin/users/${h}/${isSuspend ? 'suspend' : 'unsuspend'}`, {
|
||||||
|
method: 'POST', credentials: 'include',
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
setTimeout(() => load(), 400);
|
||||||
|
} else {
|
||||||
|
const d = await r.json().catch(() => ({}));
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Error: ' + (d.detail ?? r.status);
|
||||||
|
btn.classList.add('text-red-400');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Error';
|
||||||
|
btn.classList.add('text-red-400');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
tbodyEl.querySelectorAll<HTMLButtonElement>('.del-account-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
const h = btn.dataset.handle!;
|
||||||
|
if (!confirm(`Delete the account for @${h}?\n\nThe data directory will NOT be removed — use "Reset data" first if you want to wipe it.\n\nThis cannot be undone.`)) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'Deleting…';
|
||||||
|
try {
|
||||||
|
const r = await fetch(`/api/admin/users/${h}/account`, {
|
||||||
|
method: 'DELETE', credentials: 'include',
|
||||||
|
});
|
||||||
|
if (r.ok) {
|
||||||
|
btn.textContent = 'Deleted';
|
||||||
|
btn.classList.add('text-green-500');
|
||||||
|
setTimeout(() => load(), 1000);
|
||||||
|
} else {
|
||||||
|
const d = await r.json().catch(() => ({}));
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Error: ' + (d.detail ?? r.status);
|
||||||
|
btn.classList.add('text-red-400');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'Error';
|
||||||
|
btn.classList.add('text-red-400');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
tbodyEl.querySelectorAll<HTMLButtonElement>('.rmdir-btn').forEach(btn => {
|
tbodyEl.querySelectorAll<HTMLButtonElement>('.rmdir-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
const h = btn.dataset.handle!;
|
const h = btn.dataset.handle!;
|
||||||
|
|||||||
Reference in New Issue
Block a user