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');
|
const STREAMED = Symbol('streamed');
|
||||||
let boundPort = port;
|
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) {
|
function hostAllowed(h) {
|
||||||
if (!h) return false;
|
if (!h) return false;
|
||||||
const ok = [`127.0.0.1:${boundPort}`, `localhost:${boundPort}`, `[::1]:${boundPort}`, ...allowedHosts];
|
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');
|
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);
|
return serveStatic(req, res, url);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const status = err instanceof HttpError ? err.status
|
const status = err instanceof HttpError ? err.status
|
||||||
|
|
@ -649,7 +675,7 @@ function describePolicy(policy) {
|
||||||
return 'custom';
|
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(); }
|
if (req.method !== 'GET' && req.method !== 'HEAD') { res.writeHead(405); return res.end(); }
|
||||||
let rel = decodeURIComponent(url.pathname);
|
let rel = decodeURIComponent(url.pathname);
|
||||||
if (rel === '/' || !path.extname(rel)) rel = '/index.html';
|
if (rel === '/' || !path.extname(rel)) rel = '/index.html';
|
||||||
|
|
@ -662,6 +688,8 @@ function serveStatic(req, res, url) {
|
||||||
'cache-control': 'no-cache',
|
'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'",
|
'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);
|
res.end(req.method === 'HEAD' ? undefined : data);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "pithos",
|
"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.",
|
"description": "A control panel for s3d, the Sia S3 gateway: daemon, users and keys, buckets, upload pipeline.",
|
||||||
"license": "MPL-2.0",
|
"license": "MPL-2.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,18 @@
|
||||||
{
|
{
|
||||||
"id": "pithos",
|
"id": "pithos",
|
||||||
"name": "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.",
|
"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",
|
"author": "Silent Mode",
|
||||||
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",
|
"icon": "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCI+PGRlZnM+PGxpbmVhckdyYWRpZW50IGlkPSJnIiB4MT0iMCIgeTE9IjAiIHgyPSIwIiB5Mj0iMSI+PHN0b3Agb2Zmc2V0PSIwIiBzdG9wLWNvbG9yPSIjM2RkYzk3Ii8+PHN0b3Agb2Zmc2V0PSIxIiBzdG9wLWNvbG9yPSIjMWU4ZjZhIi8+PC9saW5lYXJHcmFkaWVudD48L2RlZnM+PHBhdGggZD0iTTIyIDZoMjB2NWMwIDItMiAzLTIgNSA4IDMgMTQgMTEgMTQgMjEgMCAxMi05IDIxLTIyIDIxUzEwIDQ5IDEwIDM3YzAtMTAgNi0xOCAxNC0yMSAwLTItMi0zLTItNXoiIGZpbGw9InVybCgjZykiLz48cGF0aCBkPSJNMTQgMzBoMzZNMTMgNDBoMzgiIHN0cm9rZT0iIzBiMWYxOCIgc3Ryb2tlLXdpZHRoPSIzIiBzdHJva2UtbGluZWNhcD0icm91bmQiIG9wYWNpdHk9Ii41NSIvPjwvc3ZnPgo=",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"capabilities": [
|
"capabilities": [
|
||||||
"sidebar-panel",
|
"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 }; });
|
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.
|
// ---- pithos.sia/<user>/<drive>/<folder>: the app at its own address ----
|
||||||
api.onMessage("open-in-tab", async () => {
|
// 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();
|
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 };
|
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
|
// server origin to fetch from: every call goes over the add-on IPC bridge
|
||||||
// and the add-on forwards it to the control server.
|
// and the add-on forwards it to the control server.
|
||||||
const bridge = window.silentmode && typeof window.silentmode.invoke === 'function' ? window.silentmode : null;
|
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 } = {}) {
|
async function api(method, path, body, { raw } = {}) {
|
||||||
if (bridge) {
|
if (bridge) {
|
||||||
|
|
@ -191,13 +194,43 @@ const views = { start: getStarted, account, overview, buckets, users, setup, set
|
||||||
// Pages that live under Account in the menu.
|
// Pages that live under Account in the menu.
|
||||||
const NAV_OF = { start: 'account', setup: 'account' };
|
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() {
|
function route() {
|
||||||
cleanup.forEach((fn) => fn());
|
cleanup.forEach((fn) => fn());
|
||||||
cleanup = [];
|
cleanup = [];
|
||||||
logSink = null;
|
logSink = null;
|
||||||
// First run lands on the guided setup until it has been finished once.
|
// First run lands on the guided setup until it has been finished once.
|
||||||
const fallback = !setupDone() && !state.status?.registration?.registered ? 'account' : 'overview';
|
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 view = views[name] ? name : fallback;
|
||||||
const navView = NAV_OF[view] || view;
|
const navView = NAV_OF[view] || view;
|
||||||
for (const a of document.querySelectorAll('#nav a')) a.classList.toggle('active', a.dataset.view === navView);
|
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('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('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' },
|
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('a', { class: 'btn', href: '#/users' }, 'Users & keys'),
|
||||||
h('button', { class: 'btn', onclick: switchAccount }, 'Add another account…')),
|
h('button', { class: 'btn', onclick: switchAccount }, 'Add another account…')),
|
||||||
archivesBlock());
|
archivesBlock());
|
||||||
|
|
@ -793,7 +826,7 @@ function setup(main) {
|
||||||
await refreshStatus();
|
await refreshStatus();
|
||||||
await loadAccount();
|
await loadAccount();
|
||||||
Object.assign(wiz, { step: 1, accountReady: false, openedUrl: null, key: null });
|
Object.assign(wiz, { step: 1, accountReady: false, openedUrl: null, key: null });
|
||||||
location.hash = '#/start';
|
navTo('#/start');
|
||||||
} catch (e) { fail(e); }
|
} catch (e) { fail(e); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -857,7 +890,7 @@ function users(main) {
|
||||||
h('td', {}, h('strong', {}, u.name)),
|
h('td', {}, h('strong', {}, u.name)),
|
||||||
h('td', {}, `${u.keys.length}`),
|
h('td', {}, `${u.keys.length}`),
|
||||||
h('td', { class: 'actions' },
|
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'),
|
h('button', { class: 'btn small', onclick: (e) => { e.stopPropagation(); newKey(u.name); } }, 'New key'),
|
||||||
' ',
|
' ',
|
||||||
|
|
@ -1102,7 +1135,7 @@ function itemMenu(items) {
|
||||||
});
|
});
|
||||||
return btn;
|
return btn;
|
||||||
}
|
}
|
||||||
const go = (hash) => () => { location.hash = hash; };
|
const go = (hash) => () => navTo(hash);
|
||||||
const ICONS = {
|
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>',
|
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>',
|
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);
|
main.append(head, body);
|
||||||
|
|
||||||
let users = [];
|
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 () => {
|
(async () => {
|
||||||
try { users = await api('GET', '/api/users'); }
|
try { users = await api('GET', '/api/users'); }
|
||||||
|
|
@ -1302,10 +1335,10 @@ async function accessDialog(user, bucket) {
|
||||||
function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
function objectBrowser(head, body, user, bucket, prefix, userSel) {
|
||||||
if (prefix && !prefix.endsWith('/')) prefix += '/';
|
if (prefix && !prefix.endsWith('/')) prefix += '/';
|
||||||
const base = `/api/s3/${encodeURIComponent(user)}/buckets/${encodeURIComponent(bucket)}`;
|
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' },
|
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),
|
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)]));
|
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(() => {});
|
bridge.sidebar?.isMax?.().then((max) => { if (max) bridge.sidebar.restore?.() ?? bridge.sidebar.toggleMax?.(); }).catch(() => {});
|
||||||
const row = h('div', { class: 'host-controls' },
|
const row = h('div', { class: 'host-controls' },
|
||||||
h('button', { class: 'btn small', title: 'Open Pithos in a tab and close this panel', onclick: async () => {
|
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'));
|
} }, '⤢ Full page'));
|
||||||
$('.sidebar').insertBefore(row, $('#daemon-pill'));
|
$('.sidebar').insertBefore(row, $('#daemon-pill'));
|
||||||
addMenuAction('⤢ Open as full page', () => row.querySelector('button').click());
|
addMenuAction('⤢ Open as full page', () => row.querySelector('button').click());
|
||||||
|
|
@ -1828,6 +1861,10 @@ function watchVaultLock() {
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#unlock-btn').addEventListener('click', async () => {
|
$('#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()) {
|
if (await vaultGate()) {
|
||||||
const first = $('#app').dataset.booted !== '1';
|
const first = $('#app').dataset.booted !== '1';
|
||||||
$('#app').hidden = false;
|
$('#app').hidden = false;
|
||||||
|
|
@ -1939,6 +1976,8 @@ function showSignin() {
|
||||||
|
|
||||||
async function boot() {
|
async function boot() {
|
||||||
const s = await api('GET', '/api/session');
|
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) {
|
if (!s.authenticated) {
|
||||||
showSignin();
|
showSignin();
|
||||||
if (!s.passwordLogin) {
|
if (!s.passwordLogin) {
|
||||||
|
|
@ -1962,7 +2001,16 @@ async function boot() {
|
||||||
setupFooter();
|
setupFooter();
|
||||||
if (!bridge && state.status?.host === 'theseus') addBackToSidebar();
|
if (!bridge && state.status?.host === 'theseus') addBackToSidebar();
|
||||||
connectEvents();
|
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();
|
route();
|
||||||
setInterval(refreshStatus, 15000);
|
setInterval(refreshStatus, 15000);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue