From 6a14f46657db5ccad3d22dbeac21486090bf0d9a Mon Sep 17 00:00:00 2001 From: Local Dev Date: Mon, 5 Oct 2026 23:39:11 +0200 Subject: [PATCH] Pithos: a drive shows whether it is private or public, with Share beside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Whether a drive was readable by anyone was only visible in the Drives list or behind the Access button. The drive's path now carries a chip (Private / Public / Public + list) that changes it in one click, and a Share button for the folder you are in, or the whole drive at its top level: a navigate.st/sia page on this computer's s3d, the Share… rules on Drives on Silent Mode. The public-link route accepts the drive's top level for that. --- core/server.js | 7 ++++--- ui/app.css | 8 ++++++++ ui/app.js | 28 +++++++++++++++++++++++----- 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/core/server.js b/core/server.js index 640e94b..64b023e 100644 --- a/core/server.js +++ b/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 }; diff --git a/ui/app.css b/ui/app.css index 954f851..71ba1bf 100644 --- a/ui/app.css +++ b/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/ui/app.js b/ui/app.js index 8da4785..3cee78b 100644 --- a/ui/app.js +++ b/ui/app.js @@ -1616,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' }); @@ -1919,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 () => { @@ -1932,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