Compare commits
5 commits
068de477df
...
7fa7f71be4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fa7f71be4 | ||
|
|
fa1ecc954a | ||
|
|
db3ebc681c | ||
|
|
c6c7615ae3 | ||
|
|
f59dced33a |
9 changed files with 220 additions and 36 deletions
28
hosted/package-lock.json
generated
Normal file
28
hosted/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "pithos-hosted",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pithos-hosted",
|
||||
"version": "0.1.0",
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"@bitauth/libauth": "3.1.0-next.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/@bitauth/libauth": {
|
||||
"version": "3.1.0-next.8",
|
||||
"resolved": "https://registry.npmjs.org/@bitauth/libauth/-/libauth-3.1.0-next.8.tgz",
|
||||
"integrity": "sha512-Pm+Ju+YP3JeBLLTiVrBnia2wwE4G17r4XqpvPRMcklElJTe8J6x3JgKRg1by0Xm3ZY6UFxACkEAoSA+x419/zA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -139,7 +139,7 @@ export async function createHosted(cfg, log = console.log) {
|
|||
...st,
|
||||
host: 'hosted',
|
||||
config: st.config ? { apiAddress: null, adminAddress: null, file: null, directory: null, https: null, exists: true } : undefined,
|
||||
hosted: { id: a.id, state: a.state, publicUrl: cfg.publicUrl, usage: usageOf(a.id), downloadCapBytes: cfg.downloadCapBytes || null },
|
||||
hosted: { id: a.id, name: a.gate?.type === 'bns' ? a.gate.name : null, state: a.state, publicUrl: cfg.publicUrl, usage: usageOf(a.id), downloadCapBytes: cfg.downloadCapBytes || null },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -388,10 +388,18 @@ export async function createHosted(cfg, log = console.log) {
|
|||
} finally { release(); }
|
||||
}
|
||||
|
||||
// /p/<account id or BNS name>/<drive>/<path>. Also reached as
|
||||
// navigate.st/s3/…, so every link and redirect here is relative.
|
||||
async function servePublic(req, res, url) {
|
||||
const m = /^\/p\/([0-9a-f]{32})\/([^/]+)\/?(.*)$/.exec(url.pathname);
|
||||
const m = /^\/p\/([^/]+)\/([^/]+)(\/?)(.*)$/.exec(url.pathname);
|
||||
if (!m) throw httpError(404, 'not found');
|
||||
const [, id, bucket] = m;
|
||||
const [, who, bucket, slash] = m;
|
||||
let whoName = who;
|
||||
try { whoName = decodeURIComponent(who); } catch {}
|
||||
const id = /^[0-9a-f]{32}$/.test(who) ? who : sup.accountByName(whoName);
|
||||
if (!id) throw httpError(404, 'not found');
|
||||
// The drive itself without its slash: add it, so relative links resolve.
|
||||
if (!slash && !m[4]) { res.writeHead(301, { location: `${encodeURIComponent(bucket)}/${url.search}` }); return res.end(); }
|
||||
let key;
|
||||
try { key = decodeURIComponent(m[3]); } catch { throw httpError(404, 'not found'); }
|
||||
if (key.split('/').some((x) => x === '.' || x === '..')) throw httpError(404, 'not found');
|
||||
|
|
@ -403,11 +411,17 @@ export async function createHosted(cfg, log = console.log) {
|
|||
const client = await sup.bucketClient(inst, bucket);
|
||||
if (!client) throw httpError(404, 'not found');
|
||||
const page = await client.listObjects(bucket, { prefix: key, token: url.searchParams.get('token') || undefined });
|
||||
return json(res, 200, {
|
||||
const listing = {
|
||||
folders: page.folders.map((f) => f.slice(key.length)),
|
||||
files: page.objects.filter((o) => o.key !== key).map((o) => ({ name: o.key.slice(key.length), size: o.size, modified: o.modified })),
|
||||
next: page.truncated ? page.nextToken : null,
|
||||
});
|
||||
};
|
||||
// Browsers get a page with links and a player; tools get the JSON.
|
||||
if (/text\/html/.test(req.headers.accept || '') && !url.searchParams.has('json')) {
|
||||
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'content-security-policy': "default-src 'self'; style-src 'unsafe-inline'; img-src 'self'; media-src 'self'; script-src 'unsafe-inline'; frame-ancestors 'none'", 'x-content-type-options': 'nosniff' });
|
||||
return res.end(folderPage({ owner: whoName, bucket, key, listing, token: url.searchParams.get('token') }));
|
||||
}
|
||||
return json(res, 200, listing);
|
||||
}
|
||||
return serveObject(req, res, id, bucket, key);
|
||||
}
|
||||
|
|
@ -546,6 +560,42 @@ export async function createHosted(cfg, log = console.log) {
|
|||
};
|
||||
}
|
||||
|
||||
// A public folder as a page: breadcrumbs, folders, files (links are relative
|
||||
// to the folder, so the page works under /p/ and under navigate.st/s3/),
|
||||
// pictures shown small, music and video playable in place.
|
||||
const PAGE_ESC = (s) => String(s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const fmtSize = (n) => { const u = ['B', 'KiB', 'MiB', 'GiB', 'TiB']; let i = 0; n = Number(n) || 0; while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } return `${i ? n.toFixed(1) : n} ${u[i]}`; };
|
||||
export function folderPage({ owner, bucket, key, listing, token }) {
|
||||
const parts = key.split('/').filter(Boolean);
|
||||
const up = (n) => '../'.repeat(n) || './';
|
||||
const crumbs = [`<a href="${up(parts.length)}">${PAGE_ESC(bucket)}</a>`, ...parts.map((p, i) => i === parts.length - 1 ? `<span>${PAGE_ESC(p)}</span>` : `<a href="${up(parts.length - 1 - i)}">${PAGE_ESC(p)}</a>`)].join(' / ');
|
||||
const enc = (name) => name.split('/').map(encodeURIComponent).join('/');
|
||||
const kind = (n) => (/\.(png|jpe?g|gif|webp|avif|svg)$/i.test(n) ? 'img' : /\.(mp3|m4a|aac|ogg|oga|opus|flac|wav)$/i.test(n) ? 'audio' : /\.(mp4|webm|mov|m4v)$/i.test(n) ? 'video' : '');
|
||||
const rows = [
|
||||
...listing.folders.map((f) => `<li class="dir"><a href="${enc(f)}">📁 ${PAGE_ESC(f.replace(/\/$/, ''))}</a></li>`),
|
||||
...listing.files.map((f) => {
|
||||
const k = kind(f.name);
|
||||
const media = k === 'img' ? `<img loading="lazy" src="${enc(f.name)}" alt="">` : k ? `<button type="button" class="play" data-src="${enc(f.name)}" data-kind="${k}">▶</button>` : '';
|
||||
return `<li>${media}<a href="${enc(f.name)}">${PAGE_ESC(f.name)}</a><span class="sz">${fmtSize(f.size)}</span></li>`;
|
||||
}),
|
||||
].join('');
|
||||
const more = listing.next ? `<p><a href="?token=${encodeURIComponent(listing.next)}">More…</a></p>` : '';
|
||||
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>${PAGE_ESC(parts.at(-1) || bucket)} · ${PAGE_ESC(owner)}</title>
|
||||
<style>body{margin:0;background:#0b100e;color:#e6ece9;font:15px/1.55 system-ui,-apple-system,"Segoe UI",sans-serif}main{max-width:860px;margin:0 auto;padding:28px 18px 120px}
|
||||
a{color:#3ddc97;text-decoration:none}a:hover{text-decoration:underline}.owner{color:#8e9c96;font-size:13px}h1{font-size:20px;margin:4px 0 18px;font-weight:600}h1 span{color:#e6ece9}
|
||||
ul{list-style:none;padding:0;margin:0;border:1px solid rgba(255,255,255,.08);border-radius:10px;overflow:hidden}li{display:flex;align-items:center;gap:10px;padding:9px 14px;border-top:1px solid rgba(255,255,255,.06);min-width:0}li:first-child{border-top:none}
|
||||
li a{flex:1;min-width:0;overflow-wrap:anywhere}li img{width:44px;height:44px;object-fit:cover;border-radius:6px;flex:none}.sz{color:#8e9c96;font-size:13px;white-space:nowrap}
|
||||
.play{flex:none;width:32px;height:32px;border-radius:50%;border:0;background:#3ddc97;color:#06231a;cursor:pointer;font-size:13px}
|
||||
#player{position:fixed;left:0;right:0;bottom:0;background:#121a17;border-top:1px solid rgba(255,255,255,.08);padding:10px 16px;display:none}#player.on{display:block}#player audio,#player video{width:100%;max-height:50vh}.empty{color:#8e9c96;padding:18px}footer{color:#5f6b66;font-size:12.5px;margin-top:18px}</style></head>
|
||||
<body><main><div class="owner">${PAGE_ESC(owner)} · public folder</div><h1>${crumbs}</h1>
|
||||
${rows ? `<ul>${rows}</ul>` : '<p class="empty">This folder is empty.</p>'}${more}
|
||||
<footer>Shared with Pithos on Silent Mode. <a href="?json">JSON</a></footer></main>
|
||||
<div id="player"></div>
|
||||
<script>document.addEventListener('click',function(e){var b=e.target.closest('.play');if(!b)return;var p=document.getElementById('player');var m=document.createElement(b.dataset.kind);m.src=b.dataset.src;m.controls=true;m.autoplay=true;p.innerHTML='';p.appendChild(m);p.className='on';});</script>
|
||||
</body></html>`;
|
||||
}
|
||||
|
||||
function json(res, status, body) {
|
||||
const data = JSON.stringify(body);
|
||||
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
||||
|
|
|
|||
|
|
@ -43,7 +43,23 @@ export function createSupervisor(cfg, log = console.log, { reservedKey = () => f
|
|||
return readJson(accountFile(id), null);
|
||||
}
|
||||
|
||||
function saveAccount(a) { writeJson(accountFile(a.id), a); }
|
||||
function saveAccount(a) { writeJson(accountFile(a.id), a); nameIndex = null; }
|
||||
|
||||
// Accounts by the BNS name they signed up with (one slot per name), for
|
||||
// public addresses like /p/alice.x/<drive>/… instead of the 32-hex id.
|
||||
let nameIndex = null;
|
||||
function accountByName(name) {
|
||||
if (!nameIndex) {
|
||||
nameIndex = new Map();
|
||||
let ids = [];
|
||||
try { ids = fs.readdirSync(usersDir); } catch {}
|
||||
for (const id of ids) {
|
||||
const a = account(id);
|
||||
if (a?.gate?.type === 'bns' && a.gate.name) nameIndex.set(String(a.gate.name).toLowerCase(), id);
|
||||
}
|
||||
}
|
||||
return nameIndex.get(String(name).toLowerCase()) || null;
|
||||
}
|
||||
|
||||
function createAccount({ id, pubkey, gate }) {
|
||||
const slots = readJson(slotsFile, { next: 0 });
|
||||
|
|
@ -216,7 +232,7 @@ export function createSupervisor(cfg, log = console.log, { reservedKey = () => f
|
|||
}
|
||||
|
||||
return {
|
||||
dir, dataDir, configFile, account, createAccount, saveAccount, setState,
|
||||
dir, dataDir, configFile, account, accountByName, createAccount, saveAccount, setState,
|
||||
load, ensure, close, flushQuietly, bucketClient, mapBuckets, indexKeys, hold, keyTaken,
|
||||
ownerOfKey: (akid) => (Object.hasOwn(keyIndex, akid) ? keyIndex[akid] : null),
|
||||
async closeAll() {
|
||||
|
|
|
|||
28
hosted/test/public-page.test.js
Normal file
28
hosted/test/public-page.test.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// Public folder pages are served at /p/<who>/<drive>/<path>/ and also as
|
||||
// navigate.st/s3/…, so every link in them must be relative to the folder.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { folderPage } from '../server.js';
|
||||
|
||||
test('folder page links are relative, names escaped, media playable', () => {
|
||||
const html = folderPage({
|
||||
owner: 'alice.x', bucket: 'music-house', key: 'Deep/Night/',
|
||||
listing: { folders: ['Remixes/'], files: [{ name: 'A <i>song & co.mp3', size: 9_437_184 }, { name: 'cover.jpg', size: 2048 }, { name: 'notes.txt', size: 12 }], next: null },
|
||||
});
|
||||
assert.match(html, /<a href="Remixes\/">/);
|
||||
assert.match(html, /href="A%20%3Ci%3Esong%20%26%20co\.mp3"/);
|
||||
assert.match(html, /A <i>song & co\.mp3/);
|
||||
assert.doesNotMatch(html, /<i>song/);
|
||||
assert.match(html, /data-kind="audio"/);
|
||||
assert.match(html, /<img loading="lazy" src="cover\.jpg"/);
|
||||
// Breadcrumbs climb with ../ only: drive root is two levels up.
|
||||
assert.match(html, /<a href="\.\.\/\.\.\/">music-house<\/a> \/ <a href="\.\.\/">Deep<\/a> \/ <span>Night<\/span>/);
|
||||
assert.doesNotMatch(html, /href="\/p\//);
|
||||
});
|
||||
|
||||
test('drive root page has no stray parents', () => {
|
||||
const html = folderPage({ owner: '0123', bucket: 'd', key: '', listing: { folders: [], files: [], next: 'tok/en' } });
|
||||
assert.match(html, /This folder is empty/);
|
||||
assert.match(html, /href="\?token=tok%2Fen"/);
|
||||
assert.match(html, /<a href="\.\/">d<\/a>/);
|
||||
});
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "pithos",
|
||||
"version": "0.3.13",
|
||||
"version": "0.3.14",
|
||||
"description": "A control panel for s3d, the Sia S3 gateway: daemon, users and keys, buckets, upload pipeline.",
|
||||
"license": "MPL-2.0",
|
||||
"type": "module",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "pithos",
|
||||
"name": "Pithos",
|
||||
"version": "0.3.13",
|
||||
"version": "0.3.14",
|
||||
"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=",
|
||||
|
|
|
|||
|
|
@ -131,6 +131,12 @@ module.exports = {
|
|||
// Theseus asks the user before restarting. Older Theseus builds lack
|
||||
// these hooks; the panel then points to Settings > Extensions.
|
||||
api.onMessage("update", async ({ step = "stage" } = {}) => {
|
||||
// In place, no restart (Theseus 0.3.77+): Theseus swaps the new version in,
|
||||
// restarts the add-on and reloads this panel.
|
||||
if (step === "apply-now") {
|
||||
if (typeof api.applySelfUpdate !== "function") return { ok: false, reason: "needs a restart" };
|
||||
return await api.applySelfUpdate();
|
||||
}
|
||||
if (step === "apply") {
|
||||
if (typeof api.restartApp !== "function") return { fallback: "settings" };
|
||||
return await api.restartApp();
|
||||
|
|
@ -138,7 +144,7 @@ module.exports = {
|
|||
if (typeof api.checkAndStageSelfUpdate !== "function") return { fallback: "settings" };
|
||||
const r = await api.checkAndStageSelfUpdate();
|
||||
const staged = r?.status === "staged" || r?.status === "already-staged";
|
||||
return { staged, status: r?.status || "unknown", detail: r?.detail || null, current: r?.current || null, next: r?.next || null };
|
||||
return { staged, status: r?.status || "unknown", detail: r?.detail || null, current: r?.current || null, next: r?.next || null, inPlace: typeof api.applySelfUpdate === "function" };
|
||||
});
|
||||
api.onMessage("open-settings", ({ section = "" } = {}) => { api.openSettings?.(String(section)); return { ok: true }; });
|
||||
|
||||
|
|
|
|||
11
ui/app.css
11
ui/app.css
|
|
@ -442,3 +442,14 @@ nav a.nav-account.active .na-label { color: var(--accent); }
|
|||
background: var(--surface); color: var(--text); font: inherit; font-size: 16px; cursor: pointer; }
|
||||
.bar-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
}
|
||||
|
||||
/* Account switcher on the Accounts page */
|
||||
.acct-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(220px, 100%), 1fr)); gap: 10px; }
|
||||
.acct-tile { display: flex; flex-direction: column; gap: 4px; padding: 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-2); min-width: 0; align-items: flex-start; }
|
||||
.acct-tile .row.spread { width: 100%; }
|
||||
.acct-tile.current { border-color: var(--accent); background: var(--accent-soft); }
|
||||
.acct-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%; }
|
||||
.acct-tile .btn { margin-top: auto; }
|
||||
.acct-tile.add { appearance: none; font: inherit; color: var(--text); cursor: pointer; border-style: dashed; background: transparent; text-align: left; justify-content: center; }
|
||||
.acct-tile.add:hover { border-color: var(--accent); }
|
||||
.acct-plus { font-size: 20px; line-height: 1; color: var(--accent); }
|
||||
|
|
|
|||
97
ui/app.js
97
ui/app.js
|
|
@ -91,12 +91,15 @@ function modal(title, body, buttons = []) {
|
|||
const onKey = (e) => { if (e.key === 'Escape') close(undefined); };
|
||||
const foot = h('div', { class: 'foot' }, buttons.map((b) => h('button', {
|
||||
class: `btn ${b.kind || ''}`, type: b.submit ? 'submit' : 'button',
|
||||
onclick: b.submit ? null : async () => { const v = b.value ? await b.value() : b.result; if (v !== false) close(v); },
|
||||
// A fixed result (Cancel: false/null) always closes; only a value()
|
||||
// action that returns false keeps the dialog open (e.g. Copy link).
|
||||
onclick: b.submit ? null : async () => { if (!b.value) return close(b.result); const v = await b.value(); if (v !== false) close(v); },
|
||||
}, b.label)));
|
||||
const form = h('form', { class: 'modal', onsubmit: async (e) => {
|
||||
e.preventDefault();
|
||||
const b = buttons.find((x) => x.submit);
|
||||
const v = b.value ? await b.value() : b.result;
|
||||
if (!b.value) return close(b.result);
|
||||
const v = await b.value();
|
||||
if (v !== false) close(v);
|
||||
} }, h('h2', {}, title), body, foot);
|
||||
const root = h('div', { class: 'modal-backdrop', onmousedown: (e) => { if (e.target === root) close(undefined); } }, form);
|
||||
|
|
@ -718,22 +721,50 @@ function account(main, rest) {
|
|||
}
|
||||
|
||||
// Earlier Sia accounts set aside on this computer, each one click away.
|
||||
const archiveName = (a) => a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : a.name);
|
||||
async function switchToArchive(a, after) {
|
||||
const name = archiveName(a);
|
||||
const ok = await confirmModal(`Switch to ${name}?`, 's3d stops and switches back to that Sia account with its users, keys and drives. The current account is set aside and you can switch back the same way.', 'Switch', 'primary');
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api('POST', '/api/account/restore', { name: a.name });
|
||||
await api('POST', '/api/daemon/start').catch(() => {});
|
||||
toast(`Switched to ${name}`);
|
||||
await refreshStatus();
|
||||
await loadAccount();
|
||||
refreshNavAccount(true);
|
||||
after();
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
|
||||
// The account switcher: every Sia account on this computer side by side, the
|
||||
// one in use marked, the others one click away, and Add another account.
|
||||
function accountSwitcher(onAdd, after) {
|
||||
const list = accountInfo?.archives || [];
|
||||
const cur = h('div', { class: 'acct-tile current' },
|
||||
h('div', { class: 'row spread' }, h('span', { class: 'badge ok' }, 'in use'), h('span', { class: 'small muted' }, accountInfo?.indexerUrl ? new URL(accountInfo.indexerUrl).host : '')),
|
||||
h('strong', { class: 'acct-name' }, accountName(accountInfo)),
|
||||
h('span', { class: 'small muted', id: 'acct-space' }, ' '));
|
||||
api('GET', '/api/space').then((sp) => { if (sp?.available) put(cur.querySelector('#acct-space'), `${fmtBytes(sp.remainingStorage)} free of ${fmtBytes(sp.maxPinnedData)}`); }).catch(() => {});
|
||||
return h('div', { class: 'card acct-switcher' },
|
||||
h('div', { class: 'row spread', style: 'margin-bottom:10px' }, h('h2', { style: 'margin:0' }, 'Your Sia accounts'),
|
||||
list.length ? h('span', { class: 'small muted' }, `${list.length + 1} on this computer`) : null),
|
||||
h('div', { class: 'acct-tiles' },
|
||||
cur,
|
||||
list.map((a) => h('div', { class: 'acct-tile' },
|
||||
h('span', { class: 'small muted' }, a.archivedAt ? `set aside ${fmtDate(a.archivedAt)}` : 'set aside'),
|
||||
h('strong', { class: 'acct-name' }, archiveName(a)),
|
||||
h('span', { class: 'small muted' }, [a.fingerprint && `key ${a.fingerprint}`, a.users.length && `users: ${a.users.join(', ')}`].filter(Boolean).join(' · ')),
|
||||
a.movedTo ? h('span', { class: 'small muted' }, `Drives moved to ${a.movedTo}; kept as a backup.`)
|
||||
: h('button', { class: 'btn small', disabled: !a.hasData, onclick: () => switchToArchive(a, after) }, 'Switch to'))),
|
||||
h('button', { type: 'button', class: 'acct-tile add', onclick: onAdd }, h('span', { class: 'acct-plus' }, '+'), h('strong', {}, 'Add another account'), h('span', { class: 'small muted' }, 'This one is set aside, ready to switch back to'))));
|
||||
}
|
||||
|
||||
function otherAccounts(after) {
|
||||
const list = accountInfo?.archives || [];
|
||||
if (!list.length) return null;
|
||||
async function restore(a) {
|
||||
const name = a.label || (a.indexerUrl ? new URL(a.indexerUrl).host : a.name);
|
||||
const ok = await confirmModal(`Switch to ${name}?`, 's3d stops and switches back to that Sia account with its users, keys and drives. The current account is set aside and you can switch back the same way.', 'Switch', 'primary');
|
||||
if (!ok) return;
|
||||
try {
|
||||
await api('POST', '/api/account/restore', { name: a.name });
|
||||
await api('POST', '/api/daemon/start').catch(() => {});
|
||||
toast(`Switched to ${name}`);
|
||||
await refreshStatus();
|
||||
await loadAccount();
|
||||
refreshNavAccount(true);
|
||||
after();
|
||||
} catch (e) { fail(e); }
|
||||
return switchToArchive(a, after);
|
||||
}
|
||||
return h('div', {},
|
||||
h('h3', {}, 'Other accounts on this computer'),
|
||||
|
|
@ -747,9 +778,11 @@ function otherAccounts(after) {
|
|||
|
||||
function setup(main) {
|
||||
const body = h('div', { class: 'card' });
|
||||
const switcher = h('div', { hidden: true, style: 'margin-bottom:16px' });
|
||||
main.append(
|
||||
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Account'),
|
||||
h('p', { class: 'muted' }, 'The Sia account that stores, and pays for, everything on this s3d.'))),
|
||||
h('div', { class: 'page-head' }, h('div', {}, h('h1', {}, 'Accounts'),
|
||||
h('p', { class: 'muted' }, 'Your Sia accounts on this computer. The one in use stores, and pays for, everything on this s3d.'))),
|
||||
switcher,
|
||||
body,
|
||||
);
|
||||
|
||||
|
|
@ -764,7 +797,10 @@ function setup(main) {
|
|||
|
||||
if (l.state === 'done' || l.state === 'already') {
|
||||
const label = h('input', { type: 'text', placeholder: 'e.g. the email you sign in with', value: accountInfo?.label || '', maxlength: 120 });
|
||||
put(switcher, accountSwitcher(switchAccount, draw));
|
||||
switcher.hidden = false;
|
||||
put(body,
|
||||
h('div', { class: 'small muted', style: 'text-transform:uppercase;letter-spacing:.06em;margin-bottom:4px' }, 'Account in use'),
|
||||
h('h2', {}, accountInfo?.label || (l.indexerUrl ? new URL(l.indexerUrl).host : 'Connected to Sia')),
|
||||
h('p', { class: 'row small', style: 'margin-top:-6px' }, h('span', { class: 'badge ok' }, 'connected'), accountInfo?.label && l.indexerUrl && h('span', { class: 'muted' }, new URL(l.indexerUrl).host)),
|
||||
h('p', {}, l.indexerUrl ? ['This s3d uses a Sia account on ', h('code', {}, l.indexerUrl), '. All S3 users, drives and files on it are stored, and paid for, through that account.'] : 'This s3d is registered with an indexer.'),
|
||||
|
|
@ -775,11 +811,10 @@ function setup(main) {
|
|||
siaAccountBlock(accountInfo, async () => { try { accountInfo = await api('GET', '/api/account?fresh=1'); draw(); } catch (e) { fail(e); } }),
|
||||
h('div', { class: 'row', style: 'margin-top:16px' },
|
||||
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());
|
||||
h('a', { class: 'btn', href: '#/users' }, 'Users & keys')));
|
||||
return;
|
||||
}
|
||||
switcher.hidden = true;
|
||||
|
||||
if (l.state === 'confirm') {
|
||||
const words = (l.generatedPhrase || '').split(/\s+/);
|
||||
|
|
@ -1162,16 +1197,19 @@ function itemMenu(items) {
|
|||
btn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
document.querySelectorAll('.menu').forEach((m) => m.remove());
|
||||
const menu = h('div', { class: 'menu', role: 'menu' }, items.filter(Boolean).map(([label, run, kind]) =>
|
||||
h('button', { type: 'button', role: 'menuitem', class: kind || '', onclick: (ev) => { ev.stopPropagation(); menu.remove(); run(); } }, label)));
|
||||
// Picking an item must also drop the listeners, or a stale Escape handler
|
||||
// swallows Escape for every dialog opened afterwards.
|
||||
let menu;
|
||||
function done() { menu?.remove(); document.removeEventListener('mousedown', away, true); document.removeEventListener('keydown', esc, true); }
|
||||
menu = h('div', { class: 'menu', role: 'menu' }, items.filter(Boolean).map(([label, run, kind]) =>
|
||||
h('button', { type: 'button', role: 'menuitem', class: kind || '', onclick: (ev) => { ev.stopPropagation(); done(); run(); } }, label)));
|
||||
document.body.append(menu);
|
||||
const r = btn.getBoundingClientRect();
|
||||
const w = menu.offsetWidth, ht = menu.offsetHeight;
|
||||
menu.style.left = Math.max(8, Math.min(r.right - w, innerWidth - w - 8)) + 'px';
|
||||
menu.style.top = (r.bottom + 4 + ht > innerHeight - 8 ? Math.max(8, r.top - ht - 4) : r.bottom + 4) + 'px';
|
||||
const done = () => { menu.remove(); document.removeEventListener('mousedown', away, true); document.removeEventListener('keydown', esc, true); };
|
||||
const away = (ev) => { if (!menu.contains(ev.target)) done(); };
|
||||
const esc = (ev) => { if (ev.key === 'Escape') { ev.stopPropagation(); done(); btn.focus(); } };
|
||||
function away(ev) { if (!menu.contains(ev.target)) done(); }
|
||||
function esc(ev) { if (ev.key === 'Escape') { ev.stopPropagation(); done(); btn.focus(); } }
|
||||
setTimeout(() => { document.addEventListener('mousedown', away, true); document.addEventListener('keydown', esc, true); }, 0);
|
||||
menu.querySelector('button')?.focus();
|
||||
});
|
||||
|
|
@ -2035,8 +2073,9 @@ async function folderShareDialog(user, bucket, folder) {
|
|||
});
|
||||
put(progress, '');
|
||||
if (r.type === 'public') {
|
||||
const at = `${hostedUrl()}/p/${state.status.hosted.id}/${bucket}/${folder}`;
|
||||
put(out, secretRow(r.rights === 'read-list' ? 'Public address (lists the folder as JSON)' : 'Public address: add a file name', at),
|
||||
const who = state.status.hosted.name || state.status.hosted.id;
|
||||
const at = `https://navigate.st/s3/${encodeURIComponent(who)}/${encodeURIComponent(bucket)}/${folder.split('/').map(encodeURIComponent).join('/')}`;
|
||||
put(out, secretRow(r.rights === 'read-list' ? 'Public address (a page anyone can open)' : 'Public address: add a file name', at),
|
||||
r.rights !== 'read-list' && h('p', { class: 'small muted', style: 'margin:0' }, 'For example ', h('code', {}, `${at}index.html`), '.'));
|
||||
}
|
||||
else put(out, h('p', { class: 'small muted', style: 'margin:0' }, 'Give these to the person. The secret is shown here, and in this list, only to you.'),
|
||||
|
|
@ -2476,6 +2515,12 @@ function setupFooter() {
|
|||
return r?.status === 'up-to-date' ? flash('✓ Up to date', 'ok', '') : flash('⚠ Update failed', 'err', r?.detail || r?.status || '');
|
||||
}
|
||||
const next = r.next ? ` v${r.next}` : '';
|
||||
// Newer Theseus applies it in place: the panel reloads on the new version.
|
||||
if (r.inPlace) {
|
||||
showChip(`Updating to${next}…`, 'wait', 'Pithos reloads on the new version in a moment', null);
|
||||
const a = await bridge.invoke('update', { step: 'apply-now' }).catch(() => null);
|
||||
if (a?.ok) return;
|
||||
}
|
||||
showChip(`↻ Restart to apply${next}`, '', `Pithos${next} is ready. Theseus restarts to apply it and reopens your tabs.`, async () => {
|
||||
try {
|
||||
const a = await bridge.invoke('update', { step: 'apply' });
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue