Put staging and prod behind one shared password

The game is not public yet. parse_request is the one place every method passes
through, so the gate sits there rather than in each do_*. /api and /ws stay open
because the iOS shell talks to prod from capacitor://localhost and has no
credentials to send. SPLAT_AUTH overrides it; empty opens the site again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
wuwiandClaude Opus 5 committed 2026-09-08 16:50:28 +02:00
1 parent a09856f8c9
commit b53fc07dd1
1 file changed
+32 -1
+32 -1
View File
@@ -17,7 +17,7 @@ board is a wall for writing your name on, and the client is public JS that could
anything regardless. What IS enforced is that nothing here can be made to crash, hang or grow anything regardless. What IS enforced is that nothing here can be made to crash, hang or grow
without bound by a hostile request — see clean() and the caps below. without bound by a hostile request — see clean() and the caps below.
""" """
import base64, hashlib, json, os, random, re, threading, time import base64, hashlib, hmac, json, os, random, re, threading, time
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
HERE = os.path.dirname(os.path.abspath(__file__)) HERE = os.path.dirname(os.path.abspath(__file__))
@@ -25,6 +25,12 @@ DATA = os.environ.get('SPLAT_DATA') or ('/data' if os.path.isdir('/data') else H
FILE = os.path.join(DATA, 'scores.json') FILE = os.path.join(DATA, 'scores.json')
PORT = int(os.environ.get('PORT', 80)) PORT = int(os.environ.get('PORT', 80))
# ---- THE DOOR (2026-09-08). The game is not public yet, so staging and prod both sit behind one
# shared password. SPLAT_AUTH is 'user:pass'; set it EMPTY in the environment to open the site again
# (that is also how local dev and the browser-pane automation stay unprompted).
AUTH = os.environ.get('SPLAT_AUTH', 'tennis:BummBummBoris')
AUTH = ('Basic ' + base64.b64encode(AUTH.encode()).decode()) if AUTH else ''
KEEP = 200 # rows kept on disk PER BOARD; the board only ever shows the top slice of this KEEP = 200 # rows kept on disk PER BOARD; the board only ever shows the top slice of this
MAX_BODY = 2048 # a submission is ~60 bytes. Anything larger is not one. MAX_BODY = 2048 # a submission is ~60 bytes. Anything larger is not one.
NAME_MAX = 12 NAME_MAX = 12
@@ -230,6 +236,31 @@ class Handler(SimpleHTTPRequestHandler):
def log_message(self, *a): def log_message(self, *a):
pass # one line per sprite request is not a log, it is noise pass # one line per sprite request is not a log, it is noise
# ---- the door ----
def parse_request(self):
"""The password gate. It lives here, not in do_GET/do_POST, because parse_request is the
ONE place every method passes through — GET, POST, HEAD and the websocket upgrade — and
returning False from it drops the request before anything is dispatched."""
if not super().parse_request():
return False
if not AUTH:
return True
path = self.path.split('?')[0]
# /api and /ws STAY OPEN and this is not an oversight: the iOS shell serves the page out of
# its own bundle and talks to prod by name from capacitor://localhost, with no credentials
# it could ever send — gating them would kill the app's board, profile mirror and netplay
# while leaving the thing we actually want private (the game itself) no more private.
if path == '/ws' or path.startswith('/api/'):
return True
if hmac.compare_digest(self.headers.get('Authorization', ''), AUTH):
return True
self.send_response(401)
self.send_header('WWW-Authenticate', 'Basic realm="splatter tennis"')
self.send_header('Content-Length', '0')
self.end_headers()
self.close_connection = True # a POST body is still unread — do not parse it as the next request
return False
# ---- the API ---- # ---- the API ----
def reply(self, code, obj): def reply(self, code, obj):
body = json.dumps(obj).encode() body = json.dumps(obj).encode()