diff --git a/server.py b/server.py index d76a897..defe963 100644 --- a/server.py +++ b/server.py @@ -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 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 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') 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 MAX_BODY = 2048 # a submission is ~60 bytes. Anything larger is not one. NAME_MAX = 12 @@ -230,6 +236,31 @@ class Handler(SimpleHTTPRequestHandler): def log_message(self, *a): 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 ---- def reply(self, code, obj): body = json.dumps(obj).encode()