diff --git a/DESIGN-web-app.md b/DESIGN-web-app.md index 180cdda..5852a27 100644 --- a/DESIGN-web-app.md +++ b/DESIGN-web-app.md @@ -1,6 +1,6 @@ # Pithos in the browser (plan) -Status: plan, 2026-10-05. Nothing built yet. Decided: `https://pithos.silentmode.st`, sign-in only, "Remember on this device" allowed. +Status: 2026-10-05. Phases 1-3 built and tested locally (crypto port, gateway / + /_app/ route, sign-in with a key file, browse, view, download, upload, new folder, rename, delete, remember). Not deployed: needs DNS for pithos.silentmode.st. Decided: `https://pithos.silentmode.st`, sign-in only, "Remember on this device" allowed. ## Goal diff --git a/web/app.css b/web/app.css new file mode 100644 index 0000000..9e4caf0 --- /dev/null +++ b/web/app.css @@ -0,0 +1,72 @@ +:root{--bg:#0f1214;--panel:#161b1e;--panel2:#1c2226;--line:#263036;--ink:#e6ebe8;--mut:#9aa5a0;--acc:#3ddc97;--err:#ff7a7a;--warn:#ffc75f;color-scheme:dark} +@media (prefers-color-scheme:light){:root{--bg:#f6f8f7;--panel:#fff;--panel2:#eef2f0;--line:#dde3e0;--ink:#13201a;--mut:#5d6b65;--acc:#1e9e6a;--err:#c0392b;--warn:#9a6b00;color-scheme:light}} +*{box-sizing:border-box} +html,body{margin:0;background:var(--bg);color:var(--ink);font:15px/1.5 system-ui,-apple-system,"Segoe UI",Roboto,sans-serif} +a{color:var(--acc)} +[hidden]{display:none!important} +.muted{color:var(--mut)} +.small{font-size:13px} +.error{color:var(--err)} +.top{display:flex;align-items:center;gap:12px;padding:10px 16px;border-bottom:1px solid var(--line);position:sticky;top:0;background:var(--bg);z-index:5} +.brand{display:flex;align-items:center;gap:8px;color:var(--ink);text-decoration:none;font-weight:600;font-size:17px;margin-right:auto} +.brand img{width:26px;height:26px} +.who{color:var(--mut);font-size:13px;max-width:40vw;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.btn{display:inline-flex;align-items:center;gap:6px;padding:8px 14px;border-radius:9px;border:1px solid var(--line);background:var(--panel);color:var(--ink);font:inherit;cursor:pointer;text-decoration:none} +.btn:hover{background:var(--panel2)} +.btn.primary{background:var(--acc);border-color:var(--acc);color:#06231a;font-weight:600} +.btn.small{padding:4px 10px;font-size:13px} +.btn.danger{color:var(--err)} +.btn:disabled{opacity:.6;cursor:default} +.card{background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:20px} +.narrow{max-width:560px;margin:32px auto} +.signin{padding:0 16px} +.signin h1{font-size:22px;margin:0 0 6px} +.stack{display:flex;flex-direction:column;gap:12px} +.row{display:flex;gap:8px;flex-wrap:wrap;align-items:center} +.field{display:flex;flex-direction:column;gap:4px;font-size:14px} +.field span{color:var(--mut)} +input[type=password],input[type=text],textarea{background:var(--panel2);color:var(--ink);border:1px solid var(--line);border-radius:8px;padding:9px 10px;font:inherit;width:100%} +textarea{font-family:ui-monospace,Consolas,monospace;font-size:13px} +.drop{display:flex;flex-direction:column;align-items:center;gap:4px;padding:28px 16px;border:2px dashed var(--line);border-radius:12px;cursor:pointer;margin:16px 0 10px;text-align:center} +.drop.over,.drop:focus{border-color:var(--acc);outline:none} +.paste{margin-bottom:10px} +.paste summary{cursor:pointer;color:var(--mut);font-size:14px} +.paste textarea{margin-top:8px} +.check{display:flex;gap:8px;align-items:center;margin:10px 0;font-size:14px} +#pass-row{margin-bottom:12px} +#go{width:100%;justify-content:center;padding:10px} +.help{margin-top:16px;font-size:14px;border-top:1px solid var(--line);padding-top:8px} +.help ul{padding-left:18px;margin:6px 0} +.app{max-width:1100px;margin:0 auto;padding:12px 16px 40px} +.crumbs{display:flex;flex-wrap:wrap;gap:4px;align-items:center;margin:4px 0 12px;font-size:15px} +.crumbs a{color:var(--ink);text-decoration:none;padding:2px 6px;border-radius:6px} +.crumbs a:hover{background:var(--panel2)} +.crumbs .sep{color:var(--mut)} +.toolbar{display:flex;gap:8px;align-items:center;margin-bottom:12px;flex-wrap:wrap} +.spacer{flex:1} +.list{padding:0;overflow:hidden} +.list.over{outline:2px dashed var(--acc);outline-offset:-6px} +table.files{width:100%;border-collapse:collapse} +.files th{text-align:left;font-weight:500;color:var(--mut);font-size:13px;padding:10px 14px;border-bottom:1px solid var(--line)} +.files td{padding:9px 14px;border-bottom:1px solid var(--line);vertical-align:middle} +.files tr:last-child td{border-bottom:0} +.files tr.clickable{cursor:pointer} +.files tr.clickable:hover td{background:var(--panel2)} +.files .name{display:flex;gap:10px;align-items:center;min-width:0} +.files .name .label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.files .ico{width:22px;text-align:center;flex:none} +.files .num{white-space:nowrap;color:var(--mut);font-size:13px} +.files .actions{text-align:right;white-space:nowrap} +.lock{font-size:12px;color:var(--warn)} +.empty{padding:40px 16px;text-align:center;color:var(--mut)} +.foot{text-align:center;margin-top:20px} +.viewer{position:fixed;inset:0;background:rgba(0,0,0,.88);z-index:20;display:flex;flex-direction:column} +.viewer-bar{display:flex;gap:8px;align-items:center;padding:10px 14px;color:#e6ebe8} +.viewer-body{flex:1;display:flex;align-items:center;justify-content:center;overflow:auto;padding:12px} +.viewer-body img,.viewer-body video{max-width:100%;max-height:100%} +.viewer-body pre{background:#161b1e;color:#e6ebe8;padding:16px;border-radius:10px;max-width:100%;max-height:100%;overflow:auto;white-space:pre-wrap;word-break:break-word} +.toasts{position:fixed;right:16px;bottom:16px;display:flex;flex-direction:column;gap:8px;z-index:30;max-width:min(420px,calc(100vw - 32px))} +.toast{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:10px 14px;box-shadow:0 6px 24px rgba(0,0,0,.25);font-size:14px} +.toast.err{border-color:var(--err);color:var(--err)} +progress{width:100%;height:6px;accent-color:var(--acc)} +@media (max-width:640px){.files .hide-sm{display:none}.files td,.files th{padding:9px 10px}.who{display:none}} diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..ac723fd --- /dev/null +++ b/web/app.js @@ -0,0 +1,374 @@ +// 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); diff --git a/web/icon.svg b/web/icon.svg new file mode 100644 index 0000000..4335a25 --- /dev/null +++ b/web/icon.svg @@ -0,0 +1 @@ + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..ebfbd94 --- /dev/null +++ b/web/index.html @@ -0,0 +1,79 @@ + + + + + + + +Pithos + + + + + +
+ Pithos + + +
+ +
+
+

