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:
Davide Scaini
2026-05-08 10:36:21 +02:00
parent 680ef9d440
commit 12693dbd60
9 changed files with 465 additions and 8 deletions
+74 -1
View File
@@ -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,