This commit is contained in:
2026-10-18 14:23:38 -04:00
parent 7b2ff0ed63
commit 36572f8163
9 changed files with 1107 additions and 1107 deletions
+3 -3
View File
@@ -1,3 +1,3 @@
* *
!server.py !server.py
!static/ !static/
+5 -5
View File
@@ -1,5 +1,5 @@
data.json data.json
data.tmp data.tmp
mailing_list.csv mailing_list.csv
test-data.json test-data.json
__pycache__/ __pycache__/
+22 -22
View File
@@ -1,22 +1,22 @@
FROM python:3.12-slim FROM python:3.12-slim
ENV PYTHONUNBUFFERED=1 \ ENV PYTHONUNBUFFERED=1 \
BUTTON_HOST=0.0.0.0 \ BUTTON_HOST=0.0.0.0 \
BUTTON_PORT=8000 \ BUTTON_PORT=8000 \
BUTTON_DATA_FILE=/data/data.json \ BUTTON_DATA_FILE=/data/data.json \
BUTTON_MAILING_LIST_FILE=/data/mailing_list.csv BUTTON_MAILING_LIST_FILE=/data/mailing_list.csv
RUN useradd --system --uid 10001 --no-create-home button \ RUN useradd --system --uid 10001 --no-create-home button \
&& mkdir /data && chown button /data && mkdir /data && chown button /data
WORKDIR /app WORKDIR /app
COPY server.py ./ COPY server.py ./
COPY static ./static COPY static ./static
USER button USER button
VOLUME /data VOLUME /data
EXPOSE 8000 EXPOSE 8000
# Python as PID 1 ignores SIGTERM; SIGINT raises KeyboardInterrupt for a clean exit. # Python as PID 1 ignores SIGTERM; SIGINT raises KeyboardInterrupt for a clean exit.
STOPSIGNAL SIGINT STOPSIGNAL SIGINT
CMD ["python", "server.py"] CMD ["python", "server.py"]
+104 -104
View File
@@ -1,104 +1,104 @@
# The Button # The Button
A web game of patience and nerve. A 24-hour countdown resets every time any player presses the button. If the countdown ever reaches zero, the game is over forever, and the final global stats are published. A web game of patience and nerve. A 24-hour countdown resets every time any player presses the button. If the countdown ever reaches zero, the game is over forever, and the final global stats are published.
Inspired by Reddit's [The Button](https://en.wikipedia.org/wiki/The_Button_(Reddit)) experiment. Inspired by Reddit's [The Button](https://en.wikipedia.org/wiki/The_Button_(Reddit)) experiment.
## How to play ## How to play
1. Sign up with a name and password (or log back in). 1. Sign up with a name and password (or log back in).
2. Press the button. The countdown resets to 24 hours. 2. Press the button. The countdown resets to 24 hours.
3. You score points based on the zone the countdown was in when you pressed. The longer you wait, the more you score, but if everyone waits too long, the game ends. 3. You score points based on the zone the countdown was in when you pressed. The longer you wait, the more you score, but if everyone waits too long, the game ends.
### Zones ### Zones
Higher-value zones are shorter, so scoring big is risky. Higher-value zones are shorter, so scoring big is risky.
| Zone | Points | Duration (24h timer) | | Zone | Points | Duration (24h timer) |
|--------|-------:|---------------------:| |--------|-------:|---------------------:|
| Green | 0 | 12h 11m | | Green | 0 | 12h 11m |
| Blue | 2 | 6h 6m | | Blue | 2 | 6h 6m |
| Yellow | 4 | 3h 3m | | Yellow | 4 | 3h 3m |
| Red | 8 | 1h 31m | | Red | 8 | 1h 31m |
| Black | 16 | 46m | | Black | 16 | 46m |
| White | 32 | 23m | | White | 32 | 23m |
Every press records the zone, the points, and the exact remaining time in milliseconds. Players can view their own score, presses per zone, and press history at any time. Global stats (leaderboard, presses per zone, closest call, full press log) stay hidden until the game ends. Every press records the zone, the points, and the exact remaining time in milliseconds. Players can view their own score, presses per zone, and press history at any time. Global stats (leaderboard, presses per zone, closest call, full press log) stay hidden until the game ends.
Logged-in players can also join the mailing list. Logged-in players can also join the mailing list.
## Running ## Running
### Docker (recommended) ### Docker (recommended)
```sh ```sh
docker compose up -d --build docker compose up -d --build
``` ```
Open http://localhost:8000. Game data and the mailing list are stored in the `button-data` volume. Open http://localhost:8000. Game data and the mailing list are stored in the `button-data` volume.
```sh ```sh
docker compose logs -f # view logs docker compose logs -f # view logs
docker compose cp thebutton:/data/mailing_list.csv . # export the mailing list docker compose cp thebutton:/data/mailing_list.csv . # export the mailing list
docker compose down # stop (data is kept) docker compose down # stop (data is kept)
docker compose down -v # stop and delete all data (new game) docker compose down -v # stop and delete all data (new game)
``` ```
### Without Docker ### Without Docker
Requires Python 3.9+. No third-party packages. Requires Python 3.9+. No third-party packages.
```sh ```sh
python server.py python server.py
``` ```
Open http://127.0.0.1:8000. Delete `data.json` to start a new game. Open http://127.0.0.1:8000. Delete `data.json` to start a new game.
## Configuration ## Configuration
Set via environment variables (in Docker, add them under `environment:` in `compose.yaml`). Set via environment variables (in Docker, add them under `environment:` in `compose.yaml`).
| Variable | Default | Description | | Variable | Default | Description |
|-----------------------------|--------------------------|--------------------------------------| |-----------------------------|--------------------------|--------------------------------------|
| `BUTTON_DURATION_SECONDS` | `86400` | Countdown length. Zones scale with it. | | `BUTTON_DURATION_SECONDS` | `86400` | Countdown length. Zones scale with it. |
| `BUTTON_HOST` | `127.0.0.1` (`0.0.0.0` in Docker) | Address to listen on. | | `BUTTON_HOST` | `127.0.0.1` (`0.0.0.0` in Docker) | Address to listen on. |
| `BUTTON_PORT` | `8000` | Port to listen on. | | `BUTTON_PORT` | `8000` | Port to listen on. |
| `BUTTON_DATA_FILE` | `data.json` | Game state file. | | `BUTTON_DATA_FILE` | `data.json` | Game state file. |
| `BUTTON_MAILING_LIST_FILE` | `mailing_list.csv` | Mailing list file. | | `BUTTON_MAILING_LIST_FILE` | `mailing_list.csv` | Mailing list file. |
Tip: `BUTTON_DURATION_SECONDS=60` makes for quick testing. Tip: `BUTTON_DURATION_SECONDS=60` makes for quick testing.
## API ## API
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
|--------|------------------|------|-----------------------------------------------| |--------|------------------|------|-----------------------------------------------|
| GET | `/api/state` | | Deadline, server time, zones, whether ended | | GET | `/api/state` | | Deadline, server time, zones, whether ended |
| POST | `/api/register` | | `{name, password}` → `{name, token}` | | POST | `/api/register` | | `{name, password}` → `{name, token}` |
| POST | `/api/login` | | `{name, password}` → `{name, token}` | | POST | `/api/login` | | `{name, password}` → `{name, token}` |
| POST | `/api/logout` | ✓ | Revoke the current session token | | POST | `/api/logout` | ✓ | Revoke the current session token |
| POST | `/api/press` | ✓ | Press the button | | POST | `/api/press` | ✓ | Press the button |
| GET | `/api/me` | ✓ | Your score, zone counts, and press history | | GET | `/api/me` | ✓ | Your score, zone counts, and press history |
| GET | `/api/stats` | | Global stats (403 until the game ends) | | GET | `/api/stats` | | Global stats (403 until the game ends) |
| POST | `/api/subscribe` | ✓ | `{first_name, email, last_name?, phone?}` | | POST | `/api/subscribe` | ✓ | `{first_name, email, last_name?, phone?}` |
Authenticated requests use `Authorization: Bearer <token>`. Authenticated requests use `Authorization: Bearer <token>`.
## Security notes ## Security notes
- Passwords are hashed with PBKDF2-SHA256 (600k iterations); session tokens are stored only as SHA-256 hashes. - Passwords are hashed with PBKDF2-SHA256 (600k iterations); session tokens are stored only as SHA-256 hashes.
- Logins lock for 15 minutes after 5 failed attempts per name. - Logins lock for 15 minutes after 5 failed attempts per name.
- The server speaks plain HTTP. **Put it behind a TLS-terminating reverse proxy** (e.g. Caddy or nginx) before exposing it to the internet. - The server speaks plain HTTP. **Put it behind a TLS-terminating reverse proxy** (e.g. Caddy or nginx) before exposing it to the internet.
- Registration and mailing-list signup are not rate limited. - Registration and mailing-list signup are not rate limited.
## Project layout ## Project layout
``` ```
server.py Game server and API (Python standard library only) server.py Game server and API (Python standard library only)
static/ Frontend (HTML, CSS, vanilla JS) static/ Frontend (HTML, CSS, vanilla JS)
Dockerfile Container image Dockerfile Container image
compose.yaml Docker Compose service and data volume compose.yaml Docker Compose service and data volume
``` ```
## Credits ## Credits
Created by [Eric Meehan](https://www.eom.dev/). Built with [GitHub Copilot](https://github.com/features/copilot). Created by [Eric Meehan](https://www.eom.dev/). Built with [GitHub Copilot](https://github.com/features/copilot).
+11 -11
View File
@@ -1,11 +1,11 @@
services: services:
thebutton: thebutton:
build: . build: .
ports: ports:
- "8000:8000" - "8000:8000"
volumes: volumes:
- button-data:/data - button-data:/data
restart: unless-stopped restart: unless-stopped
volumes: volumes:
button-data: button-data:
+495 -495
View File
@@ -1,495 +1,495 @@
"""The Button - a single-file, dependency-free game server (Python 3.9+).""" """The Button - a single-file, dependency-free game server (Python 3.9+)."""
import csv import csv
import hashlib import hashlib
import hmac import hmac
import json import json
import os import os
import re import re
import secrets import secrets
import threading import threading
import time import time
from http import HTTPStatus from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
STATIC_DIR = ROOT / "static" STATIC_DIR = ROOT / "static"
DATA_FILE = Path(os.environ.get("BUTTON_DATA_FILE", ROOT / "data.json")) DATA_FILE = Path(os.environ.get("BUTTON_DATA_FILE", ROOT / "data.json"))
MAILING_LIST_FILE = Path(os.environ.get("BUTTON_MAILING_LIST_FILE", ROOT / "mailing_list.csv")) 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"] MAILING_LIST_FIELDS = ["subscribed_at", "player", "first_name", "last_name", "email", "phone"]
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
PHONE_RE = re.compile(r"^\+?[0-9 ()\-.]{7,25}$") PHONE_RE = re.compile(r"^\+?[0-9 ()\-.]{7,25}$")
HOST = os.environ.get("BUTTON_HOST", "127.0.0.1") HOST = os.environ.get("BUTTON_HOST", "127.0.0.1")
PORT = int(os.environ.get("BUTTON_PORT", "8000")) PORT = int(os.environ.get("BUTTON_PORT", "8000"))
DURATION_MS = int(float(os.environ.get("BUTTON_DURATION_SECONDS", 24 * 60 * 60)) * 1000) DURATION_MS = int(float(os.environ.get("BUTTON_DURATION_SECONDS", 24 * 60 * 60)) * 1000)
MAX_BODY_BYTES = 1024 MAX_BODY_BYTES = 1024
MAX_NAME_LEN = 24 MAX_NAME_LEN = 24
MIN_PASSWORD_LEN = 8 MIN_PASSWORD_LEN = 8
MAX_PASSWORD_LEN = 128 MAX_PASSWORD_LEN = 128
PBKDF2_ITERATIONS = 600_000 PBKDF2_ITERATIONS = 600_000
MAX_SESSIONS_PER_PLAYER = 10 MAX_SESSIONS_PER_PLAYER = 10
LOGIN_MAX_FAILURES = 5 LOGIN_MAX_FAILURES = 5
LOGIN_LOCKOUT_MS = 15 * 60 * 1000 LOGIN_LOCKOUT_MS = 15 * 60 * 1000
# Ordered from a freshly reset countdown to an almost-expired one. # 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. # "share" is each zone's relative slice of the timer, so high-value zones are brief.
ZONES = [ ZONES = [
{"name": "green", "points": 0, "share": 32}, {"name": "green", "points": 0, "share": 32},
{"name": "blue", "points": 2, "share": 16}, {"name": "blue", "points": 2, "share": 16},
{"name": "yellow", "points": 4, "share": 8}, {"name": "yellow", "points": 4, "share": 8},
{"name": "red", "points": 8, "share": 4}, {"name": "red", "points": 8, "share": 4},
{"name": "black", "points": 16, "share": 2}, {"name": "black", "points": 16, "share": 2},
{"name": "white", "points": 32, "share": 1}, {"name": "white", "points": 32, "share": 1},
] ]
_total_share = sum(z["share"] for z in ZONES) _total_share = sum(z["share"] for z in ZONES)
_upper = DURATION_MS _upper = DURATION_MS
for _z in ZONES: for _z in ZONES:
_z["remaining_from_ms"] = _upper _z["remaining_from_ms"] = _upper
_upper = round(_upper - DURATION_MS * _z["share"] / _total_share) _upper = round(_upper - DURATION_MS * _z["share"] / _total_share)
_z["remaining_to_ms"] = max(_upper, 0) _z["remaining_to_ms"] = max(_upper, 0)
ZONES[-1]["remaining_to_ms"] = 0 ZONES[-1]["remaining_to_ms"] = 0
STATIC_FILES = { STATIC_FILES = {
"/": ("index.html", "text/html; charset=utf-8"), "/": ("index.html", "text/html; charset=utf-8"),
"/index.html": ("index.html", "text/html; charset=utf-8"), "/index.html": ("index.html", "text/html; charset=utf-8"),
"/app.js": ("app.js", "text/javascript; charset=utf-8"), "/app.js": ("app.js", "text/javascript; charset=utf-8"),
"/style.css": ("style.css", "text/css; charset=utf-8"), "/style.css": ("style.css", "text/css; charset=utf-8"),
} }
lock = threading.Lock() lock = threading.Lock()
def now_ms(): def now_ms():
return int(time.time() * 1000) return int(time.time() * 1000)
def hash_token(token): def hash_token(token):
return hashlib.sha256(token.encode("utf-8")).hexdigest() return hashlib.sha256(token.encode("utf-8")).hexdigest()
def hash_password(password, salt=None, iterations=PBKDF2_ITERATIONS): def hash_password(password, salt=None, iterations=PBKDF2_ITERATIONS):
salt = salt or secrets.token_hex(16) salt = salt or secrets.token_hex(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), bytes.fromhex(salt), iterations).hex() digest = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), bytes.fromhex(salt), iterations).hex()
return f"pbkdf2_sha256${iterations}${salt}${digest}" return f"pbkdf2_sha256${iterations}${salt}${digest}"
def verify_password(password, stored): def verify_password(password, stored):
_, iterations, salt, _ = stored.split("$") _, iterations, salt, _ = stored.split("$")
return hmac.compare_digest(hash_password(password, salt, int(iterations)), stored) return hmac.compare_digest(hash_password(password, salt, int(iterations)), stored)
# Verified against when the name doesn't exist so response timing doesn't reveal valid names. # Verified against when the name doesn't exist so response timing doesn't reveal valid names.
DUMMY_PASSWORD_HASH = hash_password(secrets.token_hex(16)) DUMMY_PASSWORD_HASH = hash_password(secrets.token_hex(16))
def zone_for_remaining(remaining_ms): def zone_for_remaining(remaining_ms):
for zone in ZONES: for zone in ZONES:
if remaining_ms > zone["remaining_to_ms"]: if remaining_ms > zone["remaining_to_ms"]:
return zone return zone
return ZONES[-1] return ZONES[-1]
def new_game(): def new_game():
t = now_ms() t = now_ms()
return { return {
"duration_ms": DURATION_MS, "duration_ms": DURATION_MS,
"started_at": t, "started_at": t,
"deadline": t + DURATION_MS, "deadline": t + DURATION_MS,
"ended": False, "ended": False,
"ended_at": None, "ended_at": None,
"players": {}, # id -> {name, password_hash, token_hashes, created_at} "players": {}, # id -> {name, password_hash, token_hashes, created_at}
"presses": [], # {player_id, at, remaining_ms, zone, points} "presses": [], # {player_id, at, remaining_ms, zone, points}
} }
def load_state(): def load_state():
if DATA_FILE.exists(): if DATA_FILE.exists():
with DATA_FILE.open("r", encoding="utf-8") as f: with DATA_FILE.open("r", encoding="utf-8") as f:
return json.load(f) return json.load(f)
state = new_game() state = new_game()
save_state(state) save_state(state)
return state return state
def save_state(state): def save_state(state):
tmp = DATA_FILE.with_suffix(".tmp") tmp = DATA_FILE.with_suffix(".tmp")
with tmp.open("w", encoding="utf-8") as f: with tmp.open("w", encoding="utf-8") as f:
json.dump(state, f) json.dump(state, f)
os.replace(tmp, DATA_FILE) os.replace(tmp, DATA_FILE)
state = load_state() state = load_state()
for _p in state["players"].values(): for _p in state["players"].values():
if "token_hash" in _p: # migrate pre-login data if "token_hash" in _p: # migrate pre-login data
_p["token_hashes"] = [_p.pop("token_hash")] _p["token_hashes"] = [_p.pop("token_hash")]
token_index = {th: pid for pid, p in state["players"].items() for th in p["token_hashes"]} 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) failed_logins = {} # lowercase name -> (failure count, first failure ms)
def find_player_by_name(name): def find_player_by_name(name):
for pid, p in state["players"].items(): for pid, p in state["players"].items():
if p["name"].lower() == name.lower(): if p["name"].lower() == name.lower():
return pid return pid
return None return None
def issue_token(pid): def issue_token(pid):
"""Must be called with the lock held.""" """Must be called with the lock held."""
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
th = hash_token(token) th = hash_token(token)
hashes = state["players"][pid]["token_hashes"] hashes = state["players"][pid]["token_hashes"]
hashes.append(th) hashes.append(th)
token_index[th] = pid token_index[th] = pid
while len(hashes) > MAX_SESSIONS_PER_PLAYER: while len(hashes) > MAX_SESSIONS_PER_PLAYER:
token_index.pop(hashes.pop(0), None) token_index.pop(hashes.pop(0), None)
save_state(state) save_state(state)
return token return token
def validate_credentials(data): def validate_credentials(data):
"""Returns (name, password, error).""" """Returns (name, password, error)."""
name, password = data.get("name"), data.get("password") name, password = data.get("name"), data.get("password")
if not isinstance(name, str) or not (1 <= len(name.strip()) <= MAX_NAME_LEN): if not isinstance(name, str) or not (1 <= len(name.strip()) <= MAX_NAME_LEN):
return None, None, f"Name must be 1-{MAX_NAME_LEN} characters" return None, None, f"Name must be 1-{MAX_NAME_LEN} characters"
name = " ".join(name.split()) name = " ".join(name.split())
if not name.isprintable(): if not name.isprintable():
return None, None, "Name contains invalid characters" return None, None, "Name contains invalid characters"
if not isinstance(password, str) or not (MIN_PASSWORD_LEN <= len(password) <= MAX_PASSWORD_LEN): if not isinstance(password, str) or not (MIN_PASSWORD_LEN <= len(password) <= MAX_PASSWORD_LEN):
return None, None, f"Password must be {MIN_PASSWORD_LEN}-{MAX_PASSWORD_LEN} characters" return None, None, f"Password must be {MIN_PASSWORD_LEN}-{MAX_PASSWORD_LEN} characters"
return name, password, None return name, password, None
def check_ended(): def check_ended():
"""Must be called with the lock held.""" """Must be called with the lock held."""
if not state["ended"] and now_ms() >= state["deadline"]: if not state["ended"] and now_ms() >= state["deadline"]:
state["ended"] = True state["ended"] = True
state["ended_at"] = state["deadline"] state["ended_at"] = state["deadline"]
save_state(state) save_state(state)
def empty_zone_counts(): def empty_zone_counts():
return {z["name"]: 0 for z in ZONES} return {z["name"]: 0 for z in ZONES}
def player_summary(pid): def player_summary(pid):
presses = [p for p in state["presses"] if p["player_id"] == pid] presses = [p for p in state["presses"] if p["player_id"] == pid]
counts = empty_zone_counts() counts = empty_zone_counts()
for p in presses: for p in presses:
counts[p["zone"]] += 1 counts[p["zone"]] += 1
return { return {
"name": state["players"][pid]["name"], "name": state["players"][pid]["name"],
"subscribed": state["players"][pid].get("subscribed", False), "subscribed": state["players"][pid].get("subscribed", False),
"total_points": sum(p["points"] for p in presses), "total_points": sum(p["points"] for p in presses),
"press_count": len(presses), "press_count": len(presses),
"zone_counts": counts, "zone_counts": counts,
"best_remaining_ms": min((p["remaining_ms"] for p in presses), default=None), "best_remaining_ms": min((p["remaining_ms"] for p in presses), default=None),
"presses": [ "presses": [
{k: p[k] for k in ("at", "remaining_ms", "zone", "points")} for p in presses {k: p[k] for k in ("at", "remaining_ms", "zone", "points")} for p in presses
], ],
} }
def public_press(p): def public_press(p):
return { return {
"name": state["players"][p["player_id"]]["name"], "name": state["players"][p["player_id"]]["name"],
"at": p["at"], "at": p["at"],
"remaining_ms": p["remaining_ms"], "remaining_ms": p["remaining_ms"],
"zone": p["zone"], "zone": p["zone"],
"points": p["points"], "points": p["points"],
} }
def global_stats(): def global_stats():
counts = empty_zone_counts() counts = empty_zone_counts()
for p in state["presses"]: for p in state["presses"]:
counts[p["zone"]] += 1 counts[p["zone"]] += 1
leaderboard = [] leaderboard = []
for pid in state["players"]: for pid in state["players"]:
s = player_summary(pid) s = player_summary(pid)
del s["presses"], s["subscribed"] del s["presses"], s["subscribed"]
leaderboard.append(s) leaderboard.append(s)
leaderboard.sort(key=lambda s: (-s["total_points"], s["name"].lower())) leaderboard.sort(key=lambda s: (-s["total_points"], s["name"].lower()))
presses = state["presses"] presses = state["presses"]
lowest = min(presses, key=lambda p: p["remaining_ms"], default=None) lowest = min(presses, key=lambda p: p["remaining_ms"], default=None)
return { return {
"started_at": state["started_at"], "started_at": state["started_at"],
"ended_at": state["ended_at"], "ended_at": state["ended_at"],
"total_players": len(state["players"]), "total_players": len(state["players"]),
"total_presses": len(presses), "total_presses": len(presses),
"zone_counts": counts, "zone_counts": counts,
"leaderboard": leaderboard, "leaderboard": leaderboard,
"last_press": public_press(presses[-1]) if presses else None, "last_press": public_press(presses[-1]) if presses else None,
"lowest_press": public_press(lowest) if lowest else None, "lowest_press": public_press(lowest) if lowest else None,
"presses": [public_press(p) for p in presses], "presses": [public_press(p) for p in presses],
} }
class Handler(BaseHTTPRequestHandler): class Handler(BaseHTTPRequestHandler):
server_version = "TheButton/1.0" server_version = "TheButton/1.0"
def log_message(self, fmt, *args): def log_message(self, fmt, *args):
pass pass
def send_json(self, status, payload): def send_json(self, status, payload):
body = json.dumps(payload).encode("utf-8") body = json.dumps(payload).encode("utf-8")
self.send_response(status) self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-store") self.send_header("Cache-Control", "no-store")
self.send_common_headers() self.send_common_headers()
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
def send_common_headers(self): def send_common_headers(self):
self.send_header("X-Content-Type-Options", "nosniff") self.send_header("X-Content-Type-Options", "nosniff")
self.send_header("X-Frame-Options", "DENY") self.send_header("X-Frame-Options", "DENY")
self.send_header("Referrer-Policy", "no-referrer") self.send_header("Referrer-Policy", "no-referrer")
self.send_header( self.send_header(
"Content-Security-Policy", "Content-Security-Policy",
"default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'", "default-src 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
) )
def read_json(self): def read_json(self):
length = int(self.headers.get("Content-Length") or 0) length = int(self.headers.get("Content-Length") or 0)
if length > MAX_BODY_BYTES: if length > MAX_BODY_BYTES:
raise ValueError("Request body too large") raise ValueError("Request body too large")
raw = self.rfile.read(length) if length else b"{}" raw = self.rfile.read(length) if length else b"{}"
data = json.loads(raw.decode("utf-8")) data = json.loads(raw.decode("utf-8"))
if not isinstance(data, dict): if not isinstance(data, dict):
raise ValueError("Expected a JSON object") raise ValueError("Expected a JSON object")
return data return data
def bearer_hash(self): def bearer_hash(self):
auth = self.headers.get("Authorization", "") auth = self.headers.get("Authorization", "")
if not auth.startswith("Bearer "): if not auth.startswith("Bearer "):
return None return None
return hash_token(auth[7:].strip()) return hash_token(auth[7:].strip())
def authed_player(self): def authed_player(self):
th = self.bearer_hash() th = self.bearer_hash()
return token_index.get(th) if th else None return token_index.get(th) if th else None
def do_GET(self): def do_GET(self):
path = self.path.split("?", 1)[0] path = self.path.split("?", 1)[0]
if path == "/api/state": if path == "/api/state":
with lock: with lock:
check_ended() check_ended()
return self.send_json(HTTPStatus.OK, { return self.send_json(HTTPStatus.OK, {
"now": now_ms(), "now": now_ms(),
"started_at": state["started_at"], "started_at": state["started_at"],
"deadline": state["deadline"], "deadline": state["deadline"],
"duration_ms": DURATION_MS, "duration_ms": DURATION_MS,
"ended": state["ended"], "ended": state["ended"],
"zones": ZONES, "zones": ZONES,
}) })
if path == "/api/me": if path == "/api/me":
with lock: with lock:
pid = self.authed_player() pid = self.authed_player()
if pid is None: if pid is None:
return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Not registered"}) return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Not registered"})
return self.send_json(HTTPStatus.OK, player_summary(pid)) return self.send_json(HTTPStatus.OK, player_summary(pid))
if path == "/api/stats": if path == "/api/stats":
with lock: with lock:
check_ended() check_ended()
if not state["ended"]: if not state["ended"]:
return self.send_json(HTTPStatus.FORBIDDEN, {"error": "Stats are published when the game ends"}) return self.send_json(HTTPStatus.FORBIDDEN, {"error": "Stats are published when the game ends"})
return self.send_json(HTTPStatus.OK, global_stats()) return self.send_json(HTTPStatus.OK, global_stats())
if path in STATIC_FILES: if path in STATIC_FILES:
filename, ctype = STATIC_FILES[path] filename, ctype = STATIC_FILES[path]
body = (STATIC_DIR / filename).read_bytes() body = (STATIC_DIR / filename).read_bytes()
self.send_response(HTTPStatus.OK) self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", ctype) self.send_header("Content-Type", ctype)
self.send_common_headers() self.send_common_headers()
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
return return
self.send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"}) self.send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"})
def do_POST(self): def do_POST(self):
path = self.path.split("?", 1)[0] path = self.path.split("?", 1)[0]
try: try:
data = self.read_json() data = self.read_json()
except (ValueError, UnicodeDecodeError): except (ValueError, UnicodeDecodeError):
return self.send_json(HTTPStatus.BAD_REQUEST, {"error": "Invalid JSON body"}) return self.send_json(HTTPStatus.BAD_REQUEST, {"error": "Invalid JSON body"})
if path == "/api/register": if path == "/api/register":
name, password, error = validate_credentials(data) name, password, error = validate_credentials(data)
if error: if error:
return self.send_json(HTTPStatus.BAD_REQUEST, {"error": error}) return self.send_json(HTTPStatus.BAD_REQUEST, {"error": error})
password_hash = hash_password(password) # slow; done outside the lock password_hash = hash_password(password) # slow; done outside the lock
with lock: with lock:
check_ended() check_ended()
if state["ended"]: if state["ended"]:
return self.send_json(HTTPStatus.CONFLICT, {"error": "The game is over"}) return self.send_json(HTTPStatus.CONFLICT, {"error": "The game is over"})
if find_player_by_name(name) is not None: if find_player_by_name(name) is not None:
return self.send_json(HTTPStatus.CONFLICT, {"error": "Name already taken"}) return self.send_json(HTTPStatus.CONFLICT, {"error": "Name already taken"})
pid = secrets.token_hex(8) pid = secrets.token_hex(8)
state["players"][pid] = { state["players"][pid] = {
"name": name, "name": name,
"password_hash": password_hash, "password_hash": password_hash,
"token_hashes": [], "token_hashes": [],
"created_at": now_ms(), "created_at": now_ms(),
} }
token = issue_token(pid) token = issue_token(pid)
return self.send_json(HTTPStatus.CREATED, {"name": name, "token": token}) return self.send_json(HTTPStatus.CREATED, {"name": name, "token": token})
if path == "/api/login": if path == "/api/login":
name, password = data.get("name"), data.get("password") name, password = data.get("name"), data.get("password")
if not isinstance(name, str) or not isinstance(password, str) or len(password) > MAX_PASSWORD_LEN: if not isinstance(name, str) or not isinstance(password, str) or len(password) > MAX_PASSWORD_LEN:
return self.send_json(HTTPStatus.BAD_REQUEST, {"error": "Name and password required"}) return self.send_json(HTTPStatus.BAD_REQUEST, {"error": "Name and password required"})
name = " ".join(name.split()) name = " ".join(name.split())
key = name.lower() key = name.lower()
with lock: with lock:
count, first = failed_logins.get(key, (0, 0)) count, first = failed_logins.get(key, (0, 0))
if count >= LOGIN_MAX_FAILURES and now_ms() - first < LOGIN_LOCKOUT_MS: if count >= LOGIN_MAX_FAILURES and now_ms() - first < LOGIN_LOCKOUT_MS:
return self.send_json(HTTPStatus.TOO_MANY_REQUESTS, {"error": "Too many failed attempts; try again later"}) return self.send_json(HTTPStatus.TOO_MANY_REQUESTS, {"error": "Too many failed attempts; try again later"})
pid = find_player_by_name(name) pid = find_player_by_name(name)
stored = state["players"][pid].get("password_hash") if pid else None 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 ok = verify_password(password, stored or DUMMY_PASSWORD_HASH) and stored is not None
with lock: with lock:
if not ok: if not ok:
count, first = failed_logins.get(key, (0, 0)) count, first = failed_logins.get(key, (0, 0))
if now_ms() - first >= LOGIN_LOCKOUT_MS: if now_ms() - first >= LOGIN_LOCKOUT_MS:
count, first = 0, now_ms() count, first = 0, now_ms()
failed_logins[key] = (count + 1, first) failed_logins[key] = (count + 1, first)
return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Invalid name or password"}) return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Invalid name or password"})
failed_logins.pop(key, None) failed_logins.pop(key, None)
token = issue_token(pid) token = issue_token(pid)
return self.send_json(HTTPStatus.OK, {"name": state["players"][pid]["name"], "token": token}) return self.send_json(HTTPStatus.OK, {"name": state["players"][pid]["name"], "token": token})
if path == "/api/logout": if path == "/api/logout":
with lock: with lock:
th = self.bearer_hash() th = self.bearer_hash()
pid = token_index.pop(th, None) if th else None pid = token_index.pop(th, None) if th else None
if pid is not None: if pid is not None:
state["players"][pid]["token_hashes"].remove(th) state["players"][pid]["token_hashes"].remove(th)
save_state(state) save_state(state)
return self.send_json(HTTPStatus.OK, {"ok": True}) return self.send_json(HTTPStatus.OK, {"ok": True})
if path == "/api/subscribe": if path == "/api/subscribe":
with lock: with lock:
pid = self.authed_player() pid = self.authed_player()
player_name = state["players"][pid]["name"] if pid else None player_name = state["players"][pid]["name"] if pid else None
already_subscribed = bool(pid and state["players"][pid].get("subscribed")) already_subscribed = bool(pid and state["players"][pid].get("subscribed"))
if pid is None: if pid is None:
return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Log in to join the mailing list"}) return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Log in to join the mailing list"})
if already_subscribed: if already_subscribed:
return self.send_json(HTTPStatus.OK, {"ok": True}) return self.send_json(HTTPStatus.OK, {"ok": True})
entry, error = validate_subscription(data) entry, error = validate_subscription(data)
if error: if error:
return self.send_json(HTTPStatus.BAD_REQUEST, {"error": error}) return self.send_json(HTTPStatus.BAD_REQUEST, {"error": error})
entry["player"] = player_name entry["player"] = player_name
with mailing_lock: with mailing_lock:
# Same response for repeat emails so the endpoint doesn't reveal who is subscribed. # Same response for repeat emails so the endpoint doesn't reveal who is subscribed.
if entry["email"].lower() not in subscribed_emails: if entry["email"].lower() not in subscribed_emails:
add_subscriber(entry) add_subscriber(entry)
with lock: with lock:
state["players"][pid]["subscribed"] = True state["players"][pid]["subscribed"] = True
save_state(state) save_state(state)
return self.send_json(HTTPStatus.OK, {"ok": True}) return self.send_json(HTTPStatus.OK, {"ok": True})
if path == "/api/press": if path == "/api/press":
with lock: with lock:
pid = self.authed_player() pid = self.authed_player()
if pid is None: if pid is None:
return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Not registered"}) return self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "Not registered"})
check_ended() check_ended()
if state["ended"]: if state["ended"]:
return self.send_json(HTTPStatus.CONFLICT, {"error": "The game is over"}) return self.send_json(HTTPStatus.CONFLICT, {"error": "The game is over"})
t = now_ms() t = now_ms()
remaining = state["deadline"] - t remaining = state["deadline"] - t
zone = zone_for_remaining(remaining) zone = zone_for_remaining(remaining)
press = { press = {
"player_id": pid, "player_id": pid,
"at": t, "at": t,
"remaining_ms": remaining, "remaining_ms": remaining,
"zone": zone["name"], "zone": zone["name"],
"points": zone["points"], "points": zone["points"],
} }
state["presses"].append(press) state["presses"].append(press)
state["deadline"] = t + DURATION_MS state["deadline"] = t + DURATION_MS
save_state(state) save_state(state)
return self.send_json(HTTPStatus.OK, { return self.send_json(HTTPStatus.OK, {
"press": {k: press[k] for k in ("at", "remaining_ms", "zone", "points")}, "press": {k: press[k] for k in ("at", "remaining_ms", "zone", "points")},
"deadline": state["deadline"], "deadline": state["deadline"],
"now": t, "now": t,
}) })
self.send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"}) self.send_json(HTTPStatus.NOT_FOUND, {"error": "Not found"})
mailing_lock = threading.Lock() mailing_lock = threading.Lock()
def load_subscribed_emails(): def load_subscribed_emails():
if not MAILING_LIST_FILE.exists(): if not MAILING_LIST_FILE.exists():
return set() return set()
with MAILING_LIST_FILE.open("r", newline="", encoding="utf-8") as f: with MAILING_LIST_FILE.open("r", newline="", encoding="utf-8") as f:
return {row["email"].lower() for row in csv.DictReader(f)} return {row["email"].lower() for row in csv.DictReader(f)}
subscribed_emails = load_subscribed_emails() subscribed_emails = load_subscribed_emails()
def clean_text(value, max_len): def clean_text(value, max_len):
"""Returns a whitespace-normalized string, "" for missing values, or None if invalid.""" """Returns a whitespace-normalized string, "" for missing values, or None if invalid."""
if value is None: if value is None:
return "" return ""
if not isinstance(value, str): if not isinstance(value, str):
return None return None
value = " ".join(value.split()) value = " ".join(value.split())
if len(value) > max_len or not value.isprintable(): if len(value) > max_len or not value.isprintable():
return None return None
return value return value
def validate_subscription(data): def validate_subscription(data):
first = clean_text(data.get("first_name"), 50) first = clean_text(data.get("first_name"), 50)
last = clean_text(data.get("last_name"), 50) last = clean_text(data.get("last_name"), 50)
email = clean_text(data.get("email"), 254) email = clean_text(data.get("email"), 254)
phone = clean_text(data.get("phone"), 25) phone = clean_text(data.get("phone"), 25)
if not first: if not first:
return None, "First name is required (max 50 characters)" return None, "First name is required (max 50 characters)"
if last is None: if last is None:
return None, "Last name must be at most 50 characters" return None, "Last name must be at most 50 characters"
if not email or not EMAIL_RE.match(email): if not email or not EMAIL_RE.match(email):
return None, "A valid email address is required" return None, "A valid email address is required"
if phone is None or (phone and not PHONE_RE.match(phone)): if phone is None or (phone and not PHONE_RE.match(phone)):
return None, "Phone number is invalid" return None, "Phone number is invalid"
return {"first_name": first, "last_name": last, "email": email, "phone": phone}, None return {"first_name": first, "last_name": last, "email": email, "phone": phone}, None
def csv_safe(value): def csv_safe(value):
# Stops spreadsheet apps from interpreting user input as a formula. # Stops spreadsheet apps from interpreting user input as a formula.
return "'" + value if value[:1] in ("=", "+", "-", "@") else value return "'" + value if value[:1] in ("=", "+", "-", "@") else value
def add_subscriber(entry): def add_subscriber(entry):
"""Must be called with mailing_lock held.""" """Must be called with mailing_lock held."""
is_new_file = not MAILING_LIST_FILE.exists() is_new_file = not MAILING_LIST_FILE.exists()
row = {k: csv_safe(v) for k, v in entry.items()} row = {k: csv_safe(v) for k, v in entry.items()}
row["subscribed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) row["subscribed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
with MAILING_LIST_FILE.open("a", newline="", encoding="utf-8") as f: with MAILING_LIST_FILE.open("a", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=MAILING_LIST_FIELDS) writer = csv.DictWriter(f, fieldnames=MAILING_LIST_FIELDS)
if is_new_file: if is_new_file:
writer.writeheader() writer.writeheader()
writer.writerow(row) writer.writerow(row)
subscribed_emails.add(entry["email"].lower()) subscribed_emails.add(entry["email"].lower())
def end_watcher(): def end_watcher():
while True: while True:
with lock: with lock:
check_ended() check_ended()
if state["ended"]: if state["ended"]:
return return
wait = (state["deadline"] - now_ms()) / 1000 wait = (state["deadline"] - now_ms()) / 1000
time.sleep(min(max(wait, 0.05), 60)) time.sleep(min(max(wait, 0.05), 60))
if __name__ == "__main__": if __name__ == "__main__":
threading.Thread(target=end_watcher, daemon=True).start() threading.Thread(target=end_watcher, daemon=True).start()
print(f"The Button running on http://{HOST}:{PORT} (timer: {DURATION_MS / 1000:.0f}s)") print(f"The Button running on http://{HOST}:{PORT} (timer: {DURATION_MS / 1000:.0f}s)")
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever() ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()
+273 -273
View File
@@ -1,273 +1,273 @@
"use strict"; "use strict";
const TOKEN_KEY = "thebutton-token"; const TOKEN_KEY = "thebutton-token";
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
let game = null; // latest /api/state payload let game = null; // latest /api/state payload
let clockOffset = 0; // serverTime - clientTime let clockOffset = 0; // serverTime - clientTime
let token = localStorage.getItem(TOKEN_KEY); let token = localStorage.getItem(TOKEN_KEY);
let statsLoaded = false; let statsLoaded = false;
let subscribed = false; let subscribed = false;
async function api(method, path, body) { async function api(method, path, body) {
const headers = { "Content-Type": "application/json" }; const headers = { "Content-Type": "application/json" };
if (token) headers.Authorization = `Bearer ${token}`; if (token) headers.Authorization = `Bearer ${token}`;
const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined }); const res = await fetch(path, { method, headers, body: body ? JSON.stringify(body) : undefined });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) throw Object.assign(new Error(data.error || res.statusText), { status: res.status }); if (!res.ok) throw Object.assign(new Error(data.error || res.statusText), { status: res.status });
return data; return data;
} }
function serverNow() { function serverNow() {
return Date.now() + clockOffset; return Date.now() + clockOffset;
} }
function formatMs(ms) { function formatMs(ms) {
ms = Math.max(0, ms); ms = Math.max(0, ms);
const h = Math.floor(ms / 3600000); const h = Math.floor(ms / 3600000);
const m = Math.floor((ms % 3600000) / 60000); const m = Math.floor((ms % 3600000) / 60000);
const s = Math.floor((ms % 60000) / 1000); const s = Math.floor((ms % 60000) / 1000);
const cs = Math.floor((ms % 1000) / 10); const cs = Math.floor((ms % 1000) / 10);
const pad = (n) => String(n).padStart(2, "0"); const pad = (n) => String(n).padStart(2, "0");
return `${pad(h)}:${pad(m)}:${pad(s)}.${pad(cs)}`; return `${pad(h)}:${pad(m)}:${pad(s)}.${pad(cs)}`;
} }
function formatPrecise(ms) { function formatPrecise(ms) {
return `${formatMs(ms).slice(0, -3)}.${String(Math.max(0, ms) % 1000).padStart(3, "0")}`; return `${formatMs(ms).slice(0, -3)}.${String(Math.max(0, ms) % 1000).padStart(3, "0")}`;
} }
function zoneFor(remaining) { function zoneFor(remaining) {
return game.zones.find((z) => remaining > z.remaining_to_ms) ?? game.zones[game.zones.length - 1]; return game.zones.find((z) => remaining > z.remaining_to_ms) ?? game.zones[game.zones.length - 1];
} }
function formatDuration(ms) { function formatDuration(ms) {
const totalMin = Math.round(ms / 60000); const totalMin = Math.round(ms / 60000);
const h = Math.floor(totalMin / 60); const h = Math.floor(totalMin / 60);
const m = totalMin % 60; const m = totalMin % 60;
if (h && m) return `${h}h ${m}m`; if (h && m) return `${h}h ${m}m`;
if (h) return `${h}h`; if (h) return `${h}h`;
if (m) return `${m}m`; if (m) return `${m}m`;
return `${Math.round(ms / 1000)}s`; return `${Math.round(ms / 1000)}s`;
} }
function setZoneClass(el, zoneName) { function setZoneClass(el, zoneName) {
el.className = el.className.replace(/\bzone-\w+/g, "").trim(); el.className = el.className.replace(/\bzone-\w+/g, "").trim();
el.classList.add(`zone-${zoneName}`); el.classList.add(`zone-${zoneName}`);
} }
function el(tag, text, className) { function el(tag, text, className) {
const e = document.createElement(tag); const e = document.createElement(tag);
if (text !== undefined) e.textContent = text; if (text !== undefined) e.textContent = text;
if (className) e.className = className; if (className) e.className = className;
return e; return e;
} }
function fillTable(table, headers, rows) { function fillTable(table, headers, rows) {
table.replaceChildren(); table.replaceChildren();
const head = table.createTHead().insertRow(); const head = table.createTHead().insertRow();
headers.forEach((h) => head.appendChild(el("th", h))); headers.forEach((h) => head.appendChild(el("th", h)));
const tbody = table.createTBody(); const tbody = table.createTBody();
rows.forEach((cells) => { rows.forEach((cells) => {
const tr = tbody.insertRow(); const tr = tbody.insertRow();
cells.forEach((c) => { cells.forEach((c) => {
const td = tr.insertCell(); const td = tr.insertCell();
if (c instanceof Node) td.appendChild(c); if (c instanceof Node) td.appendChild(c);
else td.textContent = c; else td.textContent = c;
}); });
}); });
} }
function zoneSwatch(name) { function zoneSwatch(name) {
return el("span", name, `swatch zone-${name}`); return el("span", name, `swatch zone-${name}`);
} }
function zoneRows(counts) { function zoneRows(counts) {
return game.zones.map((z) => [zoneSwatch(z.name), z.points, counts[z.name] ?? 0]); return game.zones.map((z) => [zoneSwatch(z.name), z.points, counts[z.name] ?? 0]);
} }
function buildStaticUi() { function buildStaticUi() {
const bar = $("zone-bar"); const bar = $("zone-bar");
game.zones.forEach((z) => { game.zones.forEach((z) => {
const seg = el("span", undefined, `zone-${z.name}`); const seg = el("span", undefined, `zone-${z.name}`);
seg.style.flexGrow = z.remaining_from_ms - z.remaining_to_ms; seg.style.flexGrow = z.remaining_from_ms - z.remaining_to_ms;
bar.insertBefore(seg, $("zone-marker")); bar.insertBefore(seg, $("zone-marker"));
}); });
$("rules-zones").replaceChildren(...game.zones.map((z) => { $("rules-zones").replaceChildren(...game.zones.map((z) => {
const s = zoneSwatch(z.name); const s = zoneSwatch(z.name);
s.textContent = `${z.name} (${z.points} pt${z.points === 1 ? "" : "s"}, ${formatDuration(z.remaining_from_ms - z.remaining_to_ms)})`; s.textContent = `${z.name} (${z.points} pt${z.points === 1 ? "" : "s"}, ${formatDuration(z.remaining_from_ms - z.remaining_to_ms)})`;
return s; return s;
})); }));
} }
async function refreshState() { async function refreshState() {
const t0 = Date.now(); const t0 = Date.now();
const s = await api("GET", "/api/state"); const s = await api("GET", "/api/state");
const first = !game; const first = !game;
clockOffset = s.now - (t0 + Date.now()) / 2; clockOffset = s.now - (t0 + Date.now()) / 2;
game = s; game = s;
if (first) buildStaticUi(); if (first) buildStaticUi();
if (game.ended && !statsLoaded) loadStats(); if (game.ended && !statsLoaded) loadStats();
updateButton(); updateButton();
} }
function updateButton() { function updateButton() {
$("the-button").disabled = !token || !game || game.ended; $("the-button").disabled = !token || !game || game.ended;
$("register-section").hidden = !!token; $("register-section").hidden = !!token;
$("mailing-list").hidden = !token || subscribed; $("mailing-list").hidden = !token || subscribed;
$("my-subscribed").hidden = !token || !subscribed; $("my-subscribed").hidden = !token || !subscribed;
} }
function tick() { function tick() {
if (game) { if (game) {
const remaining = game.ended ? 0 : game.deadline - serverNow(); const remaining = game.ended ? 0 : game.deadline - serverNow();
const zone = zoneFor(remaining); const zone = zoneFor(remaining);
const cd = $("countdown"); const cd = $("countdown");
cd.textContent = formatMs(remaining); cd.textContent = formatMs(remaining);
setZoneClass(cd, zone.name); setZoneClass(cd, zone.name);
setZoneClass($("the-button"), zone.name); setZoneClass($("the-button"), zone.name);
$("zone-label").textContent = game.ended ? "game over" : `${zone.name} zone \u00b7 ${zone.points} pt${zone.points === 1 ? "" : "s"}`; $("zone-label").textContent = game.ended ? "game over" : `${zone.name} zone \u00b7 ${zone.points} pt${zone.points === 1 ? "" : "s"}`;
const pct = Math.min(100, Math.max(0, ((game.duration_ms - remaining) / game.duration_ms) * 100)); const pct = Math.min(100, Math.max(0, ((game.duration_ms - remaining) / game.duration_ms) * 100));
$("zone-marker").style.left = `${pct}%`; $("zone-marker").style.left = `${pct}%`;
if (!game.ended && remaining <= 0) refreshState().catch(() => {}); if (!game.ended && remaining <= 0) refreshState().catch(() => {});
} }
requestAnimationFrame(tick); requestAnimationFrame(tick);
} }
async function loadMe() { async function loadMe() {
if (!token) return; if (!token) return;
try { try {
const me = await api("GET", "/api/me"); const me = await api("GET", "/api/me");
$("me-section").hidden = false; $("me-section").hidden = false;
$("my-name").textContent = me.name; $("my-name").textContent = me.name;
$("my-points").textContent = me.total_points.toLocaleString(); $("my-points").textContent = me.total_points.toLocaleString();
$("my-presses").textContent = me.press_count; $("my-presses").textContent = me.press_count;
subscribed = me.subscribed; subscribed = me.subscribed;
updateButton(); updateButton();
fillTable($("my-zones"), ["Zone", "Points each", "Your presses"], zoneRows(me.zone_counts)); fillTable($("my-zones"), ["Zone", "Points each", "Your presses"], zoneRows(me.zone_counts));
fillTable( fillTable(
$("my-history"), $("my-history"),
["When", "Remaining", "Zone", "Points"], ["When", "Remaining", "Zone", "Points"],
me.presses.slice().reverse().map((p) => [ me.presses.slice().reverse().map((p) => [
new Date(p.at).toLocaleString(), formatPrecise(p.remaining_ms), zoneSwatch(p.zone), p.points, new Date(p.at).toLocaleString(), formatPrecise(p.remaining_ms), zoneSwatch(p.zone), p.points,
]), ]),
); );
} catch (e) { } catch (e) {
if (e.status === 401) { if (e.status === 401) {
token = null; token = null;
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
updateButton(); updateButton();
} }
} }
} }
async function loadStats() { async function loadStats() {
try { try {
const s = await api("GET", "/api/stats"); const s = await api("GET", "/api/stats");
statsLoaded = true; statsLoaded = true;
$("stats-section").hidden = false; $("stats-section").hidden = false;
const summary = $("stats-summary"); const summary = $("stats-summary");
summary.replaceChildren( summary.replaceChildren(
el("p", `Lasted ${formatMs(s.ended_at - s.started_at)} \u00b7 ${s.total_players} players \u00b7 ${s.total_presses} presses`), el("p", `Lasted ${formatMs(s.ended_at - s.started_at)} \u00b7 ${s.total_players} players \u00b7 ${s.total_presses} presses`),
); );
if (s.last_press) { if (s.last_press) {
summary.appendChild(el("p", `Last press: ${s.last_press.name} with ${formatPrecise(s.last_press.remaining_ms)} remaining`)); summary.appendChild(el("p", `Last press: ${s.last_press.name} with ${formatPrecise(s.last_press.remaining_ms)} remaining`));
} }
if (s.lowest_press) { if (s.lowest_press) {
summary.appendChild(el("p", `Closest call: ${s.lowest_press.name} with ${formatPrecise(s.lowest_press.remaining_ms)} remaining`)); summary.appendChild(el("p", `Closest call: ${s.lowest_press.name} with ${formatPrecise(s.lowest_press.remaining_ms)} remaining`));
} }
fillTable($("stats-zones"), ["Zone", "Points each", "Presses"], zoneRows(s.zone_counts)); fillTable($("stats-zones"), ["Zone", "Points each", "Presses"], zoneRows(s.zone_counts));
fillTable( fillTable(
$("stats-leaderboard"), $("stats-leaderboard"),
["#", "Player", "Points", "Presses", ...game.zones.map((z) => z.name), "Best remaining"], ["#", "Player", "Points", "Presses", ...game.zones.map((z) => z.name), "Best remaining"],
s.leaderboard.map((p, i) => [ s.leaderboard.map((p, i) => [
i + 1, p.name, p.total_points.toLocaleString(), p.press_count, i + 1, p.name, p.total_points.toLocaleString(), p.press_count,
...game.zones.map((z) => p.zone_counts[z.name]), ...game.zones.map((z) => p.zone_counts[z.name]),
p.best_remaining_ms == null ? "-" : formatPrecise(p.best_remaining_ms), p.best_remaining_ms == null ? "-" : formatPrecise(p.best_remaining_ms),
]), ]),
); );
fillTable( fillTable(
$("stats-log"), $("stats-log"),
["When", "Player", "Remaining", "Zone", "Points"], ["When", "Player", "Remaining", "Zone", "Points"],
s.presses.map((p) => [ s.presses.map((p) => [
new Date(p.at).toLocaleString(), p.name, formatPrecise(p.remaining_ms), zoneSwatch(p.zone), p.points, new Date(p.at).toLocaleString(), p.name, formatPrecise(p.remaining_ms), zoneSwatch(p.zone), p.points,
]), ]),
); );
} catch { } catch {
// Not ended yet; ignore. // Not ended yet; ignore.
} }
} }
$("the-button").addEventListener("click", async () => { $("the-button").addEventListener("click", async () => {
const btn = $("the-button"); const btn = $("the-button");
btn.disabled = true; btn.disabled = true;
try { try {
const r = await api("POST", "/api/press"); const r = await api("POST", "/api/press");
game.deadline = r.deadline; game.deadline = r.deadline;
$("press-result").textContent = $("press-result").textContent =
`You pressed in the ${r.press.zone} zone with ${formatPrecise(r.press.remaining_ms)} remaining: +${r.press.points} points`; `You pressed in the ${r.press.zone} zone with ${formatPrecise(r.press.remaining_ms)} remaining: +${r.press.points} points`;
await loadMe(); await loadMe();
} catch (e) { } catch (e) {
$("press-result").textContent = e.message; $("press-result").textContent = e.message;
await refreshState().catch(() => {}); await refreshState().catch(() => {});
} finally { } finally {
updateButton(); updateButton();
} }
}); });
$("register-form").addEventListener("submit", async (ev) => { $("register-form").addEventListener("submit", async (ev) => {
ev.preventDefault(); ev.preventDefault();
$("register-error").textContent = ""; $("register-error").textContent = "";
const action = ev.submitter?.value === "register" ? "register" : "login"; const action = ev.submitter?.value === "register" ? "register" : "login";
try { try {
const r = await api("POST", `/api/${action}`, { const r = await api("POST", `/api/${action}`, {
name: $("name-input").value, name: $("name-input").value,
password: $("password-input").value, password: $("password-input").value,
}); });
token = r.token; token = r.token;
localStorage.setItem(TOKEN_KEY, token); localStorage.setItem(TOKEN_KEY, token);
$("password-input").value = ""; $("password-input").value = "";
updateButton(); updateButton();
await loadMe(); await loadMe();
} catch (e) { } catch (e) {
$("register-error").textContent = e.message; $("register-error").textContent = e.message;
} }
}); });
$("logout-button").addEventListener("click", async () => { $("logout-button").addEventListener("click", async () => {
await api("POST", "/api/logout").catch(() => {}); await api("POST", "/api/logout").catch(() => {});
token = null; token = null;
subscribed = false; subscribed = false;
localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(TOKEN_KEY);
$("me-section").hidden = true; $("me-section").hidden = true;
$("press-result").textContent = ""; $("press-result").textContent = "";
$("subscribe-result").textContent = ""; $("subscribe-result").textContent = "";
updateButton(); updateButton();
}); });
$("subscribe-form").addEventListener("submit", async (ev) => { $("subscribe-form").addEventListener("submit", async (ev) => {
ev.preventDefault(); ev.preventDefault();
const result = $("subscribe-result"); const result = $("subscribe-result");
result.className = ""; result.className = "";
try { try {
await api("POST", "/api/subscribe", { await api("POST", "/api/subscribe", {
first_name: $("sub-first").value, first_name: $("sub-first").value,
last_name: $("sub-last").value, last_name: $("sub-last").value,
email: $("sub-email").value, email: $("sub-email").value,
phone: $("sub-phone").value, phone: $("sub-phone").value,
}); });
ev.target.reset(); ev.target.reset();
result.textContent = ""; result.textContent = "";
subscribed = true; subscribed = true;
updateButton(); updateButton();
} catch (e) { } catch (e) {
result.className = "error"; result.className = "error";
result.textContent = e.message; result.textContent = e.message;
} }
}); });
(async function init() { (async function init() {
await refreshState(); await refreshState();
await loadMe(); await loadMe();
requestAnimationFrame(tick); requestAnimationFrame(tick);
setInterval(() => refreshState().catch(() => {}), 5000); setInterval(() => refreshState().catch(() => {}), 5000);
})(); })();
+87 -87
View File
@@ -1,87 +1,87 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Button</title> <title>The Button</title>
<link rel="stylesheet" href="/style.css"> <link rel="stylesheet" href="/style.css">
</head> </head>
<body> <body>
<main> <main>
<h1>THE BUTTON</h1> <h1>THE BUTTON</h1>
<section id="timer-section"> <section id="timer-section">
<div id="countdown" class="zone-green">--:--:--.--</div> <div id="countdown" class="zone-green">--:--:--.--</div>
<div id="zone-label"></div> <div id="zone-label"></div>
<div id="zone-bar"><div id="zone-marker"></div></div> <div id="zone-bar"><div id="zone-marker"></div></div>
</section> </section>
<section id="button-section"> <section id="button-section">
<button id="the-button" class="zone-green" disabled>PRESS</button> <button id="the-button" class="zone-green" disabled>PRESS</button>
<p id="press-result" aria-live="polite"></p> <p id="press-result" aria-live="polite"></p>
</section> </section>
<section id="register-section" hidden> <section id="register-section" hidden>
<h2>Log in or join the game</h2> <h2>Log in or join the game</h2>
<form id="register-form"> <form id="register-form">
<input id="name-input" maxlength="24" placeholder="Your name" required autocomplete="username"> <input id="name-input" maxlength="24" placeholder="Your name" required autocomplete="username">
<input id="password-input" type="password" minlength="8" maxlength="128" placeholder="Password (8+ chars)" required autocomplete="current-password"> <input id="password-input" type="password" minlength="8" maxlength="128" placeholder="Password (8+ chars)" required autocomplete="current-password">
<button type="submit" value="login">Log in</button> <button type="submit" value="login">Log in</button>
<button type="submit" value="register">Sign up</button> <button type="submit" value="register">Sign up</button>
</form> </form>
<p id="register-error" class="error"></p> <p id="register-error" class="error"></p>
</section> </section>
<section id="me-section" hidden> <section id="me-section" hidden>
<h2>Your score: <span id="my-name"></span></h2> <h2>Your score: <span id="my-name"></span></h2>
<button id="logout-button" type="button">Log out</button> <button id="logout-button" type="button">Log out</button>
<p id="my-subscribed" hidden>You're on the mailing list. Thanks!</p> <p id="my-subscribed" hidden>You're on the mailing list. Thanks!</p>
<p>Total points: <strong id="my-points">0</strong> &middot; Presses: <strong id="my-presses">0</strong></p> <p>Total points: <strong id="my-points">0</strong> &middot; Presses: <strong id="my-presses">0</strong></p>
<table id="my-zones" class="zone-table"></table> <table id="my-zones" class="zone-table"></table>
<details> <details>
<summary>Your press history</summary> <summary>Your press history</summary>
<table id="my-history"></table> <table id="my-history"></table>
</details> </details>
</section> </section>
<section id="stats-section" hidden> <section id="stats-section" hidden>
<h2>Game over &mdash; final global stats</h2> <h2>Game over &mdash; final global stats</h2>
<div id="stats-summary"></div> <div id="stats-summary"></div>
<h3>Presses per zone</h3> <h3>Presses per zone</h3>
<table id="stats-zones" class="zone-table"></table> <table id="stats-zones" class="zone-table"></table>
<h3>Leaderboard</h3> <h3>Leaderboard</h3>
<table id="stats-leaderboard"></table> <table id="stats-leaderboard"></table>
<details> <details>
<summary>Full press log</summary> <summary>Full press log</summary>
<table id="stats-log"></table> <table id="stats-log"></table>
</details> </details>
</section> </section>
<section id="rules"> <section id="rules">
<h2>Rules</h2> <h2>Rules</h2>
<ul> <ul>
<li>A 24 hour countdown resets whenever anyone presses the button.</li> <li>A 24 hour countdown resets whenever anyone presses the button.</li>
<li>If the countdown reaches zero, the game is over forever.</li> <li>If the countdown reaches zero, the game is over forever.</li>
<li>The lower the countdown when you press, the more points you score.</li> <li>The lower the countdown when you press, the more points you score.</li>
<li>Zones: <span id="rules-zones"></span></li> <li>Zones: <span id="rules-zones"></span></li>
<li>Each press scores the fixed point value of the zone it lands in.</li> <li>Each press scores the fixed point value of the zone it lands in.</li>
<li>Global stats are published when the game ends.</li> <li>Global stats are published when the game ends.</li>
<li>Inspired by the original <a href="https://en.wikipedia.org/wiki/The_Button_(Reddit)">Reddit "The Button"</a> experiment.</li> <li>Inspired by the original <a href="https://en.wikipedia.org/wiki/The_Button_(Reddit)">Reddit "The Button"</a> experiment.</li>
</ul> </ul>
</section> </section>
<section id="mailing-list" hidden> <section id="mailing-list" hidden>
<h2>Join the mailing list to keep up with <a href="https://www.eom.dev/">Eric</a>'s projects</h2> <h2>Join the mailing list to keep up with <a href="https://www.eom.dev/">Eric</a>'s projects</h2>
<form id="subscribe-form"> <form id="subscribe-form">
<input id="sub-first" maxlength="50" placeholder="First name" required autocomplete="given-name"> <input id="sub-first" maxlength="50" placeholder="First name" required autocomplete="given-name">
<input id="sub-last" maxlength="50" placeholder="Last name (optional)" autocomplete="family-name"> <input id="sub-last" maxlength="50" placeholder="Last name (optional)" autocomplete="family-name">
<input id="sub-email" type="email" maxlength="254" placeholder="Email" required autocomplete="email"> <input id="sub-email" type="email" maxlength="254" placeholder="Email" required autocomplete="email">
<input id="sub-phone" type="tel" maxlength="25" placeholder="Phone (optional)" autocomplete="tel"> <input id="sub-phone" type="tel" maxlength="25" placeholder="Phone (optional)" autocomplete="tel">
<button type="submit">Sign up</button> <button type="submit">Sign up</button>
</form> </form>
<p id="subscribe-result" aria-live="polite"></p> <p id="subscribe-result" aria-live="polite"></p>
</section> </section>
</main> </main>
<script src="/app.js"></script> <script src="/app.js"></script>
</body> </body>
</html> </html>
+107 -107
View File
@@ -1,107 +1,107 @@
:root { :root {
--green: #2ecc40; --green: #2ecc40;
--blue: #0074d9; --blue: #0074d9;
--yellow: #ffdc00; --yellow: #ffdc00;
--red: #ff4136; --red: #ff4136;
--black: #000; --black: #000;
--white: #fff; --white: #fff;
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
body { body {
margin: 0; margin: 0;
background: #444; background: #444;
color: #eee; color: #eee;
font-family: system-ui, sans-serif; font-family: system-ui, sans-serif;
} }
main { main {
max-width: 720px; max-width: 720px;
margin: 0 auto; margin: 0 auto;
padding: 1rem; padding: 1rem;
text-align: center; text-align: center;
} }
h1 { letter-spacing: 0.3em; } h1 { letter-spacing: 0.3em; }
section { margin: 1.5rem 0; } section { margin: 1.5rem 0; }
#countdown { #countdown {
font-family: ui-monospace, monospace; font-family: ui-monospace, monospace;
font-size: clamp(2rem, 9vw, 4rem); font-size: clamp(2rem, 9vw, 4rem);
font-weight: bold; font-weight: bold;
background: none !important; background: none !important;
border: none !important; border: none !important;
} }
#zone-label { text-transform: uppercase; letter-spacing: 0.2em; } #zone-label { text-transform: uppercase; letter-spacing: 0.2em; }
#zone-bar { #zone-bar {
position: relative; position: relative;
display: flex; display: flex;
height: 14px; height: 14px;
margin: 0.75rem auto; margin: 0.75rem auto;
border: 1px solid #222; border: 1px solid #222;
} }
#zone-bar span { flex: 1 1 0; } #zone-bar span { flex: 1 1 0; }
#zone-marker { #zone-marker {
position: absolute; position: absolute;
top: -6px; top: -6px;
width: 3px; width: 3px;
height: 26px; height: 26px;
background: #f0f; background: #f0f;
transition: left 0.2s linear; transition: left 0.2s linear;
} }
#the-button { #the-button {
width: 200px; width: 200px;
height: 200px; height: 200px;
border-radius: 50%; border-radius: 50%;
font-size: 1.6rem; font-size: 1.6rem;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
border: 6px solid #222; border: 6px solid #222;
box-shadow: 0 8px 0 #222; box-shadow: 0 8px 0 #222;
transition: transform 0.05s; transition: transform 0.05s;
} }
#the-button:active:not(:disabled) { transform: translateY(6px); box-shadow: 0 2px 0 #222; } #the-button:active:not(:disabled) { transform: translateY(6px); box-shadow: 0 2px 0 #222; }
#the-button:disabled { cursor: not-allowed; opacity: 0.5; } #the-button:disabled { cursor: not-allowed; opacity: 0.5; }
.zone-green { background: var(--green); color: #000; } .zone-green { background: var(--green); color: #000; }
.zone-blue { background: var(--blue); color: #fff; } .zone-blue { background: var(--blue); color: #fff; }
.zone-yellow { background: var(--yellow); color: #000; } .zone-yellow { background: var(--yellow); color: #000; }
.zone-red { background: var(--red); color: #fff; } .zone-red { background: var(--red); color: #fff; }
.zone-black { background: var(--black); color: #fff; border-color: #888 !important; } .zone-black { background: var(--black); color: #fff; border-color: #888 !important; }
.zone-white { background: var(--white); color: #000; } .zone-white { background: var(--white); color: #000; }
#countdown.zone-green { color: var(--green); } #countdown.zone-green { color: var(--green); }
#countdown.zone-blue { color: var(--blue); } #countdown.zone-blue { color: var(--blue); }
#countdown.zone-yellow { color: var(--yellow); } #countdown.zone-yellow { color: var(--yellow); }
#countdown.zone-red { color: var(--red); } #countdown.zone-red { color: var(--red); }
#countdown.zone-black { color: var(--black); } #countdown.zone-black { color: var(--black); }
#countdown.zone-white { color: var(--white); } #countdown.zone-white { color: var(--white); }
.swatch { .swatch {
display: inline-block; display: inline-block;
padding: 0 0.4em; padding: 0 0.4em;
margin: 0 0.15em; margin: 0 0.15em;
border: 1px solid #222; border: 1px solid #222;
border-radius: 3px; border-radius: 3px;
} }
table { table {
margin: 0.5rem auto; margin: 0.5rem auto;
border-collapse: collapse; border-collapse: collapse;
min-width: 60%; min-width: 60%;
} }
th, td { padding: 0.3rem 0.6rem; border-bottom: 1px solid #555; } th, td { padding: 0.3rem 0.6rem; border-bottom: 1px solid #555; }
input, form button { input, form button {
font-size: 1rem; font-size: 1rem;
padding: 0.4rem 0.8rem; padding: 0.4rem 0.8rem;
} }
.error { color: var(--red); } .error { color: var(--red); }
#rules { text-align: left; font-size: 0.9rem; color: #ccc; } #rules { text-align: left; font-size: 0.9rem; color: #ccc; }