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
+24 -4
View File
@@ -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"],
)