idk
This commit is contained in:
+3
-3
@@ -1,3 +1,3 @@
|
|||||||
*
|
*
|
||||||
!server.py
|
!server.py
|
||||||
!static/
|
!static/
|
||||||
|
|||||||
+5
-5
@@ -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
@@ -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"]
|
||||||
|
|||||||
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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> · Presses: <strong id="my-presses">0</strong></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>
|
<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 — final global stats</h2>
|
<h2>Game over — 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
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user