From eed30a2ba55acbc0de5a27c2a72450931b3ce69e Mon Sep 17 00:00:00 2001 From: type-two Date: Tue, 7 Jul 2026 15:14:03 +1000 Subject: [PATCH] Harden hosted relay with access codes + illustrated manual page --tokens FILE gates session hosting: hosts present an access code, patients join live sessions freely (no code), un-joined connections can't relay, and joined members can't spoof state into other rooms. Easy Mode grows a gentle access-code card (one-computer mode stays free/ungated); advanced panel gets a token field; patient view shows a waiting note and auto-retries. manual.html: picture-book how-to, images slot in under img/manual/. Co-Authored-By: Claude Fable 5 --- README.md | 5 +++ easy.html | 43 ++++++++++++++++++++++--- index.html | 1 + js/admin.js | 2 +- js/patient.js | 6 +++- manual.html | 89 +++++++++++++++++++++++++++++++++++++++++++++++++++ server.py | 27 ++++++++++++++-- 7 files changed, 165 insertions(+), 8 deletions(-) create mode 100644 manual.html diff --git a/README.md b/README.md index 425574c..91eb6d6 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,11 @@ controls". - **Session IDs are random by default** (e.g. `room-k3x9q2f1`). Anyone on the same network who knows a session ID can join it and control what the patient sees β€” leave the random ID alone and share only the join link. +- **Hosting it on the internet?** Run `server.py --tokens FILE` (one access + code per line, `#` comments allowed). Hosts must present a valid code to + open a session; patients never need one β€” they can only join a session + that's already live, via the therapist's link. Without a code the app still + works fully in one-computer mode. - Stop the server with the **Stop Server** button (works from the therapist machine only) or `Ctrl+C` in the terminal. - A manual peer-to-peer WebRTC mode exists for networks where the relay is diff --git a/easy.html b/easy.html index 364fd25..4e53f2d 100644 --- a/easy.html +++ b/easy.html @@ -110,6 +110,21 @@ + + + πŸ“– How it works (picture guide) Advanced controls β†’ @@ -148,14 +163,34 @@ var ws; try{ ws = new WebSocket(BLS.relayUrl()) }catch(_){ return } ws.onopen = function(){ - net.ws = ws; - try{ ws.send(JSON.stringify({type:'join', sessionId: net.sid, role:'admin'})) }catch(_){ } - document.getElementById('copyLinkBig').hidden = false; + var token = null; try{ token = localStorage.getItem('bls_relay_token') }catch(_){ } + try{ ws.send(JSON.stringify({type:'join', sessionId: net.sid, role:'admin', token: token || undefined})) }catch(_){ } }; - ws.onclose = function(){ net.ws = null; setTimeout(connect, 3000) }; + ws.onmessage = function(ev){ + try{ + var msg = JSON.parse(ev.data); + if(msg.type === 'joined'){ + net.ws = ws; + document.getElementById('copyLinkBig').hidden = false; + document.getElementById('accessCard').hidden = true; + } else if(msg.type === 'join_denied'){ + document.getElementById('accessCard').hidden = false; + try{ ws.close() }catch(_){ } + } + }catch(_){ } + }; + ws.onclose = function(){ net.ws = null; setTimeout(connect, 5000) }; ws.onerror = function(){ try{ ws.close() }catch(_){ } }; } connect(); + document.getElementById('accessSave').addEventListener('click', function(){ + var v = document.getElementById('accessInput').value.trim(); + if(!v) return; + try{ localStorage.setItem('bls_relay_token', v) }catch(_){ } + this.textContent = 'Connecting…'; + var b = this; + setTimeout(function(){ b.textContent = 'Save' }, 6000); + }); })(); function apply(next){ diff --git a/index.html b/index.html index 64a06ba..e25a412 100644 --- a/index.html +++ b/index.html @@ -630,6 +630,7 @@
+
diff --git a/js/admin.js b/js/admin.js index 322993b..a92f955 100644 --- a/js/admin.js +++ b/js/admin.js @@ -460,7 +460,7 @@ if(document.readyState==='loading'){ document.addEventListener('DOMContentLoaded })(); if(startRelayBtn){ startRelayBtn.addEventListener('click', function(){ var h = renderOsHelp(); var ok = copyText(h.start); var old = startRelayBtn.textContent; startRelayBtn.textContent = ok? 'Copied!' : 'Copy Start Command'; setTimeout(function(){ startRelayBtn.textContent = old }, 1200) }) } if(stopRelayBtn){ stopRelayBtn.addEventListener('click', function(){ var url = BLS.relayUrl(); try{ var temp = new WebSocket(url); temp.onopen = function(){ try{ temp.send(JSON.stringify({type:'shutdown'})) }catch(e){} }; setTimeout(function(){ setRelayStatus(false) }, 1500) }catch(e){} }) } - function wsConnect(){ var url = (wsUrl.value||'').trim(); var sid = (wsSession.value||'').trim(); if(!sid){ sid = randSid(); wsSession.value = sid; updateJoinLink() } var btn = document.getElementById('wsConnect'); if(btn && btn.disabled){ wsStatus.textContent = 'Relay not running'; return } if(ws){ try{ ws.close() }catch(e){} ws=null } if(!url){ wsStatus.textContent = 'Missing URL'; return } try{ ws = new WebSocket(url) }catch(e){ wsStatus.textContent = 'Error'; return } wsSid = sid; wsStatus.textContent = 'Connecting...'; ws.onopen = function(){ wsConnected = true; wsStatus.textContent = 'Connected'; try{ ws.send(JSON.stringify({type:'join', sessionId:sid, role:'admin'})) }catch(e){} publishState() }; ws.onclose = function(){ wsConnected = false; wsStatus.textContent = 'Disconnected' }; ws.onerror = function(){ wsStatus.textContent = 'Error' }; ws.onmessage = function(ev){ try{ var msg = JSON.parse(ev.data); if(msg && msg.type==='joined'){ wsStatus.textContent = 'Connected' } else if(msg && msg.type==='patientViewport'){ var pv = msg.payload||{}; try{ window.PatientViewport = pv }catch(_){ } var r = window.AdminRenderer; if(r){ r.resize(parseInt(pv.canvasW||640,10), parseInt(pv.canvasH||360,10)) } var pvShort = document.getElementById('patientViewportShort'); if(pvShort){ pvShort.textContent = (pv.canvasW||'?')+'Γ—'+(pv.canvasH||'?') } } }catch(e){} } } + function wsConnect(){ var url = (wsUrl.value||'').trim(); var sid = (wsSession.value||'').trim(); if(!sid){ sid = randSid(); wsSession.value = sid; updateJoinLink() } var btn = document.getElementById('wsConnect'); if(btn && btn.disabled){ wsStatus.textContent = 'Relay not running'; return } if(ws){ try{ ws.close() }catch(e){} ws=null } if(!url){ wsStatus.textContent = 'Missing URL'; return } try{ ws = new WebSocket(url) }catch(e){ wsStatus.textContent = 'Error'; return } wsSid = sid; wsStatus.textContent = 'Connecting...'; ws.onopen = function(){ wsConnected = true; wsStatus.textContent = 'Connected'; var tokEl = document.getElementById('wsToken'); var tok = (tokEl && (tokEl.value||'').trim()) || ''; if(!tok){ try{ tok = localStorage.getItem('bls_relay_token')||'' }catch(_){ } } else { try{ localStorage.setItem('bls_relay_token', tok) }catch(_){ } } try{ ws.send(JSON.stringify({type:'join', sessionId:sid, role:'admin', token: tok || undefined})) }catch(e){} publishState() }; ws.onclose = function(){ wsConnected = false; wsStatus.textContent = 'Disconnected' }; ws.onerror = function(){ wsStatus.textContent = 'Error' }; ws.onmessage = function(ev){ try{ var msg = JSON.parse(ev.data); if(msg && msg.type==='joined'){ wsStatus.textContent = 'Connected' } else if(msg && msg.type==='join_denied'){ wsStatus.textContent = 'Access code required β€” enter it below'; wsConnected = false } else if(msg && msg.type==='patientViewport'){ var pv = msg.payload||{}; try{ window.PatientViewport = pv }catch(_){ } var r = window.AdminRenderer; if(r){ r.resize(parseInt(pv.canvasW||640,10), parseInt(pv.canvasH||360,10)) } var pvShort = document.getElementById('patientViewportShort'); if(pvShort){ pvShort.textContent = (pv.canvasW||'?')+'Γ—'+(pv.canvasH||'?') } } }catch(e){} } } function wsDisconnect(){ if(ws){ try{ ws.close() }catch(e){} } ws=null; wsConnected=false; wsStatus.textContent = 'Disconnected' } function refreshNetMode(){ var m = (netModeSel && netModeSel.value) || 'relay'; if(rtcControls) rtcControls.style.display = (m==='webrtc'? 'flex':'none'); var relayRows = document.querySelectorAll('#osHelp, #wsUrl, #wsSession, #detectIP, #wsConnect, #wsDisconnect'); for(var i=0;i + + + + + backnforth β€” How it works + + + + +
+

backnforth

+
Bilateral stimulation for therapy β€” on whatever screen you already have.
+ +
+ +

What is this?

+

A gentle moving dot (and optional sound) that a patient follows with + their eyes while you guide the session. It runs in any web browser β€” + an old laptop, a second-hand phone, anything with a screen.

+
+ +
+ +

1Open it

+

Open the site on your computer. You'll see one big green + START button and a few large sliders. That's the whole + cockpit.

+
+ +
+ +

2Give the patient a screen

+

Easiest: tap Open patient screen and turn that window + to face them. Or tap Copy link and open it on their + phone or tablet β€” the two screens stay in sync by themselves.

+
+ +
+ +

3Press start

+

The dot glides left and right. The patient follows it with their + eyes while you lead the session. Sound can tick from side to side too + if you want it.

+
+ +
+ +

4Make it comfortable

+

Slide between turtle and rabbit until the pace feels right. Change + the size, the color, the sound β€” the patient's screen updates + instantly.

+
+ +
+ +

⚠️ Go gently

+

Ask about seizure history before showing moving or pulsing visuals. + Bilateral stimulation can bring up strong feelings β€” this tool assists + a trained clinician, it isn't therapy by itself. Pause any time; the + stop button is always one tap away.

+
+ + Open backnforth β†’ +
Free and open source (MIT). Patient data never leaves + your device.
+
+ + diff --git a/server.py b/server.py index c2d6bec..a50ab05 100644 --- a/server.py +++ b/server.py @@ -25,6 +25,7 @@ _WS_GUID = b'258EAFA5-E914-47DA-95CA-C5AB0DC85B11' SESSIONS = {} # session id -> set of live WSHandler LOCK = threading.Lock() _SERVERS = [] +TOKENS = None # None = open relay; a set = hosts must present a valid access code def local_ip(): @@ -86,12 +87,28 @@ class WSHandler(socketserver.BaseRequestHandler): return t = data.get('type') if t == 'join': - self.session_id = data.get('sessionId') or 'default' + sid = data.get('sessionId') or 'default' + if TOKENS is not None: + # hosts need an access code; patients may only join a LIVE session + # (they got the random room id from their therapist's link) + if data.get('role') == 'patient': + with LOCK: + live = bool(SESSIONS.get(sid)) + if not live: + self._send_json({'type': 'join_denied', 'reason': 'session not live'}) + return + elif data.get('token') not in TOKENS: + self._send_json({'type': 'join_denied', 'reason': 'access code required'}) + return + self.session_id = sid 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' + if TOKENS is not None and not self.session_id: + return # hardened relay only forwards for joined connections + sid = (self.session_id if TOKENS is not None + else 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, ())) @@ -219,9 +236,15 @@ def _argval(flag, default): def main(): + global TOKENS os.chdir(os.path.dirname(os.path.abspath(__file__))) bind = _argval('--bind', '0.0.0.0') # e.g. a tailnet IP behind a reverse proxy http_port = int(_argval('--http-port', HTTP_PORT)) + tokens_file = _argval('--tokens', None) # one access code per line, # comments ok + if tokens_file: + with open(tokens_file, encoding='utf-8') as f: + TOKENS = {l.strip() for l in f if l.strip() and not l.startswith('#')} + print('relay hardened: %d access codes loaded' % len(TOKENS)) socketserver.ThreadingTCPServer.allow_reuse_address = True ThreadingHTTPServer.allow_reuse_address = True