diff --git a/bundled-addons/pithos/addon.json b/bundled-addons/pithos/addon.json index 8d1c54f9..f4209fae 100644 --- a/bundled-addons/pithos/addon.json +++ b/bundled-addons/pithos/addon.json @@ -1,7 +1,7 @@ { "id": "pithos", "name": "Pithos", - "version": "0.3.20", + "version": "0.3.21", "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=", diff --git a/bundled-addons/pithos/core/server.js b/bundled-addons/pithos/core/server.js index 64b023e3..97b6a4e0 100644 --- a/bundled-addons/pithos/core/server.js +++ b/bundled-addons/pithos/core/server.js @@ -42,6 +42,15 @@ class HttpError extends Error { constructor(status, message) { super(message); this.status = status; } } +// Pithos's own version: addon.json in the Theseus add-on, package.json elsewhere. +function readOwnVersion() { + for (const f of ['../addon.json', '../package.json']) { + try { const v = JSON.parse(fs.readFileSync(new URL(f, import.meta.url), 'utf8')).version; if (v) return v; } catch {} + } + return null; +} +const LOADED_VERSION = readOwnVersion(); + export async function createPithos(opts = {}) { const { host = '127.0.0.1', @@ -90,13 +99,11 @@ export async function createPithos(opts = {}) { const tickets = new Map(); const subscribers = new Set(); let versionCache = null; - // Pithos's own version: addon.json in the Theseus add-on, package.json elsewhere. - const pithosVersion = (() => { - for (const f of ['../addon.json', '../package.json']) { - try { const v = JSON.parse(fs.readFileSync(new URL(f, import.meta.url), 'utf8')).version; if (v) return v; } catch {} - } - return null; - })(); + // The version of the code running (read when this module loaded) and the one + // on disk now. Theseus can swap an add-on's files in place, but Node keeps + // the modules it already loaded, so they differ until Theseus restarts. + const pithosVersion = LOADED_VERSION; + const installedVersion = readOwnVersion(); let hosted = null; // "On Silent Mode", installed below const broadcast = (event, data) => { @@ -182,6 +189,8 @@ export async function createPithos(opts = {}) { const local = { host: hostName, pithos: pithosVersion, + // differs from pithos after an in-place update until the host restarts + installed: readOwnVersion() || installedVersion, // electron-builder's portable exe sets this; updates then fetch the portable build. portable: hostName === 'desktop' && !!process.env.PORTABLE_EXECUTABLE_FILE, platform: process.platform, @@ -535,7 +544,7 @@ export async function createPithos(opts = {}) { const out = await res.json().catch(() => ({})); if (!res.ok) throw new HttpError(res.status === 429 ? 429 : 502, out.error || `navigate.st answered ${res.status}`); const list = readSiaLinks(); - list.unshift({ id: out.id, url: out.url, deleteToken: out.deleteToken, user: p.user, bucket: p.bucket, key: key || null, prefix: prefix || null, until, created: Date.now(), files: body.files ? body.files.length : 1 }); + list.unshift({ id: out.id, url: out.url, deleteToken: out.deleteToken, user: p.user, bucket: p.bucket, key: key || null, prefix: key ? null : (prefix ?? null), until, created: Date.now(), files: body.files ? body.files.length : 1 }); writeSiaLinks(list); return { id: out.id, url: out.url, until }; }); diff --git a/bundled-addons/pithos/index.js b/bundled-addons/pithos/index.js index 3d1fa158..1595b5c4 100644 --- a/bundled-addons/pithos/index.js +++ b/bundled-addons/pithos/index.js @@ -180,6 +180,10 @@ module.exports = { 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); + // pithos.sia/l//: a public link, served by navigate.st/sia + if (/^\/l\/[A-Za-z0-9_-]{4,64}(\/|$)/.test(u.pathname)) { + return new Response(null, { status: 302, headers: { location: `https://navigate.st/sia/${u.pathname.slice(3)}${u.search}`, "cache-control": "no-store" } }); + } const fetchSite = request.headers.get("sec-fetch-site"); const origin = request.headers.get("origin"); const own = fetchSite ? fetchSite === "same-origin" || fetchSite === "none" diff --git a/bundled-addons/pithos/ui/app.css b/bundled-addons/pithos/ui/app.css index 71ba1bfb..28de4f6b 100644 --- a/bundled-addons/pithos/ui/app.css +++ b/bundled-addons/pithos/ui/app.css @@ -465,3 +465,13 @@ a.brand:hover > span:first-of-type { color: var(--accent); } .access-chip.private { color: var(--muted); } .access-chip.public { color: var(--accent); border-color: var(--accent); } @media (max-width: 760px) { .crumb-row { row-gap: 6px; } .chip { font-size: 12px; padding: 2px 8px; } } + +/* Public links in the Share dialogs */ +.link-card { border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; display: flex; flex-direction: column; gap: 6px; background: var(--surface-2); } +.link-card.fresh { border-color: var(--accent); } +.link-card .link-url { color: var(--accent); font-family: ui-monospace, Consolas, monospace; font-size: 13px; word-break: break-all; text-decoration: none; } +.link-card .link-url:hover { text-decoration: underline; } +.link-card code { font-size: 12px; word-break: break-all; } +.link-card .row { gap: 6px; flex-wrap: wrap; } +.link-make { gap: 10px; align-items: center; flex-wrap: wrap; } +.link-card a.btn { text-decoration: none; } diff --git a/bundled-addons/pithos/ui/app.js b/bundled-addons/pithos/ui/app.js index 3cee78b5..777b26f5 100644 --- a/bundled-addons/pithos/ui/app.js +++ b/bundled-addons/pithos/ui/app.js @@ -1921,7 +1921,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { endpoint.addEventListener('change', gen); gen(); await modal('Share', h('div', { class: 'stack' }, - siaLinkBlock({ key }), + siaLinkBlock({ key, heading: true }), h('details', { class: 'more-opts', open: !!savedEndpoint }, h('summary', {}, 'Link for this computer, or your own server'), h('div', { class: 'stack', style: 'margin-top:8px' }, @@ -1935,30 +1935,64 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { // A public link anyone can open, at navigate.st/sia: the file is read from // your Sia account through Silent Mode, so it works with this computer off. - function siaLinkBlock({ key, prefix }) { + // The links already made for this file, folder or drive come first, each + // with Copy, Open and Switch off; then one line to make a new one. + function siaLinkBlock({ key, prefix, heading = false }) { const folder = prefix != null && key == null; - const days = h('select', {}, [['30', '1 month'], ['365', '1 year'], ['1825', '5 years']].map(([v, l]) => h('option', { value: v, selected: v === '365' }, l))); - const out = h('div'); + const mine = (l) => l.bucket === bucket && (folder ? l.key == null && (l.prefix ?? null) === prefix : l.key === key); + const days = h('select', { 'aria-label': 'Works for' }, [['30', '1 month'], ['365', '1 year'], ['1825', '5 years']].map(([v, l]) => h('option', { value: v, selected: v === '365' }, l))); + const list = h('div', { class: 'stack' }); + const err = h('p', { class: 'error small', hidden: true }); + const theseusForm = (u) => { try { return `pithos.sia/l/${new URL(u).pathname.replace(/^\/sia\//, '')}`; } catch { return ''; } }; + const card = (l, fresh) => h('div', { class: `link-card${fresh ? ' fresh' : ''}` }, + h('a', { class: 'link-url', href: l.url, target: '_blank', rel: 'noopener', title: l.url }, l.url.replace(/^https:\/\//, '')), + h('div', { class: 'small muted' }, `Works until ${new Date(l.until * 1000).toLocaleDateString()}${l.files > 1 ? ` · ${l.files} files` : ''}`, + theseusForm(l.url) && [' · in Theseus also ', h('code', {}, theseusForm(l.url))]), + h('div', { class: 'row' }, + h('button', { type: 'button', class: 'btn small primary', onclick: () => copy(l.url, 'Link copied') }, 'Copy link'), + h('a', { class: 'btn small', href: l.url, target: '_blank', rel: 'noopener' }, 'Open'), + h('button', { type: 'button', class: 'btn small danger', onclick: async () => { + if (!(await confirmModal('Switch this link off?', 'Anyone who has it will no longer be able to open it.', 'Switch off'))) return; + try { await api('DELETE', `/api/sia-links/${encodeURIComponent(l.id)}`); toast('Link switched off'); refresh(); } catch (e) { fail(e); } + } }, 'Switch off'))); + let freshId = null; + async function refresh() { + let links = []; + try { links = (await api('GET', '/api/sia-links')).filter(mine); } catch {} + put(list, links.map((l) => card(l, l.id === freshId))); + put(btn, links.length ? 'Make another link' : 'Create public link'); + } const btn = h('button', { type: 'button', class: 'btn primary', onclick: async () => { - btn.disabled = true; put(btn, h('span', { class: 'spinner' }), ' Making the link…'); + btn.disabled = true; err.hidden = true; + put(btn, h('span', { class: 'spinner' }), ' Making the link…'); try { const r = await api('POST', `${base}/sia-link`, { key, prefix, days: Number(days.value) }); - put(out, secretRow('Public link', r.url), h('p', { class: 'small muted', style: 'margin:0' }, `Works until ${new Date(r.until * 1000).toLocaleDateString()}. Switch it off any time in Settings › Public links.`)); - put(btn, 'Make another link'); - } catch (e) { put(out, h('p', { class: 'error small', style: 'margin:0' }, e.message)); put(btn, 'Try again'); } + freshId = r.id; + await refresh(); + copy(r.url, 'Link created and copied'); + } catch (e) { + // an engine from before this version reads a whole-drive link as a file + err.textContent = folder && /a file is required/.test(e.message) + ? 'Pithos is still running its previous engine. Restart Theseus to finish the update, then try again.' + : e.message; + err.hidden = false; + await refresh(); + } btn.disabled = false; } }, 'Create public link'); + refresh(); return h('div', { class: 'stack' }, - h('h3', { style: 'margin:0' }, 'Public link'), + heading && h('h3', { style: 'margin:0' }, 'Public link'), h('p', { class: 'small muted', style: 'margin:0' }, folder - ? `A page with the files ${prefix ? 'in this folder' : 'at the top of this drive'} that anyone can open, on any device, even with this computer off. Files in sub-folders are not included.` - : 'Anyone with the link can open this file on any device, even with this computer off. It is read from your Sia account through navigate.st; only this file is shared.'), - h('div', { class: 'row' }, h('label', { class: 'row small' }, h('span', { class: 'muted' }, 'Works for'), days), btn), - out); + ? `Anyone with the link sees ${prefix ? 'the files in this folder' : 'the files at the top of this drive'} and can open them on any device, even with this computer off. Sub-folders are not included.` + : 'Anyone with the link can open this file on any device, even with this computer off. Only this file is shared.'), + list, + h('div', { class: 'row link-make' }, h('label', { class: 'row small' }, h('span', { class: 'muted' }, 'Works for'), days), btn), + err); } async function siaFolderLink(prefix) { - await modal(`Public link for ${prefix ? prefix.slice(0, -1).split('/').pop() : bucket}`, siaLinkBlock({ prefix }), [{ label: 'Done', kind: 'primary', submit: true, result: true }]); + await modal(`Share ${prefix ? prefix.slice(0, -1).split('/').pop() : bucket}`, siaLinkBlock({ prefix }), [{ label: 'Done', kind: 'primary', submit: true, result: true }]); } // On Silent Mode a link is a rule on the server, and the file is stored @@ -2732,6 +2766,16 @@ function setupFooter() { } check.hidden = false; check.addEventListener('click', () => scan(true)); + // An update applied in place swaps the files, but the engine keeps running + // the version it loaded until Theseus restarts. + const installed = state.status?.installed; + if (installed && installed !== current) { + busy = true; + showChip(`↻ Restart to finish v${installed}`, '', `Pithos v${installed} is installed, but its engine still runs v${current} until Theseus restarts. Theseus reopens your tabs.`, async () => { + try { await bridge.invoke('update', { step: 'apply' }); } catch (e) { flash('⚠ Restart failed', 'err', e.message); } + }); + return; + } setTimeout(() => scan(false), 1500); setInterval(() => scan(false), 6 * 3600 * 1000); }