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.
This commit is contained in:
Local Dev 2026-10-05 02:25:02 +02:00
parent e5dfb67678
commit b8ec745a43
6 changed files with 868 additions and 1 deletions

View file

@ -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

72
web/app.css Normal file
View file

@ -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}}

374
web/app.js Normal file
View file

@ -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/<drive>/<folder>/ -----------------------------------------------
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);

1
web/icon.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><defs><linearGradient id="g" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#3ddc97"/><stop offset="1" stop-color="#1e8f6a"/></linearGradient></defs><path d="M22 6h20v5c0 2-2 3-2 5 8 3 14 11 14 21 0 12-9 21-22 21S10 49 10 37c0-10 6-18 14-21 0-2-2-3-2-5z" fill="url(#g)"/><path d="M14 30h36M13 40h38" stroke="#0b1f18" stroke-width="3" stroke-linecap="round" opacity=".55"/></svg>

After

Width:  |  Height:  |  Size: 449 B

79
web/index.html Normal file
View file

@ -0,0 +1,79 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<meta name="color-scheme" content="dark light">
<title>Pithos</title>
<link rel="icon" href="/_app/icon.svg" type="image/svg+xml">
<link rel="stylesheet" href="/_app/app.css">
<script type="module" src="/_app/app.js"></script>
</head>
<body>
<header class="top">
<a class="brand" href="#/"><img src="/_app/icon.svg" alt=""><span>Pithos</span></a>
<span class="who" id="who" hidden></span>
<button class="btn small" id="signout" hidden>Sign out</button>
</header>
<main id="signin" class="signin" hidden>
<section class="card narrow">
<h1>Your drives on Silent Mode</h1>
<p class="muted">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.</p>
<div id="unlock" hidden>
<p>A key is remembered on this device.</p>
<form id="unlock-form" class="stack">
<label class="field"><span>Passphrase</span><input type="password" id="unlock-pass" autocomplete="current-password" required></label>
<div class="row"><button class="btn primary" type="submit">Unlock</button><button class="btn" type="button" id="forget">Use another key</button></div>
</form>
</div>
<div id="keyform">
<label class="drop" id="drop" tabindex="0">
<input type="file" id="keyfile" accept=".json,application/json" hidden>
<strong>Drop your key file here</strong>
<span class="muted">or click to choose it</span>
</label>
<details class="paste">
<summary>Paste it instead</summary>
<textarea id="keytext" rows="5" spellcheck="false" autocomplete="off" placeholder='{"v":1,"secret":"…"} or {"endpoint":"…","accessKey":"…","secretKey":"…","bucket":"…"}'></textarea>
</details>
<label class="check"><input type="checkbox" id="remember"> Remember on this device</label>
<label class="field" id="pass-row" hidden><span>Passphrase to protect it (at least 10 characters)</span><input type="password" id="pass" autocomplete="new-password" minlength="10"></label>
<button class="btn primary" id="go">Sign in</button>
<div class="help">
<p><b>Which key?</b></p>
<ul>
<li><b>Your Pithos key</b> opens all your drives, encrypted files included: in Pithos, Accounts › On Silent Mode › Export key (in Theseus: Key for the browser app).</li>
<li><b>An S3 key file</b> (Users &amp; keys › Export JSON…, or a folder key someone shared with you) opens readable files and shared folders.</li>
</ul>
<p class="muted">The key stays in this tab, or sealed with your passphrase if you choose Remember. Close the tab to sign out.</p>
</div>
</div>
<p class="error" id="signin-error" hidden></p>
</section>
</main>
<main id="app" class="app" hidden>
<nav class="crumbs" id="crumbs"></nav>
<div class="toolbar" id="toolbar">
<button class="btn primary" id="upload">Upload</button>
<input type="file" id="files" multiple hidden>
<button class="btn" id="newfolder">New folder</button>
<span class="spacer"></span>
<span class="muted small" id="count"></span>
</div>
<section class="card list" id="list"></section>
<p class="muted small foot">Pithos in the browser · <a href="https://silentmode.st/pithos/" rel="noopener">pithos.sia</a></p>
</main>
<div class="viewer" id="viewer" hidden>
<div class="viewer-bar"><span id="viewer-name"></span><span class="spacer"></span><button class="btn small" id="viewer-dl">Download</button><button class="btn small" id="viewer-close">Close</button></div>
<div class="viewer-body" id="viewer-body"></div>
</div>
<div class="toasts" id="toasts" aria-live="polite"></div>
</body>
</html>

341
web/lib/hosted.js Normal file
View file

@ -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 /<drive>/<key>. 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 <audio>/<video>/<img> without decrypting:
// only for readable files (encrypted ones go through blob()).
presigned(bucket, storageKey, seconds = 3600) {
return presign(this.creds, bucket, storageKey, seconds);
}
async write(bucket, key, file, onProgress = () => {}) {
const dk = await this.dk(bucket);
if (await this.storeAsPlain(bucket, key)) {
await this.req('PUT', objectPath(bucket, key), { headers: { 'content-type': file.type || guessType(key) }, body: file });
if (dk) { const ek = await C.encryptPath(dk, key); if (ek !== key) await this.req('DELETE', objectPath(bucket, ek), { ok: false }); }
onProgress(file.size);
return;
}
const ek = await C.encryptPath(dk, key);
const parts = [];
let done = 0;
for await (const p of C.encryptBlob(dk, key, file)) { parts.push(p); done = Math.min(file.size, done + p.length); onProgress(done); }
await this.req('PUT', objectPath(bucket, ek), { headers: { 'content-type': 'application/octet-stream' }, body: new Blob(parts) });
if (ek !== key) await this.req('DELETE', objectPath(bucket, key), { ok: false });
}
async mkdir(bucket, folder) {
const dk = await this.dk(bucket);
const key = folder.endsWith('/') ? folder : folder + '/';
const store = dk && !(await this.storeAsPlain(bucket, key)) ? await C.encryptPath(dk, key) : key;
await this.req('PUT', objectPath(bucket, store), { body: new Uint8Array(0) });
}
async remove(bucket, key) {
const dk = await this.dk(bucket);
if (dk) await this.req('DELETE', objectPath(bucket, await C.encryptPath(dk, key)), { ok: false });
await this.req('DELETE', objectPath(bucket, key), { ok: false });
}
async removeFolder(bucket, prefix, onEach = () => {}) {
const all = [];
const walk = async (p) => {
const l = await this.list(bucket, p);
for (const f of l.files) all.push(f);
for (const d of l.folders) { await walk(d.path); all.push({ key: d.path, storageKey: null }); }
};
await walk(prefix);
for (const f of all) { await this.remove(bucket, f.key); onEach(f.key); }
await this.remove(bucket, prefix);
}
// Encrypted names and contents are bound to their path, so a rename is a
// decrypt-and-encrypt copy here, then a delete.
async rename(bucket, from, to) {
if (from === to) return;
const blob = await this.blob(bucket, from);
await this.write(bucket, to, blob);
await this.remove(bucket, from);
}
}
const MIME = {
html: 'text/html', htm: 'text/html', txt: 'text/plain', md: 'text/markdown', css: 'text/css', js: 'text/javascript', json: 'application/json',
xml: 'application/xml', csv: 'text/csv', pdf: 'application/pdf', zip: 'application/zip', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', avif: 'image/avif', mp3: 'audio/mpeg', ogg: 'audio/ogg', oga: 'audio/ogg', wav: 'audio/wav',
flac: 'audio/flac', m4a: 'audio/mp4', opus: 'audio/opus', mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime',
};
export const guessType = (key) => MIME[String(key).split('.').pop().toLowerCase()] || 'application/octet-stream';
// ---- remember on this device ------------------------------------------------------------------
// The key file, sealed with a passphrase (PBKDF2-SHA256 600k + AES-GCM), in
// IndexedDB on this origin. Nothing usable is stored without the passphrase.
const DB = 'pithos-web';
function idb() {
return new Promise((resolve, reject) => {
const r = indexedDB.open(DB, 1);
r.onupgradeneeded = () => r.result.createObjectStore('kv');
r.onsuccess = () => resolve(r.result);
r.onerror = () => reject(r.error);
});
}
async function kv(mode, fn) {
const db = await idb();
return new Promise((resolve, reject) => {
const tx = db.transaction('kv', mode);
const req = fn(tx.objectStore('kv'));
tx.oncomplete = () => resolve(req?.result);
tx.onerror = () => reject(tx.error);
});
}
async function passKey(pass, salt) {
const base = await subtle.importKey('raw', C.utf8(pass), 'PBKDF2', false, ['deriveKey']);
return subtle.deriveKey({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: 600000 }, base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
}
export async function remember(keyText, pass) {
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv }, await passKey(pass, salt), C.utf8(keyText)));
await kv('readwrite', (s) => s.put({ v: 1, salt, iv, ct, saved: Date.now() }, 'key'));
}
export const remembered = () => kv('readonly', (s) => s.get('key')).catch(() => null);
export async function recall(pass) {
const r = await remembered();
if (!r) return null;
try { return new TextDecoder().decode(await subtle.decrypt({ name: 'AES-GCM', iv: r.iv }, await passKey(pass, r.salt), r.ct)); } catch { throw new Error('Wrong passphrase.'); }
}
export const forget = () => kv('readwrite', (s) => s.delete('key')).catch(() => {});