From 62c2f269af4705dedcd00386aabe3cf0af2ab120 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 5 Oct 2026 23:52:35 +0200 Subject: [PATCH] Pithos 0.3.20: drive mode chip and Share, pithos.sia/app, Linux built on Hephaestus --- bundled-addons/pithos/addon.json | 2 +- bundled-addons/pithos/core/server.js | 8 +++++--- bundled-addons/pithos/ui/app.css | 8 ++++++++ bundled-addons/pithos/ui/app.js | 30 +++++++++++++++++++++++----- 4 files changed, 39 insertions(+), 9 deletions(-) diff --git a/bundled-addons/pithos/addon.json b/bundled-addons/pithos/addon.json index 05382c09..8d1c54f9 100644 --- a/bundled-addons/pithos/addon.json +++ b/bundled-addons/pithos/addon.json @@ -1,7 +1,7 @@ { "id": "pithos", "name": "Pithos", - "version": "0.3.19", + "version": "0.3.20", "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 0128d49d..64b023e3 100644 --- a/bundled-addons/pithos/core/server.js +++ b/bundled-addons/pithos/core/server.js @@ -512,8 +512,9 @@ export async function createPithos(opts = {}) { const { key, prefix, days = 365 } = await jsonBody(req); const until = Math.floor(Date.now() / 1000) + Math.min(3650, Math.max(1, Number(days) || 365)) * 86400; let body; - if (prefix) { - if (!prefix.endsWith('/')) throw new HttpError(400, 'a folder ends with /'); + // prefix "" (with no key) is the drive's top level + if (prefix != null && !key) { + if (prefix && !prefix.endsWith('/')) throw new HttpError(400, 'a folder ends with /'); const s3 = await s3For(p.user); const files = []; let token; @@ -525,7 +526,7 @@ export async function createPithos(opts = {}) { if (!files.length) throw new HttpError(400, 'This folder has no files to share (files in sub-folders are not included).'); const out = []; for (const k of files) out.push({ name: k.slice(prefix.length), url: (await shareUrlFor(p.bucket, k, until)).url }); - body = { name: prefix.slice(0, -1).split('/').pop(), folder: true, files: out }; + body = { name: prefix ? prefix.slice(0, -1).split('/').pop() : p.bucket, folder: true, files: out }; } else { if (!key || key.endsWith('/')) throw new HttpError(400, 'a file is required'); body = { name: key.split('/').pop(), url: (await shareUrlFor(p.bucket, key, until)).url }; @@ -960,6 +961,7 @@ export function handoffRoute(p) { if (!segs.length || segs.some((x) => !/^[A-Za-z0-9._~%=-]{1,120}$/.test(x))) return '#/overview'; const pages = ['overview', 'users', 'buckets', 'account', 'settings', 'logs']; if (segs[0] === 'drives') return '#/buckets'; + if (segs[0] === 'app') return segs.length > 1 ? handoffRoute('/' + segs.slice(1).join('/')) : '#/overview'; if (pages.includes(segs[0])) return `#/${segs.join('/')}`; return `#/u/${segs.join('/')}`; // /// } diff --git a/bundled-addons/pithos/ui/app.css b/bundled-addons/pithos/ui/app.css index 954f8516..71ba1bfb 100644 --- a/bundled-addons/pithos/ui/app.css +++ b/bundled-addons/pithos/ui/app.css @@ -457,3 +457,11 @@ nav a.nav-account.active .na-label { color: var(--accent); } /* The Pithos name is a link to Overview */ a.brand { color: inherit; text-decoration: none; } a.brand:hover > span:first-of-type { color: var(--accent); } + +/* Drive header: who can read it, and Share, beside the path */ +.crumb-row { gap: 8px; align-items: center; } +.chip { border: 1px solid var(--border); background: var(--surface-2); color: inherit; font: inherit; font-size: 12.5px; padding: 3px 10px; border-radius: 999px; cursor: pointer; white-space: nowrap; } +.chip:hover { border-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; } } diff --git a/bundled-addons/pithos/ui/app.js b/bundled-addons/pithos/ui/app.js index 4b0241bb..3cee78b5 100644 --- a/bundled-addons/pithos/ui/app.js +++ b/bundled-addons/pithos/ui/app.js @@ -221,6 +221,8 @@ function currentRoute() { const segs = location.pathname.split('/').filter(Boolean); if (!segs.length) return []; const first = decodeURIComponent(segs[0]); + // pithos.sia/app: the app's front door (the bare pithos.sia is the website) + if (first === 'app') return segs.slice(1); if (first === 'drives') return ['buckets']; if (views[first]) return segs; if (state.selectedUser !== first) { state.selectedUser = first; try { localStorage.setItem('pithos.user', first); } catch {} } @@ -1614,11 +1616,28 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { prefix.split('/').filter(Boolean).map((part, i, all) => [h('span', { class: 'sep' }, '/'), h('button', { onclick: () => go(all.slice(0, i + 1).join('/')) }, part)])); const fileInput = h('input', { type: 'file', multiple: true, hidden: true, onchange: (e) => { uploadFiles([...e.target.files]); e.target.value = ''; } }); + // Private or public at a glance, one click to change; Share for this folder + // (or, at the top, the whole drive). + const accessChip = h('button', { type: 'button', class: 'chip access-chip', title: 'Who can read this drive. Click to change.', onclick: async () => { await accessDialog(user, bucket); paintAccess(); } }, 'โ€ฆ'); + async function paintAccess() { + try { + let mode; + if (isHosted()) { + const rules = await api('GET', '/api/hosted/rules'); + const pub = rules.find((r) => r.bucket === bucket && r.prefix === '' && r.type === 'public'); + mode = pub ? pub.rights : 'private'; + } else mode = (await api('GET', `${base}/policy`)).public; + accessChip.className = `chip access-chip ${mode === 'private' ? 'private' : 'public'}`; + put(accessChip, { private: isHosted() ? '๐Ÿ”’ Private ยท encrypted' : '๐Ÿ”’ Private', read: '๐ŸŒ Public', 'read-list': '๐ŸŒ Public + list', custom: 'โš™ Custom' }[mode] || mode); + } catch { put(accessChip, 'Access'); } + } + paintAccess(); + const shareHere = () => (isHosted() ? folderShareDialog(user, bucket, prefix).then(paintAccess) : siaFolderLink(prefix)); + const shareBtn = h('button', { type: 'button', class: 'chip', title: prefix ? 'Share this folder' : 'Share this drive', onclick: shareHere }, '๐Ÿ”— Share'); put(head, - h('div', { class: 'stack' }, crumbs, accountLine()), + h('div', { class: 'stack' }, h('div', { class: 'row crumb-row' }, crumbs, accessChip, shareBtn), accountLine()), h('div', { class: 'row' }, userPicker(userSel), h('button', { class: 'btn', onclick: newFolder }, 'New folder'), - h('button', { class: 'btn', onclick: () => accessDialog(user, bucket) }, 'Access'), h('button', { class: 'btn primary', onclick: () => fileInput.click() }, 'Upload'), fileInput)); const uploads = h('div', { class: 'uploads' }); @@ -1917,6 +1936,7 @@ 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 }) { + 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 btn = h('button', { type: 'button', class: 'btn primary', onclick: async () => { @@ -1930,15 +1950,15 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { } }, 'Create public link'); return h('div', { class: 'stack' }, h('h3', { style: 'margin:0' }, 'Public link'), - h('p', { class: 'small muted', style: 'margin:0' }, prefix - ? 'A page with the files in this folder that anyone can open, on any device, even with this computer off. Files in sub-folders are not included.' + 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); } async function siaFolderLink(prefix) { - await modal(`Public link for ${prefix.slice(0, -1).split('/').pop()}`, siaLinkBlock({ prefix }), [{ label: 'Done', kind: 'primary', submit: true, result: true }]); + await modal(`Public link for ${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