Admin interface
This commit is contained in:
@@ -27,10 +27,46 @@ Every press records the zone, the points, and the exact remaining time in millis
|
||||
|
||||
Logged-in players can also join the mailing list.
|
||||
|
||||
The Chat section is a simple public chatroom. Logged-in players can post plain-text messages (up to 200 characters, one message every two seconds). The latest 100 messages are displayed oldest first and refresh every five seconds while Chat is open. Messages persist in SQLite across restarts, including after the game ends. There are no editing, deletion, or moderation controls yet.
|
||||
The Chat section is a simple public chatroom. Logged-in players can post plain-text messages (up to 200 characters, one message every two seconds). The latest 100 messages are displayed oldest first and refresh every five seconds while Chat is open. Messages persist in SQLite across restarts, including after the game ends. Administrators can remove messages through the Admin dashboard; players cannot edit messages.
|
||||
|
||||
Every successful button press also posts an automatic chat announcement with the player's name, exact time remaining, and points earned. These announcements are saved with the press and do not count toward the player's chat cooldown.
|
||||
|
||||
## Administrator dashboard
|
||||
|
||||
Administrator controls are available in the **Admin** section after signing in with an admin account. The server checks administrator permission on every dashboard request and action; hiding the menu is not the authorization boundary. Public registration cannot grant admin access, and legacy JSON imports do not import privileged flags.
|
||||
|
||||
### Assigning the first administrator
|
||||
|
||||
1. Upgrade/start the server once so it adds the new database fields, then create a regular account if needed.
|
||||
2. **Stop the server and back up the SQLite database.** The game uses an in-memory snapshot; editing flags while it runs is unsupported and can be overwritten.
|
||||
3. Open the configured SQLite database with a trusted local SQLite editor. In Docker, the database lives in the persistent `/data` volume; stop the service before editing/copying it. Do not modify the legacy JSON backup.
|
||||
4. Find the intended account's exact ID, then promote only that ID:
|
||||
|
||||
```sql
|
||||
SELECT id, name, is_admin, is_banned, is_deleted FROM players;
|
||||
UPDATE players SET is_admin = 1 WHERE id = 'REPLACE_WITH_EXACT_PLAYER_ID'
|
||||
AND is_banned = 0 AND is_deleted = 0;
|
||||
```
|
||||
|
||||
5. Verify exactly one intended row changed, save/close the database, and restart the server. Sign in and open **Admin**. No public bootstrap endpoint or default admin password exists.
|
||||
|
||||
The database fields `players.is_admin`, `players.is_banned`, and `players.is_deleted` are boolean integers constrained to 0/1 and default to 0. Existing SQLite accounts receive non-admin defaults; their flags persist across normal saves, restarts, and seasons. For emergency recovery use the same stopped-server procedure to restore access to a non-deleted account.
|
||||
|
||||
### Controls and safeguards
|
||||
|
||||
- **Overview:** current season/game status, player count, season presses, session counts, and stored chat-row count. Password hashes, session tokens, and mailing-list contact data are never returned by the dashboard.
|
||||
- **Admin access:** grant/revoke admin status for another account. Any role change revokes that account's current sessions, requiring login again.
|
||||
- **Ban/unban:** bans immediately revoke all sessions and presence and block login and authenticated actions. Existing scores and messages remain. Unbanning does not revive old sessions. Banned players cannot be promoted until unbanned. This is an account ban, not an IP ban; it does not prevent creating another account.
|
||||
- **Delete account:** irreversible logical deletion. Removes the display name, password hash, sessions, subscription flag, and privileges; anonymizes authored chat and press announcements and matching archived display names. The account disappears from admin account lists and its original name can be registered again. An inert `Deleted player` tombstone retains the internal ID/scored presses so historical scores, archives, and foreign keys remain consistent. Old archived standings have no player IDs, so exact duplicate names are conservatively anonymized together. Anonymized scores may remain on leaderboards.
|
||||
- **Remove chat:** removes a message from the visible feed and scrubs its text while retaining row identity/timestamps for cooldowns and stable ordering. Also supports automatic press/milestone messages. Suppression survives restarts and seasonal milestone archival; removing a milestone announcement does not undo the unlock. Only messages in the latest 100 visible feed are currently offered for moderation.
|
||||
- **Safety:** confirmation prompts for each action; cannot remove your own access or the last active administrator. Admin actions and audit writes commit together; failed actions do not partially update game memory. The last 50 successful actions appear in the dashboard. The SQLite `admin_audit` table retains action, actor/target IDs, and timestamp, not message bodies, passwords, or tokens.
|
||||
|
||||
Account deletion is **not** a comprehensive personal-data erasure tool: the separate mailing-list CSV, backups, exported data, and third-party copies are not edited automatically. Handle those separately when necessary. Audit identifiers and anonymized game history are deliberately retained. Dashboard controls refresh manually to avoid moving focused actions unexpectedly; access is still rechecked by the server each time. No game reset/end controls are included.
|
||||
|
||||
Admin access uses the existing bearer-token authentication, with cross-site fetch rejection on admin mutations and no permissive CORS. Use HTTPS behind a reverse proxy before exposing the service; administrators should log out on shared devices. The existing login failure limits apply, but there is no separate MFA or password reconfirmation flow yet.
|
||||
|
||||
Admin API: authenticated admin-only `GET /api/admin`; `POST /api/admin/action` accepts `{action, target_id, value?}` for `set_admin`, `set_banned`, and `delete_player`, or `{action: "delete_message", target_id, at}`. Boolean flags must be actual JSON booleans; IDs/timestamps must match the selected record. Anonymous requests receive 401, non-admin accounts 403, invalid inputs 400, missing targets 404, and protected actions 409. All JSON responses use `Cache-Control: no-store`.
|
||||
|
||||
## Seasons and champions
|
||||
|
||||
Chat supports emoji autocomplete: type `:` followed by a name (for example `:sm`, `:heart`, or `:fire:`), then choose a suggestion with the arrow keys and Enter/Tab, or click/tap it. Escape dismisses suggestions. Selection inserts the actual Unicode emoji at the cursor without sending the message; press Enter again or Send to post. Unknown shortcodes remain unchanged. Suggestions are a built-in curated list, with no external service, and respect the message length limit.
|
||||
|
||||
@@ -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})
|
||||
|
||||
+105
-1
@@ -12,6 +12,9 @@ let currentShareText = "";
|
||||
let selectedSection = null;
|
||||
let chatSignature = null;
|
||||
let chatLoading = false;
|
||||
let isAdmin = false;
|
||||
let adminSnapshot = null;
|
||||
let adminBusy = false;
|
||||
const PRESENCE_KEY = "thebutton-share-presence";
|
||||
let sharePresence = localStorage.getItem(PRESENCE_KEY) !== "false";
|
||||
let presenceTab = sessionStorage.getItem("thebutton-presence-tab");
|
||||
@@ -208,9 +211,11 @@ window.addEventListener("storage", (event) => {
|
||||
}
|
||||
if (event.key === PRESENCE_KEY || event.key === TOKEN_KEY) {
|
||||
sharePresence = localStorage.getItem(PRESENCE_KEY) !== "false";
|
||||
if (event.key === TOKEN_KEY) isAdmin = false;
|
||||
token = localStorage.getItem(TOKEN_KEY);
|
||||
updateButton();
|
||||
syncPresence();
|
||||
if (event.key === TOKEN_KEY && token) loadMe();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -221,6 +226,7 @@ function renderSections() {
|
||||
"seasons-section": true,
|
||||
"chat-section": true,
|
||||
"alerts-section": true,
|
||||
"admin-section": !!token && isAdmin,
|
||||
"stats-section": statsLoaded,
|
||||
rules: true,
|
||||
"share-controls": !!currentShareText,
|
||||
@@ -242,6 +248,7 @@ function renderSections() {
|
||||
$("chat-login").hidden = !!token;
|
||||
$("section-hint").hidden = selectedSection !== null;
|
||||
$("presence-opt-in").checked = sharePresence;
|
||||
if (!token || !isAdmin) clearAdminData();
|
||||
}
|
||||
|
||||
$("section-nav").addEventListener("click", (event) => {
|
||||
@@ -253,6 +260,7 @@ $("section-nav").addEventListener("click", (event) => {
|
||||
if (selectedSection === "leaderboard-section") loadLeaderboard();
|
||||
if (selectedSection === "chat-section") loadChat();
|
||||
if (selectedSection === "seasons-section") loadSeasons();
|
||||
if (selectedSection === "admin-section") loadAdmin();
|
||||
syncPresence();
|
||||
});
|
||||
|
||||
@@ -487,8 +495,11 @@ function renderAchievements(achievements) {
|
||||
|
||||
async function loadMe() {
|
||||
if (!token) return;
|
||||
const requestedToken = token;
|
||||
try {
|
||||
const me = await api("GET", "/api/me");
|
||||
if (token !== requestedToken) return;
|
||||
isAdmin = me.is_admin === true;
|
||||
$("my-name").textContent = me.name;
|
||||
$("my-points").textContent = me.total_points.toLocaleString();
|
||||
$("my-presses").textContent = me.press_count;
|
||||
@@ -531,12 +542,105 @@ async function loadMe() {
|
||||
} catch (e) {
|
||||
if (e.status === 401) {
|
||||
token = null;
|
||||
isAdmin = false;
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
updateButton();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function clearAdminData() {
|
||||
adminSnapshot = null;
|
||||
for (const id of ["admin-overview", "admin-players", "admin-messages", "admin-audit"]) $(id).replaceChildren();
|
||||
$("admin-status").textContent = "";
|
||||
$("admin-error").textContent = "";
|
||||
$("admin-player-filter").value = "";
|
||||
}
|
||||
|
||||
function adminButton(text, action, target, value, extra = {}) {
|
||||
const button = el("button", text, action === "delete_player" || action === "delete_message" ? "danger-button" : "");
|
||||
button.type = "button";
|
||||
button.disabled = adminBusy;
|
||||
button.addEventListener("click", async () => {
|
||||
const confirmation = action === "delete_player"
|
||||
? `Delete account ${extra.name}? Login credentials will be removed, messages anonymized, and historical scores kept as Deleted player. This cannot be undone.`
|
||||
: action === "delete_message" ? "Permanently remove this message from chat?"
|
||||
: `${text} for ${extra.name}? Existing sessions will be revoked and the player must log in again.`;
|
||||
if (adminBusy || !window.confirm(confirmation)) return;
|
||||
adminBusy = true;
|
||||
$("admin-error").textContent = "";
|
||||
document.querySelectorAll("#admin-section button").forEach((b) => { b.disabled = true; });
|
||||
try {
|
||||
await api("POST", "/api/admin/action", { action, target_id: String(target),
|
||||
...(value === undefined ? {} : { value }), ...(extra.at === undefined ? {} : { at: extra.at }) });
|
||||
$("admin-status").textContent = "Action completed.";
|
||||
chatSignature = null;
|
||||
await loadAdmin();
|
||||
await loadMe();
|
||||
} catch (error) {
|
||||
$("admin-error").textContent = error.message;
|
||||
if (error.status === 401 || error.status === 403) { isAdmin = false; renderSections(); }
|
||||
} finally {
|
||||
adminBusy = false;
|
||||
document.querySelectorAll("#admin-section button").forEach((b) => { b.disabled = false; });
|
||||
renderAdminPlayers();
|
||||
}
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
function renderAdminPlayers() {
|
||||
if (!adminSnapshot) return;
|
||||
const query = $("admin-player-filter").value.toLowerCase();
|
||||
const players = adminSnapshot.players.filter((p) => p.name.toLowerCase().includes(query));
|
||||
$("admin-players").replaceChildren(...players.map((player) => {
|
||||
const item = el("li", undefined, "admin-card");
|
||||
item.append(el("strong", player.name), el("p", `${player.is_admin ? "Admin" : "Player"} / ${player.is_banned ? "Banned" : "Active"} / ${player.total_points} points / ${player.press_count} presses / ${player.sessions} sessions`, "fine-print"));
|
||||
const actions = el("div", undefined, "form-actions");
|
||||
if (player.id === adminSnapshot.actor_id) {
|
||||
actions.append(el("span", "Your account (protected)", "site-badge"));
|
||||
} else {
|
||||
actions.append(adminButton(player.is_admin ? "Remove admin" : "Make admin", "set_admin", player.id, !player.is_admin, { name: player.name }),
|
||||
adminButton(player.is_banned ? "Unban" : "Ban", "set_banned", player.id, !player.is_banned, { name: player.name }),
|
||||
adminButton("Delete account", "delete_player", player.id, undefined, { name: player.name }));
|
||||
}
|
||||
item.append(actions);
|
||||
return item;
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadAdmin() {
|
||||
if (!token || !isAdmin) return;
|
||||
const requestedToken = token;
|
||||
try {
|
||||
const data = await api("GET", "/api/admin");
|
||||
if (token !== requestedToken || !isAdmin) return;
|
||||
adminSnapshot = data;
|
||||
$("admin-overview").replaceChildren(...[
|
||||
[data.season.name, data.game.ended ? "Season finished" : "Season active"],
|
||||
[data.players.length.toLocaleString(), "Player accounts"],
|
||||
[data.game.total_presses.toLocaleString(), "Season presses"],
|
||||
[data.game.stored_chat_messages.toLocaleString(), "Stored chat rows"],
|
||||
].map(([value, label]) => { const card = el("div", undefined, "metric-card"); card.append(el("span", label), el("strong", value)); return card; }));
|
||||
renderAdminPlayers();
|
||||
$("admin-messages").replaceChildren(...data.messages.slice().reverse().map((message) => {
|
||||
const item = el("li", undefined, "admin-card");
|
||||
item.append(el("strong", message.name), el("small", `${new Date(message.at).toLocaleString()} / ${message.kind}`, "fine-print"),
|
||||
el("p", message.message), adminButton("Remove message", "delete_message", message.id, undefined, { at: message.at }));
|
||||
return item;
|
||||
}));
|
||||
fillTable($("admin-audit"), ["When", "Actor", "Action", "Target"], data.audit.map((entry) => [
|
||||
new Date(entry.at).toLocaleString(), entry.actor_id, entry.action, entry.target_id,
|
||||
]));
|
||||
$("admin-status").textContent = `Last refreshed ${new Date().toLocaleTimeString()}. Refresh manually to keep controls stable.`;
|
||||
} catch (error) {
|
||||
$("admin-error").textContent = error.message;
|
||||
if (error.status === 401 || error.status === 403) { isAdmin = false; renderSections(); }
|
||||
}
|
||||
}
|
||||
$("admin-refresh").addEventListener("click", () => { if (!adminBusy) loadAdmin(); });
|
||||
$("admin-player-filter").addEventListener("input", renderAdminPlayers);
|
||||
|
||||
function seasonDuration(ms) {
|
||||
const days = Math.floor(ms / 86400000);
|
||||
return `${days ? `${days}d ` : ""}${formatMs(ms % 86400000).slice(0, -3)}`;
|
||||
@@ -602,7 +706,7 @@ async function loadChat() {
|
||||
chatLoading = true;
|
||||
try {
|
||||
const { messages } = await api("GET", "/api/chat");
|
||||
const signature = messages.map((message) => message.id).join(",");
|
||||
const signature = JSON.stringify(messages);
|
||||
const list = $("chat-messages");
|
||||
if (signature !== chatSignature) {
|
||||
const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 40;
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
<button type="button" data-section="seasons-section" aria-controls="seasons-section" aria-expanded="false">Seasons</button>
|
||||
<button type="button" data-section="chat-section" aria-controls="chat-section" aria-expanded="false">Chat</button>
|
||||
<button type="button" data-section="alerts-section" aria-controls="alerts-section" aria-expanded="false">Alerts</button>
|
||||
<button type="button" data-section="admin-section" aria-controls="admin-section" aria-expanded="false" hidden>Admin</button>
|
||||
<button type="button" data-section="stats-section" aria-controls="stats-section" aria-expanded="false" hidden>Final stats</button>
|
||||
<button type="button" data-section="rules" aria-controls="rules" aria-expanded="false">Rules</button>
|
||||
<button type="button" data-section="share-controls" aria-controls="share-controls" aria-expanded="false" hidden>Share</button>
|
||||
@@ -61,6 +62,29 @@
|
||||
<p class="welcome-hint">Choose a section above to join in, compare scores, or say hello.</p>
|
||||
</div>
|
||||
|
||||
<section id="admin-section" data-panel hidden aria-labelledby="admin-heading">
|
||||
<header class="panel-header"><div class="header-topline"><p class="eyebrow">KEEP THE EXPERIMENT WELCOMING</p><span class="site-badge">Administrator</span></div>
|
||||
<h2 id="admin-heading">Control room.</h2>
|
||||
<p class="panel-description">Manage player access and keep the conversation on track. Every action is recorded.</p>
|
||||
</header>
|
||||
<div class="panel-body">
|
||||
<div class="form-actions"><button id="admin-refresh" type="button">Refresh dashboard</button></div>
|
||||
<p id="admin-status" role="status" class="fine-print"></p>
|
||||
<p id="admin-error" role="status" class="error"></p>
|
||||
<div id="admin-overview" class="score-grid"></div>
|
||||
<h3 class="subheading">Players</h3>
|
||||
<label class="field" for="admin-player-filter"><span>Filter by player name</span><input id="admin-player-filter" type="search" placeholder="Find a player" autocomplete="off"></label>
|
||||
<p class="fine-print">Role changes and bans revoke the player's sessions. You cannot remove your own access or the last active administrator. Delete account removes credentials and anonymizes the player; historical scores stay.</p>
|
||||
<ul id="admin-players" class="admin-list" aria-label="Player administration"></ul>
|
||||
<h3 class="subheading">Recent chat</h3>
|
||||
<p class="fine-print">Latest 100 visible messages, including automatic announcements. Removal is permanent; it does not undo presses or milestone unlocks.</p>
|
||||
<ul id="admin-messages" class="admin-list" aria-label="Chat moderation"></ul>
|
||||
<h3 class="subheading">Recent admin actions</h3>
|
||||
<p class="fine-print">Most recent 50 actions. IDs are internal account/message identifiers; no passwords or tokens are recorded.</p>
|
||||
<div class="table-scroll"><table id="admin-audit" aria-label="Administrator audit log"></table></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="seasons-section" data-panel hidden aria-labelledby="seasons-heading">
|
||||
<header class="panel-header"><p class="eyebrow">A NEW CHAPTER, FOUR TIMES A YEAR</p>
|
||||
<h2 id="seasons-heading">The seasonal leaderboard.</h2>
|
||||
|
||||
@@ -321,6 +321,17 @@ button:focus-visible, a:focus-visible, input:focus-visible, summary:focus-visibl
|
||||
.error { color: var(--red); }
|
||||
.panel-body > .error { color: var(--red); }
|
||||
.error:empty { display: none; }
|
||||
.admin-list { list-style: none; display: grid; gap: 0.75rem; padding: 0; max-height: 520px; overflow-y: auto; }
|
||||
.admin-card { background: var(--base); border: 1px solid var(--surface); border-radius: 12px; padding: 1rem; min-width: 0; overflow-wrap: anywhere; }
|
||||
.admin-card > strong { color: var(--lavender); font-size: 0.9rem; }
|
||||
.admin-card > small { display: block; margin-top: 0.3rem; }
|
||||
.admin-card > p { font-size: 0.85rem; }
|
||||
.admin-card .form-actions button { flex: 0 1 auto; font-size: 0.75rem; }
|
||||
button.danger-button:not(#the-button) { color: var(--red); border-color: #f38ba840; background: #f38ba808; }
|
||||
button.danger-button:not(#the-button):hover { background: #f38ba820; }
|
||||
#admin-overview { margin-top: 1rem; }
|
||||
#admin-overview .metric-card strong { font-size: 1.2rem; }
|
||||
#admin-audit td { max-width: 160px; overflow-wrap: anywhere; }
|
||||
#alerts-form { display: grid; justify-content: stretch; gap: 1rem; max-width: 460px; }
|
||||
.alert-option { display: flex; align-items: center; gap: 0.65rem; color: var(--text); font-size: 0.85rem; cursor: pointer; }
|
||||
.alert-option input { width: 18px; height: 18px; min-height: 0; padding: 0; margin: 0; accent-color: var(--mauve); flex-shrink: 0; }
|
||||
|
||||
+215
@@ -48,6 +48,7 @@ class PersistenceTests(unittest.TestCase):
|
||||
"token_hashes": ["first-session", "second-session"],
|
||||
"created_at": 123,
|
||||
"subscribed": True,
|
||||
"is_admin": False, "is_banned": False, "is_deleted": False,
|
||||
},
|
||||
"bob": {
|
||||
"name": "Bøb",
|
||||
@@ -55,6 +56,7 @@ class PersistenceTests(unittest.TestCase):
|
||||
"token_hashes": [],
|
||||
"created_at": 456,
|
||||
"subscribed": False,
|
||||
"is_admin": False, "is_banned": False, "is_deleted": False,
|
||||
},
|
||||
}
|
||||
state["presses"] = [
|
||||
@@ -67,6 +69,219 @@ class PersistenceTests(unittest.TestCase):
|
||||
def write_legacy(self, state):
|
||||
self.game.DATA_FILE.write_text(json.dumps(state), encoding="utf-8")
|
||||
|
||||
def admin_client(self):
|
||||
request = self.presence_client()
|
||||
self.game.state["players"]["alice"]["is_admin"] = True
|
||||
self.game.save_state(self.game.state)
|
||||
return request
|
||||
|
||||
def test_admin_endpoints_deny_guests_and_regular_players(self):
|
||||
request = self.admin_client()
|
||||
for path in ("/api/admin",):
|
||||
self.assertEqual(request(path=path)[0], 401)
|
||||
self.assertEqual(request(token="bob-token", path=path)[0], 403)
|
||||
for action in ("set_admin", "set_banned", "delete_player", "delete_message"):
|
||||
payload = {"action": action, "target_id": "alice", "value": True, "at": 1}
|
||||
self.assertEqual(request("POST", payload, path="/api/admin/action")[0], 401)
|
||||
self.assertEqual(request("POST", payload, "bob-token", "/api/admin/action")[0], 403)
|
||||
self.assertTrue(self.game.state["players"]["alice"]["is_admin"])
|
||||
|
||||
def test_admin_overview_does_not_expose_credentials_and_role_is_private(self):
|
||||
request = self.admin_client()
|
||||
status, dashboard = request(token="alice-token", path="/api/admin")
|
||||
self.assertEqual(status, 200)
|
||||
self.assertEqual(dashboard["actor_id"], "alice")
|
||||
self.assertEqual(len(dashboard["players"]), 2)
|
||||
raw = json.dumps(dashboard)
|
||||
for secret in ("password_hash", "token_hash", "existing-password-hash", "first-session"):
|
||||
self.assertNotIn(secret, raw)
|
||||
self.assertTrue(request(token="alice-token", path="/api/me")[1]["is_admin"])
|
||||
for row in self.game.public_leaderboard() + self.game.global_stats()["leaderboard"]:
|
||||
self.assertNotIn("is_admin", row)
|
||||
|
||||
def test_admin_role_changes_revoke_sessions_and_survive_save_reload(self):
|
||||
request = self.admin_client()
|
||||
payload = {"action": "set_admin", "target_id": "bob", "value": True}
|
||||
self.assertEqual(request("POST", payload, "alice-token", "/api/admin/action")[0], 200)
|
||||
self.assertTrue(self.game.state["players"]["bob"]["is_admin"])
|
||||
self.assertEqual(request(token="bob-token", path="/api/admin")[0], 401)
|
||||
self.assertEqual(self.game.state["players"]["bob"]["token_hashes"], [])
|
||||
self.game.save_state(self.game.state)
|
||||
self.game.state = self.game.load_state()
|
||||
self.assertTrue(self.game.state["players"]["bob"]["is_admin"])
|
||||
# Simulate a new authenticated session, then demote it from a different admin.
|
||||
self.game.token_index[self.game.hash_token("bob-new")] = "bob"
|
||||
self.game.state["players"]["bob"]["token_hashes"] = [self.game.hash_token("bob-new")]
|
||||
self.assertEqual(request(token="bob-new", path="/api/admin")[0], 200)
|
||||
self.assertEqual(request("POST", {**payload, "value": False}, "alice-token", "/api/admin/action")[0], 200)
|
||||
self.assertEqual(request(token="bob-new", path="/api/admin")[0], 401)
|
||||
|
||||
def test_ban_revokes_all_access_and_unban_requires_new_login(self):
|
||||
request = self.admin_client()
|
||||
self.game.state["players"]["bob"]["password_hash"] = self.game.hash_password("test-password")
|
||||
self.game.save_state(self.game.state)
|
||||
update = {"tab_id": "a" * 32, "active": True, "in_chat": True}
|
||||
self.assertEqual(request("POST", update, "bob-token")[0], 200)
|
||||
payload = {"action": "set_banned", "target_id": "bob", "value": True}
|
||||
self.assertEqual(request("POST", payload, "alice-token", "/api/admin/action")[0], 200)
|
||||
for path, method, data in (("/api/me", "GET", None), ("/api/press", "POST", {}),
|
||||
("/api/chat", "POST", {"message": "Banned"}), ("/api/presence", "POST", update),
|
||||
("/api/subscribe", "POST", {})):
|
||||
self.assertEqual(request(method, data, "bob-token", path)[0], 401)
|
||||
self.assertEqual(request()[1]["online_count"], 0)
|
||||
self.assertEqual(request("POST", {"name": "Bøb", "password": "test-password"}, path="/api/login")[0], 401)
|
||||
self.assertEqual(request("POST", {"action": "set_admin", "target_id": "bob", "value": True}, "alice-token", "/api/admin/action")[0], 409)
|
||||
self.assertEqual(request("POST", {**payload, "value": False}, "alice-token", "/api/admin/action")[0], 200)
|
||||
self.assertFalse(self.game.state["players"]["bob"]["is_banned"])
|
||||
self.assertEqual(request(token="bob-token", path="/api/me")[0], 401)
|
||||
self.assertEqual(request("POST", {"name": "Bøb", "password": "test-password"}, path="/api/login")[0], 200)
|
||||
|
||||
def test_admin_self_protection_and_last_active_admin(self):
|
||||
request = self.admin_client()
|
||||
for action, value in (("set_admin", False), ("set_banned", True), ("delete_player", None)):
|
||||
payload = {"action": action, "target_id": "alice", "value": value}
|
||||
self.assertEqual(request("POST", payload, "alice-token", "/api/admin/action")[0], 409)
|
||||
self.assertEqual(sum(self.game.active_admin(p) for p in self.game.state["players"].values()), 1)
|
||||
self.assertEqual(request("POST", {"action": "set_admin", "target_id": "bob", "value": "true"}, "alice-token", "/api/admin/action")[0], 400)
|
||||
self.assertEqual(request("POST", {"action": "delete_player", "target_id": "missing"}, "alice-token", "/api/admin/action")[0], 404)
|
||||
self.assertEqual(request("POST", {"action": "execute_sql", "target_id": "bob"}, "alice-token", "/api/admin/action")[0], 400)
|
||||
|
||||
def test_admin_deletes_chat_without_id_reuse_or_cooldown_bypass(self):
|
||||
request = self.admin_client()
|
||||
with self.game.database() as connection:
|
||||
at = self.game.now_ms()
|
||||
cursor = connection.execute("INSERT INTO chat_messages (player_id, name, at, message) VALUES (?, ?, ?, ?)",
|
||||
("bob", "Bøb", at, "Remove this"))
|
||||
mid = cursor.lastrowid
|
||||
payload = {"action": "delete_message", "target_id": str(mid), "at": at}
|
||||
self.assertEqual(request("POST", payload, "alice-token", "/api/admin/action")[0], 200)
|
||||
self.assertFalse(any(m["id"] == mid for m in self.game.chat_messages()))
|
||||
self.assertEqual(request("POST", {"message": "Too soon"}, "bob-token", "/api/chat")[0], 429)
|
||||
with self.game.database() as connection:
|
||||
self.assertEqual(connection.execute("SELECT message FROM chat_messages WHERE id = ?", (mid,)).fetchone()[0], "[Message removed]")
|
||||
self.game.load_state()
|
||||
self.assertFalse(any(m["id"] == mid for m in self.game.chat_messages()))
|
||||
self.assertEqual(request("POST", payload, "alice-token", "/api/admin/action")[0], 404)
|
||||
|
||||
def test_admin_can_remove_system_milestone_without_reannouncing_it(self):
|
||||
request = self.admin_client()
|
||||
at = self.game.now_ms()
|
||||
with self.game.database() as connection:
|
||||
connection.execute("UPDATE community_milestones SET reached_at = ?, announced_at = ?, message = ? WHERE id = 'ten_players'", (at, at, "Milestone to remove"))
|
||||
payload = {"action": "delete_message", "target_id": "milestone:ten_players", "at": at}
|
||||
self.assertEqual(request("POST", payload, "alice-token", "/api/admin/action")[0], 200)
|
||||
self.game.check_ended()
|
||||
self.assertFalse(any(m["message"] == "Milestone to remove" for m in self.game.chat_messages()))
|
||||
with self.game.database() as connection:
|
||||
self.assertIsNotNone(connection.execute("SELECT reached_at FROM community_milestones WHERE id = 'ten_players'").fetchone()[0])
|
||||
boundary = self.game.current_season(connection)["ends_at"]
|
||||
self.game.check_ended(boundary)
|
||||
self.assertFalse(any(m["at"] == at and m["kind"] == "milestone" for m in self.game.chat_messages()))
|
||||
|
||||
def test_admin_account_deletion_anonymizes_without_losing_game_history(self):
|
||||
request = self.admin_client()
|
||||
before_presses = copy.deepcopy(self.game.state["presses"])
|
||||
with self.game.database() as connection:
|
||||
season = self.game.current_season(connection)
|
||||
self.game.archive_season(connection, season, self.game.state, self.game.state["deadline"], "countdown")
|
||||
connection.execute("INSERT INTO chat_messages (player_id, name, at, message) VALUES (?, ?, ?, ?)", ("bob", "Bøb", 123, "My message"))
|
||||
self.assertEqual(request("POST", {"action": "delete_player", "target_id": "bob"}, "alice-token", "/api/admin/action")[0], 200)
|
||||
player = self.game.state["players"]["bob"]
|
||||
self.assertTrue(player["is_deleted"])
|
||||
self.assertEqual(player["name"], "Deleted player")
|
||||
self.assertIsNone(player["password_hash"])
|
||||
self.assertEqual(player["token_hashes"], [])
|
||||
self.assertIsNone(self.game.find_player_by_name("Bøb"))
|
||||
self.assertEqual(self.game.state["presses"], before_presses)
|
||||
self.assertEqual(request(token="bob-token", path="/api/me")[0], 401)
|
||||
self.assertNotIn("bob", [p["id"] for p in request(token="alice-token", path="/api/admin")[1]["players"]])
|
||||
self.assertNotIn("Bøb", json.dumps(self.game.seasonal_leaderboard(), ensure_ascii=False))
|
||||
self.assertTrue(any(m["message"] == "[Account deleted]" for m in self.game.chat_messages()))
|
||||
self.game.state = self.game.load_state()
|
||||
self.assertTrue(self.game.state["players"]["bob"]["is_deleted"])
|
||||
with self.game.database() as connection:
|
||||
self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), [])
|
||||
self.assertEqual(connection.execute("SELECT action, actor_id FROM admin_audit").fetchone()["action"], "delete_player")
|
||||
|
||||
def test_admin_action_rollback_does_not_revoke_or_mutate(self):
|
||||
request = self.admin_client()
|
||||
before = copy.deepcopy(self.game.state)
|
||||
with self.game.database() as connection:
|
||||
connection.execute("""CREATE TRIGGER fail_admin_audit BEFORE INSERT ON admin_audit
|
||||
BEGIN SELECT RAISE(ABORT, 'test rollback'); END""")
|
||||
with self.assertRaises(sqlite3.IntegrityError):
|
||||
self.game.admin_action("alice", {"action": "set_banned", "target_id": "bob", "value": True})
|
||||
self.assertEqual(self.game.state, before)
|
||||
self.assertIn(self.game.hash_token("bob-token"), self.game.token_index)
|
||||
self.assertEqual(request(token="bob-token", path="/api/me")[0], 200)
|
||||
with self.game.database() as connection:
|
||||
connection.execute("DROP TRIGGER fail_admin_audit")
|
||||
self.assertEqual(self.game.load_state(), before)
|
||||
|
||||
def test_public_registration_cannot_grant_admin_or_bypass_ban_flags(self):
|
||||
request = self.admin_client()
|
||||
data = {"name": "Newcomer", "password": "test-password", "is_admin": True, "is_banned": False}
|
||||
status, result = request("POST", data, path="/api/register")
|
||||
self.assertEqual(status, 201)
|
||||
pid = self.game.find_player_by_name("Newcomer")
|
||||
self.assertFalse(self.game.state["players"][pid].get("is_admin", False))
|
||||
self.assertEqual(request(token=result["token"], path="/api/admin")[0], 403)
|
||||
|
||||
def test_admin_schema_upgrade_and_database_provisioning(self):
|
||||
self.game.load_state()
|
||||
self.game.state = self.sample_state()
|
||||
self.game.save_state(self.game.state)
|
||||
with self.game.database() as connection:
|
||||
connection.execute("PRAGMA foreign_keys = OFF")
|
||||
connection.execute("""CREATE TABLE old_players (
|
||||
id TEXT PRIMARY KEY, name TEXT NOT NULL, password_hash TEXT,
|
||||
created_at INTEGER NOT NULL, subscribed INTEGER NOT NULL
|
||||
)""")
|
||||
connection.execute("INSERT INTO old_players SELECT id, name, password_hash, created_at, subscribed FROM players")
|
||||
connection.execute("DROP TABLE players")
|
||||
connection.execute("ALTER TABLE old_players RENAME TO players")
|
||||
loaded = self.game.load_state()
|
||||
self.assertFalse(any(p["is_admin"] for p in loaded["players"].values()))
|
||||
with self.game.database() as connection:
|
||||
connection.execute("UPDATE players SET is_admin = 1 WHERE id = ?", ("alice",))
|
||||
self.game.state = self.game.load_state()
|
||||
self.assertTrue(self.game.state["players"]["alice"]["is_admin"])
|
||||
self.game.save_state(self.game.state)
|
||||
self.assertTrue(self.game.load_state()["players"]["alice"]["is_admin"])
|
||||
with self.game.database() as connection:
|
||||
self.assertEqual(connection.execute("PRAGMA foreign_key_check").fetchall(), [])
|
||||
|
||||
def test_legacy_json_cannot_provision_administrators(self):
|
||||
legacy = self.sample_state()
|
||||
legacy["players"]["alice"].update(is_admin=True, is_banned=True, is_deleted=True)
|
||||
self.write_legacy(legacy)
|
||||
self.game.state = self.game.load_state()
|
||||
for field in ("is_admin", "is_banned", "is_deleted"):
|
||||
self.assertFalse(self.game.state["players"]["alice"][field])
|
||||
self.assertFalse(self.game.load_state()["players"]["alice"][field])
|
||||
|
||||
def test_admin_malformed_request_and_cross_site_mutation(self):
|
||||
request = self.admin_client()
|
||||
self.assertEqual(request("POST", {}, "alice-token", "/api/admin/unknown")[0], 404)
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), self.game.Handler)
|
||||
thread = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
self.addCleanup(httpd.server_close)
|
||||
self.addCleanup(thread.join)
|
||||
self.addCleanup(httpd.shutdown)
|
||||
headers = {"Authorization": "Bearer alice-token", "Content-Type": "application/json"}
|
||||
for body, extra, expected in (("{broken", {}, 400), ("[]", {}, 400),
|
||||
(json.dumps({"action": "set_banned", "target_id": "bob", "value": True}), {"Sec-Fetch-Site": "cross-site"}, 403)):
|
||||
connection = HTTPConnection(*httpd.server_address, timeout=5)
|
||||
try:
|
||||
connection.request("POST", "/api/admin/action", body, {**headers, **extra})
|
||||
response = connection.getresponse()
|
||||
self.assertEqual(response.status, expected)
|
||||
response.read()
|
||||
finally:
|
||||
connection.close()
|
||||
self.assertFalse(self.game.state["players"]["bob"]["is_banned"])
|
||||
|
||||
def season_fixture(self):
|
||||
at = int(datetime(2026, 10, 21, tzinfo=timezone.utc).timestamp() * 1000)
|
||||
with patch.object(self.game, "now_ms", return_value=at):
|
||||
|
||||
Reference in New Issue
Block a user