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