From bd440b1d6f70a20d09e96cc13e1ff44de65a18f8 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 5 Oct 2026 01:47:12 +0200 Subject: [PATCH] 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=//. 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. --- core/server.js | 87 +++++++++++++++++++++++++++++++++++++++++++- desktop/main.cjs | 17 +++++++++ test/handoff.test.js | 80 ++++++++++++++++++++++++++++++++++++++++ theseus/addon.json | 3 +- theseus/index.js | 19 ++++++++++ ui/app.js | 36 ++++++++++++++++-- 6 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 test/handoff.test.js diff --git a/core/server.js b/core/server.js index 49b6199..0128d49 100644 --- a/core/server.js +++ b/core/server.js @@ -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: 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=//. 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('/')}`; // /// +} + +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(`${title}

${title}

${text}

`); + }; + 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) { diff --git a/desktop/main.cjs b/desktop/main.cjs index 1f16e20..d51254f 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -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([ diff --git a/test/handoff.test.js b/test/handoff.test.js new file mode 100644 index 0000000..6748cf7 --- /dev/null +++ b/test/handoff.test.js @@ -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/