/** * ───────────────────────────────────────────── * SPB Drive — Personal Cloud Drive * ───────────────────────────────────────────── * Author : Simon-Pierre Boucher * Contact : contact@spboucher.ai * File : src/web/assets/js/request-page.js * Purpose : Public file-request page — chunked uploads from strangers * License : MIT © Simon-Pierre Boucher * ───────────────────────────────────────────── */ import { h, fmtSize, toast } from './ui.js'; const page = document.querySelector('.share-page'); const token = page.dataset.token; const maxFiles = page.dataset.maxFiles ? Number(page.dataset.maxFiles) : null; let uploadedCount = Number(page.dataset.received || 0); const drop = document.getElementById('requestDrop'); const list = document.getElementById('requestList'); const pick = document.getElementById('requestPick'); const base = `/r/${encodeURIComponent(token)}`; async function jsonOrThrow(res) { const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null; if (!res.ok) throw new Error(data?.error?.message ?? `HTTP ${res.status}`); return data; } async function uploadFile(file) { if (maxFiles !== null && uploadedCount >= maxFiles) { toast('This link has reached its file limit', { error: true }); return; } const row = h('div.upload-item', {}, h('div.fname', {}, h('span.n', {}, file.name), h('span.s', {}, 'Starting…'), h('div.prog', {}, h('i')))); list.append(row); const setStatus = (text) => { row.querySelector('.s').textContent = text; }; const setPct = (pct) => { row.querySelector('.prog i').style.width = `${pct}%`; }; try { const init = await jsonOrThrow(await fetch(`${base}/upload/init`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ name: file.name, size: file.size }), })); const { uploadId, chunkSize, nChunks } = init; for (let n = 0; n < nChunks; n += 1) { const slice = file.slice(n * chunkSize, Math.min((n + 1) * chunkSize, file.size)); let attempt = 0; for (;;) { try { const res = await fetch(`${base}/upload/${uploadId}/chunk/${n}`, { method: 'PUT', headers: { 'content-type': 'application/octet-stream' }, body: slice, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); break; } catch (err) { attempt += 1; if (attempt > 3) throw err; await new Promise((r) => setTimeout(r, 1000 * attempt)); } } const sent = Math.min((n + 1) * chunkSize, file.size); setPct(file.size ? (sent / file.size) * 100 : 100); setStatus(`${fmtSize(sent)} / ${fmtSize(file.size)}`); } const done = await jsonOrThrow(await fetch(`${base}/upload/${uploadId}/complete`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ mime: file.type || undefined }), })); uploadedCount += 1; row.classList.add('done'); setPct(100); setStatus(`Sent · ${done.name}`); } catch (err) { row.classList.add('err'); setStatus(`Failed — ${err.message}`); } } async function handleFiles(files) { const queue = [...files]; if (!queue.length) return; if (maxFiles !== null && uploadedCount + queue.length > maxFiles) { toast(`Only ${Math.max(0, maxFiles - uploadedCount)} more file(s) allowed on this link`, { error: true }); queue.length = Math.max(0, maxFiles - uploadedCount); } for (const file of queue) await uploadFile(file); } drop.addEventListener('click', () => pick.click()); drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick.click(); } }); pick.addEventListener('change', (e) => { handleFiles(e.target.files); e.target.value = ''; }); for (const ev of ['dragenter', 'dragover']) { drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('active'); }); } for (const ev of ['dragleave', 'drop']) { drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('active'); }); } drop.addEventListener('drop', (e) => handleFiles(e.dataTransfer?.files ?? []));