// Pithos in the browser: Drives on Silent Mode, opened with a key file. // Everything is decrypted in this page; see lib/hosted.js for the protocol. import * as Hosted from './lib/hosted.js'; const $ = (s) => document.querySelector(s); const h = (tag, attrs = {}, ...kids) => { const el = document.createElement(tag); for (const [k, v] of Object.entries(attrs)) { if (v == null || v === false) continue; if (k.startsWith('on')) el.addEventListener(k.slice(2), v); else if (k === 'class') el.className = v; else el.setAttribute(k, v === true ? '' : v); } for (const c of kids.flat(Infinity)) if (c != null && c !== false) el.append(c instanceof Node ? c : String(c)); return el; }; const put = (el, ...kids) => { el.replaceChildren(); for (const c of kids.flat(Infinity)) if (c != null && c !== false) el.append(c instanceof Node ? c : String(c)); }; function toast(msg, err) { const t = h('div', { class: `toast${err ? ' err' : ''}` }, msg); $('#toasts').append(t); setTimeout(() => t.remove(), err ? 7000 : 3500); return t; } const fail = (e) => toast(e?.message || String(e), true); function fmtBytes(n) { if (!Number.isFinite(n)) return ''; const u = ['B', 'KB', 'MB', 'GB', 'TB']; let i = 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return `${n < 10 && i ? n.toFixed(1) : Math.round(n)} ${u[i]}`; } const fmtDate = (s) => { const d = new Date(s); return Number.isNaN(+d) ? '' : d.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' }); }; const baseName = (p) => p.replace(/\/$/, '').split('/').pop(); const KIND = (k) => { const e = k.split('.').pop().toLowerCase(); if (/^(png|jpe?g|gif|webp|svg|avif)$/.test(e)) return 'image'; if (/^(mp3|ogg|oga|wav|flac|m4a|opus)$/.test(e)) return 'audio'; if (/^(mp4|webm|mov)$/.test(e)) return 'video'; if (/^(txt|md|json|csv|log|xml|yml|yaml|ini|conf|js|css|html?)$/.test(e)) return 'text'; return 'file'; }; const ICON = { folder: '📁', image: '🖼️', audio: '🎵', video: '🎬', text: '📄', file: '📦', drive: '💽' }; // ---- session ----------------------------------------------------------------------------- const S = { key: null, token: null, drives: null, user: null, fixedBucket: null, label: '' }; async function signInWith(text) { const key = await Hosted.readKeyFile(text); if (key.kind === 'owner') { const s = await Hosted.signIn(key.identitySeed); if (s.state !== 'active') throw new Error(s.state === 'empty' ? 'Nothing is on Silent Mode for this account yet. Move your drives there from Pithos first.' : 'This account is being moved; try again in a few minutes.'); const s3 = await Hosted.ownerS3(s.token); const token = s.token; let rulesCache = null; S.token = token; S.user = s3.user; S.label = `Signed in · ${s.id.slice(0, 8)}…`; try { const me = await Hosted.api('GET', '/hosted/v1/me', { token }); if (me.gate?.name) S.label = `Signed in as ${me.gate.name}`; } catch {} S.drives = new Hosted.Drives({ accessKeyId: s3.accessKeyId, secretKey: s3.secretKey, master: key.encryptionKey, rules: async () => { if (!rulesCache || Date.now() - rulesCache.at > 60_000) rulesCache = { at: Date.now(), list: await Hosted.api('GET', '/hosted/v1/rules', { token }).catch(() => []) }; return Array.isArray(rulesCache.list) ? rulesCache.list : rulesCache.list?.rules || []; }, }); } else { if (key.endpoint && new URL(key.endpoint).host !== location.host && !/silentmode\.st$/.test(new URL(key.endpoint).hostname)) { throw new Error(`This key is for ${new URL(key.endpoint).host}, not Drives on Silent Mode. Use it with Pithos or another S3 app.`); } S.drives = new Hosted.Drives({ accessKeyId: key.accessKeyId, secretKey: key.secretKey }); S.fixedBucket = key.bucket || null; S.label = `S3 key ${key.accessKeyId.slice(0, 6)}…`; } S.key = key; } function signedIn() { $('#signin').hidden = true; $('#app').hidden = false; $('#who').textContent = S.label; $('#who').hidden = false; $('#signout').hidden = false; if (!location.hash || location.hash === '#/') location.hash = S.fixedBucket ? `#/d/${encodeURIComponent(S.fixedBucket)}/` : '#/'; route(); } async function signOut() { if (S.token) await Hosted.signOut(S.token); location.hash = ''; location.reload(); } // ---- sign-in screen -------------------------------------------------------------------------- async function showSignin() { $('#app').hidden = true; $('#signin').hidden = false; const saved = await Hosted.remembered(); $('#unlock').hidden = !saved; $('#keyform').hidden = !!saved; if (saved) $('#unlock-pass').focus(); } function signinError(e) { const el = $('#signin-error'); el.textContent = e?.message || String(e); el.hidden = false; } async function trySignIn(text, rememberPass) { $('#signin-error').hidden = true; const btns = [$('#go'), ...document.querySelectorAll('#unlock-form button')]; btns.forEach((b) => { b.disabled = true; }); try { await signInWith(text); if (rememberPass) { await Hosted.remember(text, rememberPass); } signedIn(); } catch (e) { signinError(e); } finally { btns.forEach((b) => { b.disabled = false; }); } } let pickedText = null; async function pickFile(file) { if (!file) return; if (file.size > 64 * 1024) return signinError(new Error('That file is too big to be a key file.')); pickedText = await file.text(); $('#drop strong').textContent = `Key: ${file.name}`; } function wireSignin() { const drop = $('#drop'); drop.addEventListener('click', () => $('#keyfile').click()); drop.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); $('#keyfile').click(); } }); $('#keyfile').addEventListener('change', () => pickFile($('#keyfile').files[0])); drop.addEventListener('dragover', (e) => { e.preventDefault(); drop.classList.add('over'); }); drop.addEventListener('dragleave', () => drop.classList.remove('over')); drop.addEventListener('drop', (e) => { e.preventDefault(); drop.classList.remove('over'); pickFile(e.dataTransfer.files[0]); }); $('#remember').addEventListener('change', () => { $('#pass-row').hidden = !$('#remember').checked; }); $('#go').addEventListener('click', () => { const text = pickedText || $('#keytext').value.trim(); if (!text) return signinError(new Error('Choose your key file first, or paste it.')); let pass = null; if ($('#remember').checked) { pass = $('#pass').value; if (pass.length < 10) return signinError(new Error('Use a passphrase of at least 10 characters to remember the key.')); } trySignIn(text, pass); }); $('#unlock-form').addEventListener('submit', async (e) => { e.preventDefault(); try { const text = await Hosted.recall($('#unlock-pass').value); if (!text) return showSignin(); await trySignIn(text, null); } catch (err) { signinError(err); } }); $('#forget').addEventListener('click', async () => { await Hosted.forget(); $('#unlock').hidden = true; $('#keyform').hidden = false; }); $('#signout').addEventListener('click', signOut); } // ---- routes: #/ (drives) · #/d/// ----------------------------------------------- function current() { const m = /^#\/d\/([^/]+)\/(.*)$/.exec(location.hash); if (!m) return { bucket: null, prefix: '' }; const prefix = m[2].split('/').map((s) => { try { return decodeURIComponent(s); } catch { return s; } }).join('/'); return { bucket: decodeURIComponent(m[1]), prefix }; } const linkTo = (bucket, prefix = '') => `#/d/${encodeURIComponent(bucket)}/${prefix.split('/').map(encodeURIComponent).join('/')}`; let routeSeq = 0; async function route() { if (!S.drives) return; const seq = ++routeSeq; const { bucket, prefix } = current(); if (!bucket) return showDrives(seq); return showFolder(seq, bucket, prefix); } function crumbs(bucket, prefix) { const parts = []; if (!S.fixedBucket) parts.push(h('a', { href: '#/' }, 'Drives')); if (bucket) { if (parts.length) parts.push(h('span', { class: 'sep' }, '›')); parts.push(h('a', { href: linkTo(bucket) }, `${ICON.drive} ${bucket}`)); let acc = ''; for (const seg of prefix.split('/').filter(Boolean)) { acc += seg + '/'; parts.push(h('span', { class: 'sep' }, '›'), h('a', { href: linkTo(bucket, acc) }, seg)); } } put($('#crumbs'), parts); } async function showDrives(seq) { crumbs(null, ''); $('#toolbar').hidden = true; put($('#list'), h('p', { class: 'empty' }, 'Loading…')); try { const list = await S.drives.listBuckets(); if (seq !== routeSeq) return; if (!list.length) return put($('#list'), h('p', { class: 'empty' }, 'No drives yet. Create one in Pithos.')); put($('#list'), h('table', { class: 'files' }, h('thead', {}, h('tr', {}, h('th', {}, 'Drive'), h('th', { class: 'hide-sm' }, 'Created'))), h('tbody', {}, list.map((b) => h('tr', { class: 'clickable', onclick: () => { location.hash = linkTo(b.name); } }, h('td', {}, h('span', { class: 'name' }, h('span', { class: 'ico' }, ICON.drive), h('span', { class: 'label' }, b.name))), h('td', { class: 'num hide-sm' }, fmtDate(b.created))))))); $('#count').textContent = ''; } catch (e) { if (seq === routeSeq) put($('#list'), h('p', { class: 'empty error' }, e.message)); } } async function showFolder(seq, bucket, prefix) { crumbs(bucket, prefix); $('#toolbar').hidden = false; put($('#list'), h('p', { class: 'empty' }, 'Loading…')); let l; try { l = await S.drives.list(bucket, prefix); } catch (e) { // a folder key may only list its own folder: go there const only = /may only list (.+)$/.exec(e.message || ''); if (only && !prefix && seq === routeSeq) { location.hash = linkTo(bucket, only[1]); return; } if (seq === routeSeq) put($('#list'), h('p', { class: 'empty error' }, e.message)); return; } if (seq !== routeSeq) return; const rows = []; for (const f of l.folders) { rows.push(h('tr', { class: 'clickable', onclick: () => { location.hash = linkTo(bucket, f.path); } }, h('td', {}, h('span', { class: 'name' }, h('span', { class: 'ico' }, ICON.folder), h('span', { class: 'label' }, baseName(f.path)), f.locked && h('span', { class: 'lock' }, 'encrypted'))), h('td', { class: 'num hide-sm' }, ''), h('td', { class: 'num hide-sm' }, ''), h('td', { class: 'actions', onclick: (e) => e.stopPropagation() }, !S.fixedBucket || S.key.kind === 'owner' ? h('button', { class: 'btn small danger', onclick: () => deleteFolder(bucket, f.path) }, 'Delete') : null))); } for (const o of l.files) { const kind = KIND(o.key); const open = o.locked ? null : () => view(bucket, o); rows.push(h('tr', { class: open ? 'clickable' : '', onclick: open }, h('td', {}, h('span', { class: 'name' }, h('span', { class: 'ico' }, ICON[kind]), h('span', { class: 'label', title: o.key }, baseName(o.key)), o.locked && h('span', { class: 'lock' }, 'encrypted'))), h('td', { class: 'num hide-sm' }, fmtBytes(o.size)), h('td', { class: 'num hide-sm' }, fmtDate(o.modified)), h('td', { class: 'actions', onclick: (e) => e.stopPropagation() }, !o.locked && h('button', { class: 'btn small', onclick: () => download(bucket, o.key) }, 'Download'), ' ', !o.locked && h('button', { class: 'btn small', onclick: () => rename(bucket, o.key) }, 'Rename'), ' ', h('button', { class: 'btn small danger', onclick: () => remove(bucket, o.key) }, 'Delete')))); } $('#count').textContent = `${l.folders.length} folders · ${l.files.length} files`; if (!rows.length) return put($('#list'), h('p', { class: 'empty' }, 'This folder is empty. Drop files here or press Upload.')); put($('#list'), h('table', { class: 'files' }, h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'hide-sm' }, 'Size'), h('th', { class: 'hide-sm' }, 'Modified'), h('th', {}))), h('tbody', {}, rows))); } // ---- actions ------------------------------------------------------------------------------------------ async function uploadFiles(files) { const { bucket, prefix } = current(); if (!bucket || !files.length) return; for (const file of files) { const bar = h('progress', { max: file.size || 1, value: 0 }); const t = h('div', { class: 'toast' }, `Uploading ${file.name}`, bar); $('#toasts').append(t); try { await S.drives.write(bucket, prefix + file.name, file, (n) => { bar.value = n; }); t.replaceChildren(`Uploaded ${file.name}`); } catch (e) { t.classList.add('err'); t.replaceChildren(`${file.name}: ${e.message}`); } setTimeout(() => t.remove(), 4000); } route(); } async function newFolder() { const { bucket, prefix } = current(); const name = (prompt('Folder name') || '').trim(); if (!name) return; if (name.includes('/')) return fail(new Error('A folder name cannot contain /')); try { await S.drives.mkdir(bucket, `${prefix}${name}/`); route(); } catch (e) { fail(e); } } function saveBlob(blob, name) { const url = URL.createObjectURL(blob); const a = h('a', { href: url, download: name }); document.body.append(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 60_000); } async function download(bucket, key) { const t = toast(`Preparing ${baseName(key)}…`); try { saveBlob(await S.drives.blob(bucket, key), baseName(key)); t.remove(); } catch (e) { t.remove(); fail(e); } } async function rename(bucket, key) { const name = (prompt('New name', baseName(key)) || '').trim(); if (!name || name === baseName(key)) return; if (name.includes('/')) return fail(new Error('A name cannot contain /')); const t = toast(`Renaming ${baseName(key)}…`); try { await S.drives.rename(bucket, key, key.slice(0, key.length - baseName(key).length) + name); t.remove(); route(); } catch (e) { t.remove(); fail(e); } } async function remove(bucket, key) { if (!confirm(`Delete ${baseName(key)}? This cannot be undone.`)) return; try { await S.drives.remove(bucket, key); toast('Deleted'); route(); } catch (e) { fail(e); } } async function deleteFolder(bucket, prefix) { if (!confirm(`Delete the folder ${baseName(prefix)} and everything in it? This cannot be undone.`)) return; const t = toast(`Deleting ${baseName(prefix)}…`); try { await S.drives.removeFolder(bucket, prefix); t.remove(); toast('Folder deleted'); route(); } catch (e) { t.remove(); fail(e); } } // ---- viewer --------------------------------------------------------------------------------------------- let viewerUrl = null; async function view(bucket, o) { const kind = KIND(o.key); if (kind === 'file') return download(bucket, o.key); const body = $('#viewer-body'); $('#viewer-name').textContent = baseName(o.key); put(body, h('p', { class: 'muted' }, 'Opening…')); $('#viewer').hidden = false; $('#viewer-dl').onclick = () => download(bucket, o.key); try { let src; if (!o.encrypted && (kind === 'audio' || kind === 'video')) src = await S.drives.presigned(bucket, o.storageKey); else { if (kind !== 'text' && o.size > 300 * 1024 * 1024) throw new Error('This file is too large to open in the browser; download it instead.'); const blob = await S.drives.blob(bucket, o.key); if (kind === 'text') { if (blob.size > 2 * 1024 * 1024) throw new Error('This text file is too large to show; download it instead.'); return put(body, h('pre', {}, await blob.text())); } viewerUrl = URL.createObjectURL(blob); src = viewerUrl; } if (kind === 'image') put(body, h('img', { src, alt: baseName(o.key) })); else if (kind === 'audio') put(body, h('audio', { src, controls: true, autoplay: true })); else put(body, h('video', { src, controls: true, autoplay: true })); } catch (e) { put(body, h('p', { class: 'error' }, e.message)); } } function closeViewer() { $('#viewer').hidden = true; put($('#viewer-body')); if (viewerUrl) { URL.revokeObjectURL(viewerUrl); viewerUrl = null; } } // ---- boot ------------------------------------------------------------------------------------------------ function wireApp() { $('#upload').addEventListener('click', () => $('#files').click()); $('#files').addEventListener('change', () => { uploadFiles([...$('#files').files]); $('#files').value = ''; }); $('#newfolder').addEventListener('click', newFolder); const list = $('#list'); list.addEventListener('dragover', (e) => { if (current().bucket) { e.preventDefault(); list.classList.add('over'); } }); list.addEventListener('dragleave', () => list.classList.remove('over')); list.addEventListener('drop', (e) => { e.preventDefault(); list.classList.remove('over'); uploadFiles([...e.dataTransfer.files]); }); $('#viewer-close').addEventListener('click', closeViewer); document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && !$('#viewer').hidden) closeViewer(); }); window.addEventListener('hashchange', route); } wireSignin(); wireApp(); showSignin().catch(fail);