pithos/ui/app.js
Local Dev afb74362b8 Pithos: show which Sia account an s3d is connected to
A person with several sia.storage accounts (or phrases) could not tell
which one a Pithos s3d used: s3d records only the indexer URL. Pithos now
reads the app key s3d stores after login and asks the indexer, with a
request signed the way indexd's app API expects, for the account's key,
storage used and last use. Those match what sia.storage shows for the app,
and the page explains that the login and the phrase together decide the
account. Earlier accounts set aside on this computer show their key too.
2026-10-04 03:19:02 +02:00

2006 lines
112 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Pithos UI. Plain DOM, no build step: every host serves these files as-is.
// All user content (object keys, user names, log lines) goes in via
// textContent through h(); nothing here assigns innerHTML.
// ---------------------------------------------------------------- helpers
function h(tag, attrs = {}, ...children) {
const el = document.createElement(tag);
for (const [k, v] of Object.entries(attrs || {})) {
if (v == null || v === false) continue;
if (k === 'class') el.className = v;
else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
else if (k === 'dataset') Object.assign(el.dataset, v);
else if (v === true) el.setAttribute(k, '');
else el.setAttribute(k, v);
}
for (const c of children.flat(Infinity)) {
if (c == null || c === false) continue;
el.append(c instanceof Node ? c : String(c));
}
return el;
}
const $ = (sel, root = document) => root.querySelector(sel);
// replaceChildren, but skipping the null/false that conditional children produce.
function put(el, ...children) {
el.replaceChildren(...children.flat(Infinity).filter((c) => c != null && c !== false));
}
// Inside the Theseus sidebar the page is the add-on's own file, with no
// server origin to fetch from: every call goes over the add-on IPC bridge
// and the add-on forwards it to the control server.
const bridge = window.silentmode && typeof window.silentmode.invoke === 'function' ? window.silentmode : null;
async function api(method, path, body, { raw } = {}) {
if (bridge) {
const r = await bridge.invoke('api', { method, path, body });
const data = r.data || {};
if (r.status >= 400) throw new Error(data.error || `HTTP ${r.status}`);
return data;
}
const opts = { method, headers: { 'x-pithos': '1' }, credentials: 'same-origin' };
if (body !== undefined) {
opts.headers['content-type'] = 'application/json';
opts.body = JSON.stringify(body);
}
const res = await fetch(path, opts);
if (res.status === 401 && path !== '/api/session') { showSignin(); throw new Error('signed out'); }
if (raw) return res;
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
return data;
}
function toast(msg, kind = '') {
const el = h('div', { class: `toast ${kind}` }, msg);
$('#toasts').append(el);
setTimeout(() => el.remove(), kind === 'error' ? 7000 : 3500);
}
const fail = (e) => { if (e.message !== 'signed out') toast(e.message, 'error'); };
function fmtBytes(n) {
if (n == null || isNaN(n)) return '–';
const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB'];
let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return `${i ? n.toFixed(n < 10 ? 2 : 1) : n} ${u[i]}`;
}
function fmtDate(s) { if (!s) return '–'; const d = new Date(s); return isNaN(d) ? s : d.toLocaleString(); }
function fmtDuration(ms) {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
if (s < 3600) return `${Math.floor(s / 60)}m ${s % 60}s`;
if (s < 86400) return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m`;
return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`;
}
async function copy(text, what = 'Copied') {
try { await navigator.clipboard.writeText(text); toast(what); }
catch { prompt('Copy:', text); }
}
function modal(title, body, buttons = []) {
return new Promise((resolve) => {
const close = (v) => { root.remove(); document.removeEventListener('keydown', onKey); resolve(v); };
const onKey = (e) => { if (e.key === 'Escape') close(undefined); };
const foot = h('div', { class: 'foot' }, buttons.map((b) => h('button', {
class: `btn ${b.kind || ''}`, type: b.submit ? 'submit' : 'button',
onclick: b.submit ? null : async () => { const v = b.value ? await b.value() : b.result; if (v !== false) close(v); },
}, b.label)));
const form = h('form', { class: 'modal', onsubmit: async (e) => {
e.preventDefault();
const b = buttons.find((x) => x.submit);
const v = b.value ? await b.value() : b.result;
if (v !== false) close(v);
} }, h('h2', {}, title), body, foot);
const root = h('div', { class: 'modal-backdrop', onmousedown: (e) => { if (e.target === root) close(undefined); } }, form);
$('#modal-root').append(root);
document.addEventListener('keydown', onKey);
setTimeout(() => (form.querySelector('input, textarea, select') || form.querySelector('.btn.primary'))?.focus(), 0);
});
}
function confirmModal(title, text, label = 'Delete', kind = 'danger') {
return modal(title, h('p', {}, text), [
{ label: 'Cancel', result: false },
{ label, kind: `primary ${kind === 'danger' ? '' : ''}`, submit: true, result: true },
]).then((v) => v === true);
}
function secretRow(label, value) {
return h('div', { class: 'stack' },
h('span', { class: 'small muted' }, label),
h('div', { class: 'secret' }, h('code', {}, value), h('button', { class: 'btn small', type: 'button', onclick: () => copy(value) }, 'Copy')));
}
// ---------------------------------------------------------------- state
const state = {
status: null,
login: { state: 'idle' },
logs: [],
logSeq: 0,
selectedUser: localStorage.getItem('pithos.user') || '',
};
const listeners = new Set();
const onState = (fn) => { listeners.add(fn); return () => listeners.delete(fn); };
const emit = () => listeners.forEach((fn) => fn());
async function refreshStatus() {
try {
state.status = await api('GET', '/api/status');
state.login = state.status.login;
renderPill();
emit();
} catch (e) { fail(e); }
}
const eventHandlers = {
status(d) {
if (!state.status) return;
state.status.daemon = d;
renderPill();
emit();
},
log(l) {
if (l.seq <= state.logSeq) return;
state.logSeq = l.seq;
state.logs.push(l);
if (state.logs.length > 2000) state.logs.shift();
logSink?.(l);
},
login(d) { state.login = d; emit(); },
install(p) { if (p.phase === 'extract') toast('Verified checksum, installing…'); },
move(p) { progressSink?.(p); },
convert(p) { progressSink?.(p); },
};
function connectEvents() {
if (bridge) {
bridge.on('pithos-event', ({ event, data }) => eventHandlers[event]?.(data));
bridge.invoke('events-start').catch(fail);
return;
}
const es = new EventSource('/api/events');
for (const [name, fn] of Object.entries(eventHandlers)) es.addEventListener(name, (e) => fn(JSON.parse(e.data)));
es.onerror = () => { /* EventSource reconnects on its own */ };
}
function daemonState() {
const s = state.status;
if (!s) return 'stopped';
if (s.daemon.state === 'stopped' && s.external) return 'external';
if (s.daemon.state === 'crashed' && s.daemon.needsLogin) return 'not connected';
return s.daemon.state;
}
function renderPill() {
const st = daemonState();
const label = { running: 's3d running', starting: 'Starting…', stopping: 'Stopping…', crashed: 's3d stopped (error)', 'not connected': 'Not connected to Sia', stopped: 's3d stopped', external: 's3d running (external)' }[st];
const pill = $('#daemon-pill');
pill.querySelector('.dot').className = `dot ${st === 'external' ? 'running' : st === 'not connected' ? 'starting' : st}`;
pill.querySelector('.label').textContent = isHosted() ? `On Silent Mode · ${label}` : label;
}
// ---------------------------------------------------------------- router
let cleanup = [];
let logSink = null;
const views = { start: getStarted, overview, buckets, users, setup, settings, logs };
function route() {
cleanup.forEach((fn) => fn());
cleanup = [];
logSink = null;
// First run lands on the guided setup until it has been finished once.
const fallback = !setupDone() && !state.status?.registration?.registered ? 'start' : 'overview';
const [, name = fallback, ...rest] = location.hash.replace(/^#/, '').split('/');
const view = views[name] ? name : fallback;
for (const a of document.querySelectorAll('#nav a')) a.classList.toggle('active', a.dataset.view === view);
const main = $('#main');
put(main);
views[view](main, rest.map(decodeURIComponent));
}
// ---------------------------------------------------------------- overview
function overview(main) {
const daemonCard = h('div', { class: 'card' });
const statsCard = h('div', { class: 'card' });
const banners = h('div');
main.append(
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Overview'), h('p', { class: 'muted' }, 'Your s3d gateway and its upload pipeline to Sia.'))),
banners,
h('div', { class: 'grid two' }, daemonCard, statsCard),
);
function draw() {
const s = state.status;
if (!s) return;
put(banners, ...bannersFor(s));
const st = daemonState();
const busy = st === 'starting' || st === 'stopping';
const running = st === 'running' || st === 'starting';
if (s.mode === 'hosted') {
put(daemonCard,
h('div', { class: 'row spread' }, h('h2', {}, 'Gateway on Silent Mode'), h('span', { class: `badge ${st === 'running' ? 'ok' : st === 'crashed' ? 'warn' : ''}` }, st)),
h('dl', { class: 'kv' },
h('dt', {}, 'Sia account'), h('dd', {}, h('strong', {}, accountName(accountInfo)), ' · your own, connected from this computer'),
h('dt', {}, 'S3 endpoint'), h('dd', {}, h('code', {}, hostedUrl()), h('span', { class: 'small muted' }, ' (path-style)')),
h('dt', {}, 's3d'), h('dd', {}, s.s3d?.version ? `v${s.s3d.version}` : '–'),
h('dt', {}, 'Uptime'), h('dd', {}, st === 'running' && s.daemon.startedAt ? fmtDuration(Date.now() - s.daemon.startedAt) : 'starts on the next request'),
h('dt', {}, 'Downloads'), h('dd', {}, `${fmtBytes(s.hosted?.usage?.down || 0)} this month${s.hosted?.downloadCapBytes ? ` of ${fmtBytes(s.hosted.downloadCapBytes)}` : ''}`),
),
h('p', { class: 'small muted' }, 'Files are encrypted on this computer before they reach the server. s3d there starts when something asks for your drives and stops when idle.'),
h('div', { class: 'row', style: 'margin-top:16px' },
h('button', { class: 'btn primary', disabled: busy || running, onclick: () => daemonAction('start') }, 'Start'),
h('button', { class: 'btn', disabled: busy || !running, onclick: () => daemonAction('restart') }, 'Restart'),
h('a', { class: 'btn', href: '#/settings' }, 'Where your drives live')));
return;
}
put(daemonCard,
h('div', { class: 'row spread' }, h('h2', {}, 'Gateway'), h('span', { class: `badge ${st === 'running' || st === 'external' ? 'ok' : st === 'crashed' ? 'warn' : ''}` }, st)),
h('dl', { class: 'kv' },
h('dt', {}, 'Sia account'), h('dd', {}, h('strong', {}, accountName(accountInfo)), ' · ', h('a', { href: '#/setup' }, accountInfo && accountInfo.registered ? 'Manage' : 'Connect')),
h('dt', {}, 'S3 endpoint'), h('dd', {}, h('code', {}, `http://${s.config.apiAddress}`)),
s.config.https && [h('dt', {}, 'S3 HTTPS'), h('dd', {}, h('code', {}, `https://${s.config.https}`))],
h('dt', {}, 's3d'), h('dd', {}, s.s3d?.version ? `v${s.s3d.version}` : (s.daemon.binary ? 'unknown version' : 'not installed')),
h('dt', {}, 'Uptime'), h('dd', {}, st === 'running' && s.daemon.startedAt ? fmtDuration(Date.now() - s.daemon.startedAt) : '–'),
h('dt', {}, 'Config'), h('dd', {}, h('code', {}, s.config.file)),
h('dt', {}, 'Data'), h('dd', {}, h('code', {}, s.config.directory)),
),
h('div', { class: 'row', style: 'margin-top:16px' },
st === 'external'
? h('span', { class: 'muted small' }, 'Another s3d process is serving this config. Stop it to manage it from here.')
: [
h('button', { class: 'btn primary', disabled: busy || running || !s.daemon.binary, onclick: () => daemonAction('start') }, 'Start'),
h('button', { class: 'btn', disabled: busy || !running, onclick: () => daemonAction('restart') }, 'Restart'),
h('button', { class: 'btn danger', disabled: busy || !running, onclick: () => daemonAction('stop') }, 'Stop'),
],
),
);
}
async function drawStats() {
const st = daemonState();
if (st !== 'running' && st !== 'external') {
put(statsCard, h('h2', {}, 'Upload pipeline'), h('p', { class: 'muted' }, 'Start s3d to see live upload stats.'));
return;
}
try {
const x = await api('GET', '/api/stats');
const af = await api('GET', '/api/autoflush').catch(() => null);
const stat = (v, l, bad) => h('div', { class: `stat ${bad ? 'bad' : ''}` }, h('div', { class: 'v' }, v), h('div', { class: 'l' }, l));
put(statsCard,
h('div', { class: 'row spread' }, h('h2', {}, 'Upload pipeline'),
h('button', { class: 'btn small', disabled: !x.pendingObjects, title: 'Upload everything waiting in the local buffer now, even if slabs are not full', onclick: flushNow }, 'Flush now')),
h('div', { class: 'stats' },
stat(x.pendingObjects, `pending · ${fmtBytes(x.pendingSize)}`),
stat(x.uploadedObjects, `on Sia · ${fmtBytes(x.uploadedSize)}`),
stat(x.unpinnedObjects, 'awaiting pin'),
stat(x.multipartUploads, 'open multipart'),
stat(x.failedUploads, 'failed uploads', x.failedUploads > 0),
stat(x.orphanedObjects, 'orphaned', x.orphanedObjects > 0),
),
h('p', { class: 'small muted', style: 'margin:12px 0 0' }, 'New objects wait in a local buffer and are packed into full slabs before upload, which keeps wasted space down.'),
af && h('p', { class: 'small muted', style: 'margin:6px 0 0' }, autoFlushLine(af, x), ' ', h('a', { href: '#/settings' }, af.enabled ? 'Change' : 'Turn on')),
);
} catch (e) {
put(statsCard, h('h2', {}, 'Upload pipeline'), h('p', { class: 'error' }, e.message));
}
}
function autoFlushLine(af, x) {
if (!af.enabled) return 'Leftovers upload only when a block fills up or you flush.';
if (af.flushing) return 'Uploading leftovers now…';
if (af.lastError && x.pendingObjects) return `The last automatic upload failed (${af.lastError}). Pithos will try again later.`;
if (x.pendingObjects && af.dueAt) {
const min = Math.max(1, Math.ceil((af.dueAt - Date.now()) / 60_000));
return `Leftovers upload automatically in about ${min} min if nothing new arrives.`;
}
return `Leftovers upload automatically after ${af.minutes} min with no new uploads.`;
}
async function flushNow(ev) {
const btn = ev.currentTarget;
btn.disabled = true;
put(btn, h('span', { class: 'spinner' }), ' Flushing…');
try { await api('POST', '/api/flush'); toast('All pending objects are on Sia'); }
catch (e) { fail(e); }
drawStats();
}
draw();
drawStats();
loadAccount().then(draw);
cleanup.push(onState(draw));
let lastState = daemonState();
cleanup.push(onState(() => { if (daemonState() !== lastState) { lastState = daemonState(); drawStats(); } }));
const t = setInterval(() => { draw(); drawStats(); }, 4000);
cleanup.push(() => clearInterval(t));
}
function bannersFor(s) {
const out = [];
if (s.mode === 'hosted') {
if (s.hosted?.error) out.push(h('div', { class: 'banner warn' }, h('span', {}, `Silent Mode is not reachable: ${s.hosted.error}`), h('a', { class: 'btn', href: '#/settings' }, 'Settings')));
return out;
}
if (!s.daemon.binary) {
out.push(h('div', { class: 'banner warn' },
h('span', {}, `s3d is not installed. Pithos can download the official v${s.s3d.pinned} release and check its SHA-256.`),
s.s3d.installable && h('button', { class: 'btn primary', onclick: installBinary }, `Install s3d v${s.s3d.pinned}`)));
} else if (s.s3d?.outdated) {
out.push(h('div', { class: 'banner warn' },
h('span', {}, `s3d v${s.s3d.version} is older than v${s.s3d.pinned}. User and key listing needs v0.2.0 or newer.`),
s.s3d.installable && h('button', { class: 'btn', onclick: installBinary }, `Install v${s.s3d.pinned}`)));
}
if (s.registration?.registered === false || s.daemon.needsLogin) {
out.push(h('div', { class: 'banner info' },
h('span', {}, 'This s3d is not connected to a Sia indexer yet.'),
h('a', { class: 'btn primary', href: '#/start' }, 'Get started')));
}
return out;
}
async function installBinary(ev) {
const btn = ev.currentTarget;
btn.disabled = true;
put(btn, h('span', { class: 'spinner' }), ' Downloading…');
try {
const r = await api('POST', '/api/binary/install');
toast(`Installed s3d v${r.version?.version || ''}`);
} catch (e) { fail(e); }
refreshStatus();
}
async function daemonAction(action) {
try {
await api('POST', `/api/daemon/${action}`);
await refreshStatus();
} catch (e) { fail(e); }
}
// ---------------------------------------------------------------- get started (guided setup)
// One step per screen, Back / Next, from "no account" to a working S3 key.
// The connect step drives the same /api/login flow as the Sia connection
// page, but opens the indexer's approval page by itself and moves on as
// soon as the approval lands.
const SETUP_DONE_KEY = 'pithos.setupDone';
const wiz = { step: 0, indexerUrl: 'https://sia.storage', mode: 'new', phrase: '', ownIndexer: false, openedUrl: null, key: null };
function setupDone() {
try { return localStorage.getItem(SETUP_DONE_KEY) === '1'; } catch { return false; }
}
function getStarted(main) {
const STEPS = ['Welcome', 'Sia Storage account', 'Install s3d', 'Recovery phrase', 'Connect', 'Start gateway', 'Access key', 'Done'];
const body = h('div', { class: 'card wizard' });
main.append(
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Get started'),
h('p', { class: 'muted' }, 'Set up your own S3 storage on Sia, one step at a time.'))),
body,
);
const go = (n) => { wiz.step = Math.max(0, Math.min(STEPS.length - 1, n)); draw(); };
const nav = (opts = {}) => h('div', { class: 'row wizard-nav' },
wiz.step > 0 && wiz.step < STEPS.length - 1 && h('button', { class: 'btn', disabled: opts.backDisabled, onclick: () => go(wiz.step - 1) }, 'Back'),
h('span', { style: 'flex:1' }),
opts.extra,
opts.next !== false && h('button', { class: 'btn primary', disabled: !!opts.nextDisabled, onclick: opts.onNext || (() => go(wiz.step + 1)) }, opts.nextLabel || 'Next'));
function progress() {
return h('div', { class: 'wizard-progress' },
h('div', { class: 'small muted' }, `Step ${wiz.step + 1} of ${STEPS.length} · ${STEPS[wiz.step]}`),
h('div', { class: 'bar' }, h('i', { style: `width:${Math.round((wiz.step / (STEPS.length - 1)) * 100)}%` })));
}
function draw() {
const s = state.status;
const registered = !!s?.registration?.registered || ['done', 'already'].includes(state.login.state);
let content;
switch (wiz.step) {
case 0:
content = [
h('h2', {}, 'Your own S3 storage, on Sia'),
h('p', {}, 'Pithos runs s3d, an S3-compatible gateway, on this computer. Your files are encrypted here and spread across independent Sia hosts.'),
h('p', {}, 'This takes about five minutes:'),
h('ol', { class: 'wizard-list' },
h('li', {}, 'Create a free Sia Storage account (50 GB free)'),
h('li', {}, 'Install s3d'),
h('li', {}, 'Choose your recovery phrase'),
h('li', {}, 'Approve Pithos on Sia Storage'),
h('li', {}, 'Start the gateway and create an access key')),
registered && h('div', { class: 'banner info' }, h('span', {}, 'This s3d is already connected to Sia. You can skip ahead.'),
h('button', { class: 'btn', onclick: () => go(5) }, 'Skip to the gateway')),
nav({ nextLabel: 'Get started' }),
];
break;
case 1: {
const url = h('input', { type: 'url', value: wiz.indexerUrl, oninput: (e) => { wiz.indexerUrl = e.target.value.trim(); } });
content = [
h('h2', {}, 'Create your Sia Storage account'),
h('p', {}, 'Sia Storage runs the indexer that rents space from Sia hosts for you. The free plan gives you 50 GB; you sign up with Sia Storage directly, and Pithos never sees your account password.'),
!wiz.ownIndexer && [
h('div', { class: 'row' },
h('button', { class: wiz.accountReady ? 'btn' : 'btn primary', onclick: () => openLink('https://sia.storage') }, 'Open sia.storage ↗'),
h('span', { class: 'muted small' }, 'Sign up or sign in there, then come back here.')),
// Pithos cannot see the sia.storage tab, so the user confirms; the
// Connect step proves it anyway, since approving needs a signed-in account.
h('label', { class: 'check', style: 'margin-top:16px' },
h('input', { type: 'checkbox', checked: !!wiz.accountReady, onchange: (e) => { wiz.accountReady = e.target.checked; draw(); } }),
'I have created my Sia Storage account and I am signed in'),
],
h('label', { class: 'check', style: 'margin-top:12px' },
h('input', { type: 'checkbox', checked: wiz.ownIndexer, onchange: (e) => { wiz.ownIndexer = e.target.checked; if (!wiz.ownIndexer) wiz.indexerUrl = 'https://sia.storage'; draw(); } }),
'I run my own indexer instead'),
wiz.ownIndexer && h('label', { class: 'field', style: 'margin-top:8px' }, h('span', {}, 'Indexer URL'), url),
nav({
nextDisabled: !wiz.ownIndexer && !wiz.accountReady,
onNext: () => {
if (!/^https?:\/\/\S+$/.test(wiz.indexerUrl)) return toast('Enter the indexer URL', 'error');
go(2);
},
}),
];
break;
}
case 2: {
const ok = !!s?.daemon.binary && !s?.s3d?.outdated && !!s?.s3d?.version;
content = [
h('h2', {}, 'Install s3d'),
ok
? h('p', {}, '✓ s3d ', h('code', {}, `v${s.s3d.version}`), ' is installed.')
: [
h('p', {}, s?.daemon.binary ? `Your s3d (v${s.s3d.version || '?'}) is too old for Pithos.` : 's3d is not installed yet.'),
h('p', { class: 'muted small' }, `Pithos downloads the official v${s?.s3d?.pinned} release from the Sia Foundation and checks its SHA-256 before installing it.`),
h('button', { class: 'btn primary', disabled: !s?.s3d?.installable, onclick: async (e) => { await installBinary(e); draw(); } }, `Install s3d v${s?.s3d?.pinned}`),
],
nav({ nextDisabled: !ok }),
];
break;
}
case 3: {
const phrase = h('textarea', { rows: 3, autocomplete: 'off', spellcheck: 'false', placeholder: 'twelve words separated by spaces', oninput: (e) => { wiz.phrase = e.target.value; } });
phrase.value = wiz.phrase;
const vaultOk = !!(bridge && vaultInfo && vaultInfo.setup);
content = [
h('h2', {}, 'Your recovery phrase'),
h('p', {}, 'Twelve words that let you reconnect to the same storage later, on this computer or a new one. Your files cannot be reached without them.'),
h('div', { class: 'stack' },
vaultOk && h('label', { class: 'check', style: 'align-items:flex-start' }, h('input', { type: 'radio', name: 'wm', checked: wiz.mode === 'vault', onchange: () => { wiz.mode = 'vault'; wiz.modeChosen = true; draw(); } }),
h('span', {}, h('strong', {}, 'Use my Theseus vault'), ' (recommended)', h('br'), h('span', { class: 'small muted' }, 'Nothing to write down. The phrase comes from your password vault, so backing up the vault backs up your storage too.'))),
h('label', { class: 'check' }, h('input', { type: 'radio', name: 'wm', checked: wiz.mode === 'new', onchange: () => { wiz.mode = 'new'; wiz.modeChosen = true; draw(); } }), 'Create a new phrase for me to write down'),
h('label', { class: 'check' }, h('input', { type: 'radio', name: 'wm', checked: wiz.mode === 'existing', onchange: () => { wiz.mode = 'existing'; wiz.modeChosen = true; draw(); } }), 'I already have a phrase (reconnecting)')),
wiz.mode === 'existing' && h('label', { class: 'field', style: 'margin-top:12px' }, h('span', {}, 'Recovery phrase'), phrase,
h('small', {}, 'Sent only to the s3d process on this computer.')),
wiz.mode === 'new' && h('p', { class: 'muted small', style: 'margin-top:12px' }, 'You will see the new phrase on the next screen and confirm you saved it before anything is registered.'),
wiz.mode === 'vault' && h('p', { class: 'muted small', style: 'margin-top:12px' }, 'Theseus may ask for your PIN or master password to open the vault.'),
nav({ nextLabel: 'Connect', onNext: () => {
if (wiz.mode === 'existing' && wiz.phrase.trim().split(/\s+/).length !== 12) return toast('A recovery phrase is 12 words', 'error');
wiz.openedUrl = null;
go(4);
startConnect();
} }),
];
break;
}
case 4:
content = connectStep(registered);
break;
case 5: {
const st = daemonState();
const running = st === 'running' || st === 'external';
content = [
h('h2', {}, 'Start your gateway'),
running
? h('p', {}, '✓ s3d is running. Your S3 endpoint is ', h('code', {}, `http://${s.config.apiAddress}`), '.')
: st === 'starting'
? h('p', { class: 'muted row' }, h('span', { class: 'spinner' }), ' Starting s3d…')
: [
st === 'crashed' && h('div', { class: 'banner warn' }, h('span', {}, 's3d stopped. The Logs page shows why.'), h('a', { class: 'btn', href: '#/logs' }, 'Logs')),
h('p', {}, 'Pithos starts s3d for you. It keeps running while Pithos is open.'),
h('button', { class: 'btn primary', onclick: () => daemonAction(st === 'crashed' ? 'restart' : 'start') }, 'Start s3d'),
],
nav({ nextDisabled: !running }),
];
break;
}
case 6: {
if (wiz.key) {
const k = wiz.key;
const endpoint = `http://${s?.config.apiAddress}`;
content = [
h('h2', {}, 'Your first access key'),
h('p', {}, 'S3 apps sign in with this key pair. You can see it again under Users & keys.'),
secretRow('Access key ID', k.accessKeyId),
secretRow('Secret key', k.secretKey),
h('div', { class: 'row', style: 'margin-top:10px' }, h('button', { class: 'btn small', type: 'button', onclick: () => saveCredentialsDialog(k) }, 'Save credentials…')),
h('p', { class: 'small muted', style: 'margin:14px 0 6px' }, 'Try it with the aws CLI:'),
h('pre', { class: 'snippet' }, `aws configure set aws_access_key_id ${k.accessKeyId} --profile ${k.user}\naws configure set aws_secret_access_key ${k.secretKey} --profile ${k.user}\naws configure set region us-east-1 --profile ${k.user}\naws --profile ${k.user} --endpoint-url ${endpoint} s3 mb s3://my-first-bucket`),
nav({ backDisabled: true }),
];
} else {
const name = h('input', { type: 'text', value: 'me', pattern: '[A-Za-z0-9][A-Za-z0-9._@-]{0,63}' });
content = [
h('h2', {}, 'Create an access key'),
h('p', {}, 'Each S3 user owns its own buckets. Start with one for yourself; you can add more for apps or people later.'),
h('label', { class: 'field' }, h('span', {}, 'User name'), name),
nav({ nextLabel: 'Create user and key', onNext: async (e) => {
e.currentTarget.disabled = true;
try {
const r = await api('POST', '/api/users', { name: name.value.trim(), withKey: true });
wiz.key = r.key;
state.selectedUser = r.name;
try { localStorage.setItem('pithos.user', r.name); } catch {}
} catch (err) { fail(err); }
draw();
} }),
];
}
break;
}
default:
try { localStorage.setItem(SETUP_DONE_KEY, '1'); } catch {}
content = [
h('h2', {}, '🎉 You are all set'),
h('p', {}, 'Your S3 gateway is running and stores everything on Sia. Point any S3 app at it with the key you just created, or manage files right here.'),
h('div', { class: 'row' },
h('a', { class: 'btn primary', href: '#/buckets' }, 'Open buckets'),
h('a', { class: 'btn', href: '#/overview' }, 'Overview')),
];
}
put(body, progress(), content);
}
async function startConnect() {
if (state.status?.registration?.registered) return;
if (wiz.mode === 'vault' && bridge) {
try {
const r = await bridge.invoke('connect-with-vault', { indexerUrl: wiz.indexerUrl });
if (r.status >= 400) throw new Error(r.data?.error || `HTTP ${r.status}`);
} catch (e) { fail(e); }
return;
}
try {
await api('POST', '/api/login', { indexerUrl: wiz.indexerUrl, phrase: wiz.mode === 'existing' ? wiz.phrase.trim() : '' });
wiz.phrase = '';
} catch (e) { fail(e); }
}
function connectStep(registered) {
const l = state.login;
const retry = h('button', { class: 'btn', onclick: () => go(3) }, 'Back to the phrase');
if (registered) {
// Approval landed: move on by itself.
setTimeout(() => { if (wiz.step === 4) { refreshStatus(); go(5); } }, 1200);
return [h('h2', {}, 'Connected'), h('p', {}, '✓ Pithos is approved on ', h('code', {}, l.indexerUrl || wiz.indexerUrl), '. Continuing…'), nav({ next: false })];
}
switch (l.state) {
case 'confirm': {
const ack = h('input', { type: 'checkbox' });
const go2 = h('button', { class: 'btn primary', disabled: true, onclick: () => api('POST', '/api/login/confirm').catch(fail) }, 'I saved it — continue');
ack.addEventListener('change', () => { go2.disabled = !ack.checked; });
return [
h('h2', {}, 'Write down your recovery phrase'),
h('p', {}, 'This is the only way to reconnect to your storage. Pithos does not keep it. Write it on paper or store it in a password manager.'),
h('div', { class: 'phrase' }, (l.generatedPhrase || '').split(/\s+/).map((w) => h('span', {}, w))),
h('div', { class: 'row', style: 'margin-top:12px' }, h('button', { class: 'btn small', onclick: () => copy(l.generatedPhrase, 'Phrase copied: clear your clipboard after storing it') }, 'Copy')),
h('label', { class: 'check', style: 'margin:16px 0' }, ack, 'I have stored these 12 words somewhere safe'),
nav({ next: false, extra: go2 }),
];
}
case 'approve': {
// Open the approval page once, by itself; the button re-opens it.
if (l.approvalUrl && wiz.openedUrl !== l.approvalUrl) {
wiz.openedUrl = l.approvalUrl;
wiz.autoOpened = null;
openLink(l.approvalUrl).then((ok) => { wiz.autoOpened = ok; if (wiz.step === 4) draw(); });
}
return [
h('h2', {}, 'Approve Pithos on Sia Storage'),
h('p', {}, wiz.autoOpened === false
? 'Open the approval page, sign in to Sia Storage and approve the "S3d" app. Pithos continues on its own once you do.'
: 'The approval page has opened. Sign in to Sia Storage there and approve the "S3d" app. Pithos continues on its own once you do.'),
h('div', { class: 'row' },
h('button', { class: wiz.autoOpened === false ? 'btn primary' : 'btn', onclick: () => openLink(l.approvalUrl) }, wiz.autoOpened === false ? 'Open the approval page ↗' : 'Open the approval page again ↗'),
h('button', { class: 'btn small', onclick: () => copy(l.approvalUrl) }, 'Copy link')),
h('p', { class: 'muted row', style: 'margin-top:16px' }, h('span', { class: 'spinner' }), ' Waiting for your approval…'),
nav({ next: false, extra: h('button', { class: 'btn danger', onclick: () => api('POST', '/api/login/cancel').then(() => go(3)).catch(fail) }, 'Cancel') }),
];
}
case 'terminal':
return [
h('h2', {}, 'Finish in the console window'),
h('p', {}, 'Terminal support could not be set up here, so a console window running "s3d login" has opened. Enter the indexer URL and your recovery phrase there, then open the link it prints. This page continues when the window closes.'),
h('p', { class: 'muted row' }, h('span', { class: 'spinner' }), ' Waiting for the login window…'),
nav({ next: false }),
];
case 'failed':
return [
h('h2', {}, 'Connection failed'),
h('div', { class: 'banner warn' }, h('span', {}, l.error || 'The login did not finish.')),
// The phrase is dropped from memory once sent, so a retry with an
// existing phrase has to ask for it again; a blank one would make
// s3d generate a new phrase instead.
nav({ next: false, extra: [retry, h('button', { class: 'btn primary', onclick: () => {
wiz.openedUrl = null;
if (wiz.mode === 'existing' && !wiz.phrase.trim()) return go(3);
startConnect();
} }, 'Try again')] }),
];
case 'idle':
return [h('h2', {}, 'Connect to Sia'), h('p', {}, 'Ready to connect.'),
nav({ nextLabel: 'Connect', onNext: () => startConnect() })];
default: {
const what = l.state === 'preparing' ? 'Setting up terminal support (first time only)…' : 'Contacting the indexer…';
return [h('h2', {}, 'Connecting'), h('p', { class: 'muted row' }, h('span', { class: 'spinner' }), ` ${what}`), nav({ next: false })];
}
}
}
if (bridge && vaultInfo?.setup && !wiz.modeChosen) wiz.mode = 'vault';
// Resume where things stand if the user comes back mid-setup.
if (wiz.step === 0 && state.login.state === 'approve') wiz.step = 4;
draw();
cleanup.push(onState(draw));
}
// ---------------------------------------------------------------- setup (indexer login)
function setup(main) {
const body = h('div', { class: 'card' });
main.append(
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Sia connection'),
h('p', { class: 'muted' }, 'Register this s3d with a Sia indexer. You do this once; the indexer then handles hosts and contracts for it.'))),
body,
);
let draft = { indexerUrl: 'https://sia.storage', phrase: '', mode: 'existing' };
function draw() {
let l = state.login;
// Registered already (e.g. on an earlier run): show that instead of the form.
if (l.state === 'idle' && state.status?.registration?.registered) l = { state: 'already', indexerUrl: state.status.registration.indexerUrl };
const step = ['idle', 'failed'].includes(l.state) ? 0 : ['done', 'already'].includes(l.state) ? 2 : 1;
const steps = h('div', { class: 'steps' }, ['Recovery phrase', 'Approve on the indexer', 'Connected'].map((t, i) => h('span', { class: i === step ? 'on' : '' }, `${i + 1}. ${t}`)));
if (l.state === 'done' || l.state === 'already') {
const label = h('input', { type: 'text', placeholder: 'e.g. the email you sign in with', value: accountInfo?.label || '', maxlength: 120 });
put(body, steps,
h('h2', {}, l.state === 'done' ? 'Connected' : 'Connected to Sia'),
h('p', {}, l.indexerUrl ? ['This s3d uses a Sia account on ', h('code', {}, l.indexerUrl), '. All S3 users, buckets and files on it are stored, and paid for, through that account.'] : 'This s3d is registered with an indexer.'),
h('label', { class: 'field', style: 'max-width:420px' }, h('span', {}, 'Account label'),
h('div', { class: 'row', style: 'flex-wrap:nowrap' }, label,
h('button', { class: 'btn', onclick: async () => { try { await api('PUT', '/api/account/label', { label: label.value }); await loadAccount(); toast('Label saved'); } catch (e) { fail(e); } } }, 'Save')),
h('small', {}, 'Pithos cannot see the login email, so name it here: the email you approved the connection with is a good label.')),
siaAccountBlock(accountInfo, async () => { try { accountInfo = await api('GET', '/api/account?fresh=1'); draw(); } catch (e) { fail(e); } }),
h('div', { class: 'row', style: 'margin-top:16px' },
daemonState() !== 'running' && h('button', { class: 'btn primary', onclick: async () => { await daemonAction(daemonState() === 'crashed' ? 'restart' : 'start'); location.hash = '#/overview'; } }, 'Start s3d'),
h('a', { class: 'btn', href: '#/users' }, 'Users & keys'),
h('button', { class: 'btn danger', onclick: switchAccount }, 'Switch Sia account…')),
archivesBlock());
return;
}
if (l.state === 'confirm') {
const words = (l.generatedPhrase || '').split(/\s+/);
const ack = h('input', { type: 'checkbox', id: 'ack' });
const go = h('button', { class: 'btn primary', disabled: true, onclick: () => api('POST', '/api/login/confirm').catch(fail) }, 'Continue');
ack.addEventListener('change', () => { go.disabled = !ack.checked; });
put(body, steps,
h('h2', {}, 'Write down your new recovery phrase'),
h('p', {}, 'This phrase is the only way to register this storage again on another machine or after a reinstall. Pithos does not keep it. Store it offline.'),
h('div', { class: 'phrase' }, words.map((w) => h('span', {}, w))),
h('div', { class: 'row', style: 'margin-top:14px' }, h('button', { class: 'btn small', onclick: () => copy(l.generatedPhrase, 'Phrase copied - clear your clipboard after storing it') }, 'Copy')),
h('label', { class: 'check', style: 'margin:16px 0' }, ack, 'I have stored the phrase somewhere safe'),
h('div', { class: 'row' }, go, h('button', { class: 'btn', onclick: cancel }, 'Cancel')));
return;
}
if (l.state === 'terminal') {
put(body, steps,
h('h2', {}, 'Finish in the console window'),
h('p', {}, 'A console window running "s3d login" has opened. Enter the indexer URL and your recovery phrase there, then open the approval link it prints. This page updates when the window closes.'),
h('p', { class: 'muted row' }, h('span', { class: 'spinner' }), ' Waiting for the login window…'),
h('div', { class: 'row' }, h('button', { class: 'btn danger', onclick: cancel }, 'Cancel')));
return;
}
if (step === 1) {
const waiting = l.state === 'approve' ? null : h('p', { class: 'muted row' }, h('span', { class: 'spinner' }), ' Talking to the indexer…');
put(body, steps,
h('h2', {}, 'Approve the connection'),
waiting,
l.approvalUrl && [
h('p', {}, 'Open this link, sign in to your indexer account and approve the "S3d" app. This page continues by itself once you approve.'),
h('div', { class: 'secret' }, h('code', {}, l.approvalUrl)),
h('div', { class: 'row', style: 'margin-top:12px' },
h('button', { class: 'btn primary', onclick: () => openLink(l.approvalUrl) }, 'Open approval page'),
h('button', { class: 'btn', onclick: () => copy(l.approvalUrl) }, 'Copy link')),
h('p', { class: 'muted row', style: 'margin-top:16px' }, h('span', { class: 'spinner' }), ' Waiting for approval…'),
],
h('div', { class: 'row', style: 'margin-top:12px' }, h('button', { class: 'btn danger', onclick: cancel }, 'Cancel')));
return;
}
// step 0: form
const url = h('input', { type: 'url', value: draft.indexerUrl, required: true, oninput: (e) => { draft.indexerUrl = e.target.value; } });
const phrase = h('textarea', { rows: 3, autocomplete: 'off', spellcheck: 'false', placeholder: 'twelve words separated by spaces', oninput: (e) => { draft.phrase = e.target.value; } });
phrase.value = draft.phrase;
const radios = h('div', { class: 'stack' },
h('label', { class: 'check' }, h('input', { type: 'radio', name: 'mode', checked: draft.mode === 'existing', onchange: () => { draft.mode = 'existing'; draw(); } }), 'I have a recovery phrase'),
h('label', { class: 'check' }, h('input', { type: 'radio', name: 'mode', checked: draft.mode === 'new', onchange: () => { draft.mode = 'new'; draw(); } }), 'Generate a new one'));
put(body, steps,
l.state === 'failed' && h('div', { class: 'banner warn' }, h('span', {}, l.error || 'Login failed')),
h('form', { class: 'stack', onsubmit: start },
h('label', { class: 'field' }, h('span', {}, 'Indexer'), url, h('small', {}, 'https://sia.storage is the hosted indexer run by the Sia Foundation. Use your own indexd URL if you run one.')),
radios,
draft.mode === 'existing' && h('label', { class: 'field' }, h('span', {}, 'Recovery phrase'), phrase,
h('small', {}, 'Sent only to the local s3d process. It derives this app\'s key and is not stored by Pithos.')),
h('div', { class: 'row' }, h('button', { class: 'btn primary', type: 'submit' }, 'Connect'))),
archivesBlock());
}
// Earlier Sia accounts set aside by "Switch Sia account", restorable.
function archivesBlock() {
const list = accountInfo?.archives || [];
if (!list.length) return null;
return h('div', { style: 'margin-top:22px' },
h('h3', {}, 'Earlier Sia accounts on this computer'),
h('p', { class: 'small muted', style: 'margin-top:0' }, 'Set aside by "Switch Sia account". Restoring one puts its users, keys and buckets back (the current account is set aside the same way).'),
h('table', {}, h('tbody', {}, list.map((a) => h('tr', {},
h('td', {}, h('strong', {}, a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : a.name)),
h('div', { class: 'small muted' }, `${a.archivedAt ? fmtDate(a.archivedAt) : a.name}${a.fingerprint ? ` · key ${a.fingerprint}` : ''}${a.users.length ? ` · users: ${a.users.join(', ')}` : ''}`)),
h('td', { class: 'actions' }, h('button', { class: 'btn small', disabled: !a.hasData, onclick: () => restoreAccount(a) }, 'Restore')))))));
}
async function switchAccount() {
let pending = null;
if (daemonState() === 'running') { try { pending = await api('GET', '/api/stats'); } catch {} }
const users = (await api('GET', '/api/users').catch(() => [])).map((u) => u.name);
const waiting = pending && pending.pendingObjects > 0;
const ok = await modal('Switch Sia account?', h('div', { class: 'stack' },
h('p', { style: 'margin:0' }, `s3d is connected to ${accountName(accountInfo)}. Switching connects it to a different Sia account.`),
h('ul', { class: 'small', style: 'margin:0;padding-left:1.2em' },
h('li', {}, `The current account keeps its files. Its S3 users${users.length ? ` (${users.join(', ')})` : ''}, keys and buckets are set aside on this computer, not deleted, and can be restored here.`),
h('li', {}, 'The new account starts empty: you create users and keys again, and S3 apps need the new keys.'),
h('li', {}, 's3d stops now; the guide then connects the other account.')),
waiting && h('div', { class: 'banner warn' },
h('span', {}, `${pending.pendingObjects} object(s) (${fmtBytes(pending.pendingSize)}) are still waiting to upload to Sia.`),
h('button', { class: 'btn', type: 'button', onclick: async (e) => { e.currentTarget.disabled = true; try { await api('POST', '/api/flush'); toast('Pending uploads are on Sia'); } catch (err) { fail(err); } } }, 'Flush now'))),
[{ label: 'Cancel', result: false }, { label: 'Switch account', kind: 'primary', submit: true, result: true }]);
if (!ok) return;
try {
const r = await api('POST', '/api/account/switch');
toast(`Previous account set aside in ${r.archived}`);
await refreshStatus();
await loadAccount();
Object.assign(wiz, { step: 1, accountReady: false, openedUrl: null, key: null });
location.hash = '#/start';
} catch (e) { fail(e); }
}
async function restoreAccount(a) {
const name = a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : a.name);
const ok = await confirmModal(`Restore ${name}?`, 's3d stops and switches back to that Sia account with its users, keys and buckets. The current account is set aside and can be restored the same way.', 'Restore', 'primary');
if (!ok) return;
try {
await api('POST', '/api/account/restore', { name: a.name });
await api('POST', '/api/daemon/start').catch(() => {});
toast(`Restored ${name}`);
await refreshStatus();
await loadAccount();
draw();
} catch (e) { fail(e); }
}
async function start(e) {
e.preventDefault();
const phrase = draft.mode === 'existing' ? draft.phrase.trim() : '';
if (draft.mode === 'existing' && phrase.split(/\s+/).length !== 12) return toast('A recovery phrase is 12 words', 'error');
try {
await api('POST', '/api/login', { indexerUrl: draft.indexerUrl, phrase });
draft.phrase = '';
} catch (err) { fail(err); }
}
async function cancel() { try { await api('POST', '/api/login/cancel'); } catch (e) { fail(e); } }
draw();
loadAccount().then(draw);
cleanup.push(onState(draw));
}
async function openLink(url) {
if (bridge) { try { await bridge.invoke('open-external', { url }); return true; } catch (e) { fail(e); return false; } }
const host = state.status?.host;
if (host === 'desktop' || host === 'theseus') {
try { await api('POST', '/api/open-external', { url }); return true; } catch { /* fall through */ }
}
// Returns false when a pop-up blocker stopped it (no click behind the call).
return !!window.open(url, '_blank', 'noopener');
}
// ---------------------------------------------------------------- users & keys
function users(main) {
const list = h('div', { class: 'card' });
main.append(
h('div', { class: 'page-head' },
h('div', {}, h('h1', {}, 'Users & keys'), h('p', { class: 'muted' }, 'Each S3 user owns its own buckets. Access keys let S3 clients (aws, rclone, apps) act as that user.')),
h('div', { class: 'row' },
h('button', { class: 'btn', onclick: openCredentialsDialog }, 'Open saved credentials'),
h('button', { class: 'btn primary', onclick: createUser }, 'New user'))),
list,
);
let expanded = new Set();
async function load() {
let rows;
try { rows = await api('GET', '/api/users'); }
catch (e) { put(list, h('p', { class: 'error' }, e.message)); return; }
if (!rows.length) {
put(list, h('div', { class: 'empty' }, h('p', {}, 'No users yet.'), h('button', { class: 'btn primary', onclick: createUser }, 'Create the first user')));
return;
}
const keys = await api('GET', '/api/keys').catch(() => []);
put(list, h('table', {},
h('thead', {}, h('tr', {}, h('th', {}, 'User'), h('th', {}, 'Access keys'), h('th', {}))),
h('tbody', {}, rows.flatMap((u) => {
const open = expanded.has(u.name);
const userKeys = keys.filter((k) => k.user === u.name);
const main = h('tr', { class: 'clickable', onclick: () => { open ? expanded.delete(u.name) : expanded.add(u.name); load(); } },
h('td', {}, h('strong', {}, u.name)),
h('td', {}, `${u.keys.length}`),
h('td', { class: 'actions' },
h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); state.selectedUser = u.name; localStorage.setItem('pithos.user', u.name); location.hash = '#/buckets'; } }, 'Drives'),
' ',
h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); newKey(u.name); } }, 'New key'),
' ',
h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); deleteUser(u.name); } }, 'Delete')));
if (!open) return [main];
const detail = h('tr', {}, h('td', { colspan: 3, style: 'background:var(--surface-2)' },
userKeys.length ? h('table', {}, h('tbody', {}, userKeys.map((k) => h('tr', {},
h('td', {}, h('code', {}, k.accessKeyId)),
h('td', { class: 'actions' },
h('button', { class: 'btn small', onclick: () => showKey(k) }, 'Show secret & config'),
' ',
h('button', { class: 'btn small danger', onclick: () => deleteKey(k.accessKeyId) }, 'Revoke'))))))
: h('p', { class: 'muted', style: 'margin:0' }, 'No keys. Create one to use S3 clients or browse buckets.')));
return [main, detail];
}))));
}
async function createUser() {
const name = h('input', { type: 'text', required: true, pattern: '[A-Za-z0-9][A-Za-z0-9._@-]{0,63}', placeholder: 'e.g. backups' });
const withKey = h('input', { type: 'checkbox', checked: true });
// The user's own bucket, named after them unless edited.
const bucket = h('input', { type: 'text', pattern: '[a-z0-9][a-z0-9-]{1,61}[a-z0-9]', placeholder: 'bucket name' });
let bucketEdited = false;
bucket.addEventListener('input', () => { bucketEdited = true; });
name.addEventListener('input', () => { if (!bucketEdited) bucket.value = suggestBucket(name.value); });
const v = await modal('New user', h('div', { class: 'stack' },
h('label', { class: 'field' }, h('span', {}, 'Name'), name, h('small', {}, 'Letters, digits, . _ @ -')),
h('label', { class: 'field' }, h('span', {}, 'Their drive'), bucket, h('small', {}, 'Created with the user, like a home drive (an S3 bucket). Lowercase letters, digits and hyphens. They can add more later.')),
h('label', { class: 'check' }, withKey, 'Create an access key now')),
[{ label: 'Cancel', result: null }, { label: 'Create', kind: 'primary', submit: true, value: () => ({ name: name.value.trim(), withKey: withKey.checked, bucket: bucket.value.trim() || undefined }) }]);
if (!v) return;
try {
const r = await api('POST', '/api/users', v);
toast(r.bucket ? `Created ${r.name} with bucket ${r.bucket}` : r.bucketError ? `Created ${r.name}; bucket not created: ${r.bucketError}` : `Created ${r.name} (start s3d to give them a bucket)`);
expanded.add(r.name);
if (!state.selectedUser) { state.selectedUser = r.name; localStorage.setItem('pithos.user', r.name); }
await load();
if (r.key) showKey(r.key, true);
} catch (e) { fail(e); }
}
async function newKey(user) {
try {
const k = await api('POST', '/api/keys', { user });
expanded.add(user);
await load();
showKey(k, true);
} catch (e) { fail(e); }
}
async function deleteUser(name) {
if (!(await confirmModal(`Delete ${name}?`, 'Its access keys stop working immediately. s3d refuses if the user still owns buckets.'))) return;
try { await api('DELETE', `/api/users/${encodeURIComponent(name)}`); toast(`Deleted ${name}`); load(); } catch (e) { fail(e); }
}
async function deleteKey(id) {
if (!(await confirmModal('Revoke this key?', `Clients using ${id} will be refused from now on.`, 'Revoke'))) return;
try { await api('DELETE', `/api/keys/${encodeURIComponent(id)}`); toast('Key revoked'); load(); } catch (e) { fail(e); }
}
load();
}
function showKey(k, fresh) {
const s = state.status;
const endpoint = s?.config.apiAddress || isHosted() ? s3Endpoint() : 'http://127.0.0.1:8000';
const aws = `aws configure set aws_access_key_id ${k.accessKeyId} --profile ${k.user}
aws configure set aws_secret_access_key ${k.secretKey} --profile ${k.user}
aws configure set region us-east-1 --profile ${k.user}
aws --profile ${k.user} --endpoint-url ${endpoint} s3 ls`;
const rclone = `[sia-${k.user}]
type = s3
provider = Other
access_key_id = ${k.accessKeyId}
secret_access_key = ${k.secretKey}
endpoint = ${endpoint}
region = us-east-1
force_path_style = true`;
const env = `AWS_ACCESS_KEY_ID=${k.accessKeyId}
AWS_SECRET_ACCESS_KEY=${k.secretKey}
AWS_REGION=us-east-1
AWS_ENDPOINT_URL=${endpoint}`;
const tabs = { 'aws cli': aws, rclone, env };
const pre = h('pre', { class: 'snippet' }, aws);
const tabBtns = h('div', { class: 'row' }, Object.keys(tabs).map((t, i) => h('button', {
type: 'button', class: `btn small ${i === 0 ? 'primary' : ''}`,
onclick: (e) => { for (const b of tabBtns.children) b.classList.remove('primary'); e.currentTarget.classList.add('primary'); pre.textContent = tabs[t]; },
}, t)));
modal(fresh ? `Access key for ${k.user}` : `Key ${k.accessKeyId}`, h('div', { class: 'stack' },
fresh && h('p', { class: 'small muted', style: 'margin:0' }, 'Treat the secret like a password. You can view it again here later.'),
secretRow('Access key ID', k.accessKeyId),
secretRow('Secret key', k.secretKey),
h('div', {}, h('button', { class: 'btn small', type: 'button', onclick: () => saveCredentialsDialog(k) }, 'Save credentials…')),
h('h3', { style: 'margin-top:8px' }, 'Client config'),
tabBtns, pre,
h('div', {}, h('button', { class: 'btn small', type: 'button', onclick: () => copy(pre.textContent) }, 'Copy snippet'))),
[{ label: 'Done', kind: 'primary', submit: true, result: true }]);
}
// ---------------------------------------------------------------- saved credentials (encrypted file)
// A key pair saved to a file the user keeps. Password files use PBKDF2-SHA256
// (600k) + AES-256-GCM in the page; vault files are sealed by the Theseus
// add-on with a key derived from the vault, so they need no extra password.
// Only the user name and date stay readable, to tell files apart.
const CRED_FORMAT = 'pithos-credentials';
const b64 = (buf) => btoa(String.fromCharCode(...new Uint8Array(buf)));
const unb64 = (s) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
async function passwordKey(password, salt, iterations) {
const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations }, base, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']);
}
async function sealCredentials(k, { mode, password }) {
const payload = JSON.stringify({
user: k.user, accessKeyId: k.accessKeyId, secretKey: k.secretKey,
endpoint: state.status?.config.apiAddress || isHosted() ? s3Endpoint() : 'http://127.0.0.1:8000', region: 'us-east-1',
});
const head = { format: CRED_FORMAT, v: 1, user: k.user, saved: new Date().toISOString() };
if (mode === 'vault') {
const r = await bridge.invoke('vault-seal', { plaintext: payload });
return { ...head, protection: 'theseus-vault', iv: r.iv, ct: r.ct };
}
const salt = crypto.getRandomValues(new Uint8Array(16));
const iv = crypto.getRandomValues(new Uint8Array(12));
const iterations = 600000;
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, await passwordKey(password, salt, iterations), new TextEncoder().encode(payload));
return { ...head, protection: 'password', kdf: { name: 'PBKDF2', hash: 'SHA-256', iterations, salt: b64(salt) }, iv: b64(iv), ct: b64(ct) };
}
async function openCredentials(file, password) {
if (!file || file.format !== CRED_FORMAT || file.v !== 1) throw new Error('This is not a Pithos credentials file');
let text;
if (file.protection === 'theseus-vault') {
if (!bridge) throw new Error('This file is protected with a Theseus vault. Open it in Pithos inside Theseus.');
text = (await bridge.invoke('vault-open', { iv: file.iv, ct: file.ct })).plaintext;
} else {
try {
const key = await passwordKey(password, unb64(file.kdf.salt), file.kdf.iterations);
text = new TextDecoder().decode(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: unb64(file.iv) }, key, unb64(file.ct)));
} catch { throw new Error('Wrong password, or the file is damaged'); }
}
return JSON.parse(text);
}
async function saveFile(name, text) {
if (bridge) {
const r = await bridge.invoke('save-file', { name, text });
return !!r.saved;
}
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
const a = h('a', { href: url, download: name });
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 5000);
return true;
}
async function saveCredentialsDialog(k) {
const vaultOk = !!(bridge && vaultInfo && vaultInfo.setup && vaultInfo.prompt);
let mode = vaultOk ? 'vault' : 'password';
const p1 = h('input', { type: 'password', placeholder: 'Password (at least 8 characters)', autocomplete: 'new-password' });
const p2 = h('input', { type: 'password', placeholder: 'Repeat the password', autocomplete: 'new-password' });
const pwBox = h('div', { class: 'stack', hidden: mode === 'vault' }, p1, p2,
h('small', { class: 'muted' }, 'Pithos cannot recover this password. Without it the file cannot be opened.'));
const choice = vaultOk && h('div', { class: 'stack' },
h('label', { class: 'check', style: 'align-items:flex-start' },
h('input', { type: 'radio', name: 'credmode', checked: true, onchange: () => { mode = 'vault'; pwBox.hidden = true; } }),
h('span', {}, h('strong', {}, 'Protect with my Theseus vault'), h('br'), h('span', { class: 'small muted' }, 'No extra password. Opens in Pithos with the same vault unlocked.'))),
h('label', { class: 'check', style: 'align-items:flex-start' },
h('input', { type: 'radio', name: 'credmode', onchange: () => { mode = 'password'; pwBox.hidden = false; } }),
h('span', {}, h('strong', {}, 'Protect with a password'), h('br'), h('span', { class: 'small muted' }, 'Opens in Pithos anywhere, with the password.'))));
await modal('Save credentials', h('div', { class: 'stack' },
h('p', { class: 'small muted', style: 'margin:0' }, `Saves the access key and secret for ${k.user} to an encrypted file. Open it later under Users & keys.`),
choice, pwBox),
[{ label: 'Cancel', result: null }, { label: 'Save file', kind: 'primary', submit: true, value: async () => {
if (mode === 'password') {
if (p1.value.length < 8) { toast('Use at least 8 characters', 'error'); return false; }
if (p1.value !== p2.value) { toast("The two passwords don't match", 'error'); return false; }
}
try {
const sealed = await sealCredentials(k, { mode, password: p1.value });
if (await saveFile(`pithos-${k.user}-${k.accessKeyId.slice(0, 6)}.pithoskey`, JSON.stringify(sealed, null, 2))) toast('Credentials saved');
return true;
} catch (e) { fail(e); return false; }
} }]);
}
function openCredentialsDialog() {
const input = h('input', { type: 'file', accept: '.pithoskey,application/json' });
input.addEventListener('change', async () => {
const f = input.files[0];
if (!f) return;
let file;
try { file = JSON.parse(await f.text()); } catch { return toast('This is not a Pithos credentials file', 'error'); }
let password = '';
if (file.protection === 'password') {
const pw = h('input', { type: 'password', placeholder: 'Password', autocomplete: 'current-password' });
password = await modal(`Open credentials${file.user ? ` for ${file.user}` : ''}`, h('label', { class: 'field' }, h('span', {}, 'Password'), pw),
[{ label: 'Cancel', result: null }, { label: 'Open', kind: 'primary', submit: true, value: () => pw.value }]);
if (!password) return;
}
try { showKey(await openCredentials(file, password)); } catch (e) { fail(e); }
});
input.click();
}
// ---------------------------------------------------------------- buckets & objects
const EXT = (key) => (key.split('/').pop().match(/.([a-z0-9]+)$/i) || [])[1]?.toLowerCase() || '';
function fileKind(key) {
const e = EXT(key);
if (['png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'bmp', 'svg', 'ico'].includes(e)) return 'image';
if (['mp3', 'wav', 'ogg', 'oga', 'm4a', 'aac', 'flac', 'opus'].includes(e)) return 'audio';
if (['mp4', 'webm', 'mov', 'm4v', 'ogv'].includes(e)) return 'video';
if (['txt', 'md', 'json', 'csv', 'log', 'xml', 'yml', 'yaml', 'toml', 'ini', 'html', 'htm', 'css', 'js', 'mjs', 'ts', 'py', 'go', 'rs', 'sh', 'sql'].includes(e)) return 'text';
return null;
}
function suggestBucket(name) {
let b = String(name || '').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '');
if (b && b.length < 3) b += '-files';
return b.slice(0, 63).replace(/-+$/, '');
}
function fileIcon(key) {
return { image: '🖼️', audio: '🎵', video: '🎬', text: '📄' }[fileKind(key)] || '📦';
}
function buckets(main, [bucket, ...prefixParts]) {
const head = h('div', { class: 'page-head' });
const body = h('div');
main.append(head, body);
let users = [];
const userSel = h('select', { onchange: (e) => { state.selectedUser = e.target.value; localStorage.setItem('pithos.user', e.target.value); location.hash = '#/buckets'; route(); } });
(async () => {
try { users = await api('GET', '/api/users'); }
catch (e) { put(body, h('div', { class: 'card' }, h('p', { class: 'error' }, e.message))); return; }
const usable = users.filter((u) => u.keys.length);
if (!usable.length) {
put(head, h('div', {}, h('h1', {}, 'Drives')));
put(body, h('div', { class: 'card empty' }, h('p', {}, users.length ? 'None of your users has an access key yet.' : 'Create a user first; buckets belong to users.'), h('a', { class: 'btn primary', href: '#/users' }, 'Users & keys')));
return;
}
if (!usable.some((u) => u.name === state.selectedUser)) state.selectedUser = usable[0].name;
put(userSel, ...usable.map((u) => h('option', { value: u.name, selected: u.name === state.selectedUser }, u.name)));
const st = daemonState();
if (st !== 'running' && st !== 'external') {
put(head, h('div', {}, h('h1', {}, 'Drives')));
put(body, h('div', { class: 'card empty' }, h('p', {}, 's3d is not running.'), h('button', { class: 'btn primary', onclick: async () => { await daemonAction('start'); setTimeout(route, 1500); } }, 'Start s3d')));
return;
}
if (bucket) objectBrowser(head, body, state.selectedUser, bucket, prefixParts.join('/'), userSel);
else bucketList(head, body, state.selectedUser, userSel);
})();
// Follow s3d: the page was drawn for one state; redraw when it flips.
const runningNow = () => ['running', 'external'].includes(daemonState());
let wasRunning = runningNow();
cleanup.push(onState(() => { if (runningNow() !== wasRunning) { wasRunning = runningNow(); route(); } }));
}
function userPicker(userSel) {
return h('label', { class: 'row small', title: 'The S3 user whose buckets you see. All users share the one Sia account below.' },
h('span', { class: 'muted' }, 'S3 user'), h('span', { style: 'width:180px' }, userSel));
}
// ---- Sia account (one per s3d) -------------------------------------------
let accountInfo = null;
async function loadAccount() {
try { accountInfo = await api('GET', '/api/account'); } catch { accountInfo = null; }
return accountInfo;
}
const accountName = (a) => (a && (a.label || a.sia?.fingerprint || (a.indexerUrl ? new URL(a.indexerUrl).host : ''))) || 'not connected';
// What the indexer says about this s3d's Sia account. The figures are the
// ones sia.storage shows for the app, which is how a person tells which of
// their accounts (or apps) this is.
function siaAccountBlock(a, onRefresh) {
const sia = a?.sia;
if (!sia?.connected) return null;
const used = sia.pinnedData != null
? `${fmtBytes(sia.pinnedData)}${sia.maxPinnedData ? ` of ${fmtBytes(sia.maxPinnedData)}` : ''}`
: '–';
return h('div', { class: 'card', style: 'margin:14px 0;background:var(--surface-2)' },
h('div', { class: 'row spread' }, h('h3', { style: 'margin:0' }, 'Which Sia account this is'),
onRefresh && h('button', { class: 'btn small', type: 'button', onclick: onRefresh }, 'Check again')),
sia.error && h('p', { class: 'small error' }, `The indexer did not answer: ${sia.error}`),
h('dl', { class: 'kv' },
h('dt', {}, 'Account key'), h('dd', {}, h('code', { title: sia.publicKey }, sia.fingerprint), ' ',
h('button', { class: 'btn small', type: 'button', onclick: () => copy(`ed25519:${sia.publicKey}`, 'Account key copied') }, 'Copy')),
h('dt', {}, 'App'), h('dd', {}, sia.app?.name || 'S3d'),
h('dt', {}, 'Stored'), h('dd', {}, used),
h('dt', {}, 'Last used'), h('dd', {}, sia.lastUsed ? fmtDate(sia.lastUsed) : '–'),
h('dt', {}, 'Indexer'), h('dd', {}, h('code', {}, sia.indexerUrl))),
h('p', { class: 'small muted', style: 'margin:8px 0 0' },
'On sia.storage, the account whose app "', sia.app?.name || 'S3d', '" shows this storage and last-used time is this one. ',
'The account is decided by two things together: the sia.storage login that approved the connection, and the recovery phrase. ',
'The same phrase approved from two different logins makes two different accounts; the same phrase from the same login is the same account, even on another computer.'));
}
// "Sia account: …" line for page headers; fills itself in.
function accountLine() {
const el = h('div', { class: 'small muted account-line' }, 'Sia account: …');
loadAccount().then((a) => put(el,
'Sia account: ', h('strong', {}, accountName(a)),
a && a.label && a.indexerUrl ? h('span', {}, ` (${new URL(a.indexerUrl).host})`) : null,
' · ', h('a', { href: '#/setup' }, a && a.registered ? 'Manage' : 'Connect')));
return el;
}
async function bucketList(head, body, user, userSel) {
put(head,
h('div', {}, h('h1', {}, 'Drives'), h('p', { class: 'muted' }, 'Each drive is an S3 bucket: top-level storage owned by one S3 user. S3 apps call it a bucket.'), accountLine()),
h('div', { class: 'row' }, userPicker(userSel), h('button', { class: 'btn primary', onclick: create }, 'New drive')));
const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…'));
put(body, card);
const base = `/api/s3/${encodeURIComponent(user)}/buckets`;
async function load() {
try {
const list = await api('GET', base);
if (!list.length) { put(card, h('div', { class: 'empty' }, h('p', {}, 'No drives yet.'), h('button', { class: 'btn primary', onclick: create }, 'Create a drive'))); return; }
// Buckets read as drives: the top level you put folders into.
put(card, h('div', { class: 'tiles' }, list.map((b) => {
const access = h('span', { class: 'badge' }, '…');
api('GET', `${base}/${encodeURIComponent(b.name)}/policy`).then((p) => {
put(access, h('span', { class: `badge ${p.public === 'private' ? '' : 'warn'}` }, { private: isHosted() ? '🔒 encrypted' : '🔒 private', read: '🌐 public', 'read-list': '🌐 public + list', custom: 'custom' }[p.public]));
}).catch(() => put(access, h('span', { class: 'badge' }, '?')));
return h('div', { class: 'tile', tabindex: '0', role: 'button',
onclick: () => { location.hash = `#/buckets/${encodeURIComponent(b.name)}`; },
onkeydown: (e) => { if (e.key === 'Enter') location.hash = `#/buckets/${encodeURIComponent(b.name)}`; } },
h('div', { class: 'drive-icon', 'aria-hidden': 'true' }),
h('div', { class: 'tile-name' }, b.name),
usageLine(b.name),
h('div', { class: 'tile-meta' }, access),
h('div', { class: 'tile-actions' },
h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); accessDialog(user, b.name).then(load); } }, 'Access'),
h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); remove(b.name); } }, 'Delete')));
})));
} catch (e) { put(card, h('p', { class: 'error' }, e.message)); }
}
// Files and size on a drive, counted in the background.
function usageLine(bucketName) {
const el = h('div', { class: 'small muted' }, 'counting…');
api('GET', `${base}/${encodeURIComponent(bucketName)}/usage`)
.then((u) => put(el, `${u.files.toLocaleString()}${u.partial ? '+' : ''} file${u.files === 1 ? '' : 's'} · ${fmtBytes(u.bytes)}${u.partial ? '+' : ''}`))
.catch(() => put(el, ''));
return el;
}
async function create() {
const name = h('input', { type: 'text', required: true, pattern: '[a-z0-9][a-z0-9.\\-]{1,61}[a-z0-9]', placeholder: 'my-drive' });
const v = await modal('New bucket', h('label', { class: 'field' }, h('span', {}, 'Name'), name, h('small', {}, '3-63 characters: lowercase letters, digits, dots and hyphens. Names are unique across this s3d.')),
[{ label: 'Cancel', result: null }, { label: 'Create', kind: 'primary', submit: true, value: () => name.value.trim() }]);
if (!v) return;
try { await api('POST', base, { name: v }); toast(`Created ${v}`); load(); } catch (e) { fail(e); }
}
async function remove(name) {
if (!(await confirmModal(`Delete bucket ${name}?`, 'The bucket must be empty. This cannot be undone.'))) return;
try { await api('DELETE', `${base}/${encodeURIComponent(name)}`); toast(`Deleted ${name}`); load(); } catch (e) { fail(e); }
}
load();
}
async function accessDialog(user, bucket) {
if (isHosted()) return hostedAccessDialog(user, bucket);
const base = `/api/s3/${encodeURIComponent(user)}/buckets/${encodeURIComponent(bucket)}/policy`;
let current = 'private';
try { current = (await api('GET', base)).public; } catch (e) { return fail(e); }
const opts = [
['private', 'Private', 'Only this user\'s access keys can read.'],
['read', 'Public read', 'Anyone who knows an object\'s URL can download it.'],
['read-list', 'Public read + list', 'Anyone can also list the bucket\'s contents.'],
];
let choice = current === 'custom' ? 'private' : current;
const endpoint = `http://${state.status?.config.apiAddress}`;
const v = await modal(`Access for ${bucket}`, h('div', { class: 'stack' },
current === 'custom' && h('div', { class: 'banner warn' }, 'This bucket has a custom policy. Saving replaces it.'),
opts.map(([val, label, desc]) => h('label', { class: 'check', style: 'align-items:flex-start' },
h('input', { type: 'radio', name: 'acc', checked: choice === val, onchange: () => { choice = val; } }),
h('span', {}, h('strong', {}, label), h('br'), h('span', { class: 'small muted' }, desc)))),
h('p', { class: 'small muted', style: 'margin:0' }, 'Public objects are served at ', h('code', {}, `${endpoint}/${bucket}/<key>`), '. Writes and deletes always need a key.'),
h('p', { class: 'small muted', style: 'margin:0' }, 'Access applies to the whole bucket: s3d cannot make a single folder public, or give other people write rights. Keep public files in a bucket of their own, or send a share link for a single file.')),
[{ label: 'Cancel', result: null }, { label: 'Save', kind: 'primary', submit: true, value: () => choice }]);
if (!v || v === current) return;
try { await api('PUT', base, { mode: v }); toast('Access updated'); } catch (e) { fail(e); }
}
function objectBrowser(head, body, user, bucket, prefix, userSel) {
if (prefix && !prefix.endsWith('/')) prefix += '/';
const base = `/api/s3/${encodeURIComponent(user)}/buckets/${encodeURIComponent(bucket)}`;
const go = (p) => { location.hash = `#/buckets/${encodeURIComponent(bucket)}${p ? '/' + p.split('/').filter(Boolean).map(encodeURIComponent).join('/') : ''}`; };
const crumbs = h('div', { class: 'crumbs' },
h('button', { onclick: () => { location.hash = '#/buckets'; } }, 'Drives'), h('span', { class: 'sep' }, '/'),
h('button', { onclick: () => go('') }, bucket),
prefix.split('/').filter(Boolean).map((part, i, all) => [h('span', { class: 'sep' }, '/'), h('button', { onclick: () => go(all.slice(0, i + 1).join('/')) }, part)]));
const fileInput = h('input', { type: 'file', multiple: true, hidden: true, onchange: (e) => { uploadFiles([...e.target.files]); e.target.value = ''; } });
put(head,
h('div', { class: 'stack' }, crumbs, accountLine()),
h('div', { class: 'row' }, userPicker(userSel),
h('button', { class: 'btn', onclick: newFolder }, 'New folder'),
h('button', { class: 'btn', onclick: () => accessDialog(user, bucket) }, 'Access'),
h('button', { class: 'btn primary', onclick: () => fileInput.click() }, 'Upload'), fileInput));
const uploads = h('div', { class: 'uploads' });
const card = h('div', { class: 'card dropzone' }, h('p', { class: 'muted' }, 'Loading…'));
put(body, uploads, card);
let depth = 0;
card.addEventListener('dragenter', (e) => { e.preventDefault(); depth++; card.classList.add('over'); });
card.addEventListener('dragleave', () => { if (--depth <= 0) { depth = 0; card.classList.remove('over'); } });
card.addEventListener('dragover', (e) => e.preventDefault());
card.addEventListener('drop', (e) => {
e.preventDefault(); depth = 0; card.classList.remove('over');
uploadFiles([...e.dataTransfer.files]);
});
let pages = [];
async function load(token) {
try {
const q = new URLSearchParams({ prefix });
if (token) q.set('token', token);
const page = await api('GET', `${base}/objects?${q}`);
pages = token ? [...pages, page] : [page];
draw();
} catch (e) { put(card, h('p', { class: 'error' }, e.message)); }
}
function draw() {
const folders = pages.flatMap((p) => p.folders);
const objects = pages.flatMap((p) => p.objects).filter((o) => o.key !== prefix);
const last = pages[pages.length - 1];
if (!folders.length && !objects.length) {
put(card, h('div', { class: 'empty' }, h('p', {}, prefix ? 'This folder is empty.' : 'This bucket is empty.'), h('p', { class: 'small' }, 'Drag files here or use Upload.')));
return;
}
put(card, h('table', {},
h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'num' }, 'Size'), h('th', { class: 'hide-sm' }, 'Modified'), h('th', {}))),
h('tbody', {},
folders.map((f) => h('tr', { class: 'clickable', onclick: () => go(f) },
h('td', {}, '📁 ', f.slice(prefix.length)),
h('td', { class: 'num muted' }, '–'), h('td', { class: 'hide-sm' }),
h('td', { class: 'actions' },
isHosted() && [h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); folderShareDialog(user, bucket, f); } }, 'Share'), ' '],
h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); deleteFolder(f); } }, 'Delete')))),
objects.map((o) => h('tr', {},
h('td', {}, h('a', { href: '#', onclick: (e) => { e.preventDefault(); openViewer(o); } }, `${fileIcon(o.key)} ${o.key.slice(prefix.length)}`), readableMark(o)),
h('td', { class: 'num' }, fmtBytes(o.size)),
h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)),
h('td', { class: 'actions' },
h('button', { class: 'btn small', onclick: () => downloadObject(o.key) }, 'Download'),
' ',
h('button', { class: 'btn small', onclick: () => share(o.key) }, 'Share'),
' ',
h('button', { class: 'btn small danger', onclick: () => deleteObject(o.key) }, 'Delete')))))),
last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more')));
}
// On Silent Mode, files the server can read: shared on purpose, or stored
// before the drive moved there.
let shareRules = [];
if (isHosted()) api('GET', '/api/hosted/rules').then((r) => { shareRules = r; draw(); }).catch(() => {});
function readableMark(o) {
if (!isHosted() || o.encrypted !== false) return null;
const shared = shareRules.some((r) => r.bucket === bucket && (r.type === 'link' ? r.prefix === o.key : o.key.startsWith(r.prefix)));
return shared
? h('span', { class: 'small muted', title: 'Shared, so stored readable on the server' }, ' 🌐')
: h('span', { class: 'small', title: 'Stored readable on the server (from before your drives moved). Encrypt it under Access.' }, ' 🔓');
}
function uploadFiles(files) {
for (const file of files) {
if (file.size > 5 * 1024 ** 3) { toast(`${file.name}: single uploads are limited to 5 GiB`, 'error'); continue; }
const key = prefix + file.name;
const bar = h('i');
const pct = h('span', { class: 'muted' }, '0%');
const row = h('div', { class: 'upload' }, h('span', {}, key), h('div', { class: 'bar' }, bar), pct);
uploads.append(row);
if (bridge) {
// The whole file crosses the IPC bridge as one buffer, so keep it to
// sizes a renderer can hold comfortably; S3 clients have no limit.
if (file.size > 1024 ** 3) { row.remove(); toast(`${file.name}: uploads from the sidebar are limited to 1 GiB; use an S3 client for larger files`, 'error'); continue; }
pct.textContent = 'uploading…';
bar.style.width = '100%';
bar.style.opacity = '.4';
file.arrayBuffer()
.then((bytes) => bridge.invoke('upload', { path: `${base}/object?key=${encodeURIComponent(key)}`, bytes, type: file.type }))
.then((r) => {
if (r.status >= 200 && r.status < 300) { pct.textContent = 'done'; bar.style.opacity = '1'; setTimeout(() => row.remove(), 1500); load(); }
else { pct.textContent = 'failed'; pct.className = 'error'; toast(`${file.name}: ${r.data?.error || `HTTP ${r.status}`}`, 'error'); }
})
.catch((e) => { pct.textContent = 'failed'; pct.className = 'error'; toast(`${file.name}: ${e.message}`, 'error'); });
continue;
}
const xhr = new XMLHttpRequest();
xhr.open('PUT', `${base}/object?key=${encodeURIComponent(key)}`);
xhr.setRequestHeader('x-pithos', '1');
if (file.type) xhr.setRequestHeader('content-type', file.type);
xhr.upload.onprogress = (e) => { if (e.lengthComputable) { const p = Math.round((e.loaded / e.total) * 100); bar.style.width = p + '%'; pct.textContent = p + '%'; } };
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) { pct.textContent = 'done'; setTimeout(() => row.remove(), 1500); load(); }
else { pct.textContent = 'failed'; pct.className = 'error'; let m = xhr.statusText; try { m = JSON.parse(xhr.responseText).error; } catch {} toast(`${file.name}: ${m}`, 'error'); }
};
xhr.onerror = () => { pct.textContent = 'failed'; pct.className = 'error'; toast(`${file.name}: network error`, 'error'); };
xhr.send(file);
}
}
// An empty "<name>/" marker object keeps the folder visible while empty.
async function newFolder() {
const name = h('input', { type: 'text', required: true, pattern: '[^/]+', placeholder: 'photos' });
const v = await modal('New folder', h('label', { class: 'field' }, h('span', {}, 'Name'), name),
[{ label: 'Cancel', result: null }, { label: 'Create', kind: 'primary', submit: true, value: () => name.value.trim() }]);
if (!v) return;
try { await api('PUT', `${base}/folder?prefix=${encodeURIComponent(prefix + v + '/')}`); toast(`Created ${v}`); load(); } catch (e) { fail(e); }
}
function downloadObject(key) {
const path = `${base}/object?key=${encodeURIComponent(key)}`;
// The sidebar page has no server origin; Theseus's download manager
// fetches the file through a one-time link instead.
if (bridge) return bridge.invoke('download', { path }).then(() => toast('Download started')).catch(fail);
const a = h('a', { href: path, download: '' });
document.body.append(a);
a.click();
a.remove();
}
// Viewer: images, audio, video and text open inside Pithos; anything else
// offers a download.
async function openViewer(o) {
const name = o.key.slice(prefix.length);
const kind = fileKind(o.key);
const path = `${base}/object?key=${encodeURIComponent(o.key)}`;
const src = async () => (bridge ? (await bridge.invoke('view-url', { path })).url : `${path}&inline=1`);
let content;
try {
if (kind === 'image') content = h('img', { class: 'viewer-media', src: await src(), alt: name });
else if (kind === 'audio') content = h('audio', { class: 'viewer-audio', src: await src(), controls: true, autoplay: true });
else if (kind === 'video') content = h('video', { class: 'viewer-media', src: await src(), controls: true, autoplay: true });
else if (kind === 'text') {
const t = await api('GET', `${base}/text?key=${encodeURIComponent(o.key)}`);
content = h('div', {}, h('pre', { class: 'snippet viewer-text' }, t.text), t.truncated && h('p', { class: 'small muted' }, `Showing the first 512 KiB of ${fmtBytes(t.size)}.`));
} else content = h('div', { class: 'empty' }, h('p', {}, 'Pithos cannot show this kind of file.'), h('p', { class: 'small' }, 'Download it to open it on your computer.'));
} catch (e) { return fail(e); }
await modal(name, h('div', { class: 'viewer' }, content, h('p', { class: 'small muted', style: 'margin:10px 0 0' }, `${fmtBytes(o.size)} · ${fmtDate(o.modified)}`)),
[{ label: 'Share', value: () => { share(o.key); return false; } }, { label: 'Download', value: () => { downloadObject(o.key); return false; } }, { label: 'Close', kind: 'primary', submit: true, result: true }]);
}
async function deleteObject(key) {
if (!(await confirmModal('Delete object?', key))) return;
try { await api('DELETE', `${base}/object?key=${encodeURIComponent(key)}`); toast('Deleted'); load(); } catch (e) { fail(e); }
}
async function deleteFolder(p) {
if (!(await confirmModal('Delete folder?', `Every object under ${p} will be deleted.`))) return;
try { const r = await api('DELETE', `${base}/prefix?prefix=${encodeURIComponent(p)}`); toast(`Deleted ${r.deleted} objects`); load(); } catch (e) { fail(e); }
}
async function share(key) {
if (isHosted()) return hostedShare(key);
const expires = h('select', {}, [['3600', '1 hour'], ['86400', '1 day'], ['604800', '7 days (max)']].map(([v, l]) => h('option', { value: v }, l)));
const savedEndpoint = localStorage.getItem('pithos.publicEndpoint') || '';
const endpoint = h('input', { type: 'url', value: savedEndpoint, placeholder: `http://${state.status?.config.apiAddress}` });
const out = h('div');
const gen = async () => {
const q = new URLSearchParams({ key, expires: expires.value });
if (endpoint.value.trim()) { q.set('endpoint', endpoint.value.trim()); localStorage.setItem('pithos.publicEndpoint', endpoint.value.trim()); }
try {
const { url } = await api('GET', `${base}/presign?${q}`);
put(out, secretRow('Link', url));
} catch (e) { fail(e); }
return false;
};
await modal('Share link', h('div', { class: 'stack' },
h('p', { class: 'small muted', style: 'margin:0' }, 'A signed link that works without a key until it expires.'),
h('label', { class: 'field' }, h('span', {}, 'Valid for'), expires),
h('label', { class: 'field' }, h('span', {}, 'Public address of s3d'), endpoint, h('small', {}, 'The address recipients will use to reach s3d (e.g. behind a reverse proxy). Leave blank for the local endpoint.')),
out),
[{ label: 'Close', result: true }, { label: 'Create link', kind: 'primary', submit: true, value: gen }]);
}
// On Silent Mode a link is a rule on the server, and the file is stored
// readable while it works.
async function hostedShare(key) {
const expires = h('select', {}, [['3600', '1 hour'], ['86400', '1 day'], ['604800', '7 days'], ['2592000', '30 days (max)']].map(([v, l]) => h('option', { value: v }, l)));
const out = h('div');
const gen = async () => {
put(out, h('span', { class: 'spinner' }));
try {
const { url } = await api('GET', `${base}/presign?${new URLSearchParams({ key, expires: expires.value })}`);
put(out, secretRow('Link', url));
} catch (e) { put(out); fail(e); }
return false;
};
await modal('Share link', h('div', { class: 'stack' },
h('p', { class: 'small muted', style: 'margin:0' }, 'Anyone with the link can open this file until it ends, even with this computer off. While the link works the file is stored readable on the server; afterwards Pithos encrypts it again.'),
h('label', { class: 'field' }, h('span', {}, 'Valid for'), expires),
out),
[{ label: 'Close', result: true }, { label: 'Create link', kind: 'primary', submit: true, value: gen }]);
}
load();
}
// ---------------------------------------------------------------- on Silent Mode
const isHosted = () => state.status?.mode === 'hosted';
const hostedUrl = () => state.status?.hosted?.url || 'https://s3.silentmode.st';
// Where S3 clients reach this s3d: the server, or the local address.
const s3Endpoint = () => (isHosted() ? hostedUrl() : `http://${state.status?.config.apiAddress}`);
// Long jobs (moving drives, converting folders) report through events.
let progressSink = null;
function progressLine(p) {
if (p.phase === 'flush') return 'Sending leftovers to Sia…';
if (p.phase === 'stop') return 'Stopping s3d…';
if (p.phase === 'upload') return `Sending the s3d folder… ${fmtBytes(p.sent)} of ${fmtBytes(p.total)}`;
if (p.phase === 'download') return `Bringing the s3d folder back… ${fmtBytes(p.got)} of ${fmtBytes(p.total)}`;
if (p.phase === 'start') return 'Starting s3d…';
if (p.phase === 'done') return 'Done.';
if (p.phase === 'failed') return `Failed: ${p.error}`;
if (p.total != null) return `Converting files… ${p.done} of ${p.total}`;
return '';
}
function hostedCard(card) {
let info = null;
async function load() {
try { info = await api('GET', '/api/hosted'); } catch (e) { put(card, h('h2', {}, 'Where your drives live'), h('p', { class: 'error' }, e.message)); return; }
draw();
}
function draw() {
const me = info.me;
const intro = h('p', { class: 'small muted' },
'On this computer, s3d runs here and needs this computer on. On Silent Mode, the same s3d runs on Silent Mode\'s server with your own Sia account: apps, S3 clients and share links work with this computer off, and you can reach your drives from other devices. ',
'Your recovery phrase never leaves this computer, and Pithos encrypts file names and contents before they leave, so the server cannot read them. Only folders you make public or share stay readable.');
const parts = [h('div', { class: 'row spread' }, h('h2', {}, 'Where your drives live'),
h('span', { class: `badge ${info.mode === 'hosted' ? 'ok' : ''}` }, info.mode === 'hosted' ? 'On Silent Mode' : 'On this computer')), intro];
if (!info.signedIn) {
const url = h('input', { type: 'url', value: info.url, autocomplete: 'off' });
parts.push(
info.error && h('p', { class: 'small error' }, info.error),
h('label', { class: 'field' }, h('span', {}, 'Silent Mode server'), url),
h('div', { class: 'row' }, h('button', { class: 'btn primary', onclick: () => signIn(url.value.trim()) }, 'Sign in to Silent Mode')),
h('p', { class: 'small muted' }, info.secrets === 'vault'
? 'You sign in with a key from your Theseus vault: the same vault on another computer reaches the same drives.'
: 'You sign in with a key kept in a file beside s3d.yml. Back it up below: without it, encrypted files cannot be read.'));
} else {
parts.push(h('dl', { class: 'kv' },
h('dt', {}, 'Server'), h('dd', {}, h('code', {}, info.url)),
h('dt', {}, 'Account'), h('dd', {}, h('code', {}, info.id), me?.gate?.name ? ` · ${me.gate.name}` : ''),
me && [h('dt', {}, 'Downloads'), h('dd', {}, `${fmtBytes(me.usage?.down || 0)} this month${me.downloadCapBytes ? ` of ${fmtBytes(me.downloadCapBytes)}` : ''}`)],
me && [h('dt', {}, 'Drives there'), h('dd', {}, { empty: 'none yet', active: 'yes', 'moving-out': 'being moved back' }[me.state] || me.state)]));
const status = h('p', { class: 'small muted' });
progressSink = (p) => put(status, progressLine(p));
const buttons = [];
if (info.mode === 'local' && me?.state === 'empty') {
buttons.push(h('button', { class: 'btn primary', onclick: moveUp }, 'Move my drives to Silent Mode'));
}
if (info.mode === 'local' && me?.state === 'active') {
buttons.push(h('button', { class: 'btn primary', onclick: () => setMode('hosted') }, 'Use my drives on Silent Mode'));
}
if (info.mode === 'hosted') {
buttons.push(h('button', { class: 'btn', onclick: moveDown }, 'Move my drives back to this computer'));
}
buttons.push(h('button', { class: 'btn', onclick: signOut }, 'Sign out'));
parts.push(h('div', { class: 'row' }, buttons), status);
if (info.mode === 'local' && me?.state === 'empty') {
parts.push(h('p', { class: 'small muted' }, 'Moving sends s3d\'s database (your drives, users and keys) and any files still waiting for Sia. Files already on Sia are not uploaded again. This computer keeps a copy of the old folder, set aside.'));
}
}
if (info.secrets === 'file') {
parts.push(h('div', { class: 'row', style: 'margin-top:12px' },
h('button', { class: 'btn small', onclick: exportKey }, 'Back up my key'),
!info.signedIn && h('button', { class: 'btn small', onclick: importKey }, 'Restore a key')));
}
put(card, ...parts);
}
async function signIn(url, signup) {
try {
const r = await api('POST', '/api/hosted/signin', { url, signup });
if (r.needsSignup) return signUp(url, r);
toast('Signed in to Silent Mode');
load();
} catch (e) { fail(e); }
}
// A slot on the server costs memory and traffic, so opening one needs a
// BNS name you own, a small deposit, or an invite.
async function signUp(url, r) {
const tabs = [];
if (r.gate.bns) tabs.push('bns');
if (r.gate.deposit) tabs.push('deposit');
if (r.gate.invite) tabs.push('invite');
if (!tabs.length) return toast('This server is not taking new accounts', 'error');
let kind = tabs[0];
const name = h('input', { type: 'text', placeholder: 'yourname.bch', autocomplete: 'off' });
const sig = h('textarea', { rows: 3, placeholder: 'Signature (base64)' });
const txid = h('input', { type: 'text', placeholder: 'Transaction id', autocomplete: 'off' });
const code = h('input', { type: 'text', placeholder: 'Invite code', autocomplete: 'off' });
const panes = {
bns: h('div', { class: 'stack' },
h('p', { class: 'small muted', style: 'margin:0' }, 'Sign this message with the wallet that owns the name (Electron Cash: Tools › Sign/verify message; Cashonize and Paytaca have the same):'),
secretRow('Message', r.bnsMessage),
h('label', { class: 'field' }, h('span', {}, 'BNS name'), name),
h('label', { class: 'field' }, h('span', {}, 'Signature'), sig)),
deposit: r.gate.deposit && h('div', { class: 'stack' },
h('p', { class: 'small muted', style: 'margin:0' }, `Send at least ${r.gate.deposit.minSats.toLocaleString()} sats to the address below, with this memo (OP_RETURN). It is refundable after fair use.`),
secretRow('Address', r.gate.deposit.address),
secretRow('Memo', r.depositMemo),
h('label', { class: 'field' }, h('span', {}, 'Transaction id'), txid, h('small', {}, 'Wait for one confirmation (about ten minutes).'))),
invite: h('div', { class: 'stack' }, h('label', { class: 'field' }, h('span', {}, 'Invite code'), code)),
};
const holder = h('div');
const label = { bns: 'BNS name', deposit: 'Deposit', invite: 'Invite' };
const switcher = h('div', { class: 'row' }, tabs.map((t) => h('label', { class: 'check' },
h('input', { type: 'radio', name: 'gate', checked: t === kind, onchange: () => { kind = t; put(holder, panes[t]); } }), label[t])));
put(holder, panes[kind]);
const proof = await modal('Open your space on Silent Mode', h('div', { class: 'stack' },
h('p', { class: 'small muted', style: 'margin:0' }, 'Each person gets one slot. Choose how to show you are one person:'),
tabs.length > 1 && switcher, holder),
[{ label: 'Cancel', result: null }, { label: 'Open my space', kind: 'primary', submit: true, value: () => (
kind === 'bns' ? { type: 'bns', name: name.value.trim(), signature: sig.value.trim() }
: kind === 'deposit' ? { type: 'deposit', txid: txid.value.trim() }
: { type: 'invite', code: code.value.trim() }) }]);
if (proof) signIn(url, proof);
}
async function moveUp(ev, force = false) {
if (!force && !(await confirmModal('Move your drives to Silent Mode?',
'Pithos sends leftovers to Sia, stops s3d here and moves its folder to the server. From then on your drives run on Silent Mode, and new files are encrypted before they leave this computer.', 'Move', 'primary'))) return;
try {
const r = await api('POST', '/api/hosted/move-up', { force });
toast(`Your drives are on Silent Mode (${r.users.length} user${r.users.length === 1 ? '' : 's'})`);
await refreshStatus();
route();
} catch (e) {
if (!force && /move anyway/.test(e.message)) {
if (await confirmModal('Some uploads have not reached Sia', `${e.message}`, 'Move anyway', 'primary')) return moveUp(ev, true);
return;
}
fail(e);
load();
}
}
async function moveDown(ev, force = false) {
if (!force && !(await confirmModal('Move your drives back here?',
'Silent Mode sends leftovers to Sia, stops its s3d and hands the folder back. s3d then runs on this computer again; files encrypted on Silent Mode stay encrypted and Pithos keeps reading them. Public folders, share links and shared keys stop working.', 'Move back', 'primary'))) return;
try {
await api('POST', '/api/hosted/move-down', { force });
toast('Your drives are back on this computer');
await refreshStatus();
route();
} catch (e) {
if (!force && /move anyway/.test(e.message)) {
if (await confirmModal('Some uploads have not reached Sia', e.message, 'Move anyway', 'primary')) return moveDown(ev, true);
return;
}
fail(e);
load();
}
}
async function setMode(mode) {
try { await api('POST', '/api/hosted/mode', { mode }); await refreshStatus(); route(); } catch (e) { fail(e); }
}
async function signOut() {
if (info.mode === 'hosted' && !(await confirmModal('Sign out?', 'Your drives stay on Silent Mode and keep working for apps and links. This computer shows its own s3d until you sign in again.', 'Sign out', 'primary'))) return;
try { await api('POST', '/api/hosted/signout'); await refreshStatus(); route(); } catch (e) { fail(e); }
}
async function exportKey() {
try {
const k = await api('GET', '/api/hosted/secret');
const text = JSON.stringify(k, null, 2);
if (bridge) { await bridge.invoke('save-file', { name: 'pithos-hosted-key.json', text }); return; }
const a = h('a', { href: URL.createObjectURL(new Blob([text], { type: 'application/json' })), download: 'pithos-hosted-key.json' });
document.body.append(a); a.click(); a.remove();
} catch (e) { fail(e); }
}
async function importKey() {
const ta = h('textarea', { rows: 6, placeholder: '{ "v": 1, "secret": "…" }' });
const v = await modal('Restore a key', h('div', { class: 'stack' },
h('p', { class: 'small muted', style: 'margin:0' }, 'Paste the contents of a pithos-hosted-key.json backup. It replaces the key this computer uses.'),
h('div', { class: 'banner warn' }, 'Files encrypted with the current key can only be read with it. Back it up first if anything was encrypted with it.'), ta),
[{ label: 'Cancel', result: null }, { label: 'Restore', kind: 'primary', submit: true, value: () => ta.value }]);
if (!v) return;
try { await api('POST', '/api/hosted/secret', JSON.parse(v)); toast('Key restored'); load(); } catch (e) { fail(e); }
}
load();
}
// Folder and drive sharing on Silent Mode: public, or a key for someone.
async function folderShareDialog(user, bucket, folder) {
const where = folder ? `${bucket}/${folder}` : bucket;
const list = h('div', { class: 'stack' });
async function loadRules() {
let rules = [];
try { rules = (await api('GET', '/api/hosted/rules')).filter((r) => r.bucket === bucket && r.prefix === folder && r.type !== 'link'); } catch (e) { return put(list, h('p', { class: 'error' }, e.message)); }
if (!rules.length) return put(list, h('p', { class: 'small muted', style: 'margin:0' }, 'Not shared: only you can read it, and it is stored encrypted.'));
put(list, rules.map((r) => h('div', { class: 'row spread' },
h('span', {}, r.type === 'public'
? (r.rights === 'read-list' ? '🌐 Public, with a file list' : '🌐 Public')
: `🔑 Key ${r.accessKeyId} · ${{ read: 'read', add: 'add files', edit: 'edit' }[r.rights]}${r.label ? ` · ${r.label}` : ''}${r.expires ? ` · until ${fmtDate(r.expires)}` : ''}`),
h('button', { class: 'btn small danger', type: 'button', onclick: () => removeRule(r) }, r.type === 'public' ? 'Make private' : 'Revoke'))));
}
async function removeRule(r) {
try { await api('DELETE', `/api/hosted/rules?id=${encodeURIComponent(r.id)}&user=${encodeURIComponent(user)}`); toast('Stopped sharing; files are encrypted again'); loadRules(); } catch (e) { fail(e); }
}
const kind = h('select', {},
h('option', { value: 'public:read' }, 'Anyone: read files'),
h('option', { value: 'public:read-list' }, 'Anyone: read files and see the list'),
h('option', { value: 'grant:read' }, 'Someone with a key: read'),
h('option', { value: 'grant:add' }, 'Someone with a key: add new files'),
h('option', { value: 'grant:edit' }, 'Someone with a key: add, change and delete'));
const label = h('input', { type: 'text', placeholder: 'Who it is for (optional)' });
const until = h('select', {}, [['', 'No expiry'], ['86400', '1 day'], ['604800', '7 days'], ['2592000', '30 days']].map(([v, l]) => h('option', { value: v }, l)));
const out = h('div', { class: 'stack' });
const progress = h('p', { class: 'small muted', style: 'margin:0' });
const add = async () => {
const [type, rights] = kind.value.split(':');
progressSink = (p) => put(progress, progressLine(p));
try {
const r = await api('POST', '/api/hosted/rules', {
user, type, bucket, prefix: folder, rights, label: label.value.trim(),
expires: until.value ? Date.now() + Number(until.value) * 1000 : undefined,
});
put(progress, '');
if (r.type === 'public') {
const at = `${hostedUrl()}/p/${state.status.hosted.id}/${bucket}/${folder}`;
put(out, secretRow(r.rights === 'read-list' ? 'Public address (lists the folder as JSON)' : 'Public address: add a file name', at),
r.rights !== 'read-list' && h('p', { class: 'small muted', style: 'margin:0' }, 'For example ', h('code', {}, `${at}index.html`), '.'));
}
else put(out, h('p', { class: 'small muted', style: 'margin:0' }, 'Give these to the person. The secret is shown here, and in this list, only to you.'),
secretRow('S3 endpoint', hostedUrl()), secretRow('Access key', r.accessKeyId), secretRow('Secret key', r.secretKey),
secretRow('Bucket and folder', `${bucket}/${folder}`));
loadRules();
} catch (e) { put(progress, ''); fail(e); }
return false;
};
loadRules();
await modal(`Share ${where}`, h('div', { class: 'stack' },
list,
h('label', { class: 'field' }, h('span', {}, 'Share with'), kind),
h('label', { class: 'field' }, h('span', {}, 'Label'), label),
h('label', { class: 'field' }, h('span', {}, 'Ends'), until),
h('p', { class: 'small muted', style: 'margin:0' }, 'Shared files are stored readable on the server, because the people you share with have no decryption key. Stop sharing and Pithos encrypts them again.'),
progress, out),
[{ label: 'Close', result: true }, { label: 'Share', kind: 'primary', submit: true, value: add }]);
}
// A drive on Silent Mode: everything shared in it, in one place.
async function hostedAccessDialog(user, bucket) {
const list = h('div', { class: 'stack' });
const progress = h('p', { class: 'small muted', style: 'margin:0' });
progressSink = (p) => put(progress, progressLine(p));
async function load() {
let rules = [];
try { rules = (await api('GET', '/api/hosted/rules')).filter((r) => r.bucket === bucket); } catch (e) { return put(list, h('p', { class: 'error' }, e.message)); }
if (!rules.length) return put(list, h('p', { class: 'small muted', style: 'margin:0' }, 'Nothing in this drive is shared. Everything is encrypted, and only you can read it.'));
const what = (r) => (r.type === 'public' ? (r.rights === 'read-list' ? '🌐 public + list' : '🌐 public')
: r.type === 'link' ? `🔗 link until ${fmtDate(r.expires)}`
: `🔑 ${r.accessKeyId} · ${{ read: 'read', add: 'add files', edit: 'edit' }[r.rights]}`);
put(list, h('table', {}, h('tbody', {}, rules.map((r) => h('tr', {},
h('td', {}, h('code', {}, `/${r.prefix}`)),
h('td', {}, what(r), r.label ? h('span', { class: 'small muted' }, ` · ${r.label}`) : ''),
h('td', { class: 'actions' }, h('button', { class: 'btn small danger', type: 'button', onclick: async () => {
try { await api('DELETE', `/api/hosted/rules?id=${encodeURIComponent(r.id)}&user=${encodeURIComponent(user)}`); toast('Stopped sharing; files are encrypted again'); load(); } catch (e) { fail(e); }
} }, 'Stop')))))));
}
load();
const encryptOld = h('button', { class: 'btn small', type: 'button', onclick: async () => {
encryptOld.disabled = true;
try {
const r = await api('POST', '/api/hosted/encrypt', { user, bucket });
toast(r.converted ? `Encrypted ${r.converted} file${r.converted === 1 ? '' : 's'}` : 'Every private file was already encrypted');
} catch (e) { fail(e); }
encryptOld.disabled = false;
} }, 'Encrypt files stored readable');
await modal(`Access for ${bucket}`, h('div', { class: 'stack' },
list, progress,
h('div', { class: 'row' }, encryptOld, h('span', { class: 'small muted' }, 'Files from before your drives moved here are stored as they were. This encrypts every one that is not shared.')),
h('p', { class: 'small muted', style: 'margin:0' }, 'Share a folder from its row in the drive, or the whole drive here. Public files are at ',
h('code', {}, `${hostedUrl()}/p/${state.status?.hosted?.id}/${bucket}/<path>`), '.')),
[{ label: 'Close', result: true }, { label: 'Share the whole drive…', kind: 'primary', submit: true, value: () => { folderShareDialog(user, bucket, ''); return true; } }]);
}
// ---------------------------------------------------------------- settings
function settings(main) {
const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…'));
const flushCard = h('div', { class: 'card', style: 'margin-top:16px' });
const whereCard = h('div', { class: 'card', style: 'margin-bottom:16px' });
main.append(h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Settings'), h('p', { class: 'muted' }, 'Where your drives live, and s3d.yml for s3d on this computer. s3d reads it at start, so a restart applies the changes.'))), whereCard, card, flushCard);
hostedCard(whereCard);
// s3d.yml and the leftovers setting are for s3d on this computer; on Silent
// Mode the server manages both (leftovers always go to Sia).
if (isHosted()) {
put(card, h('h2', {}, 's3d on this computer'), h('p', { class: 'small muted' }, 'Your drives run on Silent Mode, so s3d on this computer is not used. Its settings come back when you move your drives back here.'));
flushCard.remove();
return;
}
// Kept by Pithos itself (pithos-prefs.json), applies at once, no restart.
(async () => {
let af;
try { af = await api('GET', '/api/autoflush'); } catch (e) { put(flushCard, h('p', { class: 'error' }, e.message)); return; }
const on = h('input', { type: 'checkbox', checked: af.enabled });
const minutes = h('input', { type: 'number', min: 1, max: 1440, value: af.minutes, style: 'width:6em', disabled: !af.enabled });
on.addEventListener('change', () => { minutes.disabled = !on.checked; });
put(flushCard,
h('h2', {}, 'Leftover uploads'),
h('p', { class: 'small muted' }, 's3d packs uploads into blocks of about 40 MB before sending them to Sia, so a few small files can wait in the local buffer indefinitely. Pithos can upload them for you once uploading has stopped.'),
h('label', { class: 'check' }, on, 'Upload leftovers automatically'),
h('label', { class: 'field' }, h('span', {}, 'after this many minutes with no new uploads'), minutes,
h('small', {}, 'Partly filled blocks use a little more Sia space than full ones. Pithos must be open for this to run.')),
h('div', { class: 'row' }, h('button', { class: 'btn primary', onclick: saveFlush }, 'Save')));
async function saveFlush() {
try {
await api('PUT', '/api/autoflush', { enabled: on.checked, minutes: Number(minutes.value) || 10 });
toast(on.checked ? 'Leftovers will upload automatically' : 'Automatic upload is off');
} catch (e) { fail(e); }
}
})();
const fields = [
['Network', [
['apiAddress', 'S3 API address', 'text', 'host:port the S3 API listens on. Use 0.0.0.0:8000 to serve other machines.'],
['apiHTTPSAddress', 'S3 HTTPS address', 'text', 'Optional. s3d serves a self-signed certificate here.'],
['adminAddress', 'Admin API address', 'text', 'Pithos reads stats from here. Keep it on loopback.'],
['adminPassword', 'Admin API password', 'password', 'Generated on first start.'],
['s3.hostBases', 'Virtual-host bases', 'list', 'Comma-separated domains for bucket.domain style addressing.'],
]],
['Storage', [
['directory', 'Data directory', 'text', 'Database and upload buffer. Changing it points s3d at a different store.'],
['sia.diskUsageLimit', 'Buffer disk limit (bytes)', 'number', 'Upper bound for objects waiting to upload.'],
['sia.uploadThreads', 'Upload threads', 'number', ''],
['sia.dataShards', 'Data shards', 'number', 'Erasure coding: data shards (default 10). Applies to new uploads only.'],
['sia.parityShards', 'Parity shards', 'number', 'Erasure coding: parity shards (default 20).'],
]],
['Logging', [
['log.stdout.level', 'Console log level', 'select:debug,info,warn,error', ''],
['log.file.enabled', 'Write a log file', 'bool', ''],
['log.file.level', 'File log level', 'select:debug,info,warn,error', ''],
]],
];
(async () => {
let cfg;
try { cfg = await api('GET', '/api/config'); } catch (e) { put(card, h('p', { class: 'error' }, e.message)); return; }
const inputs = {};
const sections = fields.map(([title, list]) => h('div', { class: 'stack', style: 'margin-bottom:22px' }, h('h2', {}, title), h('div', { class: 'form-grid' }, list.map(([key, label, type, help]) => {
const v = cfg.values[key];
let input;
if (type === 'bool') input = h('input', { type: 'checkbox', checked: !!v });
else if (type.startsWith('select:')) input = h('select', {}, h('option', { value: '' }, '(default)'), type.slice(7).split(',').map((o) => h('option', { value: o, selected: v === o }, o)));
else input = h('input', { type: type === 'list' ? 'text' : type, value: Array.isArray(v) ? v.join(', ') : (v ?? ''), placeholder: '(default)', autocomplete: 'off' });
inputs[key] = { input, type, orig: v };
return type === 'bool'
? h('label', { class: 'check' }, input, label)
: h('label', { class: 'field' }, h('span', {}, label), input, help && h('small', {}, help));
}))));
put(card,
h('p', { class: 'small muted' }, 'File: ', h('code', {}, cfg.file)),
...sections,
h('div', { class: 'row' }, h('button', { class: 'btn primary', onclick: save }, 'Save')));
async function save() {
const patch = {};
for (const [key, { input, type, orig }] of Object.entries(inputs)) {
let v;
if (type === 'bool') v = input.checked;
else if (type === 'number') v = input.value === '' ? null : Number(input.value);
else if (type === 'list') v = input.value.split(',').map((s) => s.trim()).filter(Boolean);
else v = input.value.trim() || null;
const same = JSON.stringify(v ?? null) === JSON.stringify(orig ?? null) || (type === 'list' && !v.length && orig == null) || (type === 'bool' && !v && orig == null);
if (!same) patch[key] = v;
}
if (!Object.keys(patch).length) return toast('Nothing changed');
try {
const r = await api('PUT', '/api/config', patch);
for (const k of Object.keys(patch)) inputs[k].orig = patch[k];
if (r.restartNeeded) {
if (await confirmModal('Restart s3d?', 'The new settings apply after a restart.', 'Restart now', 'primary')) await daemonAction('restart');
} else toast('Saved');
refreshStatus();
} catch (e) { fail(e); }
}
})();
}
// ---------------------------------------------------------------- logs
function logs(main) {
const box = h('div', { class: 'logs' });
let follow = true;
box.addEventListener('scroll', () => { follow = box.scrollTop + box.clientHeight >= box.scrollHeight - 20; });
main.append(
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Logs'), h('p', { class: 'muted' }, 'Output from the s3d process Pithos started.')),
h('button', { class: 'btn', onclick: () => copy(state.logs.map((l) => l.line).join('\n'), 'Logs copied') }, 'Copy all')),
box);
const line = (l) => h('div', { class: l.stream }, l.line);
box.append(...state.logs.map(line));
if (!state.logs.length) box.append(h('div', { class: 'sys' }, 'No output yet.'));
box.scrollTop = box.scrollHeight;
logSink = (l) => {
box.append(line(l));
while (box.childElementCount > 2000) box.firstChild.remove();
if (follow) box.scrollTop = box.scrollHeight;
};
}
// ---------------------------------------------------------------- boot
// Theseus sidebar: the panel starts narrow; bucket tables want room, so offer
// the host's widen/restore, plus a regular tab for anyone who prefers one.
function addSidebarControls() {
const row = h('div', { class: 'host-controls' });
if (bridge.sidebar?.toggleMax) {
const widen = h('button', { class: 'btn small', title: 'Widen the sidebar', onclick: () => bridge.sidebar.toggleMax() }, '⤢ Widen');
const label = (max) => { widen.textContent = max ? '⤡ Narrow' : '⤢ Widen'; widen.title = max ? 'Back to the normal sidebar width' : 'Widen the sidebar'; };
bridge.sidebar.isMax?.().then(label).catch(() => {});
bridge.sidebar.onMaxChange?.(label);
row.append(widen);
}
// Full page: open in a tab and close the panel, so only one Pithos shows.
row.append(h('button', { class: 'btn small', title: 'Open Pithos as a full page and close this panel', onclick: async () => {
try { await bridge.invoke('open-in-tab'); await bridge.sidebar?.close?.(); } catch (e) { fail(e); }
} }, 'Full page'));
$('.sidebar').insertBefore(row, $('#daemon-pill'));
}
// Theseus: Pithos opens only with the vault unlocked. The add-on asks Theseus
// for the unlock, and Theseus shows its own PIN / master-password prompt.
let vaultInfo = null;
async function vaultGate() {
let r;
try { r = await bridge.invoke('gate'); } catch (e) { fail(e); r = { ok: false }; }
vaultInfo = r;
if (r.ok) { $('#locked').hidden = true; return true; }
$('#app').hidden = true;
$('#locked').hidden = false;
return false;
}
// Re-lock when the vault locks (Settings › Lock now), so secrets are not left
// on screen. The add-on watches from the main process and pushes the change.
function watchVaultLock() {
bridge.on('pithos-vault', (st) => {
vaultInfo = { ...vaultInfo, ...st };
if (st.setup && st.prompt && !st.unlocked && $('#locked').hidden) {
$('#app').hidden = true;
$('#locked').hidden = false;
}
});
}
$('#unlock-btn').addEventListener('click', async () => {
if (await vaultGate()) {
const first = $('#app').dataset.booted !== '1';
$('#app').hidden = false;
if (first) location.reload();
}
});
// The full-page tab opened from the Theseus panel: one button back.
function addBackToSidebar() {
const row = h('div', { class: 'host-controls' },
h('button', { class: 'btn small', title: 'Show Pithos in the sidebar again and close this page', onclick: async () => {
try { await api('POST', '/api/host/show-panel'); } catch (e) { return fail(e); }
window.close(); // works for tabs the add-on opened; harmless otherwise
} }, '⇤ Back to sidebar'));
$('.sidebar').insertBefore(row, $('#daemon-pill'));
}
function showSignin() {
$('#app').hidden = true;
$('#signin').hidden = false;
}
async function boot() {
const s = await api('GET', '/api/session');
if (!s.authenticated) {
showSignin();
if (!s.passwordLogin) {
$('#signin-hint').textContent = 'Open Pithos with the sign-in link printed when it started.';
$('#signin-form').querySelector('input').hidden = true;
$('#signin-form').querySelector('button').hidden = true;
}
return;
}
$('#signin').hidden = true;
if (bridge && !(await vaultGate())) return;
$('#app').hidden = false;
$('#app').dataset.booted = '1';
if (bridge) { addSidebarControls(); watchVaultLock(); }
// Backfill logs the server already holds, then stream.
try {
state.logs = await api('GET', '/api/logs');
state.logSeq = state.logs.at(-1)?.seq || 0;
} catch {}
await refreshStatus();
if (!bridge && state.status?.host === 'theseus') addBackToSidebar();
connectEvents();
window.addEventListener('hashchange', route);
route();
setInterval(refreshStatus, 15000);
}
$('#signin-form').addEventListener('submit', async (e) => {
e.preventDefault();
const err = $('#signin-error');
err.hidden = true;
try {
await api('POST', '/api/session', { password: $('#signin-password').value });
location.reload();
} catch (ex) { err.textContent = ex.message; err.hidden = false; }
});
boot().catch(fail);