This commit is contained in:
2026-10-21 12:11:21 -04:00
parent ec36013313
commit b340ec3814
6 changed files with 275 additions and 0 deletions
+49
View File
@@ -34,6 +34,9 @@ PBKDF2_ITERATIONS = 600_000
MAX_SESSIONS_PER_PLAYER = 10
LOGIN_MAX_FAILURES = 5
LOGIN_LOCKOUT_MS = 15 * 60 * 1000
MAX_CHAT_LENGTH = 200
CHAT_HISTORY_LIMIT = 100
CHAT_COOLDOWN_MS = 2000
# Ordered from a freshly reset countdown to an almost-expired one.
# "share" is each zone's relative slice of the timer, so high-value zones are brief.
@@ -186,6 +189,14 @@ def load_state():
zone TEXT NOT NULL,
points INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS chat_messages (
id INTEGER PRIMARY KEY,
player_id TEXT NOT NULL REFERENCES players(id) DEFERRABLE INITIALLY DEFERRED,
name TEXT NOT NULL,
at INTEGER NOT NULL,
message TEXT NOT NULL CHECK (length(message) BETWEEN 1 AND 200)
);
CREATE INDEX IF NOT EXISTS chat_player_messages ON chat_messages (player_id, id);
""")
# Serialize initialization. A committed game row is the migration marker,
# not merely the existence of a possibly empty or interrupted database.
@@ -235,6 +246,15 @@ token_index = {th: pid for pid, p in state["players"].items() for th in p["token
failed_logins = {} # lowercase name -> (failure count, first failure ms)
def chat_messages():
with database() as connection:
rows = connection.execute(
"SELECT id, name, at, message FROM chat_messages ORDER BY id DESC LIMIT ?",
(CHAT_HISTORY_LIMIT,),
).fetchall()
return [dict(row) for row in reversed(rows)]
def find_player_by_name(name):
for pid, p in state["players"].items():
if p["name"].lower() == name.lower():
@@ -413,6 +433,8 @@ class Handler(BaseHTTPRequestHandler):
if path == "/api/leaderboard":
with lock:
return self.send_json(HTTPStatus.OK, {"leaderboard": public_leaderboard()})
if path == "/api/chat":
return self.send_json(HTTPStatus.OK, {"messages": chat_messages()})
if path == "/api/me":
with lock:
pid = self.authed_player()
@@ -444,6 +466,33 @@ class Handler(BaseHTTPRequestHandler):
except (ValueError, UnicodeDecodeError):
return self.send_json(HTTPStatus.BAD_REQUEST, {"error": "Invalid JSON body"})
if path == "/api/chat":
with lock:
pid = self.authed_player()
if pid is None:
return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Log in to send messages"})
message = data.get("message")
if not isinstance(message, str) or not (1 <= len(message.strip()) <= MAX_CHAT_LENGTH):
return self.send_json(HTTPStatus.BAD_REQUEST, {"error": f"Message must be 1-{MAX_CHAT_LENGTH} characters"})
message = message.strip()
if not message.isprintable():
return self.send_json(HTTPStatus.BAD_REQUEST, {"error": "Message contains invalid characters"})
with database() as connection:
connection.execute("BEGIN IMMEDIATE")
t = now_ms()
last = connection.execute(
"SELECT at FROM chat_messages WHERE player_id = ? ORDER BY id DESC LIMIT 1", (pid,),
).fetchone()
if last is not None and t - last["at"] < CHAT_COOLDOWN_MS:
return self.send_json(HTTPStatus.TOO_MANY_REQUESTS, {"error": "Please wait two seconds between messages"})
name = state["players"][pid]["name"]
cursor = connection.execute(
"INSERT INTO chat_messages (player_id, name, at, message) VALUES (?, ?, ?, ?)",
(pid, name, t, message),
)
posted = {"id": cursor.lastrowid, "name": name, "at": t, "message": message}
return self.send_json(HTTPStatus.CREATED, posted)
if path == "/api/register":
name, password, error = validate_credentials(data)
if error: