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:
+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"],
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user