This commit is contained in:
2026-10-21 11:53:46 -04:00
parent eb6f6b3f3d
commit 77ec52b053
4 changed files with 336 additions and 16 deletions
+119 -13
View File
@@ -7,8 +7,10 @@ import json
import os
import re
import secrets
import sqlite3
import threading
import time
from contextlib import contextmanager
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
@@ -16,6 +18,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parent
STATIC_DIR = ROOT / "static"
DATA_FILE = Path(os.environ.get("BUTTON_DATA_FILE", ROOT / "data.json"))
DATABASE_FILE = Path(os.environ.get("BUTTON_DATABASE_FILE", DATA_FILE.with_suffix(".sqlite3")))
MAILING_LIST_FILE = Path(os.environ.get("BUTTON_MAILING_LIST_FILE", ROOT / "mailing_list.csv"))
MAILING_LIST_FIELDS = ["subscribed_at", "player", "first_name", "last_name", "email", "phone"]
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
@@ -105,26 +108,129 @@ def new_game():
}
@contextmanager
def database():
"""Use a separate connection per operation, with atomic writes and FK checks."""
connection = sqlite3.connect(DATABASE_FILE)
connection.row_factory = sqlite3.Row
try:
connection.execute("PRAGMA foreign_keys = ON")
with connection:
yield connection
finally:
connection.close()
def write_state(connection, state):
"""Persist a complete snapshot inside the caller's transaction."""
connection.execute("DELETE FROM presses")
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))
for pid, p in state["players"].items()],
)
connection.executemany(
"INSERT INTO sessions (player_id, position, token_hash) VALUES (?, ?, ?)",
[(pid, position, token_hash) for pid, p in state["players"].items()
for position, token_hash in enumerate(p["token_hashes"])],
)
connection.executemany(
"INSERT INTO presses (position, player_id, at, remaining_ms, zone, points) VALUES (?, ?, ?, ?, ?, ?)",
[(position, p["player_id"], p["at"], p["remaining_ms"], p["zone"], p["points"])
for position, p in enumerate(state["presses"])],
)
connection.execute(
"""INSERT INTO game (id, duration_ms, started_at, deadline, ended, ended_at)
VALUES (1, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET duration_ms = excluded.duration_ms,
started_at = excluded.started_at, deadline = excluded.deadline,
ended = excluded.ended, ended_at = excluded.ended_at""",
(state["duration_ms"], state["started_at"], state["deadline"], state["ended"], state["ended_at"]),
)
def load_state():
if DATA_FILE.exists():
with DATA_FILE.open("r", encoding="utf-8") as f:
return json.load(f)
state = new_game()
save_state(state)
return state
if DATABASE_FILE.resolve() == DATA_FILE.resolve():
raise ValueError("BUTTON_DATABASE_FILE must differ from the legacy BUTTON_DATA_FILE")
DATABASE_FILE.parent.mkdir(parents=True, exist_ok=True)
with database() as connection:
connection.executescript("""
CREATE TABLE IF NOT EXISTS game (
id INTEGER PRIMARY KEY CHECK (id = 1),
duration_ms INTEGER NOT NULL,
started_at INTEGER NOT NULL,
deadline INTEGER NOT NULL,
ended INTEGER NOT NULL CHECK (ended IN (0, 1)),
ended_at INTEGER
);
CREATE TABLE IF NOT EXISTS players (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
password_hash TEXT,
created_at INTEGER NOT NULL,
subscribed INTEGER NOT NULL CHECK (subscribed IN (0, 1))
);
CREATE TABLE IF NOT EXISTS sessions (
player_id TEXT NOT NULL REFERENCES players(id),
position INTEGER NOT NULL,
token_hash TEXT NOT NULL UNIQUE,
PRIMARY KEY (player_id, position)
);
CREATE TABLE IF NOT EXISTS presses (
position INTEGER PRIMARY KEY,
player_id TEXT NOT NULL REFERENCES players(id),
at INTEGER NOT NULL,
remaining_ms INTEGER NOT NULL,
zone TEXT NOT NULL,
points INTEGER NOT NULL
);
""")
# Serialize initialization. A committed game row is the migration marker,
# not merely the existence of a possibly empty or interrupted database.
connection.execute("BEGIN IMMEDIATE")
game = connection.execute("SELECT * FROM game WHERE id = 1").fetchone()
if game is None:
if DATA_FILE.exists():
with DATA_FILE.open("r", encoding="utf-8") as f:
loaded = json.load(f)
for player in loaded["players"].values():
if "token_hash" in player: # migrate pre-login data as well
player["token_hashes"] = [player.pop("token_hash")]
else:
loaded = new_game()
# Any read/validation/SQL failure aborts startup without consuming the
# JSON source or committing a partial game. The next start can retry.
write_state(connection, loaded)
return loaded
loaded = dict(game)
del loaded["id"]
loaded["ended"] = bool(loaded["ended"])
loaded["players"] = {}
for row in connection.execute("SELECT * FROM players ORDER BY rowid"):
player = dict(row)
pid = player.pop("id")
player["subscribed"] = bool(player["subscribed"])
player["token_hashes"] = []
loaded["players"][pid] = player
for row in connection.execute("SELECT * FROM sessions ORDER BY player_id, position"):
loaded["players"][row["player_id"]]["token_hashes"].append(row["token_hash"])
loaded["presses"] = []
for row in connection.execute("SELECT * FROM presses ORDER BY position"):
press = dict(row)
del press["position"]
loaded["presses"].append(press)
return loaded
def save_state(state):
tmp = DATA_FILE.with_suffix(".tmp")
with tmp.open("w", encoding="utf-8") as f:
json.dump(state, f)
os.replace(tmp, DATA_FILE)
with database() as connection:
write_state(connection, state)
state = load_state()
for _p in state["players"].values():
if "token_hash" in _p: # migrate pre-login data
_p["token_hashes"] = [_p.pop("token_hash")]
token_index = {th: pid for pid, p in state["players"].items() for th in p["token_hashes"]}
failed_logins = {} # lowercase name -> (failure count, first failure ms)