404 lines
20 KiB
Python
404 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""The whole backend: static files plus a two-route high-score API, on the standard library.
|
|
|
|
It replaced the nginx image because the leaderboard needs ONE writable process and nginx is not
|
|
one. Everything nginx was doing here is four lines of headers, so a second container (and a second
|
|
Coolify resource, and a proxy rule) would have bought separation we have no use for at this size.
|
|
|
|
GET /api/scores?m=g|z|c -> {"m", "scores": [{"n","k","g","t","m"}, ...]} best first
|
|
POST /api/scores <- {"n","k","g","m"} -> {"ok", "rank", "m", "scores"}
|
|
|
|
One board per game mode; `m` defaults to 'g' (splatter gore) at both ends, which is what every row
|
|
written before the split is.
|
|
|
|
Scores live in ONE json file under SPLAT_DATA (default /data, which must be a persistent volume —
|
|
anything written inside the container image is gone on the next deploy). There is no auth: the
|
|
board is a wall for writing your name on, and the client is public JS that could be made to post
|
|
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
|
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
DATA = os.environ.get('SPLAT_DATA') or ('/data' if os.path.isdir('/data') else HERE)
|
|
FILE = os.path.join(DATA, 'scores.json')
|
|
PORT = int(os.environ.get('PORT', 80))
|
|
|
|
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
|
|
# ONE BOARD PER GAME MODE (2026-08-15). The three do not share a currency — gore ranks bodies
|
|
# killed, zombie tennis ranks opponents felled, classic ranks games taken off the CPU — so a single
|
|
# board was really the gore mode's with the other two locked out of it. The server does not know or
|
|
# care what the numbers MEAN (see stats.BOARDS on the client); it sorts them within a mode and
|
|
# nothing crosses. Rows written before the split carry no mode and ARE gore rows, which is why 'g'
|
|
# is the default everywhere and no migration is needed.
|
|
MODES = ('g', 'z', 'c')
|
|
lock = threading.Lock()
|
|
|
|
# ---- THE PROFILE MIRROR (2026-08-29, the leagues). localStorage alone dies with cleared browser
|
|
# data, and the meta-progression asks for hours of investment — so the client mirrors its profile
|
|
# here, keyed by the same random id that owns board names. This is a BACKUP, not an account: the
|
|
# client is the authority while it has one, and adopts the mirror only when its own storage is
|
|
# empty (or behind on xp). Same stance as the scores: no auth beyond the id, nothing here may
|
|
# crash, hang or grow without bound, every field re-derived before it touches disk.
|
|
PROFILE_FILE = os.path.join(DATA, 'profiles.json')
|
|
PROFILE_KEEP = 5000 # profiles kept; oldest-touched evicted past this
|
|
plock = threading.Lock()
|
|
|
|
|
|
def pload():
|
|
try:
|
|
with open(PROFILE_FILE) as f:
|
|
d = json.load(f)
|
|
return d if isinstance(d, dict) else {}
|
|
except (OSError, ValueError):
|
|
return {}
|
|
|
|
|
|
def psave(d):
|
|
os.makedirs(DATA, exist_ok=True)
|
|
tmp = PROFILE_FILE + '.tmp'
|
|
with open(tmp, 'w') as f:
|
|
json.dump(d, f)
|
|
os.replace(tmp, PROFILE_FILE)
|
|
|
|
|
|
def clean_profile(payload):
|
|
"""(id, profile) or None. Whitelisted and bounded — this file is read back and rendered."""
|
|
if not isinstance(payload, dict) or not isinstance(payload.get('p'), dict):
|
|
return None
|
|
who = re.sub(r'[^a-z0-9]', '', str(payload.get('id', '')))[:40]
|
|
if not who:
|
|
return None
|
|
p = payload['p']
|
|
word = lambda k, n=12: re.sub(r'[^a-z0-9_]', '', str(p.get(k, '')))[:n]
|
|
try:
|
|
out = {'v': 1,
|
|
'xp': max(0, min(int(p.get('xp', 0)), 1_000_000_000)),
|
|
'lg': max(1, min(int(p.get('lg', 1)), 5)),
|
|
'ch': 1 if p.get('ch') else 0,
|
|
'setup': 1 if p.get('setup') else 0,
|
|
'n': re.sub(r'[^A-Z0-9 ._-]', '', str(p.get('n', '')).upper()).strip()[:NAME_MAX],
|
|
'tour': word('tour'), 'kit': word('kit'), 'skin': word('skin')}
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return who, out
|
|
|
|
|
|
def mode_of(r):
|
|
return r.get('m') or 'g'
|
|
|
|
|
|
def load():
|
|
try:
|
|
with open(FILE) as f:
|
|
rows = json.load(f)
|
|
return rows if isinstance(rows, list) else []
|
|
except (OSError, ValueError):
|
|
return [] # missing, empty or corrupt — an empty board, never a 500
|
|
|
|
|
|
def save(rows):
|
|
os.makedirs(DATA, exist_ok=True)
|
|
tmp = FILE + '.tmp' # write-then-rename: a torn file here is a wiped leaderboard
|
|
with open(tmp, 'w') as f:
|
|
json.dump(rows, f)
|
|
os.replace(tmp, FILE)
|
|
|
|
|
|
def clean(payload):
|
|
"""A submission, or None. Every field is re-derived — nothing from the request reaches the file
|
|
unexamined, because that file is read back and rendered into the page."""
|
|
if not isinstance(payload, dict):
|
|
return None
|
|
name = re.sub(r'[^A-Z0-9 ._-]', '', str(payload.get('n', '')).upper()).strip()[:NAME_MAX]
|
|
if not name:
|
|
return None
|
|
try:
|
|
kills, games = int(payload['k']), int(payload['g'])
|
|
except (KeyError, TypeError, ValueError):
|
|
return None
|
|
if not (0 <= kills <= 1_000_000 and 0 <= games <= 10_000):
|
|
return None # bounds keep the sort honest and the column narrow
|
|
who = re.sub(r'[^a-z0-9]', '', str(payload.get('id', '')))[:40]
|
|
mode = str(payload.get('m', 'g'))
|
|
if mode not in MODES:
|
|
mode = 'g' # an unknown board is the default one, never a new one
|
|
return {'n': name, 'k': kills, 'g': games, 't': int(time.time()), 'id': who, 'm': mode}
|
|
|
|
|
|
def public(rows):
|
|
"""The board as anyone may see it. `id` is the ONLY thing standing between a player and someone
|
|
else typing their name, so it must never leave the server."""
|
|
return [{k: v for k, v in r.items() if k != 'id'} for r in rows[:100]]
|
|
|
|
|
|
def rank_of(rows, name):
|
|
return next((i + 1 for i, r in enumerate(rows) if r.get('n') == name), len(rows))
|
|
|
|
|
|
# ---- the multiplayer relay: RFC6455 on the standard library, because that is all a dumb relay
|
|
# needs. The netcode's sync unit is THE SHOT (see PROJECT.md, LATER item 7), so this server holds
|
|
# no game state at all: it mints a 4-letter room code, pairs exactly two sockets, and forwards
|
|
# text frames verbatim between them. The clients judge everything; a hostile client can only cheat
|
|
# its friend, which is the agreed trust model for friend codes. Caps mirror the score API's stance:
|
|
# nothing here may crash, hang or grow without bound on a hostile request.
|
|
WS_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
|
|
WS_ALPHABET = 'ABCDEFGHJKMNPQRSTVWXYZ23456789' # read aloud between phones: no I/L/O/U/0/1
|
|
WS_MAX = 2048 # a shot event is ~120 bytes; anything larger is not one
|
|
ROOMS = {} # code -> [host_conn] while waiting; deleted on pairing
|
|
ws_lock = threading.Lock()
|
|
|
|
|
|
def ws_send(conn, text):
|
|
data = text if isinstance(text, bytes) else text.encode()
|
|
head = bytes([0x81, len(data)]) if len(data) < 126 else bytes([0x81, 126]) + len(data).to_bytes(2, 'big')
|
|
try:
|
|
with conn['lock']: # relay thread and own thread share this socket
|
|
conn['sock'].sendall(head + data)
|
|
except OSError:
|
|
pass # a dead peer is handled by its own recv loop
|
|
|
|
|
|
def ws_read(rf):
|
|
"""One frame -> (opcode, payload) or (None, b'') on EOF/oversize. Client frames are masked."""
|
|
h = rf.read(2)
|
|
if len(h) < 2:
|
|
return None, b''
|
|
op, n = h[0] & 0x0f, h[1] & 0x7f
|
|
if n == 126:
|
|
n = int.from_bytes(rf.read(2), 'big')
|
|
elif n == 127:
|
|
n = int.from_bytes(rf.read(8), 'big')
|
|
if n > WS_MAX:
|
|
return None, b''
|
|
mask = rf.read(4) if h[1] & 0x80 else None
|
|
data = rf.read(n)
|
|
if mask:
|
|
data = bytes(b ^ mask[i & 3] for i, b in enumerate(data))
|
|
return op, data
|
|
|
|
|
|
class Handler(SimpleHTTPRequestHandler):
|
|
def __init__(self, *a, **kw):
|
|
super().__init__(*a, directory=HERE, **kw)
|
|
|
|
def log_message(self, *a):
|
|
pass # one line per sprite request is not a log, it is noise
|
|
|
|
# ---- the API ----
|
|
def reply(self, code, obj):
|
|
body = json.dumps(obj).encode()
|
|
self.send_response(code)
|
|
self.send_header('Content-Type', 'application/json')
|
|
self.send_header('Content-Length', str(len(body)))
|
|
self.send_header('Cache-Control', 'no-store')
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
# ---- the relay endpoint ----
|
|
def handle_ws(self):
|
|
key = self.headers.get('Sec-WebSocket-Key')
|
|
if not key or (self.headers.get('Upgrade') or '').lower() != 'websocket':
|
|
return self.send_error(400)
|
|
accept = base64.b64encode(hashlib.sha1((key + WS_GUID).encode()).digest()).decode()
|
|
self.connection.sendall(b'HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n'
|
|
b'Connection: Upgrade\r\nSec-WebSocket-Accept: ' + accept.encode() + b'\r\n\r\n')
|
|
self.close_connection = True
|
|
# a phone that vanishes without FIN must not hold a thread and a room forever — the client
|
|
# heartbeats every 20 s, so 75 s of silence is a dead radio, not a slow one
|
|
self.connection.settimeout(75)
|
|
me = {'sock': self.connection, 'lock': threading.Lock(), 'peer': None, 'room': None}
|
|
want = re.sub(r'[^A-Z0-9]', '', (self.path.split('room=')[1] if 'room=' in self.path else '').upper())[:8]
|
|
with ws_lock:
|
|
if want: # joining a friend
|
|
room = ROOMS.pop(want, None)
|
|
if not room:
|
|
ws_send(me, '{"t":"gone"}')
|
|
return
|
|
host = room[0]
|
|
me['peer'] = host
|
|
host['peer'] = me # the code is single-use: popped above, never re-joinable
|
|
else: # hosting: mint a code nobody is holding
|
|
code = ''.join(random.choice(WS_ALPHABET) for _ in range(4))
|
|
while code in ROOMS:
|
|
code = ''.join(random.choice(WS_ALPHABET) for _ in range(4))
|
|
ROOMS[code] = [me]
|
|
me['room'] = code
|
|
# the host's decisions ride the room: classic tennis or the death match, and WHICH
|
|
# COURT. The start message carries both to both phones, so the joiner is never
|
|
# asked — and never ends up playing the same rally on a different surface.
|
|
me['death'] = 'death=1' in self.path
|
|
me['court'] = re.sub(r'[^a-z]', '', self.path.split('court=')[1].split('&')[0]
|
|
if 'court=' in self.path else '')[:12]
|
|
if len(ROOMS) > 200: # a lobby is two people; 200 stale rooms is a flood
|
|
ROOMS.pop(next(iter(ROOMS)))
|
|
if me['peer']: # paired: the host serves first, on both scoreboards
|
|
host = me['peer'] # ...and the host's picks are what both phones get
|
|
for who, side in ((host, 'A'), (me, 'B')):
|
|
ws_send(who, json.dumps({'t': 'start', 'side': side,
|
|
'death': bool(host.get('death')),
|
|
'court': host.get('court') or ''}))
|
|
else:
|
|
ws_send(me, json.dumps({'t': 'room', 'code': me['room']}))
|
|
try:
|
|
while True:
|
|
try:
|
|
op, data = ws_read(self.rfile)
|
|
except OSError: # timeout or reset: same as hanging up
|
|
break
|
|
if op is None or op == 8:
|
|
break
|
|
if op == 9: # protocol ping -> pong, payload echoed
|
|
try:
|
|
with me['lock']:
|
|
me['sock'].sendall(bytes([0x8A, len(data)]) + data)
|
|
except OSError:
|
|
pass
|
|
continue
|
|
if op != 1 or data == b'{"t":"hb"}':
|
|
continue # the heartbeat is for the socket, not the friend
|
|
if me['peer']:
|
|
ws_send(me['peer'], data) # the whole job: forward the frame verbatim
|
|
finally:
|
|
with ws_lock:
|
|
if me['room'] and ROOMS.get(me['room']) and ROOMS[me['room']][0] is me:
|
|
del ROOMS[me['room']] # nobody ever came
|
|
peer = me['peer']
|
|
me['peer'] = None
|
|
if peer:
|
|
peer['peer'] = None
|
|
if peer:
|
|
ws_send(peer, '{"t":"bye"}')
|
|
|
|
def do_GET(self):
|
|
path = self.path.split('?')[0]
|
|
if path == '/ws':
|
|
return self.handle_ws()
|
|
if path == '/api/scores':
|
|
want = re.sub(r'[^gzc]', '', (self.path.split('m=')[1].split('&')[0]
|
|
if 'm=' in self.path else ''))[:1] or 'g'
|
|
with lock:
|
|
return self.reply(200, {'m': want,
|
|
'scores': public([r for r in load() if mode_of(r) == want])})
|
|
if path == '/api/profile':
|
|
who = re.sub(r'[^a-z0-9]', '', (self.path.split('id=')[1].split('&')[0]
|
|
if 'id=' in self.path else ''))[:40]
|
|
if not who:
|
|
return self.reply(400, {'error': 'no id'})
|
|
with plock:
|
|
row = pload().get(who)
|
|
return self.reply(200, {'ok': True, 'p': row and row.get('p')})
|
|
# Don't hand out the source. Nothing secret lives in it — the .env is not in the image and
|
|
# the client is public JS regardless — but serving server.py and the asset-pipeline scripts
|
|
# to anyone who guesses the name is untidy, and it publishes the exact validation rules.
|
|
if path.endswith(('.py', '.pyc')) or '__pycache__' in path:
|
|
return self.send_error(404)
|
|
return super().do_GET()
|
|
|
|
def do_POST(self):
|
|
path = self.path.split('?')[0]
|
|
if path not in ('/api/scores', '/api/profile'):
|
|
return self.send_error(404)
|
|
try:
|
|
n = int(self.headers.get('Content-Length') or 0)
|
|
except ValueError:
|
|
return self.reply(400, {'error': 'bad length'})
|
|
if not 0 < n <= MAX_BODY:
|
|
return self.reply(400, {'error': 'bad length'})
|
|
if path == '/api/profile':
|
|
try:
|
|
got = clean_profile(json.loads(self.rfile.read(n)))
|
|
except ValueError:
|
|
got = None
|
|
if not got:
|
|
return self.reply(400, {'error': 'bad profile'})
|
|
who, prof = got
|
|
with plock:
|
|
d = pload()
|
|
# the mirror never goes BACKWARD on xp: a second device with a stale localStorage
|
|
# boots, finds nothing local, adopts the mirror — but if it posts before adopting
|
|
# (or a wiped phone posts a fresh profile), a lower xp must not overwrite the
|
|
# investment the mirror exists to protect.
|
|
old = d.get(who)
|
|
if old and old.get('p', {}).get('xp', 0) > prof['xp']:
|
|
return self.reply(200, {'ok': True, 'p': old['p']})
|
|
d[who] = {'p': prof, 't': int(time.time())}
|
|
if len(d) > PROFILE_KEEP:
|
|
for k in sorted(d, key=lambda k: d[k].get('t', 0))[:len(d) - PROFILE_KEEP]:
|
|
del d[k]
|
|
psave(d)
|
|
return self.reply(200, {'ok': True, 'p': prof})
|
|
try:
|
|
row = clean(json.loads(self.rfile.read(n)))
|
|
except ValueError:
|
|
row = None
|
|
if not row:
|
|
return self.reply(400, {'error': 'bad score'})
|
|
with lock:
|
|
rows = load()
|
|
# A NAME BELONGS TO WHOEVER TOOK IT FIRST, ACROSS ALL THREE BOARDS. The claim is on the
|
|
# NAME, not on a row: one person owns "OLIVER" everywhere, or a stranger could take it
|
|
# on the classic board while you hold it on gore. The client mints a random id once and
|
|
# keeps it; the row remembers it, and a submission under someone else's name with a
|
|
# different id is refused. That is the whole of the "security" here — enough that a
|
|
# stranger cannot overwrite your row, and not one line more. Clear your browser storage
|
|
# and you lose the name, which is the honest cost of having no accounts.
|
|
owner = next((r for r in rows if r.get('n') == row['n'] and r.get('id')), None)
|
|
if owner and owner['id'] != row['id']:
|
|
return self.reply(409, {'error': 'name taken'})
|
|
# EXACTLY ONE ROW PER NAME PER BOARD, their best on it. Without this the board is one
|
|
# player's session: every personal best posts again and a good run buries everyone else
|
|
# under itself. The client only posts on a personal best, but its idea of "best" is
|
|
# per-device — the same player on a second phone starts from zero and posts worse runs,
|
|
# so a weaker submission has to be dropped HERE rather than merely not sorted to the top.
|
|
mine = [r for r in rows if r.get('n') == row['n'] and mode_of(r) == row['m']]
|
|
if row['k'] > max((r.get('k', 0) for r in mine), default=-1):
|
|
rows = [r for r in rows
|
|
if not (r.get('n') == row['n'] and mode_of(r) == row['m'])]
|
|
rows.append(row)
|
|
# sorted and trimmed PER BOARD, so a busy mode can never evict a quiet one
|
|
kept = []
|
|
for m in MODES:
|
|
rs = [r for r in rows if mode_of(r) == m]
|
|
rs.sort(key=lambda r: (-r.get('k', 0), -r.get('g', 0), r.get('t', 0)))
|
|
kept += rs[:KEEP]
|
|
rows = kept
|
|
save(rows)
|
|
board = [r for r in rows if mode_of(r) == row['m']]
|
|
board.sort(key=lambda r: (-r.get('k', 0), -r.get('g', 0), r.get('t', 0)))
|
|
return self.reply(200, {'ok': True, 'm': row['m'],
|
|
'rank': rank_of(board, row['n']), 'scores': public(board)})
|
|
|
|
# ---- static, with the headers the nginx conf used to set ----
|
|
def end_headers(self):
|
|
if self.path.startswith('/api/'):
|
|
pass # reply() sets its own no-store
|
|
elif self.path.startswith('/assets/'):
|
|
# THE COLD-START FIX. no-cache here meant ~230 sprites, three fonts and the audio were
|
|
# REVALIDATED on every launch — 230 round trips before the game had a full set of
|
|
# frames, on a phone, sometimes over a connection that dropped a few. That is why the
|
|
# fonts arrived late and the stats screen showed gaps. A ?v= URL can be cached forever
|
|
# because changing the file changes the URL (see SPRV in render.js); anything
|
|
# unversioned gets a day, so a replaced logo or icon still lands without a year of
|
|
# staleness. The HTML and JS stay no-cache — that is what makes a deploy visible.
|
|
self.send_header('Cache-Control', 'public, max-age=31536000, immutable'
|
|
if '?v=' in self.path else 'public, max-age=86400')
|
|
else:
|
|
self.send_header('Cache-Control', 'no-cache, must-revalidate')
|
|
super().end_headers()
|
|
|
|
def guess_type(self, path):
|
|
# Chrome refuses to install a homescreen app whose manifest arrives as octet-stream, and
|
|
# mimetypes has no .webmanifest entry.
|
|
if path.endswith('.webmanifest'):
|
|
return 'application/manifest+json'
|
|
return super().guess_type(path)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
print(f'splatter-tennis on :{PORT}, scores in {FILE}', flush=True)
|
|
ThreadingHTTPServer(('', PORT), Handler).serve_forever()
|