v1.1 security release: PBKDF2 passwords, session expiry, rate limiting, SSE streaming

- store.py: passwords now PBKDF2-HMAC-SHA256 (390k iters, random salt,
  constant-time compare). Legacy unsalted-SHA256 hashes upgrade transparently
  on successful login — zero impact on existing users.
- Sessions expire after 30 days (lazy cleanup on token lookup).
- Rate limiting on register (5/hr/IP), login (10/15min per IP AND per
  username), chat (30/hr/IP) — SQLite-backed, shared across gunicorn workers.
- Registration now requires 8+ char passwords.
- New /api/chat/stream SSE endpoint: citations first ('meta'), streamed
  answer deltas, 'done'. Client auto-falls back to non-streaming.
- nginx: proxy_buffering off for SSE.
This commit is contained in:
drjones
2026-09-07 19:03:37 -07:00
parent 9f9d812320
commit f02124c9de
4 changed files with 281 additions and 15 deletions

View File

@@ -316,16 +316,46 @@
async function speak(text){
try{ const r=await fetch('/api/speak',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({text:text.replace(/\n+/g,'. ').substring(0,2500),voice:mySettings.tts_voice||'aria'})}); const b=await r.blob(); new Audio(URL.createObjectURL(b)).play(); }catch(e){}
}
function liveBubble(){
const m=document.createElement('div'); m.className='msg agent';
const l=document.createElement('div'); l.className='who-label'; l.textContent=(AGENTS.find(x=>x.id===active)?.name||'Attorney');
const b=document.createElement('div'); b.className='bubble'; b.textContent='';
m.appendChild(l); m.appendChild(b);
document.getElementById('messages').appendChild(m);
document.getElementById('messages').scrollTop=document.getElementById('messages').scrollHeight;
return {m, b};
}
async function send(){
const t=document.getElementById('input').value.trim(); if(!t)return;
document.getElementById('input').value='';
addBubble('user',t); history[active].push({role:'user',content:t});
typing(true); document.getElementById('sendBtn').disabled=true;
try{
const r=await fetch('/api/chat',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify({agent_id:active,message:t,history:history[active].slice(0,-1)})});
const d=await r.json(); typing(false);
if(d.answer){ addBubble('assistant',d.answer,d.citations); history[active].push({role:'assistant',content:d.answer,citations:d.citations}); }
else addBubble('assistant',d.error||'Something went wrong.',null);
const r=await fetch('/api/chat/stream',{method:'POST',headers:{'Content-Type':'application/json',...authHeaders()},body:JSON.stringify({agent_id:active,message:t,history:history[active].slice(0,-1)})});
if(!r.ok || !r.body){ // fallback to non-streaming
const d=await r.json().catch(()=>({})); typing(false);
if(d.answer){ addBubble('assistant',d.answer,d.citations); history[active].push({role:'assistant',content:d.answer,citations:d.citations}); }
else addBubble('assistant',d.error||'Something went wrong.',null);
} else {
typing(false);
const live=liveBubble(); let acc=''; let cites=null;
const reader=r.body.getReader(); const dec=new TextDecoder(); let buf='';
while(true){
const {done,value}=await reader.read(); if(done)break;
buf+=dec.decode(value,{stream:true});
const parts=buf.split('\n\n'); buf=parts.pop();
for(const part of parts){
const ev=(part.match(/^event: (.+)$/m)||[])[1];
const dataLine=(part.match(/^data: (.+)$/m)||[])[1];
if(!ev||!dataLine)continue;
const payload=JSON.parse(dataLine);
if(ev==='delta'){ acc+=payload.t; live.b.textContent=acc; document.getElementById('messages').scrollTop=document.getElementById('messages').scrollHeight; }
else if(ev==='done'){ cites=payload.citations; }
}
}
live.m.remove();
addBubble('assistant',acc,cites); history[active].push({role:'assistant',content:acc,citations:cites});
}
}catch(e){ typing(false); addBubble('assistant','Could not reach the engine.',null); }
finally{ document.getElementById('sendBtn').disabled=false; }
}