Pithos 0.3.10: the app at pithos.sia/<user>/<drive>/<folder>
Full page opened Pithos at a loopback address such as 127.0.0.1:58678/#/buckets/music-house, which means nothing to anyone reading it and changes every launch. In Theseus the app now lives at pithos.sia/<S3 user>/<drive>/<folder>, with pages at pithos.sia/settings, /account and so on; the root page stays the pithos.sia website. The add-on answers those paths through Theseus's new site routes. It passes the session to the control server only for pithos.sia's own requests and the address bar, never another site's fetch, and only while the vault is unlocked, as the sidebar panel requires. The server answers its own files, pages and existing S3 users and reports anything else as not its own, so the website answers it. The sidebar and the web console keep #/ addresses; an older Theseus still gets the loopback address.
This commit is contained in:
parent
9a9d4d4a3f
commit
cbf4ab7a1e
5 changed files with 140 additions and 18 deletions
|
|
@ -411,6 +411,31 @@ export async function createPithos(opts = {}) {
|
|||
const STREAMED = Symbol('streamed');
|
||||
let boundPort = port;
|
||||
|
||||
// Site mode: Theseus relays pithos.sia/<path> here (header x-pithos-site).
|
||||
// Pithos answers its own files, its pages and /<S3 user>/…; 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/<user>/<drive>/… load the app's files from the root.
|
||||
if (base && file.endsWith('index.html')) data = Buffer.from(data.toString('utf8').replace('<head>', `<head><base href="${base}">`));
|
||||
res.end(req.method === 'HEAD' ? undefined : data);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "pithos",
|
||||
"version": "0.3.9",
|
||||
"version": "0.3.10",
|
||||
"description": "A control panel for s3d, the Sia S3 gateway: daemon, users and keys, buckets, upload pipeline.",
|
||||
"license": "MPL-2.0",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/<user>/<drive>/<folder>: 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/<user>/<drive>/… 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 };
|
||||
});
|
||||
|
||||
|
|
|
|||
68
ui/app.js
68
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/<user>/<drive>/<folder>,
|
||||
// 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: '<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2.5v8M4.5 7.5 8 11l3.5-3.5M3 13.5h10"/></svg>',
|
||||
share: '<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M6.6 9.4a2.6 2.6 0 0 0 3.7 0l2.2-2.2a2.6 2.6 0 0 0-3.7-3.7l-.9.9"/><path d="M9.4 6.6a2.6 2.6 0 0 0-3.7 0L3.5 8.8a2.6 2.6 0 0 0 3.7 3.7l.9-.9"/></svg>',
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue