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