Admin interface

This commit is contained in:
2026-10-22 11:26:42 -04:00
parent 2a2d976013
commit 3b81cae024
6 changed files with 583 additions and 9 deletions
+191 -7
View File
@@ -1,6 +1,7 @@
"""The Button - a single-file, dependency-free game server (Python 3.9+)."""
import csv
import copy
import hashlib
import hmac
import json
@@ -237,8 +238,10 @@ def write_state(connection, state):
connection.execute("DELETE FROM sessions")
connection.execute("DELETE FROM players")
connection.executemany(
"INSERT INTO players (id, name, password_hash, created_at, subscribed) VALUES (?, ?, ?, ?, ?)",
[(pid, p["name"], p.get("password_hash"), p["created_at"], p.get("subscribed", False))
"""INSERT INTO players (id, name, password_hash, created_at, subscribed, is_admin, is_banned, is_deleted)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
[(pid, p["name"], p.get("password_hash"), p["created_at"], p.get("subscribed", False),
p.get("is_admin", False), p.get("is_banned", False), p.get("is_deleted", False))
for pid, p in state["players"].items()],
)
connection.executemany(
@@ -362,7 +365,20 @@ def load_state():
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
origin_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS chat_moderation (
message_id TEXT NOT NULL, at INTEGER NOT NULL,
PRIMARY KEY (message_id, at)
);
CREATE TABLE IF NOT EXISTS admin_audit (
id INTEGER PRIMARY KEY, at INTEGER NOT NULL,
actor_id TEXT NOT NULL, action TEXT NOT NULL, target_id TEXT NOT NULL
);
""")
player_columns = {row["name"] for row in connection.execute("PRAGMA table_info(players)")}
for field in ("is_admin", "is_banned", "is_deleted"):
if field not in player_columns:
# Fixed internal identifiers only; never interpolate request input.
connection.execute(f"ALTER TABLE players ADD COLUMN {field} INTEGER NOT NULL DEFAULT 0 CHECK ({field} IN (0, 1))")
columns = {row["name"] for row in connection.execute("PRAGMA table_info(chat_messages)")}
if "kind" not in columns:
connection.execute("ALTER TABLE chat_messages ADD COLUMN kind TEXT NOT NULL DEFAULT 'message'")
@@ -375,6 +391,8 @@ def load_state():
with DATA_FILE.open("r", encoding="utf-8") as f:
loaded = json.load(f)
for player in loaded["players"].values():
# Legacy JSON is not an administrator provisioning mechanism.
player.update(is_admin=False, is_banned=False, is_deleted=False)
if "token_hash" in player: # migrate pre-login data as well
player["token_hashes"] = [player.pop("token_hash")]
else:
@@ -394,6 +412,8 @@ def load_state():
player = dict(row)
pid = player.pop("id")
player["subscribed"] = bool(player["subscribed"])
for field in ("is_admin", "is_banned", "is_deleted"):
player[field] = bool(player[field])
player["token_hashes"] = []
loaded["players"][pid] = player
for row in connection.execute("SELECT * FROM sessions ORDER BY player_id, position"):
@@ -424,7 +444,8 @@ def prune_presence():
"""Must be called with the lock held. Presence is never persisted."""
cutoff = time.monotonic() - PRESENCE_TTL_SECONDS
for key, (seen, _) in list(presence.items()):
if seen <= cutoff or key[0] not in token_index:
player = state["players"].get(token_index.get(key[0]), {})
if seen <= cutoff or key[0] not in token_index or player.get("is_banned") or player.get("is_deleted"):
del presence[key]
@@ -450,6 +471,8 @@ def chat_messages():
UNION ALL
SELECT id, 'The Button', at, message, 'milestone', 1, rowid
FROM season_milestone_messages
) AS feed WHERE NOT EXISTS (
SELECT 1 FROM chat_moderation m WHERE m.message_id = CAST(feed.id AS TEXT) AND m.at = feed.at
) ORDER BY at DESC, source DESC, position DESC LIMIT ?""",
(CHAT_HISTORY_LIMIT,),
).fetchall()
@@ -458,7 +481,7 @@ def chat_messages():
def find_player_by_name(name):
for pid, p in state["players"].items():
if p["name"].lower() == name.lower():
if not p.get("is_deleted") and p["name"].lower() == name.lower():
return pid
return None
@@ -527,6 +550,12 @@ def check_ended(at=None):
SELECT ? || id, announced_at, message FROM community_milestones
WHERE announced_at IS NOT NULL""", (f"milestone:{season['id']}:",),
)
connection.execute(
"""INSERT OR IGNORE INTO chat_moderation (message_id, at)
SELECT ? || c.id, c.announced_at FROM community_milestones c
JOIN chat_moderation m ON m.message_id = 'milestone:' || c.id AND m.at = c.announced_at
WHERE c.announced_at IS NOT NULL""", (f"milestone:{season['id']}:",),
)
connection.execute("DELETE FROM community_milestones")
season = season_for_time(season["ends_at"])
connection.execute(
@@ -658,6 +687,7 @@ def player_summary(pid):
best = min(presses, key=lambda p: (-p["points"], p["remaining_ms"], p["at"]), default=None)
return {
"name": state["players"][pid]["name"],
"is_admin": bool(state["players"][pid].get("is_admin", False)),
"subscribed": state["players"][pid].get("subscribed", False),
"total_players": len(state["players"]),
"total_presses": len(state["presses"]),
@@ -720,7 +750,7 @@ def global_stats():
leaderboard = []
for pid in state["players"]:
s = player_summary(pid)
del s["presses"], s["subscribed"], s["personal_bests"], s["ranking"], s["achievements"]
del s["presses"], s["subscribed"], s["personal_bests"], s["ranking"], s["achievements"], s["is_admin"]
leaderboard.append(s)
leaderboard.sort(key=lambda s: (-s["total_points"], s["name"].lower()))
presses = state["presses"]
@@ -738,6 +768,116 @@ def global_stats():
}
def active_admin(player):
return bool(player.get("is_admin") and not player.get("is_banned") and not player.get("is_deleted"))
def revoke_player_sessions(pid):
for th, owner in list(token_index.items()):
if owner == pid:
del token_index[th]
prune_presence()
def admin_overview(actor):
scores = dict(ranked_players())
with database() as connection:
audit = [dict(row) for row in connection.execute(
"SELECT at, actor_id, action, target_id FROM admin_audit ORDER BY id DESC LIMIT 50")]
total_messages = connection.execute("SELECT COUNT(*) FROM chat_messages").fetchone()[0]
return {
"actor_id": actor, "season": season_summary(),
"game": {"ended": state["ended"], "deadline": state["deadline"],
"total_presses": len(state["presses"]), "stored_chat_messages": total_messages},
"players": [
{"id": pid, "name": p["name"], "created_at": p["created_at"],
"is_admin": bool(p.get("is_admin")), "is_banned": bool(p.get("is_banned")),
"sessions": len(p["token_hashes"]), **{k: scores[pid][k] for k in ("total_points", "press_count")}}
for pid, p in state["players"].items() if not p.get("is_deleted")
],
"messages": chat_messages(), "audit": audit,
}
class AdminActionError(ValueError):
def __init__(self, status, message):
super().__init__(message)
self.status = status
def admin_action(actor, data):
"""Caller holds lock and has checked admin authority; publish memory after commit only."""
action, target = data.get("action"), data.get("target_id")
if action not in ("set_admin", "set_banned", "delete_player", "delete_message"):
raise AdminActionError(HTTPStatus.BAD_REQUEST, "Unknown admin action")
if not isinstance(target, str) or not target or len(target) > 150:
raise AdminActionError(HTTPStatus.BAD_REQUEST, "Invalid target")
if action in ("set_admin", "set_banned") and not isinstance(data.get("value"), bool):
raise AdminActionError(HTTPStatus.BAD_REQUEST, "Expected a boolean value")
replacement = None
original_name = None
with database() as connection:
connection.execute("BEGIN IMMEDIATE")
if action == "delete_message":
at = data.get("at")
if not isinstance(at, int) or isinstance(at, bool):
raise AdminActionError(HTTPStatus.BAD_REQUEST, "Message timestamp required")
# Work with the same bounded, visible feed shown to the administrator.
message = next((m for m in chat_messages() if str(m["id"]) == target and m["at"] == at), None)
if message is None:
raise AdminActionError(HTTPStatus.NOT_FOUND, "Message not found in recent chat")
connection.execute("INSERT OR IGNORE INTO chat_moderation (message_id, at) VALUES (?, ?)", (target, at))
if target.isdecimal():
# Keep row/ordering/cooldown intact without retaining the removed text.
connection.execute("UPDATE chat_messages SET message = '[Message removed]' WHERE id = ? AND at = ?", (int(target), at))
else:
connection.execute("UPDATE community_milestones SET message = '[Message removed]' WHERE 'milestone:' || id = ? AND announced_at = ?", (target, at))
connection.execute("UPDATE season_milestone_messages SET message = '[Message removed]' WHERE id = ? AND at = ?", (target, at))
else:
player = state["players"].get(target)
if player is None or player.get("is_deleted"):
raise AdminActionError(HTTPStatus.NOT_FOUND, "Player not found")
harms_admin = action == "delete_player" or (action == "set_admin" and not data["value"]) or (action == "set_banned" and data["value"])
if target == actor and harms_admin:
raise AdminActionError(HTTPStatus.CONFLICT, "You cannot remove your own access")
if active_admin(player) and harms_admin and sum(active_admin(p) for p in state["players"].values()) <= 1:
raise AdminActionError(HTTPStatus.CONFLICT, "The last active administrator must be preserved")
if action == "set_admin" and data["value"] and player.get("is_banned"):
raise AdminActionError(HTTPStatus.CONFLICT, "Unban this player before granting admin access")
replacement = copy.deepcopy(state)
changed = replacement["players"][target]
if action == "set_admin":
changed["is_admin"] = data["value"]
# Reauthentication is required after any privilege change.
changed["token_hashes"] = []
elif action == "set_banned":
changed["is_banned"] = data["value"]
changed["token_hashes"] = []
else:
original_name = player["name"]
changed.update(name="Deleted player", password_hash=None, token_hashes=[], subscribed=False,
is_admin=False, is_banned=True, is_deleted=True)
# Preserve scored presses and FK integrity with an inert anonymized tombstone.
connection.execute("UPDATE chat_messages SET name = 'Deleted player', message = '[Account deleted]' WHERE player_id = ? AND kind = 'message'", (target,))
connection.execute("UPDATE chat_messages SET message = '[Press by deleted player]' WHERE player_id = ? AND kind = 'press'", (target,))
# Legacy archive standings lack IDs. Exact-name matches are anonymized conservatively.
for row in connection.execute("SELECT id, leaderboard FROM season_results").fetchall():
standings = json.loads(row["leaderboard"])
for score in standings:
if score["name"] == original_name:
score["name"] = "Deleted player"
connection.execute("UPDATE season_results SET leaderboard = ? WHERE id = ?", (json.dumps(standings), row["id"]))
write_state(connection, replacement)
connection.execute("INSERT INTO admin_audit (at, actor_id, action, target_id) VALUES (?, ?, ?, ?)",
(now_ms(), actor, action, target))
if replacement is not None:
state.clear()
state.update(replacement)
revoke_player_sessions(target)
if original_name is not None:
failed_logins.pop(original_name.lower(), None)
class Handler(BaseHTTPRequestHandler):
server_version = "TheButton/1.0"
@@ -783,10 +923,29 @@ class Handler(BaseHTTPRequestHandler):
def authed_player(self):
th = self.bearer_hash()
return token_index.get(th) if th else None
pid = token_index.get(th) if th else None
player = state["players"].get(pid)
return pid if player and not player.get("is_banned") and not player.get("is_deleted") else None
def require_admin(self):
pid = self.authed_player()
if pid is None:
self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Log in as an administrator"})
return None
if not active_admin(state["players"][pid]):
self.send_json(HTTPStatus.FORBIDDEN, {"error": "Administrator access required"})
return None
return pid
def do_GET(self):
path = self.path.split("?", 1)[0]
if path == "/api/admin":
with lock:
actor = self.require_admin()
if actor is None:
return
check_ended()
return self.send_json(HTTPStatus.OK, admin_overview(actor))
if path == "/api/state":
with lock:
check_ended()
@@ -841,6 +1000,26 @@ class Handler(BaseHTTPRequestHandler):
def do_POST(self):
path = self.path.split("?", 1)[0]
if path.startswith("/api/admin"):
# Authorization precedes parsing/lookup; unknown admin paths also deny by default.
with lock:
actor = self.require_admin()
if actor is None:
return
if path != "/api/admin/action":
return self.send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"})
try:
data = self.read_json()
if self.headers.get("Sec-Fetch-Site") == "cross-site":
return self.send_json(HTTPStatus.FORBIDDEN, {"error": "Cross-site admin requests are not allowed"})
admin_action(actor, data)
except AdminActionError as error:
return self.send_json(error.status, {"error": str(error)})
except sqlite3.Error:
return self.send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "Admin action could not be saved; no changes were applied"})
except (ValueError, UnicodeDecodeError):
return self.send_json(HTTPStatus.BAD_REQUEST, {"error": "Invalid request body"})
return self.send_json(HTTPStatus.OK, {"ok": True})
try:
data = self.read_json()
except (ValueError, UnicodeDecodeError):
@@ -849,7 +1028,7 @@ class Handler(BaseHTTPRequestHandler):
if path == "/api/presence":
with lock:
th = self.bearer_hash()
if th not in token_index:
if self.authed_player() is None:
return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Log in to share your presence"})
tab = data.get("tab_id")
active, in_chat = data.get("active"), data.get("in_chat", False)
@@ -928,6 +1107,9 @@ class Handler(BaseHTTPRequestHandler):
stored = state["players"][pid].get("password_hash") if pid else None
ok = verify_password(password, stored or DUMMY_PASSWORD_HASH) and stored is not None
with lock:
current_player = state["players"].get(pid)
ok = bool(ok and current_player and not current_player.get("is_banned")
and not current_player.get("is_deleted") and current_player.get("password_hash") == stored)
if not ok:
count, first = failed_logins.get(key, (0, 0))
if now_ms() - first >= LOGIN_LOCKOUT_MS:
@@ -966,6 +1148,8 @@ class Handler(BaseHTTPRequestHandler):
if entry["email"].lower() not in subscribed_emails:
add_subscriber(entry)
with lock:
if self.authed_player() != pid:
return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Log in to join the mailing list"})
state["players"][pid]["subscribed"] = True
save_state(state)
return self.send_json(HTTPStatus.OK, {"ok": True})