Admin interface
This commit is contained in:
@@ -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})
|
||||
|
||||
Reference in New Issue
Block a user