diff --git a/README.md b/README.md index 58fc943..8700c0c 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ 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. + ## Running ### Docker (recommended) @@ -90,6 +92,8 @@ Tip: `BUTTON_DURATION_SECONDS=60` makes for quick testing. | POST | `/api/press` | ✓ | Press the button | | GET | `/api/me` | ✓ | Your score, zone counts, and press history | | GET | `/api/leaderboard` | | Public player names, total points, and press counts | +| GET | `/api/chat` | | Latest 100 public chat messages | +| POST | `/api/chat` | ✓ | `{message}` → saved message; 200-character limit | | GET | `/api/stats` | | Global stats (403 until the game ends) | | POST | `/api/subscribe` | ✓ | `{first_name, email, last_name?, phone?}` | diff --git a/server.py b/server.py index 7802ab9..ca8875d 100644 --- a/server.py +++ b/server.py @@ -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: diff --git a/static/app.js b/static/app.js index 68ced97..c03dc04 100644 --- a/static/app.js +++ b/static/app.js @@ -10,11 +10,14 @@ let statsLoaded = false; let subscribed = false; let currentShareText = ""; let selectedSection = null; +let chatSignature = null; +let chatLoading = false; function renderSections() { const available = { "account-section": true, "leaderboard-section": true, + "chat-section": true, "stats-section": statsLoaded, rules: true, "share-controls": !!currentShareText, @@ -32,6 +35,8 @@ function renderSections() { panel.hidden = panel.id !== selectedSection; }); $("mailing-list").hidden = !token || subscribed; + $("chat-form").hidden = !token; + $("chat-login").hidden = !!token; $("section-hint").hidden = selectedSection !== null; } @@ -42,6 +47,7 @@ $("section-nav").addEventListener("click", (event) => { renderSections(); if (selectedSection === "account-section") loadMe(); if (selectedSection === "leaderboard-section") loadLeaderboard(); + if (selectedSection === "chat-section") loadChat(); }); async function api(method, path, body) { @@ -185,6 +191,7 @@ async function refreshState() { if (game.ended && !statsLoaded) loadStats(); updateButton(); await loadLeaderboard(); + if (selectedSection === "chat-section") await loadChat(); } function updateButton() { @@ -254,6 +261,65 @@ async function loadLeaderboard() { } } +async function loadChat() { + if (chatLoading) return; + chatLoading = true; + try { + const { messages } = await api("GET", "/api/chat"); + const signature = messages.map((message) => message.id).join(","); + const list = $("chat-messages"); + if (signature !== chatSignature) { + const atBottom = list.scrollHeight - list.scrollTop - list.clientHeight < 40; + const firstLoad = chatSignature === null; + const previousScroll = list.scrollTop; + list.replaceChildren(...messages.map((message) => { + const item = el("li"); + const avatar = el("span", Array.from(message.name.trim())[0]?.toUpperCase() || "?", "chat-avatar"); + avatar.setAttribute("aria-hidden", "true"); + const content = el("div", undefined, "chat-message-content"); + const heading = el("div", undefined, "chat-heading"); + const date = new Date(message.at); + const timestamp = el("time", date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })); + timestamp.dateTime = new Date(message.at).toISOString(); + timestamp.title = date.toLocaleString(); + heading.append(el("strong", message.name), timestamp); + // Names and messages are untrusted text, never HTML. + content.append(heading, el("p", message.message)); + item.append(avatar, content); + return item; + })); + chatSignature = signature; + list.scrollTop = firstLoad || atBottom ? list.scrollHeight : previousScroll; + } + $("chat-status").textContent = messages.length ? "" : "No messages yet. Start the conversation!"; + } catch { + $("chat-status").textContent = "Messages could not be refreshed. Retrying automatically..."; + } finally { + chatLoading = false; + } +} + +$("chat-form").addEventListener("submit", async (event) => { + event.preventDefault(); + const input = $("chat-input"); + $("chat-send").disabled = true; + $("chat-error").textContent = ""; + try { + await api("POST", "/api/chat", { message: input.value }); + input.value = ""; + await loadChat(); + } catch (error) { + $("chat-error").textContent = error.message; + if (error.status === 401) { + token = null; + localStorage.removeItem(TOKEN_KEY); + updateButton(); + } + } finally { + $("chat-send").disabled = false; + } +}); + async function loadStats() { try { const s = await api("GET", "/api/stats"); diff --git a/static/index.html b/static/index.html index 1a49c4f..8569fbe 100644 --- a/static/index.html +++ b/static/index.html @@ -36,6 +36,7 @@