diff --git a/bundled-addons/pithos/addon.json b/bundled-addons/pithos/addon.json index cbde1669..aebd5226 100644 --- a/bundled-addons/pithos/addon.json +++ b/bundled-addons/pithos/addon.json @@ -1,14 +1,18 @@ { "id": "pithos", "name": "Pithos", - "version": "0.3.9", + "version": "0.3.10", "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", - "vault-derive" + "vault-derive", + "site-route" ], - "updateURL": "https://navigate.st/bns/theseus.x/extensions/pithos/updates.json" + "updateURL": "https://navigate.st/bns/theseus.x/extensions/pithos/updates.json", + "siteRoutes": [ + "pithos.sia" + ] } diff --git a/bundled-addons/pithos/core/server.js b/bundled-addons/pithos/core/server.js index 25fba25f..d639361f 100644 --- a/bundled-addons/pithos/core/server.js +++ b/bundled-addons/pithos/core/server.js @@ -411,6 +411,31 @@ export async function createPithos(opts = {}) { const STREAMED = Symbol('streamed'); let boundPort = port; + // Site mode: Theseus relays pithos.sia/ here (header x-pithos-site). + // Pithos answers its own files, its pages and //…; anything else + // is "not mine", so the pithos.sia website answers it instead. + const SITE_PAGES = new Set(['overview', 'drives', 'users', 'account', 'start', 'setup', 'settings', 'logs']); + const SITE_FILES = new Set(['app.js', 'app.css', 'icon.svg']); + let siteUsersCache = { at: 0, set: new Set() }; + async function siteUsers() { + if (Date.now() - siteUsersCache.at > 10_000) { + try { siteUsersCache = { at: Date.now(), set: new Set(await cli.listUsers()) }; } + catch { siteUsersCache.at = Date.now(); } // keep the last good list + } + return siteUsersCache.set; + } + async function serveSite(req, res, url) { + const segs = url.pathname.split('/').filter(Boolean); + let first = ''; + try { first = decodeURIComponent(segs[0] || ''); } catch {} + if (segs.length === 1 && SITE_FILES.has(first)) return serveStatic(req, res, url); + if (!(SITE_PAGES.has(first) || (await siteUsers()).has(first))) { + res.writeHead(404, { 'x-pithos-not-mine': '1' }); + return res.end('not found'); + } + return serveStatic(req, res, new URL('/', url), { base: '/' }); + } + function hostAllowed(h) { if (!h) return false; const ok = [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`, ...allowedHosts]; @@ -501,6 +526,7 @@ export async function createPithos(opts = {}) { throw new HttpError(404, 'no such endpoint'); } + if (req.headers['x-pithos-site'] === '1') return await serveSite(req, res, url); return serveStatic(req, res, url); } catch (err) { const status = err instanceof HttpError ? err.status @@ -649,7 +675,7 @@ function describePolicy(policy) { return 'custom'; } -function serveStatic(req, res, url) { +function serveStatic(req, res, url, { base } = {}) { if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405); return res.end(); } let rel = decodeURIComponent(url.pathname); if (rel === '/' || !path.extname(rel)) rel = '/index.html'; @@ -662,6 +688,8 @@ function serveStatic(req, res, url) { 'cache-control': 'no-cache', 'content-security-policy': "default-src 'self'; img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'", }); + // Pages under pithos.sia///… load the app's files from the root. + if (base && file.endsWith('index.html')) data = Buffer.from(data.toString('utf8').replace('', ``)); res.end(req.method === 'HEAD' ? undefined : data); }); } diff --git a/bundled-addons/pithos/index.js b/bundled-addons/pithos/index.js index a9238c1f..c2b885b4 100644 --- a/bundled-addons/pithos/index.js +++ b/bundled-addons/pithos/index.js @@ -133,10 +133,52 @@ module.exports = { }); api.onMessage("open-settings", ({ section = "" } = {}) => { api.openSettings?.(String(section)); return { ok: true }; }); - // A full browser-tab view, for anyone who prefers it to the sidebar. - api.onMessage("open-in-tab", async () => { + // ---- pithos.sia///: the app at its own address ---- + // Theseus hands this add-on the requests for paths under pithos.sia (the + // root page stays the website). They go to the control server with the + // internal session, but only for pithos.sia's own requests and the + // address bar, never another site's fetch or form, and only while the + // vault is unlocked, the same as the sidebar panel. + const siteRoutes = typeof api.registerSiteRoute === "function"; + if (siteRoutes) { + const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" } }); + const FORWARD = ["content-type", "x-pithos", "range", "accept", "if-range"]; + const DROP = new Set(["set-cookie", "connection", "keep-alive", "transfer-encoding"]); + api.registerSiteRoute({ host: "pithos.sia", handle: async (request) => { + const u = new URL(request.url); + const fetchSite = request.headers.get("sec-fetch-site"); + const origin = request.headers.get("origin"); + const own = fetchSite ? fetchSite === "same-origin" || fetchSite === "none" + : !origin || /^(bns|https):\/\/pithos\.sia$/i.test(origin); + const st = await vaultStatus(); + const locked = st.setup && st.prompt && !st.unlocked; + if (u.pathname === "/api/host/unlock") { + if (request.method !== "POST" || request.headers.get("x-pithos") !== "1" || !own) return json(403, { error: "forbidden" }); + if (!locked) return json(200, { ok: true }); + const r = await vault.requestUnlock({ reason: "Open Pithos, your S3 storage on Sia." }); + return json(200, { ok: !!r.ok }); + } + if (locked && u.pathname === "/api/session") return json(200, { authenticated: false, locked: true, passwordLogin: false, host: "theseus" }); + const p = await ensureServer(); + const headers = { "x-pithos-site": "1" }; + for (const k of FORWARD) { const v = request.headers.get(k); if (v) headers[k] = v; } + if (own && !locked) headers.cookie = cookie; + const init = { method: request.method, headers, redirect: "manual" }; + if (request.method !== "GET" && request.method !== "HEAD") init.body = Buffer.from(await request.arrayBuffer()); + const res = await fetch(new URL(u.pathname + u.search, p.url), init); + if (res.status === 404 && res.headers.get("x-pithos-not-mine")) return null; + const out = new Headers(); + for (const [k, v] of res.headers) if (!DROP.has(k.toLowerCase())) out.set(k, v); + return new Response(request.method === "HEAD" ? null : res.body, { status: res.status, headers: out }); + } }); + } + + // Full page: Pithos at pithos.sia///… in an ordinary tab. + // An older Theseus without site routes gets the loopback address. + api.onMessage("open-in-tab", async ({ path: appPath } = {}) => { const p = await ensureServer(); - api.openTab(p.authUrl); + const clean = /^\/[^\s]*$/.test(String(appPath || "")) && appPath !== "/" ? appPath : "/drives"; + api.openTab(siteRoutes ? `https://pithos.sia${clean}` : p.authUrl); return { ok: true }; }); diff --git a/bundled-addons/pithos/ui/app.js b/bundled-addons/pithos/ui/app.js index 43ce311d..f324b77c 100644 --- a/bundled-addons/pithos/ui/app.js +++ b/bundled-addons/pithos/ui/app.js @@ -32,6 +32,9 @@ function put(el, ...children) { // 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; +// On pithos.sia (Theseus) pages have real addresses: pithos.sia///, +// pithos.sia/settings… Elsewhere (sidebar, web console, desktop) they stay #/ routes. +const PATH_MODE = !bridge && (location.protocol === 'bns:' || location.hostname === 'pithos.sia'); async function api(method, path, body, { raw } = {}) { if (bridge) { @@ -191,13 +194,43 @@ const views = { start: getStarted, account, overview, buckets, users, setup, set // Pages that live under Account in the menu. 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); + const segs = location.pathname.split('/').filter(Boolean); + if (!segs.length) return []; + const first = decodeURIComponent(segs[0]); + if (first === 'drives') return ['buckets']; + if (views[first]) return segs; + if (state.selectedUser !== first) { state.selectedUser = first; try { localStorage.setItem('pithos.user', first); } catch {} } + return ['buckets', ...segs.slice(1)]; +} +// '#/page/parts' → the address it has on pithos.sia. +function hashToPath(hash) { + const [name = 'overview', ...rest] = String(hash).replace(/^#\/?/, '').split('/'); + if (name === 'buckets') { + const user = state.selectedUser; + if (!user) return '/drives'; + return `/${encodeURIComponent(user)}${rest.length ? '/' + rest.join('/') : ''}`; + } + return `/${[name, ...rest].join('/')}`; +} +// 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; } + const p = hashToPath(hash); + if (p !== location.pathname) history.pushState(null, '', p); + route(); +} + 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 ? 'account' : 'overview'; - const [, name = fallback, ...rest] = location.hash.replace(/^#/, '').split('/'); + const [name = fallback, ...rest] = currentRoute(); const view = views[name] ? name : fallback; const navView = NAV_OF[view] || view; for (const a of document.querySelectorAll('#nav a')) a.classList.toggle('active', a.dataset.view === navView); @@ -700,7 +733,7 @@ function setup(main) { 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'), + daemonState() !== 'running' && h('button', { class: 'btn primary', onclick: async () => { await daemonAction(daemonState() === 'crashed' ? 'restart' : 'start'); navTo('#/overview'); } }, 'Start s3d'), h('a', { class: 'btn', href: '#/users' }, 'Users & keys'), h('button', { class: 'btn', onclick: switchAccount }, 'Add another account…')), archivesBlock()); @@ -793,7 +826,7 @@ function setup(main) { await refreshStatus(); await loadAccount(); Object.assign(wiz, { step: 1, accountReady: false, openedUrl: null, key: null }); - location.hash = '#/start'; + navTo('#/start'); } catch (e) { fail(e); } } @@ -857,7 +890,7 @@ function users(main) { 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(); state.selectedUser = u.name; localStorage.setItem('pithos.user', u.name); navTo('#/buckets'); } }, 'Drives'), ' ', h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); newKey(u.name); } }, 'New key'), ' ', @@ -1102,7 +1135,7 @@ function itemMenu(items) { }); return btn; } -const go = (hash) => () => { location.hash = hash; }; +const go = (hash) => () => navTo(hash); const ICONS = { download: '', share: '', @@ -1142,7 +1175,7 @@ function buckets(main, [bucket, ...prefixParts]) { 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(); } }); + const userSel = h('select', { onchange: (e) => { state.selectedUser = e.target.value; localStorage.setItem('pithos.user', e.target.value); if (PATH_MODE) navTo('#/buckets'); else { location.hash = '#/buckets'; route(); } } }); (async () => { try { users = await api('GET', '/api/users'); } @@ -1302,10 +1335,10 @@ async function accessDialog(user, bucket) { 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 go = (p) => navTo(`#/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: () => navTo('#/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)])); @@ -1789,7 +1822,7 @@ function addSidebarControls() { 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); } + try { await bridge.invoke('open-in-tab', { path: hashToPath(location.hash || '#/overview') }); await bridge.sidebar?.close?.(); } catch (e) { fail(e); } } }, '⤢ Full page')); $('.sidebar').insertBefore(row, $('#daemon-pill')); addMenuAction('⤢ Open as full page', () => row.querySelector('button').click()); @@ -1828,6 +1861,10 @@ function watchVaultLock() { } $('#unlock-btn').addEventListener('click', async () => { + if (PATH_MODE) { + try { const r = await api('POST', '/api/host/unlock'); if (r.ok) location.reload(); } catch (e) { fail(e); } + return; + } if (await vaultGate()) { const first = $('#app').dataset.booted !== '1'; $('#app').hidden = false; @@ -1939,6 +1976,8 @@ function showSignin() { async function boot() { const s = await api('GET', '/api/session'); + // pithos.sia in Theseus: locked with the vault, like the sidebar panel. + if (s.locked) { $('#locked').hidden = false; return; } if (!s.authenticated) { showSignin(); if (!s.passwordLogin) { @@ -1962,7 +2001,16 @@ async function boot() { setupFooter(); if (!bridge && state.status?.host === 'theseus') addBackToSidebar(); connectEvents(); - window.addEventListener('hashchange', route); + if (PATH_MODE) { + window.addEventListener('popstate', route); + // In-app links are written as #/ routes; follow them as real addresses. + document.addEventListener('click', (e) => { + const a = e.target.closest?.('a[href^="#/"]'); + if (!a || e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey) return; + e.preventDefault(); + navTo(a.getAttribute('href')); + }); + } else window.addEventListener('hashchange', route); route(); setInterval(refreshStatus, 15000); }