backnforth/server.py
type-two 4a46417cba Safety, privacy, session security, bug fixes, zero-install server
- First-run safety notice (photosensitivity, clinical use, privacy); glow
  rate capped at 2.5Hz below the photosensitive trigger band
- Patient data: initials/code hint, Export All / Wipe All buttons
- Relay sessions get crypto-random room ids instead of guessable room-1
- Fix: Pan Sync off now actually stops pink-noise panning
- Fix: slider drags no longer audibly restart the noise source
- Fix: switching pink->discrete preset no longer leaves noise running
- Fix: Speed Boost no longer compounds into stored speed across reloads
- Fix: patient/preset names HTML-escaped in lists
- Fix: patientViewport now relays over LAN (old server dropped it)
- server.py rewritten stdlib-only: static site + WS relay in one process,
  no pip/venv; setup.sh/bat deleted, start scripts are now 3 lines
- MIT LICENSE added; README rewritten with safety-first quick start

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 14:38:05 +10:00

247 lines
7.6 KiB
Python

"""backnforth server — static site + WebSocket relay, Python stdlib only.
python3 server.py [--no-browser]
Serves the therapist page on http://<ip>:8000/ and the session relay on
ws://<ip>:8787. No pip installs, no virtualenv — any Python 3.7+ works,
including old machines that will never see a package index.
"""
import base64
import hashlib
import json
import os
import socket
import socketserver
import struct
import sys
import threading
import webbrowser
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
HTTP_PORT = 8000
WS_PORT = 8787
_WS_GUID = b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
SESSIONS = {} # session id -> set of live WSHandler
LOCK = threading.Lock()
_SERVERS = []
def local_ip():
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 80))
ip = s.getsockname()[0]
s.close()
return ip
except OSError:
try:
return socket.gethostbyname(socket.gethostname())
except OSError:
return '127.0.0.1'
def shutdown_all():
for srv in _SERVERS:
try:
srv.shutdown()
except Exception:
pass
class WSHandler(socketserver.BaseRequestHandler):
"""Minimal RFC6455 server: unfragmented text frames, ping/pong, close.
ponytail: no fragmentation/binary support — the state payloads are a few
hundred bytes of JSON and browsers never fragment at that size.
"""
def setup(self):
self.session_id = None
self.send_lock = threading.Lock()
def handle(self):
if not self._handshake():
return
try:
while True:
msg = self._read_message()
if msg is None:
break
self._dispatch(msg)
except (ConnectionError, OSError):
pass
def finish(self):
if self.session_id:
with LOCK:
SESSIONS.get(self.session_id, set()).discard(self)
# ---- relay protocol (same JSON messages as the old websockets server) ----
def _dispatch(self, text):
try:
data = json.loads(text)
except ValueError:
return
t = data.get('type')
if t == 'join':
self.session_id = data.get('sessionId') or 'default'
with LOCK:
SESSIONS.setdefault(self.session_id, set()).add(self)
self._send_json({'type': 'joined', 'sessionId': self.session_id})
elif t in ('state', 'patientViewport'):
sid = data.get('sessionId') or self.session_id or 'default'
out = json.dumps({'type': t, 'payload': data.get('payload')})
with LOCK:
peers = list(SESSIONS.get(sid, ()))
for peer in peers:
if peer is not self:
try:
peer._send_text(out)
except Exception:
pass
elif t in ('whoami', 'detect_ip'):
self._send_json({'type': 'detect_ip', 'ip': local_ip()})
elif t == 'ping':
self._send_json({'type': 'pong'})
elif t == 'shutdown':
if self.client_address[0] in ('127.0.0.1', '::1', local_ip()):
self._send_json({'type': 'shutdown_ack'})
threading.Thread(target=shutdown_all, daemon=True).start()
# ---- websocket plumbing ----
def _handshake(self):
self.request.settimeout(10)
data = b''
while b'\r\n\r\n' not in data:
chunk = self.request.recv(4096)
if not chunk or len(data) > 65536:
return False
data += chunk
headers = {}
for line in data.split(b'\r\n')[1:]:
if b':' in line:
k, v = line.split(b':', 1)
headers[k.strip().lower()] = v.strip()
key = headers.get(b'sec-websocket-key')
if not key:
return False
accept = base64.b64encode(hashlib.sha1(key + _WS_GUID).digest()).decode()
self.request.sendall((
'HTTP/1.1 101 Switching Protocols\r\n'
'Upgrade: websocket\r\n'
'Connection: Upgrade\r\n'
'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n'
).encode())
self.request.settimeout(None)
return True
def _recv_exact(self, n):
buf = b''
while len(buf) < n:
chunk = self.request.recv(n - len(buf))
if not chunk:
return None
buf += chunk
return buf
def _read_message(self):
while True:
head = self._recv_exact(2)
if head is None:
return None
b1, b2 = head
opcode = b1 & 0x0F
length = b2 & 0x7F
if length == 126:
ext = self._recv_exact(2)
if ext is None:
return None
length = struct.unpack('>H', ext)[0]
elif length == 127:
ext = self._recv_exact(8)
if ext is None:
return None
length = struct.unpack('>Q', ext)[0]
if length > 1_000_000:
return None
mask = self._recv_exact(4) if (b2 & 0x80) else b'\x00\x00\x00\x00'
if mask is None:
return None
payload = self._recv_exact(length) if length else b''
if payload is None:
return None
payload = bytes(c ^ mask[i % 4] for i, c in enumerate(payload))
if opcode == 0x1: # text
try:
return payload.decode('utf-8')
except UnicodeDecodeError:
return None
elif opcode == 0x9: # ping -> pong
self._send_frame(0xA, payload)
elif opcode == 0x8: # close
try:
self._send_frame(0x8, b'')
except Exception:
pass
return None
# anything else (binary/continuation): skip
def _send_frame(self, opcode, payload):
n = len(payload)
head = bytes([0x80 | opcode])
if n < 126:
head += bytes([n])
elif n < 65536:
head += bytes([126]) + struct.pack('>H', n)
else:
head += bytes([127]) + struct.pack('>Q', n)
with self.send_lock:
self.request.sendall(head + payload)
def _send_text(self, text):
self._send_frame(0x1, text.encode('utf-8'))
def _send_json(self, obj):
self._send_text(json.dumps(obj))
class _QuietHTTP(SimpleHTTPRequestHandler):
def log_message(self, *args):
pass
def main():
os.chdir(os.path.dirname(os.path.abspath(__file__)))
socketserver.ThreadingTCPServer.allow_reuse_address = True
ThreadingHTTPServer.allow_reuse_address = True
httpd = ThreadingHTTPServer(('0.0.0.0', HTTP_PORT), _QuietHTTP)
wsd = socketserver.ThreadingTCPServer(('0.0.0.0', WS_PORT), WSHandler)
httpd.daemon_threads = True
wsd.daemon_threads = True
_SERVERS.extend([httpd, wsd])
ip = local_ip()
print('backnforth running:')
print(' Therapist page: http://%s:%d/' % (ip, HTTP_PORT))
print(' Session relay: ws://%s:%d' % (ip, WS_PORT))
print('Press Ctrl+C to stop.')
threading.Thread(target=wsd.serve_forever, daemon=True).start()
if '--no-browser' not in sys.argv:
try:
webbrowser.open('http://%s:%d/' % (ip, HTTP_PORT))
except Exception:
pass
try:
httpd.serve_forever()
except KeyboardInterrupt:
print('\nStopped.')
if __name__ == '__main__':
main()