1896 lines
102 KiB
JavaScript
1896 lines
102 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);
|
||
// Compact bar (narrow hosts): name the page, fold the menu away.
|
||
const here = document.querySelector(`#nav a[data-view="${view}"]`);
|
||
$('#here').textContent = here ? here.textContent : '';
|
||
$('.sidebar').classList.remove('nav-open');
|
||
$('#nav-toggle').setAttribute('aria-expanded', 'false');
|
||
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', {}, '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 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();
|
||
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.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 which login the account belongs to. On sia.storage it is the account whose "My apps" lists "S3d".')),
|
||
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.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 = `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('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: `http://${state.status?.config.apiAddress || '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
|
||
|
||
// View types for drives and folders: Icons, List or Details, remembered per page.
|
||
const VIEW_TYPES = [['icons', '▦', 'Icons'], ['list', '☰', 'List'], ['details', '≣', 'Details']];
|
||
function viewMode(page, fallback) {
|
||
try { const v = localStorage.getItem(`pithos.view.${page}`); if (VIEW_TYPES.some(([k]) => k === v)) return v; } catch {}
|
||
return fallback;
|
||
}
|
||
function viewSwitch(page, current, onChange) {
|
||
return h('div', { class: 'seg', role: 'group', 'aria-label': 'View' }, VIEW_TYPES.map(([key, icon, label]) =>
|
||
h('button', { type: 'button', class: key === current ? 'on' : '', title: label, 'aria-label': label, 'aria-pressed': String(key === current),
|
||
onclick: () => { try { localStorage.setItem(`pithos.view.${page}`, key); } catch {} onChange(key); } }, icon)));
|
||
}
|
||
|
||
// "⋯" menu for one item: [label, action, kind?] rows; closes on any outside click.
|
||
function itemMenu(items) {
|
||
const btn = h('button', { type: 'button', class: 'btn small more', title: 'More', 'aria-label': 'More actions', 'aria-haspopup': 'menu' }, '⋯');
|
||
btn.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
document.querySelectorAll('.menu').forEach((m) => m.remove());
|
||
const menu = h('div', { class: 'menu', role: 'menu' }, items.filter(Boolean).map(([label, run, kind]) =>
|
||
h('button', { type: 'button', role: 'menuitem', class: kind || '', onclick: (ev) => { ev.stopPropagation(); menu.remove(); run(); } }, label)));
|
||
document.body.append(menu);
|
||
const r = btn.getBoundingClientRect();
|
||
const w = menu.offsetWidth, ht = menu.offsetHeight;
|
||
menu.style.left = Math.max(8, Math.min(r.right - w, innerWidth - w - 8)) + 'px';
|
||
menu.style.top = (r.bottom + 4 + ht > innerHeight - 8 ? Math.max(8, r.top - ht - 4) : r.bottom + 4) + 'px';
|
||
const done = () => { menu.remove(); document.removeEventListener('mousedown', away, true); document.removeEventListener('keydown', esc, true); };
|
||
const away = (ev) => { if (!menu.contains(ev.target)) done(); };
|
||
const esc = (ev) => { if (ev.key === 'Escape') { ev.stopPropagation(); done(); btn.focus(); } };
|
||
setTimeout(() => { document.addEventListener('mousedown', away, true); document.addEventListener('keydown', esc, true); }, 0);
|
||
menu.querySelector('button')?.focus();
|
||
});
|
||
return btn;
|
||
}
|
||
const go = (hash) => () => { location.hash = hash; };
|
||
const onEnter = (fn) => (e) => { if (e.key === 'Enter' && e.target === e.currentTarget) fn(); };
|
||
|
||
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 fileType(key) {
|
||
const e = EXT(key);
|
||
return ({ image: 'Image', audio: 'Audio', video: 'Video', text: 'Text' }[fileKind(key)] || 'File') + (e ? ` (.${e})` : '');
|
||
}
|
||
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.indexerUrl ? new URL(a.indexerUrl).host : ''))) || 'not connected';
|
||
|
||
// "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…'));
|
||
const bar = h('div', { class: 'view-bar' });
|
||
put(body, bar, card);
|
||
const base = `/api/s3/${encodeURIComponent(user)}/buckets`;
|
||
let mode = viewMode('drives', 'icons');
|
||
let list = [];
|
||
|
||
async function load() {
|
||
try {
|
||
list = await api('GET', base);
|
||
draw();
|
||
} catch (e) { put(card, h('p', { class: 'error' }, e.message)); }
|
||
}
|
||
|
||
// Buckets read as drives: the top level you put folders into.
|
||
function draw() {
|
||
put(bar, list.length ? viewSwitch('drives', mode, (m) => { mode = m; draw(); }) : null);
|
||
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; }
|
||
const open = (b) => go(`#/buckets/${encodeURIComponent(b.name)}`);
|
||
const menu = (b) => itemMenu([['Open', open(b)], ['Access…', () => accessDialog(user, b.name).then(load)], ['Delete', () => remove(b.name), 'danger']]);
|
||
if (mode === 'details') {
|
||
put(card, h('table', { class: 'files' },
|
||
h('thead', {}, h('tr', {}, h('th', {}, 'Drive'), h('th', {}, 'Contents'), h('th', { class: 'hide-sm' }, 'Access'), h('th', {}))),
|
||
h('tbody', {}, list.map((b) => h('tr', { class: 'clickable', tabindex: '0', onclick: open(b), onkeydown: onEnter(open(b)) },
|
||
h('td', { class: 'name' }, h('span', { class: 'drive-icon sm', 'aria-hidden': 'true' }), h('span', { class: 'label' }, b.name)),
|
||
h('td', { class: 'muted' }, usageLine(b.name)),
|
||
h('td', { class: 'hide-sm' }, accessBadge(b.name)),
|
||
h('td', { class: 'actions', onclick: (e) => e.stopPropagation() }, menu(b)))))));
|
||
} else if (mode === 'list') {
|
||
put(card, h('ul', { class: 'rows' }, list.map((b) => h('li', { class: 'clickable', tabindex: '0', onclick: open(b), onkeydown: onEnter(open(b)) },
|
||
h('span', { class: 'drive-icon sm', 'aria-hidden': 'true' }),
|
||
h('span', { class: 'label' }, b.name),
|
||
h('span', { class: 'meta' }, usageLine(b.name)),
|
||
h('span', { onclick: (e) => e.stopPropagation() }, menu(b))))));
|
||
} else {
|
||
put(card, h('div', { class: 'tiles' }, list.map((b) => h('div', { class: 'tile', tabindex: '0', role: 'button', onclick: open(b), onkeydown: onEnter(open(b)) },
|
||
h('div', { class: 'drive-icon', 'aria-hidden': 'true' }),
|
||
h('div', { class: 'tile-name' }, b.name),
|
||
usageLine(b.name),
|
||
h('div', { class: 'tile-meta' }, accessBadge(b.name)),
|
||
h('div', { class: 'tile-more', onclick: (e) => e.stopPropagation() }, menu(b))))));
|
||
}
|
||
}
|
||
|
||
function accessBadge(bucketName) {
|
||
const el = h('span', {}, h('span', { class: 'badge' }, '…'));
|
||
api('GET', `${base}/${encodeURIComponent(bucketName)}/policy`).then((p) => {
|
||
put(el, h('span', { class: `badge ${p.public === 'private' ? '' : 'warn'}` }, { private: '🔒 private', read: '🌐 public', 'read-list': '🌐 public + list', custom: 'custom' }[p.public]));
|
||
}).catch(() => put(el, '?'));
|
||
return el;
|
||
}
|
||
|
||
// Files and size on a drive, counted in the background.
|
||
function usageLine(bucketName) {
|
||
const el = h('span', { class: 'small muted usage' }, '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) {
|
||
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…'));
|
||
const bar = h('div', { class: 'view-bar' });
|
||
put(body, uploads, bar, card);
|
||
let mode = viewMode('files', 'details');
|
||
|
||
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];
|
||
put(bar, folders.length || objects.length ? viewSwitch('files', mode, (m) => { mode = m; draw(); }) : null);
|
||
if (!folders.length && !objects.length) {
|
||
put(card, h('div', { class: 'empty' }, h('p', {}, prefix ? 'This folder is empty.' : 'This drive is empty.'), h('p', { class: 'small' }, 'Drag files here or use Upload.')));
|
||
return;
|
||
}
|
||
const folderName = (f) => f.slice(prefix.length).replace(/\/$/, '');
|
||
const fileName = (o) => o.key.slice(prefix.length);
|
||
const folderMenu = (f) => itemMenu([['Open', () => go(f)], ['Delete', () => deleteFolder(f), 'danger']]);
|
||
const fileMenu = (o) => itemMenu([[fileKind(o.key) === 'audio' ? 'Play' : 'Open', () => openViewer(o)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(o.key)], ['Delete', () => deleteObject(o.key), 'danger']]);
|
||
const more = last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more'));
|
||
const stop = (e) => e.stopPropagation();
|
||
|
||
if (mode === 'details') {
|
||
put(card, h('table', { class: 'files' },
|
||
h('thead', {}, h('tr', {}, h('th', {}, 'Name'), h('th', { class: 'num' }, 'Size'), h('th', { class: 'hide-sm' }, 'Type'), h('th', { class: 'hide-sm' }, 'Modified'), h('th', {}))),
|
||
h('tbody', {},
|
||
folders.map((f) => h('tr', { class: 'clickable', tabindex: '0', onclick: () => go(f), onkeydown: onEnter(() => go(f)) },
|
||
h('td', { class: 'name' }, h('span', { class: 'ficon', 'aria-hidden': 'true' }, '📁'), h('span', { class: 'label' }, folderName(f))),
|
||
h('td', { class: 'num muted' }, '–'), h('td', { class: 'hide-sm muted' }, 'Folder'), h('td', { class: 'hide-sm' }),
|
||
h('td', { class: 'actions', onclick: stop }, folderMenu(f)))),
|
||
objects.map((o) => h('tr', { class: 'clickable', tabindex: '0', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) },
|
||
h('td', { class: 'name' }, h('span', { class: 'ficon', 'aria-hidden': 'true' }, fileIcon(o.key)), h('span', { class: 'label', title: fileName(o) }, fileName(o))),
|
||
h('td', { class: 'num' }, fmtBytes(o.size)),
|
||
h('td', { class: 'hide-sm muted' }, fileType(o.key)),
|
||
h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)),
|
||
h('td', { class: 'actions', onclick: stop },
|
||
h('span', { class: 'wide-only' }, h('button', { class: 'btn small', onclick: () => downloadObject(o.key) }, 'Download'), ' ',
|
||
h('button', { class: 'btn small', onclick: () => share(o.key) }, 'Share'), ' '),
|
||
fileMenu(o)))))),
|
||
more);
|
||
} else if (mode === 'list') {
|
||
put(card, h('ul', { class: 'rows' },
|
||
folders.map((f) => h('li', { class: 'clickable', tabindex: '0', onclick: () => go(f), onkeydown: onEnter(() => go(f)) },
|
||
h('span', { class: 'ficon', 'aria-hidden': 'true' }, '📁'), h('span', { class: 'label' }, folderName(f)), h('span', { class: 'meta' }),
|
||
h('span', { onclick: stop }, folderMenu(f)))),
|
||
objects.map((o) => h('li', { class: 'clickable', tabindex: '0', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) },
|
||
h('span', { class: 'ficon', 'aria-hidden': 'true' }, fileIcon(o.key)), h('span', { class: 'label', title: fileName(o) }, fileName(o)),
|
||
h('span', { class: 'meta' }, fmtBytes(o.size)),
|
||
h('span', { onclick: stop }, fileMenu(o))))),
|
||
more);
|
||
} else {
|
||
put(card, h('div', { class: 'tiles files' },
|
||
folders.map((f) => h('div', { class: 'tile', tabindex: '0', role: 'button', onclick: () => go(f), onkeydown: onEnter(() => go(f)) },
|
||
h('div', { class: 'thumb' }, h('span', { class: 'big-icon', 'aria-hidden': 'true' }, '📁')),
|
||
h('div', { class: 'tile-name' }, folderName(f)),
|
||
h('div', { class: 'tile-more', onclick: stop }, folderMenu(f)))),
|
||
objects.map((o) => h('div', { class: 'tile', tabindex: '0', role: 'button', onclick: () => openViewer(o), onkeydown: onEnter(() => openViewer(o)) },
|
||
thumbFor(o),
|
||
h('div', { class: 'tile-name', title: fileName(o) }, fileName(o)),
|
||
h('div', { class: 'small muted' }, fmtBytes(o.size)),
|
||
h('div', { class: 'tile-more', onclick: stop }, fileMenu(o))))),
|
||
more);
|
||
}
|
||
}
|
||
|
||
// Icons view: pictures show themselves once scrolled into view; larger
|
||
// ones (and everything else) keep a type icon.
|
||
const thumbs = new IntersectionObserver((entries) => {
|
||
for (const en of entries) {
|
||
if (!en.isIntersecting) continue;
|
||
thumbs.unobserve(en.target);
|
||
const key = en.target.dataset.key;
|
||
const path = `${base}/object?key=${encodeURIComponent(key)}`;
|
||
(bridge ? bridge.invoke('view-url', { path }).then((r) => r.url) : Promise.resolve(`${path}&inline=1`))
|
||
.then((src) => { const img = h('img', { alt: '', src }); img.onload = () => put(en.target, img); })
|
||
.catch(() => {});
|
||
}
|
||
}, { rootMargin: '200px' });
|
||
cleanup.push(() => thumbs.disconnect());
|
||
function thumbFor(o) {
|
||
const el = h('div', { class: 'thumb' }, h('span', { class: 'big-icon', 'aria-hidden': 'true' }, fileIcon(o.key)));
|
||
if (fileKind(o.key) === 'image' && o.size <= 8 * 1024 ** 2) { el.dataset.key = o.key; thumbs.observe(el); }
|
||
return el;
|
||
}
|
||
|
||
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);
|
||
if (kind === 'audio') return playFolder(o);
|
||
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 }]);
|
||
}
|
||
|
||
// Music plays in the docked player, on through this folder's other tracks.
|
||
function playFolder(o) {
|
||
const where = `${bucket}/${prefix}`.replace(/\/$/, '');
|
||
const tracks = pages.flatMap((p) => p.objects).filter((x) => fileKind(x.key) === 'audio').map((x) => {
|
||
const path = `${base}/object?key=${encodeURIComponent(x.key)}`;
|
||
return { key: x.key, name: x.key.slice(prefix.length), where,
|
||
src: async () => (bridge ? (await bridge.invoke('view-url', { path })).url : `${path}&inline=1`) };
|
||
});
|
||
player().play(tracks, Math.max(0, tracks.findIndex((t) => t.key === o.key)));
|
||
}
|
||
|
||
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();
|
||
}
|
||
|
||
// ---------------------------------------------------------------- music player
|
||
|
||
// A player docked at the bottom of Pithos: it lives outside the page, so it
|
||
// keeps playing while you browse other folders, drives or pages, and plays
|
||
// on through the folder it was started from.
|
||
let musicPlayer = null;
|
||
function player() {
|
||
if (musicPlayer) return musicPlayer;
|
||
const audio = new Audio();
|
||
audio.preload = 'metadata';
|
||
let queue = [];
|
||
let at = -1;
|
||
let seeking = false;
|
||
let retried = false;
|
||
const mmss = (t) => (isFinite(t) ? `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, '0')}` : '0:00');
|
||
const title = h('div', { class: 'pl-title' });
|
||
const where = h('div', { class: 'pl-where small muted' });
|
||
const cur = h('span', { class: 'pl-time' }, '0:00');
|
||
const dur = h('span', { class: 'pl-time' }, '0:00');
|
||
const seek = h('input', { type: 'range', min: '0', max: '0', step: '1', value: '0', 'aria-label': 'Position',
|
||
oninput: () => { seeking = true; cur.textContent = mmss(+seek.value); },
|
||
onchange: () => { audio.currentTime = +seek.value; seeking = false; } });
|
||
const playBtn = h('button', { type: 'button', class: 'pl-btn pl-main', 'aria-label': 'Play', onclick: () => (audio.paused ? audio.play().catch(() => {}) : audio.pause()) }, '▶');
|
||
const prevBtn = h('button', { type: 'button', class: 'pl-btn', 'aria-label': 'Previous', title: 'Previous', onclick: () => prev() }, '⏮');
|
||
const nextBtn = h('button', { type: 'button', class: 'pl-btn', 'aria-label': 'Next', title: 'Next', onclick: () => next() }, '⏭');
|
||
const closeBtn = h('button', { type: 'button', class: 'pl-btn', 'aria-label': 'Stop and close the player', title: 'Close', onclick: () => close() }, '✕');
|
||
const el = h('div', { class: 'player', role: 'region', 'aria-label': 'Music player', hidden: true },
|
||
h('div', { class: 'pl-controls' }, prevBtn, playBtn, nextBtn),
|
||
h('div', { class: 'pl-info' }, h('div', { class: 'pl-head' }, title, where), h('div', { class: 'pl-seek' }, cur, seek, dur)),
|
||
closeBtn);
|
||
document.body.append(el);
|
||
|
||
const showPlaying = () => { const p = !audio.paused; playBtn.textContent = p ? '⏸' : '▶'; playBtn.setAttribute('aria-label', p ? 'Pause' : 'Play'); if (navigator.mediaSession) navigator.mediaSession.playbackState = p ? 'playing' : 'paused'; };
|
||
audio.addEventListener('play', showPlaying);
|
||
audio.addEventListener('pause', showPlaying);
|
||
audio.addEventListener('playing', () => { retried = false; });
|
||
audio.addEventListener('loadedmetadata', () => { seek.max = String(Math.floor(audio.duration) || 0); dur.textContent = mmss(audio.duration); });
|
||
audio.addEventListener('timeupdate', () => { if (!seeking) { seek.value = String(Math.floor(audio.currentTime)); cur.textContent = mmss(audio.currentTime); } });
|
||
audio.addEventListener('ended', () => { if (at < queue.length - 1) next(); });
|
||
// Theseus view links last 10 minutes: when one runs out mid-track, fetch a
|
||
// fresh link and carry on from the same spot.
|
||
audio.addEventListener('error', () => {
|
||
if (!queue[at] || retried) return toast(`Cannot play ${queue[at]?.name || 'this file'}`, 'error');
|
||
retried = true;
|
||
load(at, audio.currentTime, true);
|
||
});
|
||
|
||
async function load(i, from = 0, play = true) {
|
||
at = i;
|
||
const t = queue[i];
|
||
title.textContent = t.name;
|
||
title.title = t.name;
|
||
where.textContent = t.where;
|
||
prevBtn.disabled = i <= 0;
|
||
nextBtn.disabled = i >= queue.length - 1;
|
||
let src;
|
||
try { src = await t.src(); } catch (e) { return fail(e); }
|
||
if (at !== i) return; // another track was chosen meanwhile
|
||
audio.src = src;
|
||
if (from) audio.addEventListener('loadedmetadata', () => { audio.currentTime = from; }, { once: true });
|
||
if (play) audio.play().catch(() => {});
|
||
if (navigator.mediaSession && window.MediaMetadata) navigator.mediaSession.metadata = new MediaMetadata({ title: t.name.replace(/\.[^.]+$/, ''), album: t.where, artist: 'Pithos' });
|
||
}
|
||
const next = () => { if (at < queue.length - 1) load(at + 1); };
|
||
const prev = () => { if (audio.currentTime > 3 || at <= 0) audio.currentTime = 0; else load(at - 1); };
|
||
function close() {
|
||
audio.pause();
|
||
audio.removeAttribute('src');
|
||
audio.load();
|
||
queue = []; at = -1;
|
||
el.hidden = true;
|
||
document.body.classList.remove('has-player');
|
||
}
|
||
if (navigator.mediaSession) {
|
||
const ms = navigator.mediaSession;
|
||
try {
|
||
ms.setActionHandler('play', () => audio.play().catch(() => {}));
|
||
ms.setActionHandler('pause', () => audio.pause());
|
||
ms.setActionHandler('previoustrack', prev);
|
||
ms.setActionHandler('nexttrack', next);
|
||
ms.setActionHandler('seekto', (d) => { audio.currentTime = d.seekTime; });
|
||
} catch {}
|
||
}
|
||
musicPlayer = {
|
||
// tracks: [{ key, name, where, src: async () => url }]
|
||
play(tracks, index) {
|
||
queue = tracks;
|
||
el.hidden = false;
|
||
document.body.classList.add('has-player');
|
||
load(index);
|
||
},
|
||
get current() { return queue[at]?.key || null; },
|
||
};
|
||
return musicPlayer;
|
||
}
|
||
|
||
// ---------------------------------------------------------------- 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: Pithos fits the narrow panel; for more room it opens as a
|
||
// regular tab, which steps back like any tab when another one is chosen. The
|
||
// panel closes so only one Pithos shows.
|
||
function addSidebarControls() {
|
||
// The panel may have been widened by an earlier Pithos; start narrow.
|
||
bridge.sidebar?.isMax?.().then((max) => { if (max) bridge.sidebar.restore?.() ?? bridge.sidebar.toggleMax?.(); }).catch(() => {});
|
||
const row = h('div', { class: 'host-controls' },
|
||
h('button', { class: 'btn small', title: 'Open Pithos in a tab 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();
|
||
}
|
||
});
|
||
|
||
// ---- Footer: pithos.sia, the version, and (in Theseus) the updater -----------
|
||
// Like Aegis: checks the extension channel when Pithos opens and every few
|
||
// hours; a newer release replaces the version with "↑ vX". One click
|
||
// downloads and verifies it, the next restarts Theseus to apply it.
|
||
const UPDATES_URL = 'https://navigate.st/bns/theseus.x/extensions/pithos/updates.json';
|
||
function cmpSemver(a, b) {
|
||
const pa = String(a || '0').split('.').map((n) => Number(n) || 0);
|
||
const pb = String(b || '0').split('.').map((n) => Number(n) || 0);
|
||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) { const d = (pa[i] || 0) - (pb[i] || 0); if (d) return d < 0 ? -1 : 1; }
|
||
return 0;
|
||
}
|
||
function setupFooter() {
|
||
const foot = $('#brandfoot');
|
||
const ver = $('#brandver');
|
||
const chip = $('#brandupd');
|
||
const check = $('#brandcheck');
|
||
const current = state.status?.pithos || null;
|
||
ver.textContent = current ? `v${current}` : '';
|
||
foot.hidden = false;
|
||
document.body.classList.add('has-foot');
|
||
if (bridge) {
|
||
$('#brandlink').addEventListener('click', (e) => { e.preventDefault(); bridge.invoke('open-external', { url: 'https://pithos.sia/' }).catch(fail); });
|
||
}
|
||
if (!bridge || !current) return; // only the Theseus add-on updates itself
|
||
|
||
let latest = null;
|
||
let busy = false;
|
||
const showChip = (text, cls, title, onclick) => {
|
||
chip.hidden = false; ver.hidden = true;
|
||
chip.className = `brandupd${cls ? ' ' + cls : ''}`;
|
||
chip.textContent = text; chip.title = title || '';
|
||
chip.disabled = !onclick; chip.onclick = onclick || null;
|
||
};
|
||
const rest = () => { chip.hidden = true; ver.hidden = false; };
|
||
const flash = (text, cls, title) => { showChip(text, cls, title, null); setTimeout(() => { if (!busy && chip.classList.contains(cls)) paint(); }, 2500); };
|
||
function paint() {
|
||
if (latest && cmpSemver(latest, current) > 0) showChip(`↑ v${latest}`, '', `Pithos v${latest} is available. Click to install it.`, install);
|
||
else rest();
|
||
}
|
||
async function scan(manual) {
|
||
check.classList.add('spin');
|
||
try {
|
||
const r = await fetch(`${UPDATES_URL}?t=${Math.floor(Date.now() / 60000)}`, { cache: 'no-store' });
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||
const list = (await r.json())?.addons || [];
|
||
latest = list.reduce((best, e) => (!best || cmpSemver(e.version, best) > 0 ? e.version : best), null);
|
||
if (busy) return;
|
||
if (manual && !(latest && cmpSemver(latest, current) > 0)) flash('✓ Up to date', 'ok', `Pithos v${current} is the latest release`);
|
||
else paint();
|
||
} catch (e) {
|
||
if (manual) flash('⚠ Check failed', 'err', e.message);
|
||
} finally { check.classList.remove('spin'); }
|
||
}
|
||
async function install() {
|
||
busy = true;
|
||
showChip('Downloading…', 'wait', 'Downloading and verifying the signed release', null);
|
||
try {
|
||
const r = await bridge.invoke('update', { step: 'stage' });
|
||
if (r?.fallback) {
|
||
busy = false;
|
||
return showChip('Update in Settings', '', 'This Theseus updates extensions in Settings › Extensions', () => bridge.invoke('open-settings', { section: 'addons' }).catch(fail));
|
||
}
|
||
if (!r?.staged) {
|
||
busy = false;
|
||
return r?.status === 'up-to-date' ? flash('✓ Up to date', 'ok', '') : flash('⚠ Update failed', 'err', r?.detail || r?.status || '');
|
||
}
|
||
const next = r.next ? ` v${r.next}` : '';
|
||
showChip(`↻ Restart to apply${next}`, '', `Pithos${next} is ready. Theseus restarts to apply it and reopens your tabs.`, async () => {
|
||
try {
|
||
const a = await bridge.invoke('update', { step: 'apply' });
|
||
if (a?.deferred) showChip(`Applies on next start${next}`, 'ok', 'Pithos updates the next time Theseus starts', null);
|
||
} catch (e) { flash('⚠ Restart failed', 'err', e.message); }
|
||
});
|
||
} catch (e) { busy = false; flash('⚠ Update failed', 'err', e.message); }
|
||
}
|
||
check.hidden = false;
|
||
check.addEventListener('click', () => scan(true));
|
||
setTimeout(() => scan(false), 1500);
|
||
setInterval(() => scan(false), 6 * 3600 * 1000);
|
||
}
|
||
|
||
// 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();
|
||
setupFooter();
|
||
if (!bridge && state.status?.host === 'theseus') addBackToSidebar();
|
||
connectEvents();
|
||
window.addEventListener('hashchange', route);
|
||
route();
|
||
setInterval(refreshStatus, 15000);
|
||
}
|
||
|
||
$('#nav-toggle').addEventListener('click', () => {
|
||
const open = $('.sidebar').classList.toggle('nav-open');
|
||
$('#nav-toggle').setAttribute('aria-expanded', String(open));
|
||
});
|
||
|
||
$('#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);
|