Pithos 0.3.19: open Pithos in a browser, from the app or the pithos.sia website

This commit is contained in:
Local Dev 2026-10-05 01:47:37 +02:00
parent 4ff75d312a
commit dbef930d45
4 changed files with 140 additions and 7 deletions

View file

@ -1,7 +1,7 @@
{
"id": "pithos",
"name": "Pithos",
"version": "0.3.18",
"version": "0.3.19",
"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=",
@ -9,7 +9,8 @@
"capabilities": [
"sidebar-panel",
"vault-derive",
"site-route"
"site-route",
"approval-modal"
],
"updateURL": "https://navigate.st/bns/theseus.x/extensions/pithos/updates.json",
"siteRoutes": [

View file

@ -53,6 +53,11 @@ export async function createPithos(opts = {}) {
allowedHosts = [], // extra Host header values (reverse proxy names)
hostName = 'web', // 'web' | 'desktop' | 'theseus' — UI tweaks only
openExternal, // (url) => void, for hosts that can open the system browser
openBrowser = openExternal, // (url) => void: the user's default browser (Theseus opens openExternal in its own tab)
// ({ path }) => Promise<boolean>: the host asks the user, in its own UI, whether
// a browser that came to the open-in-Pithos address may be signed in.
confirmBrowserOpen,
handoffPort = HANDOFF_PORT, // fixed loopback port the pithos.sia website links to; null = off
showPanel, // () => void, for hosts with a side panel to switch back to (Theseus)
secrets, // hosted identity + encryption keys; the Theseus vault, else a local key file
} = opts;
@ -67,6 +72,19 @@ export async function createPithos(opts = {}) {
});
const secret = crypto.randomBytes(24).toString('base64url');
const sessions = new Set();
// Sessions of a browser this host opened (or let in), not the host's own
// window: the UI leaves out the controls that only make sense in the host.
const externalSessions = new Set();
// One-time sign-in links handed to a browser: id -> expiry. Unlike the
// host's ?t= link they work once, for a minute.
const signins = new Map();
function signInUrl(route = '') {
for (const [k, exp] of signins) if (exp < Date.now()) signins.delete(k);
const id = crypto.randomBytes(24).toString('base64url');
signins.set(id, Date.now() + 60_000);
const frag = /^#\/[A-Za-z0-9._~%/=-]{0,300}$/.test(route) ? route : '';
return `${baseUrl}?s=${id}${frag}`;
}
// One-time download links: id -> { path, exp }. Lets a host hand a file
// to its own download manager, which carries no session cookie.
const tickets = new Map();
@ -550,6 +568,15 @@ export async function createPithos(opts = {}) {
return body;
});
// "Open in browser": the same Pithos in the user's default browser, signed
// in with a one-time link, at the page the user is on.
route('POST', '/api/open-in-browser', async (req) => {
if (!openBrowser) throw new HttpError(400, 'this Pithos cannot open a browser');
const { route: r = '' } = await jsonBody(req);
openBrowser(signInUrl(String(r)));
return { ok: true };
});
route('GET', '/api/space', async () => {
// On Silent Mode this call is forwarded: the server's s3d answers for its own folder.
if (Date.now() - spaceCache.at < 60_000 && spaceCache.value) return spaceCache.value;
@ -731,9 +758,10 @@ export async function createPithos(opts = {}) {
return m && sessions.has(m[1]) ? m[1] : null;
}
function issueSession(res) {
function issueSession(res, { external = false } = {}) {
const id = crypto.randomBytes(24).toString('base64url');
sessions.add(id);
if (external) externalSessions.add(id);
res.setHeader('set-cookie', `pithos=${id}; HttpOnly; SameSite=Strict; Path=/`);
return id;
}
@ -773,9 +801,17 @@ export async function createPithos(opts = {}) {
res.writeHead(302, { location: url.pathname });
return res.end();
}
const once = url.searchParams.get('s');
if (once && req.method === 'GET' && !url.pathname.startsWith('/api/')) {
const exp = signins.get(once);
signins.delete(once);
if (exp && exp > Date.now()) issueSession(res, { external: true });
res.writeHead(302, { location: url.pathname }); // the #route stays with the browser
return res.end();
}
if (url.pathname === '/api/session') {
if (req.method === 'GET') return sendJson(res, 200, { authenticated: !!cookieSession(req), passwordLogin: !!password, host: hostName });
if (req.method === 'GET') { const c = cookieSession(req); return sendJson(res, 200, { authenticated: !!c, external: !!c && externalSessions.has(c), passwordLogin: !!password, host: hostName, canOpenBrowser: !!openBrowser }); }
if (req.method === 'POST') {
requireCsrf(req);
const body = await jsonBody(req);
@ -866,6 +902,7 @@ export async function createPithos(opts = {}) {
autoFlushTimer.unref?.();
const displayHost = host === '0.0.0.0' || host === '::' ? '127.0.0.1' : host;
const baseUrl = `http://${displayHost.includes(':') ? `[${displayHost}]` : displayHost}:${boundPort}/`;
const handoff = handoffPort && confirmBrowserOpen ? startHandoff(handoffPort, confirmBrowserOpen, signInUrl, (m) => daemon.pushLog('sys', m)) : null;
return {
server,
@ -901,6 +938,7 @@ export async function createPithos(opts = {}) {
hosted.close();
login.cancel();
for (const s of subscribers) s.end();
handoff?.close();
await daemon.stop();
// Idle keep-alive sockets would hold close() for seconds; nobody is left to answer.
const closed = new Promise((r) => server.close(r));
@ -910,6 +948,51 @@ export async function createPithos(opts = {}) {
};
}
// ---- open from the pithos.sia website ---------------------------------------
// The website links to http://127.0.0.1:47621/open?path=/<user>/<drive>. Any
// page or local program could follow that link, so nobody is signed in until
// the user says yes in the host's own window; the page that sent the browser
// here learns nothing either way. One question at a time.
export const HANDOFF_PORT = 47621;
export function handoffRoute(p) {
const segs = String(p || '').split('/').filter(Boolean).slice(0, 12);
if (!segs.length || segs.some((x) => !/^[A-Za-z0-9._~%=-]{1,120}$/.test(x))) return '#/overview';
const pages = ['overview', 'users', 'buckets', 'account', 'settings', 'logs'];
if (segs[0] === 'drives') return '#/buckets';
if (pages.includes(segs[0])) return `#/${segs.join('/')}`;
return `#/u/${segs.join('/')}`; // /<user>/<drive>/<folder>
}
function startHandoff(port, confirm, signInUrl, log) {
const page = (res, status, title, text) => {
res.writeHead(status, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-frame-options': 'DENY', 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'" });
res.end(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${title}</title><body style="font:16px system-ui;background:#0f1214;color:#e6ebe8;display:grid;place-items:center;min-height:90vh;margin:0"><div style="max-width:460px;padding:24px"><h1 style="font-size:20px">${title}</h1><p style="color:#9aa5a0">${text}</p></div>`);
};
let pending = false;
const srv = http.createServer(async (req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
// DNS rebinding: only the loopback names, never a hostname that resolves here
if (!new RegExp(`^(127\\.0\\.0\\.1|localhost|\\[::1\\]):${port}$`).test(req.headers.host || '')) return page(res, 403, 'Not allowed', 'Use http://127.0.0.1 to open Pithos.');
if (req.method !== 'GET' || url.pathname !== '/open') return page(res, 404, 'Pithos', 'Nothing here. Open Pithos from the pithos.sia website.');
if (pending) return page(res, 429, 'Answer Pithos first', 'Pithos is already asking whether to open in a browser. Answer it there, then try again.');
pending = true;
try {
let timer;
const ok = await Promise.race([
Promise.resolve(confirm({ path: url.searchParams.get('path') || '/' })).catch(() => false),
new Promise((r) => { timer = setTimeout(() => r(false), 120_000); }),
]).finally(() => clearTimeout(timer));
if (!ok) return page(res, 403, 'Not opened', 'Pithos was not opened in this browser. If you meant to, go back and press Open my Pithos again, then choose Open in Pithos.');
res.writeHead(302, { location: signInUrl(handoffRoute(url.searchParams.get('path'))), 'cache-control': 'no-store' });
res.end();
} finally { pending = false; }
});
srv.on('error', (e) => log(e.code === 'EADDRINUSE' ? `another Pithos already answers the website on port ${port}` : `website link: ${e.message}`));
srv.listen(port, '127.0.0.1');
return { close: () => srv.close() };
}
// ---- helpers -------------------------------------------------------------
function sendJson(res, status, body) {

View file

@ -34,6 +34,25 @@ module.exports = {
hostName: "theseus",
binDir: path.join(dataDir, "bin"),
openExternal: (url) => api.openTab(url),
// "Open in browser": the user's default browser, not a Theseus tab.
openBrowser: (url) => require("electron").shell.openExternal(url),
// "Open my Pithos" on the pithos.sia website in another browser:
// only with the vault unlocked, and only if the user says yes here.
confirmBrowserOpen: async () => {
if (typeof api.approvalModal !== "function") return false;
const st = await vaultStatus();
if (st.setup && st.prompt && !st.unlocked) {
const r = await vault.requestUnlock({ reason: "Open Pithos in another browser." });
if (!r.ok) return false;
}
const pick = await api.approvalModal({
title: "Open Pithos in another browser?",
origin: "pithos.sia website",
body: "A browser on this computer asked to open Pithos, usually from \"Open my Pithos\" on the pithos.sia website. Allow it only if you just clicked that. That browser then has the same access as Pithos here until Theseus restarts.",
actions: [{ id: "allow", label: "Open in browser", primary: true }],
});
return String(pick || "").split("+")[0] === "allow";
},
// "Back to sidebar" from the full-tab view.
showPanel: () => api.revealSidebar("main"),
// "On Silent Mode": sign-in identity and file encryption keys come

View file

@ -208,7 +208,16 @@ const NAV_OF = { start: 'account', setup: 'account' };
// [page, ...parts] for the current address. On pithos.sia the first part is a
// page name or an S3 user (whose drives the rest addresses).
function currentRoute() {
if (!PATH_MODE) return location.hash.replace(/^#\/?/, '').split('/').filter((x, i) => i > 0 || x);
if (!PATH_MODE) {
const parts = location.hash.replace(/^#\/?/, '').split('/').filter((x, i) => i > 0 || x);
// #/u/<user>/<drive>/…: a drive of a given S3 user (links handed to a browser)
if (parts[0] === 'u' && parts[1]) {
const user = decodeURIComponent(parts[1]);
if (state.selectedUser !== user) { state.selectedUser = user; try { localStorage.setItem('pithos.user', user); } catch {} }
return ['buckets', ...parts.slice(2)];
}
return parts;
}
const segs = location.pathname.split('/').filter(Boolean);
if (!segs.length) return [];
const first = decodeURIComponent(segs[0]);
@ -227,6 +236,25 @@ function hashToPath(hash) {
}
return `/${[name, ...rest].join('/')}`;
}
// The page the user is on as a '#/' route another browser can open
// (drives carry their S3 user, which that browser does not know yet).
function shareableRoute() {
const [name = 'overview', ...rest] = currentRoute();
if (name === 'buckets' && state.selectedUser) return `#/u/${encodeURIComponent(state.selectedUser)}${rest.length ? '/' + rest.join('/') : ''}`;
return `#/${[name, ...rest].join('/')}`;
}
// "Open in browser": this Pithos in the default browser, at the same page.
function addOpenInBrowser() {
const run = async () => {
try { await api('POST', '/api/open-in-browser', { route: shareableRoute() }); toast('Opened in your browser'); } catch (e) { fail(e); }
};
const row = h('div', { class: 'host-controls' },
h('button', { class: 'btn small', title: 'Open this page of Pithos in your default browser', onclick: run }, '↗ Open in browser'));
$('.sidebar').insertBefore(row, $('#daemon-pill'));
addMenuAction('↗ Open in browser', run);
}
// Go to a page given as '#/page/parts', whichever kind of address this host uses.
function navTo(hash) {
if (!PATH_MODE) { location.hash = hash; return; }
@ -2623,7 +2651,7 @@ function setupFooter() {
if (bridge) {
$('#brandlink').addEventListener('click', (e) => { e.preventDefault(); bridge.invoke('open-external', { url: 'https://pithos.sia/' }).catch(fail); });
}
if (!bridge && state.status?.host === 'desktop' && current) return desktopUpdates(current, { ver, chip, check });
if (!bridge && state.status?.host === 'desktop' && current && !state.external) return desktopUpdates(current, { ver, chip, check });
if (!bridge || !current) return; // only the Theseus add-on updates itself
let latest = null;
@ -2722,7 +2750,9 @@ async function boot() {
if (bridge && !(await vaultGate())) return;
$('#app').hidden = false;
$('#app').dataset.booted = '1';
state.external = !!s.external;
if (bridge) { addSidebarControls(); watchVaultLock(); }
if (!state.external && s.canOpenBrowser && ['desktop', 'theseus'].includes(s.host)) addOpenInBrowser();
// Backfill logs the server already holds, then stream.
try {
state.logs = await api('GET', '/api/logs');
@ -2730,7 +2760,7 @@ async function boot() {
} catch {}
await refreshStatus();
setupFooter();
if (!bridge && state.status?.host === 'theseus') addBackToSidebar();
if (!bridge && state.status?.host === 'theseus' && !state.external) addBackToSidebar();
connectEvents();
if (PATH_MODE) {
window.addEventListener('popstate', route);