26 lines
5.3 KiB
JavaScript
26 lines
5.3 KiB
JavaScript
/* Protected evidence is revealed only on an explicit, attributable action.
|
|
* The controller retains no payload; the view is cleared after one minute. */
|
|
(function(root){
|
|
function createEvidenceViewer({principal,request,render,clear,setTimer=setTimeout,cancelTimer=clearTimeout}){
|
|
let generation=0,incident=null,timer=null,identity=null,known=new Map();
|
|
const allowed=()=>principal()?.permissions?.includes('evidence.read')===true;
|
|
const actor=()=>{const p=principal();return p?`${p.sub}|${p.role}`:null;};
|
|
function reset(){generation++;incident=null;identity=null;known.clear();if(timer!==null)cancelTimer(timer);timer=null;clear();}
|
|
function valid(g,id,who){return generation===g&&incident===id&&identity===who&&actor()===who&&allowed();}
|
|
async function open(id){reset();if(!allowed())return;incident=id;identity=actor();const g=generation,who=identity;try{const data=await request(`/api/admin/incidents/${encodeURIComponent(id)}/evidence`);if(valid(g,id,who)){const rows=(Array.isArray(data.evidence)?data.evidence:[]).slice(0,100);known=new Map(rows.filter(row=>typeof row.id==='string'&&row.availability==='available').map(row=>[row.id,row.sourceEventId]));render('metadata',rows);}}catch{if(valid(g,id,who))render('error','Protected evidence is unavailable.');}}
|
|
async function reveal(id,reason){if(!allowed()||!incident)return;if(!known.has(id)){render('error','Select available evidence from this incident.');return;}if(!['incident-response','investigation','compliance'].includes(reason)){render('error','Choose an access purpose before revealing evidence.');return;}const selected=incident,who=identity,g=++generation;if(timer!==null)cancelTimer(timer);timer=null;render('plaintext','');try{const data=await request(`/api/admin/evidence/${encodeURIComponent(id)}?reason=${encodeURIComponent(reason)}`);if(!valid(g,selected,who))return;if(data.id!==id||(Number.isSafeInteger(known.get(id))&&data.sourceEventId!==known.get(id))||!['download','interaction'].includes(data.type)||!data.payload||typeof data.payload!=='object')throw Error('Invalid response');const value=JSON.stringify(data.payload,null,2);if(value.length>65536)throw Error('Oversized response');render('plaintext',value);timer=setTimer(reset,60000);}catch{if(valid(g,selected,who))render('error','Evidence could not be revealed. It may be expired, unavailable or access may have changed.');}}
|
|
function permissions(){if(!allowed()||identity!==null&&identity!==actor())reset();}
|
|
return {open,reveal,reset,permissions};
|
|
}
|
|
if(typeof module==='object'&&module.exports)module.exports={createEvidenceViewer};
|
|
root.createEvidenceViewer=createEvidenceViewer;
|
|
if(typeof document==='undefined')return;
|
|
const box=document.getElementById('incident-evidence'),rows=document.getElementById('evidence-rows'),plain=document.getElementById('evidence-plaintext'),message=document.getElementById('evidence-status');
|
|
const viewer=createEvidenceViewer({principal:()=>currentPrincipal,request:async path=>{const headers={};if(credential)headers.authorization=`Bearer ${credential}`;const result=await fetch(path,{headers,cache:'no-store',redirect:'error'});if(!result.ok)throw Error('Unavailable');if(Number(result.headers.get('content-length'))>131072)throw Error('Oversized response');const reader=result.body.getReader(),decoder=new TextDecoder();let text='',bytes=0;try{for(;;){const part=await reader.read();if(part.done)break;bytes+=part.value.byteLength;if(bytes>131072)throw Error('Oversized response');text+=decoder.decode(part.value,{stream:true});}text+=decoder.decode();return JSON.parse(text);}finally{await reader.cancel().catch(()=>{});reader.releaseLock();}},clear:()=>{plain.textContent='';plain.hidden=true;rows.replaceChildren();message.textContent='';box.hidden=true;},render:(kind,value)=>{box.hidden=false;if(kind==='plaintext'){plain.textContent=value;plain.hidden=!value;message.textContent=value?'Evidence access is audited. This view clears within one minute.':'';}else if(kind==='error'){plain.textContent='';plain.hidden=true;message.textContent=value;}else{rows.replaceChildren();message.textContent=value.length?'Select a purpose before revealing protected evidence.':'No protected evidence is linked to this incident.';for(const item of value.slice(0,100)){if(!/^[a-f0-9-]{36}$/i.test(item.id)||!['download','interaction'].includes(item.type))continue;const row=document.createElement('div'),label=document.createElement('span'),button=document.createElement('button');row.className='evidence-row';label.textContent=`${item.type} · ${Number.isFinite(Date.parse(item.createdAt))?new Date(item.createdAt).toLocaleString():'Unknown date'}`;button.textContent=item.availability==='available'?'Reveal':'Unavailable';button.disabled=item.availability!=='available';button.className='secondary';button.onclick=()=>viewer.reveal(item.id,document.getElementById('evidence-reason').value);row.append(label,button);rows.append(row);}}}});
|
|
root.incidentEvidenceViewer=viewer;
|
|
document.getElementById('evidence-close').onclick=()=>viewer.reset();
|
|
for(const id of ['admin-logout','refresh'])document.getElementById(id).addEventListener('click',()=>viewer.reset(),true);
|
|
document.addEventListener('visibilitychange',()=>{if(document.hidden)viewer.reset();});
|
|
root.addEventListener('pagehide',()=>viewer.reset());
|
|
})(typeof window==='undefined'?globalThis:window);
|