105 lines
5.7 KiB
JavaScript
105 lines
5.7 KiB
JavaScript
function g(id){return document.getElementById(id)}
|
|
function status(t){g('status').textContent=t}
|
|
const params = new URLSearchParams(location.search)
|
|
const shareId = params.get('id') || location.pathname.split('/').filter(Boolean).pop()
|
|
const pAuto = params.get('p')
|
|
let meta=null
|
|
let fileKey=null
|
|
let sdebug=[]
|
|
function slog(){ const t=new Date().toISOString(); const msg=[t].concat([].slice.call(arguments)).join(' '); sdebug.push(msg); if(sdebug.length>200) sdebug.shift(); const el=g('shareDebug'); if(el) el.textContent=sdebug.join('\n') }
|
|
const CLIENT_VER = 'sharejs-2025-11-25-02'
|
|
slog('client.ver', CLIENT_VER)
|
|
slog('share.id', shareId)
|
|
slog('location', location.href)
|
|
async function deriveShareKey(pwd, salt){
|
|
const material = await crypto.subtle.importKey('raw', new TextEncoder().encode(pwd), 'PBKDF2', false, ['deriveKey'])
|
|
return crypto.subtle.deriveKey({ name:'PBKDF2', hash:'SHA-256', salt, iterations:200000 }, material, { name:'AES-GCM', length:256 }, false, ['encrypt','decrypt'])
|
|
}
|
|
async function unwrapFileKey(wrappedB64, shareKey, ivB64){
|
|
const iv = new Uint8Array([...atob(ivB64)].map(c=>c.charCodeAt(0)))
|
|
const ct = new Uint8Array([...atob(wrappedB64)].map(c=>c.charCodeAt(0)))
|
|
const pt = await crypto.subtle.decrypt({ name:'AES-GCM', iv }, shareKey, ct)
|
|
return new Uint8Array(pt)
|
|
}
|
|
async function deriveFileKeyFromBytes(bytes){
|
|
return crypto.subtle.importKey('raw', bytes, { name:'AES-GCM', length:256 }, false, ['decrypt'])
|
|
}
|
|
async function ivForChunk(saltBytes, index){
|
|
const data = new Uint8Array(saltBytes.length+4)
|
|
data.set(saltBytes,0)
|
|
data.set(new Uint8Array([index>>>24,(index>>>16)&255,(index>>>8)&255,index&255]), saltBytes.length)
|
|
const d = await crypto.subtle.digest('SHA-256', data)
|
|
return new Uint8Array(d).slice(0,12)
|
|
}
|
|
async function openShare(){
|
|
const pwd = g('sharePwd').value
|
|
slog('open.pwd', pwd)
|
|
if(!pwd){status('请输入分享密码');return}
|
|
status('验证中...')
|
|
const started = performance.now()
|
|
const r = await fetch(`/api/psc/share/${shareId}/init`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ password: pwd }) })
|
|
let j=null
|
|
try { j = await r.json() } catch(e){ j={ error:'bad_json' } }
|
|
slog('open.status', r.status); slog('open.json', JSON.stringify(j)); if(j && j.error) slog('open.json.error', j.error)
|
|
if(j && j.expected) slog('open.hash.expected', j.expected)
|
|
if(j && j.actual) slog('open.hash.actual', j.actual)
|
|
if(j && j.shareSaltB64) slog('open.shareSaltB64.len', (j.shareSaltB64||'').length)
|
|
slog('open.ms', (performance.now()-started).toFixed(2))
|
|
if(!r.ok){ status('密码错误或分享不存在'); return }
|
|
meta = j
|
|
g('fname').textContent = j.name
|
|
g('fsize').textContent = (j.size/1048576).toFixed(2)+' MB'
|
|
const shareSalt = new Uint8Array([...atob(j.shareSaltB64)].map(c=>c.charCodeAt(0)))
|
|
const sKey = await deriveShareKey(pwd, shareSalt)
|
|
const fkBytes = await unwrapFileKey(j.wrappedKeyB64, sKey, j.ivB64)
|
|
fileKey = await deriveFileKeyFromBytes(fkBytes)
|
|
const fkHash = new Uint8Array(await crypto.subtle.digest('SHA-256', fkBytes))
|
|
slog('fk.hash', btoa(String.fromCharCode(...fkHash)))
|
|
slog('meta.fileId', j.fileId)
|
|
slog('meta.chunkCount', j.chunkCount)
|
|
slog('unwrap.len', fkBytes.length)
|
|
g('info').classList.remove('hidden')
|
|
status('验证通过')
|
|
}
|
|
async function download(){
|
|
if(!meta || !fileKey){ await openShare(); if(!meta || !fileKey){ status('请先验证分享'); return } }
|
|
status('开始下载')
|
|
const fileSalt = new Uint8Array([...atob(meta.fileSalt)].map(c=>c.charCodeAt(0)))
|
|
const chunks = new Array(meta.chunkCount)
|
|
let active=0, next=0, processed=0
|
|
const maxParallel=4
|
|
async function runOne(i){
|
|
try {
|
|
active++
|
|
slog('dl.req', i)
|
|
const r = await fetch(`/api/psc/share/${shareId}/chunk?index=${i}`)
|
|
slog('dl.http', r.status)
|
|
if(!r.ok){ throw new Error('http '+r.status) }
|
|
const buf = new Uint8Array(await r.arrayBuffer())
|
|
slog('dl.buf.len', buf.length)
|
|
const iv = await ivForChunk(fileSalt,i)
|
|
slog('dl.iv.b64', btoa(String.fromCharCode(...iv)))
|
|
const pt = new Uint8Array(await crypto.subtle.decrypt({ name:'AES-GCM', iv }, fileKey, buf))
|
|
chunks[i]=pt; processed+=pt.length; status(`已下载 ${(processed/meta.size*100).toFixed(2)}%`)
|
|
slog('dl.ok', i, pt.length)
|
|
} catch(e){ slog('dl.err', i, e && (e.message||String(e))); status('分片下载失败') }
|
|
finally { active--; schedule() }
|
|
}
|
|
function schedule(){
|
|
slog('dl.schedule', 'active', active, 'next', next)
|
|
while(active<maxParallel && next<meta.chunkCount){ runOne(next); next++ }
|
|
if(active===0 && next>=meta.chunkCount){ finish() }
|
|
}
|
|
function finish(){
|
|
const out=new Uint8Array(meta.size); let off=0; for(let i=0;i<meta.chunkCount;i++){ out.set(chunks[i],off); off+=chunks[i].length }
|
|
slog('dl.finish.size', out.length)
|
|
const blob=new Blob([out]); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download=meta.name; a.target='_blank'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href), 30000); status('下载完成')
|
|
}
|
|
schedule()
|
|
}
|
|
g('btnOpen').onclick=openShare
|
|
g('btnDownload').onclick=download
|
|
if(pAuto){ g('sharePwd').value = pAuto; slog('auto.p', pAuto); openShare() }
|
|
const btnCopy = document.getElementById('btnCopyShareDebug'); if(btnCopy){ btnCopy.onclick = async()=>{ try{ await navigator.clipboard.writeText((g('shareDebug')?.textContent)||''); status('调试信息已复制') } catch{ status('请手动复制调试信息') } } }
|
|
document.addEventListener('visibilitychange', ()=>{ if(document.visibilityState==='visible'){ const inputs = Array.from(document.querySelectorAll('input,textarea')); inputs.forEach(el=>{ if(el.type==='checkbox' || el.type==='radio'){ el.checked=false } else { el.value='' } }) } })
|