From b8ec745a43161799ffbc11c49b90d2295b88802f Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 5 Oct 2026 02:25:02 +0200 Subject: [PATCH] Pithos in the browser: Drives on Silent Mode with a key file A page served by the gateway that signs in with the Pithos key file (Ed25519 in Web Crypto) or an Export JSON S3 key, then browses, views, downloads, uploads, renames and deletes, encrypting and decrypting in the page with the same formats as the desktop. Checked against a local gateway and real s3d: files the desktop encrypted open byte-identical in the browser, and browser uploads open in the desktop client; the server only ever holds encrypted names. Remember on this device keeps the key sealed with a passphrase in IndexedDB. --- DESIGN-web-app.md | 2 +- web/app.css | 72 +++++++++ web/app.js | 374 ++++++++++++++++++++++++++++++++++++++++++++++ web/icon.svg | 1 + web/index.html | 79 ++++++++++ web/lib/hosted.js | 341 ++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 868 insertions(+), 1 deletion(-) create mode 100644 web/app.css create mode 100644 web/app.js create mode 100644 web/icon.svg create mode 100644 web/index.html create mode 100644 web/lib/hosted.js 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