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 <noreply@anthropic.com>
This commit is contained in:
parent
55d1686d8d
commit
eed30a2ba5
@ -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
|
||||
|
||||
43
easy.html
43
easy.html
@ -110,6 +110,21 @@
|
||||
<button id="openPatientBig" class="bigbtn">🖥️ Open patient screen</button>
|
||||
<button id="copyLinkBig" class="bigbtn" hidden>🔗 Copy link for patient's phone / tablet</button>
|
||||
|
||||
<div id="accessCard" class="card" hidden>
|
||||
<label class="head">🔑 Access code</label>
|
||||
<div style="font-size:14px; opacity:.8; margin-bottom:10px">
|
||||
Linking a second device through this site needs a free access code
|
||||
(that's how we keep the relay clean). No code? Everything still works
|
||||
on one computer — just tap "Open patient screen" above.
|
||||
</div>
|
||||
<div style="display:flex; gap:10px">
|
||||
<input id="accessInput" type="text" placeholder="paste your code"
|
||||
style="flex:1; min-height:48px; font-size:17px; border-radius:10px; border:2px solid #2d3743; background:#222a34; color:#e6edf3; padding:0 12px">
|
||||
<button id="accessSave" class="chip" style="flex:0 0 auto; padding:0 18px">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<a class="adv" href="manual.html">📖 How it works (picture guide)</a>
|
||||
<a class="adv" href="index.html">Advanced controls →</a>
|
||||
</main>
|
||||
|
||||
@ -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){
|
||||
|
||||
@ -630,6 +630,7 @@
|
||||
<div class="connection-inputs">
|
||||
<input type="text" id="wsUrl" placeholder="ws://192.168.x.x:8787" class="text-input">
|
||||
<input type="text" id="wsSession" placeholder="auto (random room id)" class="text-input">
|
||||
<input type="text" id="wsToken" placeholder="access code (hosted relay only)" class="text-input">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -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<relayRows.length;i++){ var el=relayRows[i]; if(!el) continue }
|
||||
var btn = document.getElementById('wsConnect'); if(btn) btn.disabled = (m==='webrtc'); var s = document.getElementById('wsStatus'); if(s && m==='webrtc'){ s.textContent = 'P2P mode' }
|
||||
|
||||
@ -187,11 +187,15 @@
|
||||
var msg = JSON.parse(ev.data);
|
||||
if(msg && msg.type === 'state'){
|
||||
handleStateUpdate(msg.payload || {});
|
||||
} else if(msg && msg.type === 'join_denied'){
|
||||
// hardened relay: patients can only join a live session
|
||||
var ov = document.getElementById('overlay');
|
||||
if(ov){ ov.style.display = 'flex'; ov.innerHTML = '<span>Waiting for your therapist to connect — this will join automatically</span>'; }
|
||||
}
|
||||
} catch(e){}
|
||||
};
|
||||
|
||||
ws.onclose = function(){ ws = null; };
|
||||
ws.onclose = function(){ ws = null; setTimeout(function(){ connectWebSocket(url, sid) }, 5000); };
|
||||
}
|
||||
|
||||
function setupWebRTC(){
|
||||
|
||||
89
manual.html
Normal file
89
manual.html
Normal file
@ -0,0 +1,89 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>backnforth — How it works</title>
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='6' fill='%231f6feb'/%3E%3C/svg%3E">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; background: #fdf6ec; color: #26333b;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
||||
main { max-width: 640px; margin: 0 auto; padding: 20px 18px 60px; }
|
||||
h1 { text-align: center; font-size: 30px; margin: 18px 0 4px; color: #1b6e63; }
|
||||
.sub { text-align: center; font-size: 17px; opacity: .75; margin-bottom: 24px; }
|
||||
.step { background: #fff; border-radius: 18px; padding: 20px; margin-top: 22px;
|
||||
box-shadow: 0 3px 14px rgba(38,51,59,.08); }
|
||||
.step img { width: 100%; border-radius: 12px; display: block; }
|
||||
.step h2 { font-size: 22px; margin: 14px 0 6px; color: #1b6e63; }
|
||||
.step .n { display: inline-block; background: #e8734a; color: #fff; border-radius: 50%;
|
||||
width: 34px; height: 34px; line-height: 34px; text-align: center; font-size: 18px;
|
||||
margin-right: 8px; }
|
||||
.step p { font-size: 17px; line-height: 1.6; margin: 6px 0 0; }
|
||||
.safety { background: #fff3e6; border: 2px solid #e8734a55; }
|
||||
.go { display: block; text-align: center; background: #1b6e63; color: #fff;
|
||||
text-decoration: none; font-size: 20px; padding: 18px; border-radius: 14px;
|
||||
margin-top: 28px; }
|
||||
.tiny { text-align: center; font-size: 13px; opacity: .6; margin-top: 18px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>backnforth</h1>
|
||||
<div class="sub">Bilateral stimulation for therapy — on whatever screen you already have.</div>
|
||||
|
||||
<div class="step">
|
||||
<img src="img/manual/01-cover.png" alt="" onerror="this.style.display='none'">
|
||||
<h2>What is this?</h2>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<img src="img/manual/02-open.png" alt="" onerror="this.style.display='none'">
|
||||
<h2><span class="n">1</span>Open it</h2>
|
||||
<p>Open the site on your computer. You'll see one big green
|
||||
<strong>START</strong> button and a few large sliders. That's the whole
|
||||
cockpit.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<img src="img/manual/03-link.png" alt="" onerror="this.style.display='none'">
|
||||
<h2><span class="n">2</span>Give the patient a screen</h2>
|
||||
<p>Easiest: tap <strong>Open patient screen</strong> and turn that window
|
||||
to face them. Or tap <strong>Copy link</strong> and open it on their
|
||||
phone or tablet — the two screens stay in sync by themselves.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<img src="img/manual/04-follow.png" alt="" onerror="this.style.display='none'">
|
||||
<h2><span class="n">3</span>Press start</h2>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
|
||||
<div class="step">
|
||||
<img src="img/manual/05-adjust.png" alt="" onerror="this.style.display='none'">
|
||||
<h2><span class="n">4</span>Make it comfortable</h2>
|
||||
<p>Slide between turtle and rabbit until the pace feels right. Change
|
||||
the size, the color, the sound — the patient's screen updates
|
||||
instantly.</p>
|
||||
</div>
|
||||
|
||||
<div class="step safety">
|
||||
<img src="img/manual/06-care.png" alt="" onerror="this.style.display='none'">
|
||||
<h2>⚠️ Go gently</h2>
|
||||
<p>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.</p>
|
||||
</div>
|
||||
|
||||
<a class="go" href="easy.html">Open backnforth →</a>
|
||||
<div class="tiny">Free and open source (MIT). Patient data never leaves
|
||||
your device.</div>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
27
server.py
27
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user