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:
+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("--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")
|
||||
|
||||
+24
-4
@@ -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"],
|
||||
)
|
||||
|
||||
|
||||
+74
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user