Theseus: bundle Pithos 0.3.0 (PIN gate, vault-derived phrase, guided setup)
Rebuilt from Pithos/ with scripts/build-theseus-addon.mjs.
This commit is contained in:
parent
de7735feb3
commit
a3d7c90b78
8 changed files with 526 additions and 21 deletions
|
|
@ -1,13 +1,14 @@
|
|||
{
|
||||
"id": "pithos",
|
||||
"name": "Pithos",
|
||||
"version": "0.2.0",
|
||||
"version": "0.3.0",
|
||||
"description": "Run s3d, the Sia S3 gateway, from the Theseus sidebar: connect it to a Sia indexer, create S3 users and access keys, browse and share buckets, and watch uploads reach Sia.",
|
||||
"author": "Silent Mode",
|
||||
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",
|
||||
"main": "index.js",
|
||||
"capabilities": [
|
||||
"sidebar-panel"
|
||||
"sidebar-panel",
|
||||
"vault-derive"
|
||||
],
|
||||
"updateURL": "https://navigate.st/bns/theseus.x/extensions/pithos/updates.json"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,24 +6,19 @@
|
|||
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { loadPty } from './pty.js';
|
||||
|
||||
const ANSI = /\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07]*\x07/g;
|
||||
|
||||
let ptyModule;
|
||||
async function loadPty() {
|
||||
if (ptyModule === undefined) {
|
||||
try { ptyModule = await import('@lydell/node-pty'); } catch { ptyModule = null; }
|
||||
}
|
||||
return ptyModule && (ptyModule.default || ptyModule);
|
||||
}
|
||||
|
||||
export class LoginSession extends EventEmitter {
|
||||
// registration: async () => ({ registered, indexerUrl }), used to learn the
|
||||
// outcome when login ran in a console window we cannot read.
|
||||
constructor(daemon, { registration } = {}) {
|
||||
// ptyDir: where terminal support may be downloaded if the host lacks it.
|
||||
constructor(daemon, { registration, ptyDir } = {}) {
|
||||
super();
|
||||
this.daemon = daemon;
|
||||
this.registration = registration;
|
||||
this.ptyDir = ptyDir;
|
||||
this.term = null;
|
||||
this.buffer = '';
|
||||
this.state = 'idle';
|
||||
|
|
@ -43,7 +38,14 @@ export class LoginSession extends EventEmitter {
|
|||
if (this.term) throw new Error('a login is already in progress');
|
||||
this.answers = { indexerUrl: indexerUrl || '', phrase: phrase || '' };
|
||||
this.info = {};
|
||||
const pty = await loadPty();
|
||||
this.set('preparing');
|
||||
let pty = null;
|
||||
try {
|
||||
pty = await loadPty({ installDir: this.ptyDir });
|
||||
} catch (e) {
|
||||
this.lastError = e.message;
|
||||
}
|
||||
// Without terminal support, run s3d login in its own console window.
|
||||
if (!pty) return this.startInConsole();
|
||||
this.buffer = '';
|
||||
this.generated = null;
|
||||
|
|
|
|||
98
bundled-addons/pithos/core/pty.js
Normal file
98
bundled-addons/pithos/core/pty.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
// The pseudo-terminal that `s3d login` needs (it reads the recovery phrase
|
||||
// with term.ReadPassword, which fails on a plain pipe).
|
||||
//
|
||||
// Hosts that ship node_modules (web console, desktop) have @lydell/node-pty
|
||||
// installed. The Theseus add-on leaves it out (the Windows build is 12 MB of
|
||||
// native code), so on first use it fetches the one platform package it needs
|
||||
// from the npm registry, checks it against the integrity hash pinned below,
|
||||
// and unpacks it into the add-on's data folder.
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
import zlib from 'node:zlib';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const VERSION = '1.2.0-beta.15';
|
||||
// dist.integrity from the npm registry for each platform package.
|
||||
const PACKAGES = {
|
||||
'win32-x64': 'sha512-2f8twEmDVxZ7drchAXjtevpmSPhFok0avAnzXro4t5gmz0xsPNKkoZvymwtuIS3xo7PzQqZOPQ/YzwEMb7oIzQ==',
|
||||
'win32-arm64': 'sha512-pyAk91w7wnnKrD4mrHXtIXRfmzSWV5bEzvRhurXcMCtCc2TJ424ciUskIgWMhAPP6y3KyUnqElj+U6kY3iOt0A==',
|
||||
'darwin-x64': 'sha512-yDT2oqPqYMBScyuk1U9Rg5VKcrbMOD9o9jWYYamDADA3NSbUISroPChrqYRQ74Y7BQtNH4gqYAiWOZRi5uQZ0Q==',
|
||||
'darwin-arm64': 'sha512-6TSBbzdcLiNTHl1mTuzflqXrkmcC36USVGvERoDgvHk2ItEDaMaFZuAJ1CqPmwYj0DyhCS16TVS8OGK9xZnjyQ==',
|
||||
'linux-x64': 'sha512-+U/5AVvHT6W+8OCYcnJgN0Qgc0ycO3TfD6aaFJHK+WHij797f8gsi5dV1HEO9l6YQmWCD+VL5gaLDhx3mxHwCA==',
|
||||
'linux-arm64': 'sha512-wkbNF7dYAmtJv+o2+iztVlNwnUB4B0uX0wh/UD+mwMcmE2gNMnW9GChXO7fEE5XJokD0vB5idiHpGegaN+G/sg==',
|
||||
};
|
||||
|
||||
let cached;
|
||||
|
||||
// Returns the pty module, or null when none is available and none can be
|
||||
// installed. installDir: where a downloaded package lives (null = never download).
|
||||
export async function loadPty({ installDir, onProgress = () => {} } = {}) {
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const m = await import('@lydell/node-pty');
|
||||
return (cached = m.default || m);
|
||||
} catch { /* not shipped with this host */ }
|
||||
if (!installDir) return null;
|
||||
const plat = `${process.platform}-${process.arch}`;
|
||||
if (!PACKAGES[plat]) return null;
|
||||
const pkgDir = path.join(installDir, `node-pty-${plat}-${VERSION}`);
|
||||
const entry = path.join(pkgDir, 'lib', 'index.js');
|
||||
if (!fs.existsSync(entry)) await install(plat, pkgDir, onProgress);
|
||||
const m = await import(pathToFileURL(entry).href);
|
||||
return (cached = m.default || m);
|
||||
}
|
||||
|
||||
async function install(plat, pkgDir, onProgress) {
|
||||
const name = `node-pty-${plat}`;
|
||||
const url = `https://registry.npmjs.org/@lydell/${name}/-/${name}-${VERSION}.tgz`;
|
||||
onProgress({ phase: 'download', what: 'terminal support', url });
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) throw new Error(`could not download terminal support: HTTP ${res.status}`);
|
||||
const tgz = Buffer.from(await res.arrayBuffer());
|
||||
const want = PACKAGES[plat];
|
||||
const got = 'sha512-' + crypto.createHash('sha512').update(tgz).digest('base64');
|
||||
if (got !== want) throw new Error('terminal support package failed its integrity check');
|
||||
onProgress({ phase: 'extract', what: 'terminal support' });
|
||||
|
||||
const tmp = pkgDir + '.tmp';
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
for (const { name: file, data } of untar(zlib.gunzipSync(tgz))) {
|
||||
// npm tarballs put everything under package/.
|
||||
const rel = file.replace(/^package\//, '');
|
||||
if (!rel || rel === file) continue;
|
||||
const out = path.join(tmp, rel);
|
||||
if (!out.startsWith(tmp + path.sep)) throw new Error('unsafe path in terminal support package');
|
||||
fs.mkdirSync(path.dirname(out), { recursive: true });
|
||||
fs.writeFileSync(out, data);
|
||||
}
|
||||
fs.rmSync(pkgDir, { recursive: true, force: true });
|
||||
fs.renameSync(tmp, pkgDir);
|
||||
onProgress({ phase: 'done', what: 'terminal support' });
|
||||
}
|
||||
|
||||
// Minimal ustar reader: regular files only, with pax "path" overrides.
|
||||
function* untar(buf) {
|
||||
let off = 0;
|
||||
let paxPath = null;
|
||||
while (off + 512 <= buf.length) {
|
||||
const h = buf.subarray(off, off + 512);
|
||||
if (h.every((b) => b === 0)) break;
|
||||
const str = (a, b) => h.toString('utf8', a, b).replace(/\0.*$/s, '');
|
||||
let name = str(0, 100);
|
||||
const prefix = str(345, 500);
|
||||
if (prefix) name = prefix + '/' + name;
|
||||
const size = parseInt(str(124, 136).trim() || '0', 8);
|
||||
const type = String.fromCharCode(h[156] || 48);
|
||||
const data = buf.subarray(off + 512, off + 512 + size);
|
||||
off += 512 + Math.ceil(size / 512) * 512;
|
||||
if (type === 'x') {
|
||||
const m = /\d+ path=([^\n]+)\n/.exec(data.toString('utf8'));
|
||||
paxPath = m ? m[1] : null;
|
||||
continue;
|
||||
}
|
||||
if (type === '0' || type === '\0') yield { name: paxPath || name, data: Buffer.from(data) };
|
||||
paxPath = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -50,7 +50,11 @@ export async function createPithos(opts = {}) {
|
|||
const configFile = resolveConfigPath(configOpt);
|
||||
const daemon = new Daemon({ configFile, binary: resolveBinary(binaryOpt, binDir) });
|
||||
const cli = makeCli(daemon);
|
||||
const login = new LoginSession(daemon, { registration: () => cli.registration() });
|
||||
const login = new LoginSession(daemon, {
|
||||
registration: () => cli.registration(),
|
||||
// Hosts without node-pty (the Theseus add-on) fetch it here on first login.
|
||||
ptyDir: binDir ? path.join(binDir, '..', 'pty') : null,
|
||||
});
|
||||
const secret = crypto.randomBytes(24).toString('base64url');
|
||||
const sessions = new Set();
|
||||
// One-time download links: id -> { path, exp }. Lets a host hand a file
|
||||
|
|
|
|||
|
|
@ -114,6 +114,68 @@ module.exports = {
|
|||
return { ok: true };
|
||||
});
|
||||
|
||||
// ---- Theseus vault: PIN gate and the vault-derived recovery phrase ------
|
||||
// Older Theseus builds have the vault but no requestUnlock (no PIN prompt);
|
||||
// Pithos then runs ungated, as before.
|
||||
const vault = api.vault || null;
|
||||
const canPrompt = !!(vault && typeof vault.requestUnlock === "function");
|
||||
|
||||
async function vaultStatus() {
|
||||
if (!vault || !vault.lifecycle) return { setup: false, unlocked: false, prompt: false };
|
||||
const st = await vault.lifecycle.status();
|
||||
return { setup: !!st.setup, unlocked: !!st.unlocked, prompt: canPrompt };
|
||||
}
|
||||
|
||||
api.onMessage("vault-status", () => vaultStatus());
|
||||
|
||||
// Watch for the vault locking (Settings › Lock now, or a lock from another
|
||||
// extension) and tell the panel at once. This runs in the main process:
|
||||
// a hidden panel's own timers are throttled to about once a minute.
|
||||
if (canPrompt) {
|
||||
let lastUnlocked = null;
|
||||
setInterval(async () => {
|
||||
try {
|
||||
const st = await vaultStatus();
|
||||
if (st.unlocked !== lastUnlocked) {
|
||||
lastUnlocked = st.unlocked;
|
||||
api.emit("pithos-vault", st);
|
||||
}
|
||||
} catch {}
|
||||
}, 3000).unref?.();
|
||||
}
|
||||
|
||||
// Pithos shows access-key secrets and can delete buckets, so in Theseus it
|
||||
// opens only with the vault unlocked: Theseus asks for the PIN (or the
|
||||
// master password after three wrong tries) in its own prompt.
|
||||
api.onMessage("gate", async () => {
|
||||
const st = await vaultStatus();
|
||||
if (!st.setup || !st.prompt || st.unlocked) return { ok: true, ...st };
|
||||
const r = await vault.requestUnlock({ reason: "Open Pithos, your S3 storage on Sia." });
|
||||
return { ...r, ...(await vaultStatus()) };
|
||||
});
|
||||
|
||||
// Connect with a recovery phrase derived from the vault: nothing to write
|
||||
// down, and restoring the vault restores access. The phrase is built and
|
||||
// handed to s3d here; it never reaches the panel page.
|
||||
// CHANGING THE PURPOSE PATH OR THE DERIVATION CUTS USERS OFF FROM THEIR DATA.
|
||||
api.onMessage("connect-with-vault", async ({ indexerUrl } = {}) => {
|
||||
const st = await vaultStatus();
|
||||
if (!st.setup) throw new Error("Set up the password vault in Settings › Passwords first.");
|
||||
if (!st.unlocked) {
|
||||
if (!canPrompt) throw new Error("Unlock the password vault in Settings › Passwords first.");
|
||||
const r = await vault.requestUnlock({ reason: "Create the recovery phrase for your Sia storage." });
|
||||
if (!r.ok) throw new Error("The vault stayed locked.");
|
||||
}
|
||||
const bytes = await vault.derive("pithos/sia-recovery/v1");
|
||||
const bip39 = api.require("bip39");
|
||||
const phrase = bip39.entropyToMnemonic(Buffer.from(bytes.subarray(0, 16)).toString("hex"));
|
||||
bytes.fill(0);
|
||||
return call("POST", "/api/login", {
|
||||
body: JSON.stringify({ indexerUrl, phrase }),
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
});
|
||||
|
||||
api.registerSidebarPanel({ id: "main", title: "Pithos", page: "ui/index.html" });
|
||||
|
||||
// The host has no quit hook for add-ons; without this an s3d we started
|
||||
|
|
|
|||
|
|
@ -218,3 +218,11 @@ pre.snippet { background: var(--surface-2); padding: 10px 12px; border-radius: 8
|
|||
/* Narrow hosts (Theseus sidebar): never scroll sideways. */
|
||||
html, body { overflow-x: hidden; }
|
||||
.card { min-width: 0; }
|
||||
|
||||
/* Get started wizard */
|
||||
.wizard { max-width: 720px; }
|
||||
.wizard h2 { margin-top: 6px; }
|
||||
.wizard-progress { margin-bottom: 18px; }
|
||||
.wizard-progress .bar { margin-top: 6px; }
|
||||
.wizard-nav { margin-top: 22px; padding-top: 14px; border-top: 1px solid var(--border); }
|
||||
.wizard-list { margin: 0 0 8px 1.2em; padding: 0; display: grid; gap: 4px; }
|
||||
|
|
|
|||
|
|
@ -187,14 +187,16 @@ function renderPill() {
|
|||
|
||||
let cleanup = [];
|
||||
let logSink = null;
|
||||
const views = { overview, buckets, users, setup, settings, logs };
|
||||
const views = { start: getStarted, overview, buckets, users, setup, settings, logs };
|
||||
|
||||
function route() {
|
||||
cleanup.forEach((fn) => fn());
|
||||
cleanup = [];
|
||||
logSink = null;
|
||||
const [, name = 'overview', ...rest] = location.hash.replace(/^#/, '').split('/');
|
||||
const view = views[name] ? name : 'overview';
|
||||
// First run lands on the guided setup until it has been finished once.
|
||||
const fallback = !setupDone() && !state.status?.registration?.registered ? 'start' : 'overview';
|
||||
const [, name = fallback, ...rest] = location.hash.replace(/^#/, '').split('/');
|
||||
const view = views[name] ? name : fallback;
|
||||
for (const a of document.querySelectorAll('#nav a')) a.classList.toggle('active', a.dataset.view === view);
|
||||
const main = $('#main');
|
||||
put(main);
|
||||
|
|
@ -301,7 +303,7 @@ function bannersFor(s) {
|
|||
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: '#/setup' }, 'Connect to Sia')));
|
||||
h('a', { class: 'btn primary', href: '#/start' }, 'Get started')));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
|
@ -324,6 +326,288 @@ async function daemonAction(action) {
|
|||
} catch (e) { fail(e); }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- get started (guided setup)
|
||||
|
||||
// One step per screen, Back / Next, from "no account" to a working S3 key.
|
||||
// The connect step drives the same /api/login flow as the Sia connection
|
||||
// page, but opens the indexer's approval page by itself and moves on as
|
||||
// soon as the approval lands.
|
||||
const SETUP_DONE_KEY = 'pithos.setupDone';
|
||||
const wiz = { step: 0, indexerUrl: 'https://sia.storage', mode: 'new', phrase: '', ownIndexer: false, openedUrl: null, key: null };
|
||||
|
||||
function setupDone() {
|
||||
try { return localStorage.getItem(SETUP_DONE_KEY) === '1'; } catch { return false; }
|
||||
}
|
||||
|
||||
function getStarted(main) {
|
||||
const STEPS = ['Welcome', 'Sia Storage account', 'Install s3d', 'Recovery phrase', 'Connect', 'Start gateway', 'Access key', 'Done'];
|
||||
const body = h('div', { class: 'card wizard' });
|
||||
main.append(
|
||||
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Get started'),
|
||||
h('p', { class: 'muted' }, 'Set up your own S3 storage on Sia, one step at a time.'))),
|
||||
body,
|
||||
);
|
||||
|
||||
const go = (n) => { wiz.step = Math.max(0, Math.min(STEPS.length - 1, n)); draw(); };
|
||||
const nav = (opts = {}) => h('div', { class: 'row wizard-nav' },
|
||||
wiz.step > 0 && wiz.step < STEPS.length - 1 && h('button', { class: 'btn', disabled: opts.backDisabled, onclick: () => go(wiz.step - 1) }, 'Back'),
|
||||
h('span', { style: 'flex:1' }),
|
||||
opts.extra,
|
||||
opts.next !== false && h('button', { class: 'btn primary', disabled: !!opts.nextDisabled, onclick: opts.onNext || (() => go(wiz.step + 1)) }, opts.nextLabel || 'Next'));
|
||||
|
||||
function progress() {
|
||||
return h('div', { class: 'wizard-progress' },
|
||||
h('div', { class: 'small muted' }, `Step ${wiz.step + 1} of ${STEPS.length} · ${STEPS[wiz.step]}`),
|
||||
h('div', { class: 'bar' }, h('i', { style: `width:${Math.round((wiz.step / (STEPS.length - 1)) * 100)}%` })));
|
||||
}
|
||||
|
||||
function draw() {
|
||||
const s = state.status;
|
||||
const registered = !!s?.registration?.registered || ['done', 'already'].includes(state.login.state);
|
||||
let content;
|
||||
switch (wiz.step) {
|
||||
case 0:
|
||||
content = [
|
||||
h('h2', {}, 'Your own S3 storage, on Sia'),
|
||||
h('p', {}, 'Pithos runs s3d, an S3-compatible gateway, on this computer. Your files are encrypted here and spread across independent Sia hosts.'),
|
||||
h('p', {}, 'This takes about five minutes:'),
|
||||
h('ol', { class: 'wizard-list' },
|
||||
h('li', {}, 'Create a free Sia Storage account (50 GB free)'),
|
||||
h('li', {}, 'Install s3d'),
|
||||
h('li', {}, 'Choose your recovery phrase'),
|
||||
h('li', {}, 'Approve Pithos on Sia Storage'),
|
||||
h('li', {}, 'Start the gateway and create an access key')),
|
||||
registered && h('div', { class: 'banner info' }, h('span', {}, 'This s3d is already connected to Sia. You can skip ahead.'),
|
||||
h('button', { class: 'btn', onclick: () => go(5) }, 'Skip to the gateway')),
|
||||
nav({ nextLabel: 'Get started' }),
|
||||
];
|
||||
break;
|
||||
|
||||
case 1: {
|
||||
const url = h('input', { type: 'url', value: wiz.indexerUrl, oninput: (e) => { wiz.indexerUrl = e.target.value.trim(); } });
|
||||
content = [
|
||||
h('h2', {}, 'Create your Sia Storage account'),
|
||||
h('p', {}, 'Sia Storage runs the indexer that rents space from Sia hosts for you. The free plan gives you 50 GB; you sign up with Sia Storage directly, and Pithos never sees your account password.'),
|
||||
h('div', { class: 'row' },
|
||||
h('button', { class: 'btn primary', onclick: () => openLink('https://sia.storage') }, 'Open sia.storage ↗'),
|
||||
h('span', { class: 'muted small' }, 'Sign up there, then come back and press Next.')),
|
||||
h('label', { class: 'check', style: 'margin-top:18px' },
|
||||
h('input', { type: 'checkbox', checked: wiz.ownIndexer, onchange: (e) => { wiz.ownIndexer = e.target.checked; if (!wiz.ownIndexer) wiz.indexerUrl = 'https://sia.storage'; draw(); } }),
|
||||
'I run my own indexer instead'),
|
||||
wiz.ownIndexer && h('label', { class: 'field', style: 'margin-top:8px' }, h('span', {}, 'Indexer URL'), url),
|
||||
nav({ nextLabel: 'I have an account — Next', onNext: () => {
|
||||
if (!/^https?:\/\/\S+$/.test(wiz.indexerUrl)) return toast('Enter the indexer URL', 'error');
|
||||
go(2);
|
||||
} }),
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
case 2: {
|
||||
const ok = !!s?.daemon.binary && !s?.s3d?.outdated && !!s?.s3d?.version;
|
||||
content = [
|
||||
h('h2', {}, 'Install s3d'),
|
||||
ok
|
||||
? h('p', {}, '✓ s3d ', h('code', {}, `v${s.s3d.version}`), ' is installed.')
|
||||
: [
|
||||
h('p', {}, s?.daemon.binary ? `Your s3d (v${s.s3d.version || '?'}) is too old for Pithos.` : 's3d is not installed yet.'),
|
||||
h('p', { class: 'muted small' }, `Pithos downloads the official v${s?.s3d?.pinned} release from the Sia Foundation and checks its SHA-256 before installing it.`),
|
||||
h('button', { class: 'btn primary', disabled: !s?.s3d?.installable, onclick: async (e) => { await installBinary(e); draw(); } }, `Install s3d v${s?.s3d?.pinned}`),
|
||||
],
|
||||
nav({ nextDisabled: !ok }),
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
case 3: {
|
||||
const phrase = h('textarea', { rows: 3, autocomplete: 'off', spellcheck: 'false', placeholder: 'twelve words separated by spaces', oninput: (e) => { wiz.phrase = e.target.value; } });
|
||||
phrase.value = wiz.phrase;
|
||||
const vaultOk = !!(bridge && vaultInfo && vaultInfo.setup);
|
||||
content = [
|
||||
h('h2', {}, 'Your recovery phrase'),
|
||||
h('p', {}, 'Twelve words that let you reconnect to the same storage later, on this computer or a new one. Your files cannot be reached without them.'),
|
||||
h('div', { class: 'stack' },
|
||||
vaultOk && h('label', { class: 'check', style: 'align-items:flex-start' }, h('input', { type: 'radio', name: 'wm', checked: wiz.mode === 'vault', onchange: () => { wiz.mode = 'vault'; wiz.modeChosen = true; draw(); } }),
|
||||
h('span', {}, h('strong', {}, 'Use my Theseus vault'), ' (recommended)', h('br'), h('span', { class: 'small muted' }, 'Nothing to write down. The phrase comes from your password vault, so backing up the vault backs up your storage too.'))),
|
||||
h('label', { class: 'check' }, h('input', { type: 'radio', name: 'wm', checked: wiz.mode === 'new', onchange: () => { wiz.mode = 'new'; wiz.modeChosen = true; draw(); } }), 'Create a new phrase for me to write down'),
|
||||
h('label', { class: 'check' }, h('input', { type: 'radio', name: 'wm', checked: wiz.mode === 'existing', onchange: () => { wiz.mode = 'existing'; wiz.modeChosen = true; draw(); } }), 'I already have a phrase (reconnecting)')),
|
||||
wiz.mode === 'existing' && h('label', { class: 'field', style: 'margin-top:12px' }, h('span', {}, 'Recovery phrase'), phrase,
|
||||
h('small', {}, 'Sent only to the s3d process on this computer.')),
|
||||
wiz.mode === 'new' && h('p', { class: 'muted small', style: 'margin-top:12px' }, 'You will see the new phrase on the next screen and confirm you saved it before anything is registered.'),
|
||||
wiz.mode === 'vault' && h('p', { class: 'muted small', style: 'margin-top:12px' }, 'Theseus may ask for your PIN or master password to open the vault.'),
|
||||
nav({ nextLabel: 'Connect', onNext: () => {
|
||||
if (wiz.mode === 'existing' && wiz.phrase.trim().split(/\s+/).length !== 12) return toast('A recovery phrase is 12 words', 'error');
|
||||
wiz.openedUrl = null;
|
||||
go(4);
|
||||
startConnect();
|
||||
} }),
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
case 4:
|
||||
content = connectStep(registered);
|
||||
break;
|
||||
|
||||
case 5: {
|
||||
const st = daemonState();
|
||||
const running = st === 'running' || st === 'external';
|
||||
content = [
|
||||
h('h2', {}, 'Start your gateway'),
|
||||
running
|
||||
? h('p', {}, '✓ s3d is running. Your S3 endpoint is ', h('code', {}, `http://${s.config.apiAddress}`), '.')
|
||||
: st === 'starting'
|
||||
? h('p', { class: 'muted row' }, h('span', { class: 'spinner' }), ' Starting s3d…')
|
||||
: [
|
||||
st === 'crashed' && h('div', { class: 'banner warn' }, h('span', {}, 's3d stopped. The Logs page shows why.'), h('a', { class: 'btn', href: '#/logs' }, 'Logs')),
|
||||
h('p', {}, 'Pithos starts s3d for you. It keeps running while Pithos is open.'),
|
||||
h('button', { class: 'btn primary', onclick: () => daemonAction(st === 'crashed' ? 'restart' : 'start') }, 'Start s3d'),
|
||||
],
|
||||
nav({ nextDisabled: !running }),
|
||||
];
|
||||
break;
|
||||
}
|
||||
|
||||
case 6: {
|
||||
if (wiz.key) {
|
||||
const k = wiz.key;
|
||||
const endpoint = `http://${s?.config.apiAddress}`;
|
||||
content = [
|
||||
h('h2', {}, 'Your first access key'),
|
||||
h('p', {}, 'S3 apps sign in with this key pair. You can see it again under Users & keys.'),
|
||||
secretRow('Access key ID', k.accessKeyId),
|
||||
secretRow('Secret key', k.secretKey),
|
||||
h('p', { class: 'small muted', style: 'margin:14px 0 6px' }, 'Try it with the aws CLI:'),
|
||||
h('pre', { class: 'snippet' }, `aws configure set aws_access_key_id ${k.accessKeyId} --profile ${k.user}\naws configure set aws_secret_access_key ${k.secretKey} --profile ${k.user}\naws configure set region us-east-1 --profile ${k.user}\naws --profile ${k.user} --endpoint-url ${endpoint} s3 mb s3://my-first-bucket`),
|
||||
nav({ backDisabled: true }),
|
||||
];
|
||||
} else {
|
||||
const name = h('input', { type: 'text', value: 'me', pattern: '[A-Za-z0-9][A-Za-z0-9._@-]{0,63}' });
|
||||
content = [
|
||||
h('h2', {}, 'Create an access key'),
|
||||
h('p', {}, 'Each S3 user owns its own buckets. Start with one for yourself; you can add more for apps or people later.'),
|
||||
h('label', { class: 'field' }, h('span', {}, 'User name'), name),
|
||||
nav({ nextLabel: 'Create user and key', onNext: async (e) => {
|
||||
e.currentTarget.disabled = true;
|
||||
try {
|
||||
const r = await api('POST', '/api/users', { name: name.value.trim(), withKey: true });
|
||||
wiz.key = r.key;
|
||||
state.selectedUser = r.name;
|
||||
try { localStorage.setItem('pithos.user', r.name); } catch {}
|
||||
} catch (err) { fail(err); }
|
||||
draw();
|
||||
} }),
|
||||
];
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
try { localStorage.setItem(SETUP_DONE_KEY, '1'); } catch {}
|
||||
content = [
|
||||
h('h2', {}, '🎉 You are all set'),
|
||||
h('p', {}, 'Your S3 gateway is running and stores everything on Sia. Point any S3 app at it with the key you just created, or manage files right here.'),
|
||||
h('div', { class: 'row' },
|
||||
h('a', { class: 'btn primary', href: '#/buckets' }, 'Open buckets'),
|
||||
h('a', { class: 'btn', href: '#/overview' }, 'Overview')),
|
||||
];
|
||||
}
|
||||
put(body, progress(), content);
|
||||
}
|
||||
|
||||
async function startConnect() {
|
||||
if (state.status?.registration?.registered) return;
|
||||
if (wiz.mode === 'vault' && bridge) {
|
||||
try {
|
||||
const r = await bridge.invoke('connect-with-vault', { indexerUrl: wiz.indexerUrl });
|
||||
if (r.status >= 400) throw new Error(r.data?.error || `HTTP ${r.status}`);
|
||||
} catch (e) { fail(e); }
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api('POST', '/api/login', { indexerUrl: wiz.indexerUrl, phrase: wiz.mode === 'existing' ? wiz.phrase.trim() : '' });
|
||||
wiz.phrase = '';
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
|
||||
function connectStep(registered) {
|
||||
const l = state.login;
|
||||
const retry = h('button', { class: 'btn', onclick: () => go(3) }, 'Back to the phrase');
|
||||
if (registered) {
|
||||
// Approval landed: move on by itself.
|
||||
setTimeout(() => { if (wiz.step === 4) { refreshStatus(); go(5); } }, 1200);
|
||||
return [h('h2', {}, 'Connected'), h('p', {}, '✓ Pithos is approved on ', h('code', {}, l.indexerUrl || wiz.indexerUrl), '. Continuing…'), nav({ next: false })];
|
||||
}
|
||||
switch (l.state) {
|
||||
case 'confirm': {
|
||||
const ack = h('input', { type: 'checkbox' });
|
||||
const go2 = h('button', { class: 'btn primary', disabled: true, onclick: () => api('POST', '/api/login/confirm').catch(fail) }, 'I saved it — continue');
|
||||
ack.addEventListener('change', () => { go2.disabled = !ack.checked; });
|
||||
return [
|
||||
h('h2', {}, 'Write down your recovery phrase'),
|
||||
h('p', {}, 'This is the only way to reconnect to your storage. Pithos does not keep it. Write it on paper or store it in a password manager.'),
|
||||
h('div', { class: 'phrase' }, (l.generatedPhrase || '').split(/\s+/).map((w) => h('span', {}, w))),
|
||||
h('div', { class: 'row', style: 'margin-top:12px' }, h('button', { class: 'btn small', onclick: () => copy(l.generatedPhrase, 'Phrase copied: clear your clipboard after storing it') }, 'Copy')),
|
||||
h('label', { class: 'check', style: 'margin:16px 0' }, ack, 'I have stored these 12 words somewhere safe'),
|
||||
nav({ next: false, extra: go2 }),
|
||||
];
|
||||
}
|
||||
case 'approve': {
|
||||
// Open the approval page once, by itself; the button re-opens it.
|
||||
if (l.approvalUrl && wiz.openedUrl !== l.approvalUrl) {
|
||||
wiz.openedUrl = l.approvalUrl;
|
||||
wiz.autoOpened = null;
|
||||
openLink(l.approvalUrl).then((ok) => { wiz.autoOpened = ok; if (wiz.step === 4) draw(); });
|
||||
}
|
||||
return [
|
||||
h('h2', {}, 'Approve Pithos on Sia Storage'),
|
||||
h('p', {}, wiz.autoOpened === false
|
||||
? 'Open the approval page, sign in to Sia Storage and approve the "S3d" app. Pithos continues on its own once you do.'
|
||||
: 'The approval page has opened. Sign in to Sia Storage there and approve the "S3d" app. Pithos continues on its own once you do.'),
|
||||
h('div', { class: 'row' },
|
||||
h('button', { class: wiz.autoOpened === false ? 'btn primary' : 'btn', onclick: () => openLink(l.approvalUrl) }, wiz.autoOpened === false ? 'Open the approval page ↗' : 'Open the approval page again ↗'),
|
||||
h('button', { class: 'btn small', onclick: () => copy(l.approvalUrl) }, 'Copy link')),
|
||||
h('p', { class: 'muted row', style: 'margin-top:16px' }, h('span', { class: 'spinner' }), ' Waiting for your approval…'),
|
||||
nav({ next: false, extra: h('button', { class: 'btn danger', onclick: () => api('POST', '/api/login/cancel').then(() => go(3)).catch(fail) }, 'Cancel') }),
|
||||
];
|
||||
}
|
||||
case 'terminal':
|
||||
return [
|
||||
h('h2', {}, 'Finish in the console window'),
|
||||
h('p', {}, 'Terminal support could not be set up here, so a console window running "s3d login" has opened. Enter the indexer URL and your recovery phrase there, then open the link it prints. This page continues when the window closes.'),
|
||||
h('p', { class: 'muted row' }, h('span', { class: 'spinner' }), ' Waiting for the login window…'),
|
||||
nav({ next: false }),
|
||||
];
|
||||
case 'failed':
|
||||
return [
|
||||
h('h2', {}, 'Connection failed'),
|
||||
h('div', { class: 'banner warn' }, h('span', {}, l.error || 'The login did not finish.')),
|
||||
// The phrase is dropped from memory once sent, so a retry with an
|
||||
// existing phrase has to ask for it again; a blank one would make
|
||||
// s3d generate a new phrase instead.
|
||||
nav({ next: false, extra: [retry, h('button', { class: 'btn primary', onclick: () => {
|
||||
wiz.openedUrl = null;
|
||||
if (wiz.mode === 'existing' && !wiz.phrase.trim()) return go(3);
|
||||
startConnect();
|
||||
} }, 'Try again')] }),
|
||||
];
|
||||
case 'idle':
|
||||
return [h('h2', {}, 'Connect to Sia'), h('p', {}, 'Ready to connect.'),
|
||||
nav({ nextLabel: 'Connect', onNext: () => startConnect() })];
|
||||
default: {
|
||||
const what = l.state === 'preparing' ? 'Setting up terminal support (first time only)…' : 'Contacting the indexer…';
|
||||
return [h('h2', {}, 'Connecting'), h('p', { class: 'muted row' }, h('span', { class: 'spinner' }), ` ${what}`), nav({ next: false })];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (bridge && vaultInfo?.setup && !wiz.modeChosen) wiz.mode = 'vault';
|
||||
// Resume where things stand if the user comes back mid-setup.
|
||||
if (wiz.step === 0 && state.login.state === 'approve') wiz.step = 4;
|
||||
draw();
|
||||
cleanup.push(onState(draw));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- setup (indexer login)
|
||||
|
||||
function setup(main) {
|
||||
|
|
@ -429,12 +713,13 @@ function setup(main) {
|
|||
}
|
||||
|
||||
async function openLink(url) {
|
||||
if (bridge) { try { await bridge.invoke('open-external', { url }); } catch (e) { fail(e); } return; }
|
||||
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; } catch { /* fall through */ }
|
||||
try { await api('POST', '/api/open-external', { url }); return true; } catch { /* fall through */ }
|
||||
}
|
||||
window.open(url, '_blank', 'noopener');
|
||||
// Returns false when a pop-up blocker stopped it (no click behind the call).
|
||||
return !!window.open(url, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- users & keys
|
||||
|
|
@ -929,6 +1214,39 @@ function addSidebarControls() {
|
|||
$('.sidebar').insertBefore(row, $('#daemon-pill'));
|
||||
}
|
||||
|
||||
// Theseus: Pithos opens only with the vault unlocked. The add-on asks Theseus
|
||||
// for the unlock, and Theseus shows its own PIN / master-password prompt.
|
||||
let vaultInfo = null;
|
||||
async function vaultGate() {
|
||||
let r;
|
||||
try { r = await bridge.invoke('gate'); } catch (e) { fail(e); r = { ok: false }; }
|
||||
vaultInfo = r;
|
||||
if (r.ok) { $('#locked').hidden = true; return true; }
|
||||
$('#app').hidden = true;
|
||||
$('#locked').hidden = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Re-lock when the vault locks (Settings › Lock now), so secrets are not left
|
||||
// on screen. The add-on watches from the main process and pushes the change.
|
||||
function watchVaultLock() {
|
||||
bridge.on('pithos-vault', (st) => {
|
||||
vaultInfo = { ...vaultInfo, ...st };
|
||||
if (st.setup && st.prompt && !st.unlocked && $('#locked').hidden) {
|
||||
$('#app').hidden = true;
|
||||
$('#locked').hidden = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
$('#unlock-btn').addEventListener('click', async () => {
|
||||
if (await vaultGate()) {
|
||||
const first = $('#app').dataset.booted !== '1';
|
||||
$('#app').hidden = false;
|
||||
if (first) location.reload();
|
||||
}
|
||||
});
|
||||
|
||||
function showSignin() {
|
||||
$('#app').hidden = true;
|
||||
$('#signin').hidden = false;
|
||||
|
|
@ -946,8 +1264,10 @@ async function boot() {
|
|||
return;
|
||||
}
|
||||
$('#signin').hidden = true;
|
||||
if (bridge && !(await vaultGate())) return;
|
||||
$('#app').hidden = false;
|
||||
if (bridge) addSidebarControls();
|
||||
$('#app').dataset.booted = '1';
|
||||
if (bridge) { addSidebarControls(); watchVaultLock(); }
|
||||
// Backfill logs the server already holds, then stream.
|
||||
try {
|
||||
state.logs = await api('GET', '/api/logs');
|
||||
|
|
|
|||
|
|
@ -19,10 +19,20 @@
|
|||
</form>
|
||||
</div>
|
||||
|
||||
<div id="locked" class="signin" hidden>
|
||||
<div class="card signin-card">
|
||||
<img src="icon.svg" alt="" width="48" height="48">
|
||||
<h1>Pithos is locked</h1>
|
||||
<p class="muted">Unlock your Theseus vault with your PIN or master password to open Pithos.</p>
|
||||
<button class="btn primary" type="button" id="unlock-btn">Unlock</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="app" class="app" hidden>
|
||||
<aside class="sidebar">
|
||||
<div class="brand"><img src="icon.svg" alt="" width="28" height="28"><span>Pithos</span></div>
|
||||
<nav id="nav">
|
||||
<a href="#/start" data-view="start">Get started</a>
|
||||
<a href="#/overview" data-view="overview">Overview</a>
|
||||
<a href="#/buckets" data-view="buckets">Buckets</a>
|
||||
<a href="#/users" data-view="users">Users & keys</a>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue