1293 lines
67 KiB
JavaScript
1293 lines
67 KiB
JavaScript
// 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…'); },
|
||
};
|
||
|
||
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 = 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';
|
||
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', {}, '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 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.'),
|
||
);
|
||
} catch (e) {
|
||
put(statsCard, h('h2', {}, 'Upload pipeline'), h('p', { class: 'error' }, e.message));
|
||
}
|
||
}
|
||
|
||
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();
|
||
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.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.'),
|
||
h('div', { class: 'row' },
|
||
h('button', { class: 'btn primary', onclick: () => openLink('https://sia.storage') }, 'Open sia.storage ↗'),
|
||
h('span', { class: 'muted small' }, 'Sign up there, then come back and press Next.')),
|
||
h('label', { class: 'check', style: 'margin-top:18px' },
|
||
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({ nextLabel: 'I have an account — Next', 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('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') {
|
||
put(body, steps,
|
||
h('h2', {}, l.state === 'done' ? 'Connected' : 'Already connected'),
|
||
h('p', {}, l.indexerUrl ? ['This s3d is registered with ', h('code', {}, l.indexerUrl), '.'] : 'This s3d is registered with an indexer.'),
|
||
h('div', { class: 'row' },
|
||
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' }, 'Create a user'),
|
||
h('button', { class: 'btn', onclick: () => api('POST', '/api/login/cancel').catch(fail) }, 'Done')));
|
||
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'))));
|
||
}
|
||
|
||
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();
|
||
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('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'; } }, 'Buckets'),
|
||
' ',
|
||
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 });
|
||
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: '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 }) }]);
|
||
if (!v) return;
|
||
try {
|
||
const r = await api('POST', '/api/users', v);
|
||
toast(`Created ${r.name}`);
|
||
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 = `http://${s?.config.apiAddress || '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('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 }]);
|
||
}
|
||
|
||
// ---------------------------------------------------------------- buckets & objects
|
||
|
||
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', {}, 'Buckets')));
|
||
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', {}, 'Buckets')));
|
||
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);
|
||
})();
|
||
}
|
||
|
||
function userPicker(userSel) {
|
||
return h('label', { class: 'row small' }, h('span', { class: 'muted' }, 'Acting as'), h('span', { style: 'width:180px' }, userSel));
|
||
}
|
||
|
||
async function bucketList(head, body, user, userSel) {
|
||
put(head,
|
||
h('div', {}, h('h1', {}, 'Buckets'), h('p', { class: 'muted' }, 'Buckets owned by the selected user.')),
|
||
h('div', { class: 'row' }, userPicker(userSel), h('button', { class: 'btn primary', onclick: create }, 'New bucket')));
|
||
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 buckets yet.'), h('button', { class: 'btn primary', onclick: create }, 'Create a bucket'))); return; }
|
||
put(card, h('table', {},
|
||
h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'hide-sm' }, 'Created'), h('th', {}, 'Access'), h('th', {}))),
|
||
h('tbody', {}, list.map((b) => {
|
||
const access = h('td', {}, h('span', { class: 'badge' }, '…'));
|
||
api('GET', `${base}/${encodeURIComponent(b.name)}/policy`).then((p) => {
|
||
put(access, h('span', { class: `badge ${p.public === 'private' ? '' : 'warn'}` }, { private: 'private', read: 'public read', 'read-list': 'public read + list', custom: 'custom policy' }[p.public]));
|
||
}).catch(() => put(access, h('span', { class: 'badge' }, '?')));
|
||
return h('tr', { class: 'clickable', onclick: () => { location.hash = `#/buckets/${encodeURIComponent(b.name)}`; } },
|
||
h('td', {}, h('strong', {}, b.name)),
|
||
h('td', { class: 'hide-sm muted' }, fmtDate(b.created)),
|
||
access,
|
||
h('td', { class: '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)); }
|
||
}
|
||
|
||
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-bucket' });
|
||
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) {
|
||
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.')),
|
||
[{ 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'; } }, 'Buckets'), 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),
|
||
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' }, h('button', { class: 'btn small danger', onclick: (e) => { e.stopPropagation(); deleteFolder(f); } }, 'Delete')))),
|
||
objects.map((o) => h('tr', {},
|
||
h('td', {}, h('a', {
|
||
href: `${base}/object?key=${encodeURIComponent(o.key)}`, download: '',
|
||
// The sidebar page has no server origin; Theseus's download
|
||
// manager fetches the file through a one-time link instead.
|
||
onclick: bridge ? (e) => { e.preventDefault(); bridge.invoke('download', { path: `${base}/object?key=${encodeURIComponent(o.key)}` }).then(() => toast('Download started')).catch(fail); } : null,
|
||
}, o.key.slice(prefix.length))),
|
||
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: () => 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')));
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
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, h('small', {}, 'S3 has no real folders. This opens an empty prefix; it appears once you upload into it.')),
|
||
[{ label: 'Cancel', result: null }, { label: 'Open', kind: 'primary', submit: true, value: () => name.value.trim() }]);
|
||
if (v) go(prefix + v);
|
||
}
|
||
|
||
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) {
|
||
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 }]);
|
||
}
|
||
|
||
load();
|
||
}
|
||
|
||
// ---------------------------------------------------------------- settings
|
||
|
||
function settings(main) {
|
||
const card = h('div', { class: 'card' }, h('p', { class: 'muted' }, 'Loading…'));
|
||
main.append(h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Settings'), h('p', { class: 'muted' }, 'Edits s3d.yml. s3d reads it at start, so a restart applies the changes.'))), card);
|
||
|
||
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);
|
||
}
|
||
row.append(h('button', { class: 'btn small', title: 'Open Pithos in a browser tab', onclick: () => bridge.invoke('open-in-tab').catch(fail) }, 'Open in tab'));
|
||
$('.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();
|
||
}
|
||
});
|
||
|
||
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();
|
||
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);
|