SPB Git

spb/drive Public

SPB Drive — self-hosted personal cloud drive (files, previews, sharing) on the MacLustr cluster.

JavaScript 82.7% CSS 10.6% Nunjucks 3.6% Shell 1.8% SQL 1.3%
4.4 KB · 111 lines javascript
Raw Blame History
1/**2 * ─────────────────────────────────────────────3 *  SPB Drive — Personal Cloud Drive4 * ─────────────────────────────────────────────5 *  Author  : Simon-Pierre Boucher6 *  Contact : contact@spboucher.ai7 *  File    : src/web/assets/js/request-page.js8 *  Purpose : Public file-request page — chunked uploads from strangers9 *  License : MIT © Simon-Pierre Boucher10 * ─────────────────────────────────────────────11 */1213import { h, fmtSize, toast } from './ui.js';1415const page = document.querySelector('.share-page');16const token = page.dataset.token;17const maxFiles = page.dataset.maxFiles ? Number(page.dataset.maxFiles) : null;18let uploadedCount = Number(page.dataset.received || 0);1920const drop = document.getElementById('requestDrop');21const list = document.getElementById('requestList');22const pick = document.getElementById('requestPick');2324const base = `/r/${encodeURIComponent(token)}`;2526async function jsonOrThrow(res) {27  const data = res.headers.get('content-type')?.includes('json') ? await res.json() : null;28  if (!res.ok) throw new Error(data?.error?.message ?? `HTTP ${res.status}`);29  return data;30}3132async function uploadFile(file) {33  if (maxFiles !== null && uploadedCount >= maxFiles) {34    toast('This link has reached its file limit', { error: true });35    return;36  }37  const row = h('div.upload-item', {},38    h('div.fname', {},39      h('span.n', {}, file.name),40      h('span.s', {}, 'Starting…'),41      h('div.prog', {}, h('i'))));42  list.append(row);43  const setStatus = (text) => { row.querySelector('.s').textContent = text; };44  const setPct = (pct) => { row.querySelector('.prog i').style.width = `${pct}%`; };4546  try {47    const init = await jsonOrThrow(await fetch(`${base}/upload/init`, {48      method: 'POST',49      headers: { 'content-type': 'application/json' },50      body: JSON.stringify({ name: file.name, size: file.size }),51    }));52    const { uploadId, chunkSize, nChunks } = init;53    for (let n = 0; n < nChunks; n += 1) {54      const slice = file.slice(n * chunkSize, Math.min((n + 1) * chunkSize, file.size));55      let attempt = 0;56      for (;;) {57        try {58          const res = await fetch(`${base}/upload/${uploadId}/chunk/${n}`, {59            method: 'PUT',60            headers: { 'content-type': 'application/octet-stream' },61            body: slice,62          });63          if (!res.ok) throw new Error(`HTTP ${res.status}`);64          break;65        } catch (err) {66          attempt += 1;67          if (attempt > 3) throw err;68          await new Promise((r) => setTimeout(r, 1000 * attempt));69        }70      }71      const sent = Math.min((n + 1) * chunkSize, file.size);72      setPct(file.size ? (sent / file.size) * 100 : 100);73      setStatus(`${fmtSize(sent)} / ${fmtSize(file.size)}`);74    }75    const done = await jsonOrThrow(await fetch(`${base}/upload/${uploadId}/complete`, {76      method: 'POST',77      headers: { 'content-type': 'application/json' },78      body: JSON.stringify({ mime: file.type || undefined }),79    }));80    uploadedCount += 1;81    row.classList.add('done');82    setPct(100);83    setStatus(`Sent · ${done.name}`);84  } catch (err) {85    row.classList.add('err');86    setStatus(`Failed — ${err.message}`);87  }88}8990async function handleFiles(files) {91  const queue = [...files];92  if (!queue.length) return;93  if (maxFiles !== null && uploadedCount + queue.length > maxFiles) {94    toast(`Only ${Math.max(0, maxFiles - uploadedCount)} more file(s) allowed on this link`, { error: true });95    queue.length = Math.max(0, maxFiles - uploadedCount);96  }97  for (const file of queue) await uploadFile(file);98}99100drop.addEventListener('click', () => pick.click());101drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick.click(); } });102pick.addEventListener('change', (e) => { handleFiles(e.target.files); e.target.value = ''; });103104for (const ev of ['dragenter', 'dragover']) {105  drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.add('active'); });106}107for (const ev of ['dragleave', 'drop']) {108  drop.addEventListener(ev, (e) => { e.preventDefault(); drop.classList.remove('active'); });109}110drop.addEventListener('drop', (e) => handleFiles(e.dataTransfer?.files ?? []));111