Pithos: Share is one click, and links last 10 years

Share opened a dialog with a duration and a Create button before anything
happened. Now Share copies the link already made for that file, folder
or drive, or makes one and copies it, then shows Open and Manage; the
full list (and Switch off) is behind Manage. A Sia share link is signed
with an end date, so new links get the longest Pithos allows, 10 years.
When a file cannot reach Sia yet, the message says why instead of
passing s3d's admin error through.
This commit is contained in:
Local Dev 2026-10-07 19:34:59 +02:00
parent e4e4b3b587
commit 540828ba06
3 changed files with 54 additions and 7 deletions

View file

@ -566,7 +566,13 @@ export async function createPithos(opts = {}) {
try { return await createShareUrl(dir, bucket, key, until); }
catch (e) {
if (e.code !== 'pending') throw new HttpError(e.status || 502, e.message);
await flushNow(); // send what is waiting, then try once more
// send what is waiting, then try once more
try { await flushNow(); } catch (fe) {
const why = /not enough hosts/i.test(fe.message) ? 'not enough Sia hosts are reachable right now'
: /failed to open upload|cannot find the file/i.test(fe.message) ? 'some waiting files lost their copy on this computer (see the Drives page)'
: 'the upload to Sia failed';
throw new HttpError(502, `This file is not on Sia yet, and sending it now did not work: ${why}. Try again in a minute.`);
}
try { return await createShareUrl(dir, bucket, key, until); }
catch (e2) { throw new HttpError(e2.status || 502, e2.code === 'pending' ? 'This file is still uploading to Sia. Try again in a minute.' : e2.message); }
}

View file

@ -481,3 +481,7 @@ a.brand:hover > span:first-of-type { color: var(--accent); }
.stuck-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.stuck-list li { display: flex; gap: 8px; align-items: center; justify-content: space-between; flex-wrap: wrap; }
.stuck-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1 1 220px; }
/* Toasts with actions (Share: Open / Manage) */
.toast.action { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.toast.action > span { flex: 1 1 auto; }

View file

@ -80,6 +80,15 @@ function fmtDuration(ms) {
return `${Math.floor(s / 86400)}d ${Math.floor((s % 86400) / 3600)}h`;
}
// A toast with buttons ([label, fn] pairs); stays a little longer.
function actionToast(text, actions = [], ms = 9000) {
const el = h('div', { class: 'toast action' }, h('span', {}, text),
actions.map(([label, fn]) => h('button', { type: 'button', class: 'btn small', onclick: () => { el.remove(); fn(); } }, label)));
$('#toasts').append(el);
setTimeout(() => el.remove(), ms);
return el;
}
async function copy(text, what = 'Copied') {
try { await navigator.clipboard.writeText(text); toast(what); }
catch { prompt('Copy:', text); }
@ -1704,7 +1713,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
} catch { put(accessChip, 'Access'); }
}
paintAccess();
const shareHere = () => (isHosted() ? folderShareDialog(user, bucket, prefix).then(paintAccess) : siaFolderLink(prefix));
const shareHere = () => (isHosted() ? folderShareDialog(user, bucket, prefix).then(paintAccess) : quickShare({ 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' }, h('div', { class: 'row crumb-row' }, crumbs, accessChip, shareBtn), accountLine()),
@ -1749,14 +1758,14 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
}
const folderName = (f) => f.slice(prefix.length).replace(/\/$/, '');
const fileName = (o) => o.key.slice(prefix.length);
const folderMenu = (f) => itemMenu([['Open', () => go(f)], isHosted() ? ['Share…', () => folderShareDialog(user, bucket, f)] : ['Public link…', () => siaFolderLink(f)], ['Rename…', () => renameItem(f)], ['Delete', () => deleteFolder(f), 'danger']]);
const fileMenu = (o) => itemMenu([[fileKind(o.key) === 'audio' ? 'Play' : isHtml(o.key) ? 'Show as text' : 'Open', () => openViewer(o)], isHtml(o.key) && ['Open as page ↗', () => openPage(o.key)], ['Download', () => downloadObject(o.key)], ['Share link…', () => share(o.key)], ['Rename…', () => renameItem(o.key)], ['Delete', () => deleteObject(o.key), 'danger']]);
const folderMenu = (f) => itemMenu([['Open', () => go(f)], isHosted() ? ['Share…', () => folderShareDialog(user, bucket, f)] : ['Copy public link', () => quickShare({ prefix: f })], !isHosted() && ['Public links…', () => siaFolderLink(f)], ['Rename…', () => renameItem(f)], ['Delete', () => deleteFolder(f), 'danger']]);
const fileMenu = (o) => itemMenu([[fileKind(o.key) === 'audio' ? 'Play' : isHtml(o.key) ? 'Show as text' : 'Open', () => openViewer(o)], isHtml(o.key) && ['Open as page ↗', () => openPage(o.key)], ['Download', () => downloadObject(o.key)], !isHosted() && ['Copy public link', () => quickShare({ key: o.key })], ['Share options…', () => share(o.key)], ['Rename…', () => renameItem(o.key)], ['Delete', () => deleteObject(o.key), 'danger']]);
const more = last?.truncated && h('div', { class: 'row', style: 'justify-content:center;margin-top:12px' }, h('button', { class: 'btn', onclick: () => load(last.nextToken) }, 'Load more'));
const stop = (e) => e.stopPropagation();
// Narrow screens: Download and Share as icons beside the ⋯ menu.
const quickIcons = (o) => h('span', { class: 'narrow-only icon-row' },
iconButton('download', 'Download', () => downloadObject(o.key)),
iconButton('share', 'Share link', () => share(o.key)));
iconButton('share', 'Copy public link', () => quickShare({ key: o.key })));
if (mode === 'details') {
put(card, h('table', { class: 'files' },
@ -1773,7 +1782,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) {
h('td', { class: 'hide-sm muted' }, fmtDate(o.modified)),
h('td', { class: 'actions', onclick: stop },
h('span', { class: 'wide-only' }, h('button', { class: 'btn small', onclick: () => downloadObject(o.key) }, 'Download'), ' ',
h('button', { class: 'btn small', onclick: () => share(o.key) }, 'Share'), ' '),
h('button', { class: 'btn small', title: 'Copy a public link', onclick: () => quickShare({ key: o.key }) }, 'Share'), ' '),
quickIcons(o), fileMenu(o)))))),
more);
} else if (mode === 'list') {
@ -2007,12 +2016,40 @@ 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.
// Share in one click: the link already made for this file, folder or
// drive, or a new one valid for 10 years (a Sia share link is signed with
// an end date; Switch off ends it sooner), copied at once. Manage opens
// the full list. Drives on Silent Mode keep their own Share dialog.
async function quickShare({ key, prefix }) {
if (isHosted()) return key != null ? hostedShare(key) : folderShareDialog(user, bucket, prefix);
const folder = key == null;
const mine = (l) => l.bucket === bucket && (folder ? l.key == null && (l.prefix ?? null) === prefix : l.key === key);
const manage = () => (folder ? siaFolderLink(prefix) : share(key));
const busy = h('div', { class: 'toast' }, h('span', { class: 'spinner' }), ' Making the public link…');
$('#toasts').append(busy);
try {
let links = [];
try { links = await api('GET', '/api/sia-links'); } catch {}
let link = links.filter(mine).filter((l) => l.until * 1000 > Date.now()).sort((a, b) => b.until - a.until)[0];
if (!link) link = await api('POST', `${base}/sia-link`, { key, prefix, days: 3650 });
busy.remove();
let copied = true;
try { await navigator.clipboard.writeText(link.url); } catch { copied = false; }
actionToast(copied ? 'Public link copied' : 'Public link ready', [['Open', () => window.open(link.url, '_blank', 'noopener')], ['Manage', manage]]);
if (!copied) prompt('Copy:', link.url);
} catch (e) {
busy.remove();
const msg = 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;
actionToast(msg, [['Manage', manage]], 12000).classList.add('error');
}
}
// 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 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 days = h('select', { 'aria-label': 'Works for' }, [['365', '1 year'], ['1825', '5 years'], ['3650', '10 years']].map(([v, l]) => h('option', { value: v, selected: v === '3650' }, 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 ''; } };