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

Pithos runs on the user's computer, so the pithos.sia website could only
describe it. Two ways in now:

- Open in browser (Theseus, desktop) opens the current page in the default
  browser with a one-time sign-in link (60 s, single use), unlike the
  host's reusable ?t= link.
- The website links to http://127.0.0.1:47621/open?path=/<user>/<drive>.
  Any page or local program could follow that link, so the host first
  asks the user in its own window (a native dialog on the desktop,
  Theseus's approval prompt with the vault unlocked). Only loopback Host
  names are accepted, one question at a time, and the website learns
  nothing either way.

Sessions started this way are marked external, so the browser does not
get host-only controls such as Back to sidebar.
This commit is contained in:
Local Dev 2026-10-05 01:47:12 +02:00
parent f4d38bc4d6
commit bd440b1d6f
6 changed files with 236 additions and 6 deletions

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,23 @@ async function start() {
// The pinned s3d release installs here, outside the (read-only) app bundle.
binDir: path.join(app.getPath('userData'), 'bin'),
openExternal: (url) => shell.openExternal(url),
// The pithos.sia website's "Open my Pithos" lands here; the user decides.
confirmBrowserOpen: async () => {
if (!win) return false;
if (win.isMinimized()) win.restore();
win.show();
win.focus();
const r = await dialog.showMessageBox(win, {
type: 'question',
buttons: ['Open in browser', 'Cancel'],
defaultId: 1,
cancelId: 1,
title: 'Open Pithos in your browser?',
message: 'Open Pithos in your browser?',
detail: '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 this window until you quit Pithos.',
});
return r.response === 0;
},
});
Menu.setApplicationMenu(process.platform === 'darwin' ? Menu.buildFromTemplate([

80
test/handoff.test.js Normal file
View file

@ -0,0 +1,80 @@
// "Open my Pithos" from the pithos.sia website: nobody is signed in until the
// host says yes, and the sign-in link it hands out works once.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import http from 'node:http';
import { createPithos, handoffRoute } from '../core/server.js';
const get = (url, headers = {}) => new Promise((resolve, reject) => {
http.get(url, { headers }, (res) => { let body = ''; res.on('data', (d) => { body += d; }); res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body })); }).on('error', reject);
});
test('website paths become app routes, and odd ones go to Overview', () => {
assert.equal(handoffRoute('/'), '#/overview');
assert.equal(handoffRoute('/backup-agent/nightly-backups/2026'), '#/u/backup-agent/nightly-backups/2026');
assert.equal(handoffRoute('/settings'), '#/settings');
assert.equal(handoffRoute('/drives'), '#/buckets');
assert.equal(handoffRoute('/a/<script>'), '#/overview');
assert.equal(handoffRoute('/a/b"onload'), '#/overview');
});
test('the handoff asks first, signs in once, and marks the browser as external', async (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pithos-handoff-'));
fs.writeFileSync(path.join(dir, 's3d.yml'), `apiAddress: 127.0.0.1:18400\nadminAddress: 127.0.0.1:18401\ndirectory: ${dir.replace(/\\/g, '/')}/data\n`);
const port = 47700 + Math.floor(Math.random() * 200);
let answer = false;
const asked = [];
const opened = [];
const p = await createPithos({
configFile: path.join(dir, 's3d.yml'), binary: path.join(dir, 'no-s3d'), hostName: 'desktop', handoffPort: port,
confirmBrowserOpen: async (q) => { asked.push(q.path); return answer; },
openBrowser: (url) => opened.push(url),
});
t.after(async () => { await p.close(); fs.rmSync(dir, { recursive: true, force: true }); });
await new Promise((r) => setTimeout(r, 150));
// a hostname that merely resolves to 127.0.0.1 (DNS rebinding) is refused before asking
const rebind = await get(`http://127.0.0.1:${port}/open?path=/`, { host: `evil.example:${port}` });
assert.equal(rebind.status, 403);
assert.equal(asked.length, 0);
// declined: no session, nothing to follow
const no = await get(`http://127.0.0.1:${port}/open?path=/u1/d1`);
assert.equal(no.status, 403);
assert.deepEqual(asked, ['/u1/d1']);
// allowed: a one-time link at the right page
answer = true;
const yes = await get(`http://127.0.0.1:${port}/open?path=/u1/d1`);
assert.equal(yes.status, 302);
const link = new URL(yes.headers.location);
assert.equal(link.origin, new URL(p.url).origin);
assert.equal(link.hash, '#/u/u1/d1');
assert.ok(link.searchParams.get('s'));
const first = await get(link.href.replace(link.hash, ''));
assert.equal(first.status, 302);
const cookie = String(first.headers['set-cookie'] || '').split(';')[0];
assert.match(cookie, /^pithos=/);
const session = JSON.parse((await get(`${p.url}api/session`, { cookie })).body);
assert.equal(session.authenticated, true);
assert.equal(session.external, true);
// the same link again signs nobody in
const again = await get(link.href.replace(link.hash, ''));
assert.equal(again.headers['set-cookie'], undefined);
// "Open in browser" from the app hands the browser the same kind of link
const own = await p.internalSession();
const res = await new Promise((resolve, reject) => {
const req = http.request(`${p.url}api/open-in-browser`, { method: 'POST', headers: { cookie: own, 'x-pithos': '1', 'content-type': 'application/json' } }, (r) => { r.resume(); r.on('end', () => resolve(r.statusCode)); });
req.on('error', reject);
req.end(JSON.stringify({ route: '#/u/u1/d1' }));
});
assert.equal(res, 200);
assert.equal(opened.length, 1);
assert.match(opened[0], /\?s=[\w-]+#\/u\/u1\/d1$/);
});

View file

@ -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

@ -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);