Your drives on Silent Mode

+

Open the drives you keep on Silent Mode from any browser. Files are decrypted here, in this page; the server only ever sees them encrypted.

+ + + +
+ +
+ Paste it instead + +
+ + + +
+

Which key?

+
    +
  • Your Pithos key opens all your drives, encrypted files included: in Pithos, Accounts › On Silent Mode › Export key (in Theseus: Key for the browser app).
  • +
  • An S3 key file (Users & keys › Export JSON…, or a folder key someone shared with you) opens readable files and shared folders.
  • +
+

The key stays in this tab, or sealed with your passphrase if you choose Remember. Close the tab to sign out.

+
+
+ +
+
+ +
+ +
+ + + + + +
+
+

Pithos in the browser · pithos.sia

+
+ + + +
+ + diff --git a/web/lib/hosted.js b/web/lib/hosted.js new file mode 100644 index 0000000..907de7d --- /dev/null +++ b/web/lib/hosted.js @@ -0,0 +1,341 @@ +// The browser app's client for Drives on Silent Mode. It is served from the +// gateway's own origin, so every call here is same-origin: sign-in and the +// account API under /hosted/v1 and /api, S3 at //. Names and +// contents are encrypted and decrypted here exactly as core/hosted.js does. + +import * as C from './crypt.js'; +import { sign, presign, objectPath, canonicalQuery } from './sigv4.js'; + +const subtle = globalThis.crypto.subtle; +const b64 = (bytes) => btoa(String.fromCharCode(...bytes)); +const unb64 = (s) => Uint8Array.from(atob(String(s || '')), (c) => c.charCodeAt(0)); +const hex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); +const PKCS8_ED25519 = Uint8Array.from([0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70, 0x04, 0x22, 0x04, 0x20]); + +export class ApiError extends Error { + constructor(status, message, data) { super(message); this.status = status; this.data = data; } +} + +// ---- key files ----------------------------------------------------------------------- + +// What a dropped or pasted file is: an owner key (Pithos key file, v1 secret or +// v2 browser key) or an S3 key ({endpoint, accessKey, secretKey, bucket}). +export async function readKeyFile(text) { + let o; + try { o = typeof text === 'string' ? JSON.parse(text) : text; } catch { throw new Error('This is not a key file (it is not JSON).'); } + if (o && o.secret && (o.v === 1 || o.v == null)) { + const secret = unb64(o.secret); + if (secret.length !== 32) throw new Error('This Pithos key file is damaged.'); + return { + kind: 'owner', + identitySeed: await C.hkdf(secret, new Uint8Array(0), 'pithos/hosted-identity/v1', 32), + encryptionKey: await C.hkdf(secret, new Uint8Array(0), 'pithos/hosted-encryption/v1', 32), + }; + } + if (o && o.v === 2 && o.identitySeed && o.encryptionKey) { + const identitySeed = unb64(o.identitySeed); + const encryptionKey = unb64(o.encryptionKey); + if (identitySeed.length !== 32 || encryptionKey.length !== 32) throw new Error('This browser key file is damaged.'); + return { kind: 'owner', identitySeed, encryptionKey }; + } + if (o && o.accessKey && o.secretKey) { + return { kind: 's3', accessKeyId: String(o.accessKey), secretKey: String(o.secretKey), bucket: o.bucket ? String(o.bucket) : '', endpoint: o.endpoint ? String(o.endpoint) : '' }; + } + throw new Error('This file is neither a Pithos key nor an S3 key (Export JSON) file.'); +} + +// ---- the gateway's account API ----------------------------------------------------------- + +export async function api(method, path, { token, body } = {}) { + const headers = {}; + if (token) headers.authorization = `Bearer ${token}`; + if (body !== undefined) headers['content-type'] = 'application/json'; + const res = await fetch(path, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), cache: 'no-store' }); + const text = await res.text(); + let data = null; + try { data = text ? JSON.parse(text) : null; } catch { data = { error: text.slice(0, 300) }; } + if (!res.ok) throw new ApiError(res.status, data?.error || `HTTP ${res.status}`, data); + return data; +} + +async function identity(seed) { + const key = await subtle.importKey('pkcs8', C.concat(PKCS8_ED25519, seed), { name: 'Ed25519' }, true, ['sign']); + const jwk = await subtle.exportKey('jwk', key); + const pub = C.unb64u(jwk.x); + return { pubkey: hex(pub), sign: async (msg) => b64(new Uint8Array(await subtle.sign('Ed25519', key, C.utf8(msg)))) }; +} + +// Ed25519 sign-in. Returns { token, id, state }; accounts are made in Pithos. +export async function signIn(identitySeed) { + const id = await identity(identitySeed); + const { nonce, message } = await api('GET', '/hosted/v1/challenge'); + try { + return await api('POST', '/hosted/v1/session', { body: { pubkey: id.pubkey, nonce, signature: await id.sign(message) } }); + } catch (e) { + if (e.status === 404 && e.data?.needsSignup) throw new Error('There is no account on Silent Mode for this key yet. Create it in Pithos (Accounts › On Silent Mode), then sign in here.'); + throw e; + } +} + +export const signOut = (token) => api('DELETE', '/hosted/v1/session', { token }).catch(() => {}); + +// The owner's S3 key: the first key of the first S3 user, as Pithos does. +export async function ownerS3(token) { + const users = await api('GET', '/api/users', { token }); + const names = (Array.isArray(users) ? users : users?.users || []).map((u) => u.name || u.username || u).filter(Boolean); + for (const user of names) { + const keys = await api('GET', `/api/keys?user=${encodeURIComponent(user)}`, { token }); + if (keys?.length) return { user, accessKeyId: keys[0].accessKeyId, secretKey: keys[0].secretKey }; + } + throw new Error('This account has no S3 keys yet. Create one in Pithos (Users & keys).'); +} + +// ---- S3, signed in the page --------------------------------------------------------------- + +const xmlTag = (el, name) => el.getElementsByTagName(name)[0]?.textContent ?? ''; +const xmlAll = (doc, name) => Array.from(doc.getElementsByTagName(name)); +const parseXml = (text) => new DOMParser().parseFromString(text, 'application/xml'); + +async function s3Fail(res) { + const text = await res.text().catch(() => ''); + let msg = `HTTP ${res.status}`; + try { const d = parseXml(text); msg = xmlTag(d, 'Message') || xmlTag(d, 'Code') || msg; } catch {} + try { const j = JSON.parse(text); msg = j.error || msg; } catch {} + return new ApiError(res.status, msg); +} + +// A drive client. master: the 32-byte encryption key (null for an S3 key file, +// which reads and writes readable files only). rules: async () => sharing rules. +export class Drives { + constructor({ accessKeyId, secretKey, master = null, rules = async () => [] }) { + this.creds = { host: location.host, origin: location.origin, accessKeyId, secretKey }; + this.master = master; + this.rules = rules; + this.dks = new Map(); + } + + async dk(bucket) { + if (!this.master) return null; + if (!this.dks.has(bucket)) this.dks.set(bucket, await C.driveKeys(this.master, bucket)); + return this.dks.get(bucket); + } + + async req(method, path, { query = {}, headers = {}, body, ok = true } = {}) { + const h = await sign(this.creds, method, path, query, headers); + const qs = canonicalQuery(query); + const res = await fetch(path + (qs ? '?' + qs : ''), { method, headers: h, body, cache: 'no-store' }); + if (ok && !res.ok) throw await s3Fail(res); + return res; + } + + async listBuckets() { + const d = parseXml(await (await this.req('GET', '/')).text()); + return xmlAll(d, 'Bucket').map((b) => ({ name: xmlTag(b, 'Name'), created: xmlTag(b, 'CreationDate') })); + } + + async rawList(bucket, { prefix = '', delimiter = '/', token, max = 1000 }) { + const query = { 'list-type': '2', prefix, delimiter, 'max-keys': String(max) }; + if (token) query['continuation-token'] = token; + const d = parseXml(await (await this.req('GET', objectPath(bucket), { query })).text()); + return { + folders: xmlAll(d, 'CommonPrefixes').map((p) => xmlTag(p, 'Prefix')), + objects: xmlAll(d, 'Contents').map((o) => ({ key: xmlTag(o, 'Key'), size: Number(xmlTag(o, 'Size')), modified: xmlTag(o, 'LastModified') })), + truncated: xmlTag(d.documentElement, 'IsTruncated') === 'true', + nextToken: xmlTag(d.documentElement, 'NextContinuationToken'), + }; + } + + // One folder level, encrypted and readable names merged (core/hosted.js listObjects). + async list(bucket, prefix = '') { + const dk = await this.dk(bucket); + const folders = new Map(); + const objects = new Map(); + const sources = dk && prefix ? [await C.encryptPath(dk, prefix), prefix] : [prefix]; + for (const pfx of sources) { + let token; + do { + const page = await this.rawList(bucket, { prefix: pfx, token }); + for (const f of page.folders) { + const d = dk ? await C.decryptPath(dk, f) : { path: f, encrypted: false }; + if (d.path.startsWith(prefix) && d.path !== prefix) folders.set(d.path, { path: d.path, encrypted: d.encrypted, locked: !dk && f.split('/').some((s) => s.startsWith('~')) }); + } + for (const o of page.objects) { + const d = dk ? await C.decryptPath(dk, o.key) : { path: o.key, encrypted: false }; + if (!d.path.startsWith(prefix) || d.path === prefix) continue; + if (objects.has(d.path) && !d.encrypted) continue; + const size = d.encrypted && !d.path.endsWith('/') ? C.plainSize(o.size) : o.size; + objects.set(d.path, { key: d.path, size: size ?? o.size, modified: o.modified, encrypted: d.encrypted, storageKey: o.key, locked: !dk && o.key.split('/').some((s) => s.startsWith('~')) }); + } + token = page.truncated ? page.nextToken : null; + } while (token); + } + const order = (a, b) => (a < b ? -1 : a > b ? 1 : 0); + return { + folders: [...folders.values()].sort((a, b) => order(a.path, b.path)), + files: [...objects.values()].filter((o) => !o.key.endsWith('/')).sort((a, b) => order(a.key, b.key)), + }; + } + + async storeAsPlain(bucket, key) { + if (!this.master) return true; + const now = Date.now(); + return (await this.rules()).some((r) => r.bucket === bucket && (!r.expires || r.expires > now) && (r.type === 'link' ? r.prefix === key : key.startsWith(r.prefix))); + } + + // Where a file lives: { encrypted, storageKey, size (plain) }. + async locate(bucket, key) { + const dk = await this.dk(bucket); + if (dk) { + const ek = await C.encryptPath(dk, key); + const head = await this.req('HEAD', objectPath(bucket, ek), { ok: false }); + if (head.ok) { + const total = Number(head.headers.get('content-length')); + return { encrypted: true, storageKey: ek, cipherLen: total, size: C.plainSize(total) }; + } + } + const head = await this.req('HEAD', objectPath(bucket, key), { ok: false }); + if (!head.ok) throw new ApiError(head.status, head.status === 404 ? 'This file is gone.' : `HTTP ${head.status}`); + return { encrypted: false, storageKey: key, size: Number(head.headers.get('content-length')) }; + } + + // Plaintext as a stream of Uint8Array chunks (whole file, or [start, end]). + async *read(bucket, key, range) { + const at = await this.locate(bucket, key); + if (!at.encrypted) { + const headers = range ? { range: `bytes=${range.start}-${range.end ?? ''}` } : {}; + const res = await this.req('GET', objectPath(bucket, at.storageKey), { headers }); + const reader = res.body.getReader(); + for (;;) { const { value, done } = await reader.read(); if (done) return; yield value; } + } + const dk = await this.dk(bucket); + const r = C.cipherRange(at.cipherLen, range || null); + if (r.unsatisfiable) return; + const getBytes = async (from, to) => new Uint8Array(await (await this.req('GET', objectPath(bucket, at.storageKey), { headers: { range: `bytes=${from}-${to}` } })).arrayBuffer()); + const header = await getBytes(0, C.HEADER_LEN - 1); + const ctx = await C.bodyContext(dk, key, header); + const span = ctx.cs + C.TAG_LEN; + // fetch a few chunks per request, open them one by one + const BATCH = 8; + let cache = { first: -1, bytes: null }; + const readChunk = async (i) => { + if (i < cache.first || i >= cache.first + BATCH || !cache.bytes) { + const from = C.HEADER_LEN + i * span; + const to = Math.min(at.cipherLen - 1, from + BATCH * span - 1); + cache = { first: i, bytes: await getBytes(from, to) }; + } + const off = (i - cache.first) * span; + return cache.bytes.subarray(off, Math.min(cache.bytes.length, off + span)); + }; + yield* C.decryptChunks(ctx, r, readChunk); + } + + async blob(bucket, key, type) { + const parts = []; + for await (const p of this.read(bucket, key)) parts.push(p); + return new Blob(parts, { type: type || guessType(key) }); + } + + // A link the page can hand to