From e5f26736b4bcea29e0ecd7a1e2d88380a2159586 Mon Sep 17 00:00:00 2001 From: Local Dev Date: Wed, 7 Oct 2026 22:42:59 +0200 Subject: [PATCH] Pithos: Share opens a menu, and shared links land on a page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Share used to copy the link straight away, leaving no way to send it anywhere. It now opens a menu with the link and Copy, Open, X, Telegram and WhatsApp, plus the link options. The ⋯ menu's "Copy public link" still copies in one step. A person opening a single-file link in a browser used to get the bare file, with nothing saying where it came from. Browser visits now get a page with a preview, Download and Open, and links to pithos.sia, the Pithos app and Theseus Navigator; folder pages carry the same links. Players, embeds and download tools still get the file itself (?raw, ?dl, or any request that is not a page navigation). --- sia-share/main.go | 76 +++++++++++++++++++++++++++++++++++++--- sia-share/main_test.go | 32 ++++++++++++++++- ui/app.css | 10 ++++++ ui/app.js | 78 +++++++++++++++++++++++++++++++++++++++--- 4 files changed, 186 insertions(+), 10 deletions(-) diff --git a/sia-share/main.go b/sia-share/main.go index 73fdf85..b28011e 100644 --- a/sia-share/main.go +++ b/sia-share/main.go @@ -427,9 +427,27 @@ func (h *handler) get(w http.ResponseWriter, r *http.Request) { notFound(w) return } + if wantsPage(r) { + filePage(w, l, l.Files[0]) + return + } h.stream(w, r, l, l.Files[0]) } +// A person opening a single-file link in a browser gets a page with the +// file and where it came from; players, tags, download tools and the +// page's own preview (?raw) get the file itself. +func wantsPage(r *http.Request) bool { + q := r.URL.Query() + if r.Method != http.MethodGet || q.Has("raw") || q.Has("dl") { + return false + } + if d := r.Header.Get("Sec-Fetch-Dest"); d != "" { + return d == "document" + } + return strings.Contains(r.Header.Get("Accept"), "text/html") +} + func notFound(w http.ResponseWriter) { w.Header().Set("content-type", "text/html; charset=utf-8") w.WriteHeader(404) @@ -503,7 +521,11 @@ func (h *handler) stream(w http.ResponseWriter, r *http.Request, l *link, f file hd.Set("content-type", ct) hd.Set("accept-ranges", "bytes") hd.Set("content-length", strconv.FormatInt(length, 10)) - hd.Set("content-disposition", mime.FormatMediaType("inline", map[string]string{"filename": f.Name})) + disp := "inline" + if r.URL.Query().Has("dl") { + disp = "attachment" + } + hd.Set("content-disposition", mime.FormatMediaType(disp, map[string]string{"filename": f.Name})) hd.Set("content-security-policy", "sandbox") hd.Set("x-content-type-options", "nosniff") hd.Set("cache-control", "public, max-age=300") @@ -543,6 +565,52 @@ func fmtSize(n int64) string { return fmt.Sprintf("%.1f %s", f, units[i]) } +// Where a visitor goes from a shared link: the Pithos site, the Pithos app +// and Theseus Navigator (which opens pithos.sia itself). +const pageLinks = `` + +const pageStyle = `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;overflow-wrap:anywhere} +footer{color:#5f6b66;font-size:12.5px;margin-top:22px;padding-top:14px;border-top:1px solid rgba(255,255,255,.06)}.more{display:flex;flex-wrap:wrap;gap:8px 18px;margin-top:8px;font-size:14px}` + +func mediaKind(n string) string { + switch strings.ToLower(path.Ext(n)) { + case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif": + return "img" + case ".mp3", ".m4a", ".aac", ".ogg", ".oga", ".opus", ".flac", ".wav": + return "audio" + case ".mp4", ".webm", ".mov", ".m4v": + return "video" + } + return "" +} + +func filePage(w http.ResponseWriter, l *link, f file) { + preview := "" + switch mediaKind(f.Name) { + case "img": + preview = `` + case "audio": + preview = `` + case "video": + preview = `` + } + w.Header().Set("content-type", "text/html; charset=utf-8") + w.Header().Set("content-security-policy", "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'; media-src 'self'; frame-ancestors 'none'") + w.Header().Set("x-content-type-options", "nosniff") + w.Header().Set("cache-control", "no-store") + name := html.EscapeString(f.Name) + fmt.Fprintf(w, ` +%s · shared from Sia + +
Shared from Sia

%s

%s + +
Stored on the Sia network and shared with Pithos, private storage you own.%s
`, + name, pageStyle, name, preview, fmtSize(f.Size), pageLinks) +} + func folderPage(w http.ResponseWriter, l *link) { kind := func(n string) string { switch strings.ToLower(path.Ext(n)) { @@ -577,9 +645,9 @@ a{color:#3ddc97;text-decoration:none}a:hover{text-decoration:underline}.owner{co 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}footer{color:#5f6b66;font-size:12.5px;margin-top:18px} +#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}footer{color:#5f6b66;font-size:12.5px;margin-top:22px;padding-top:14px;border-top:1px solid rgba(255,255,255,.06)}.more{display:flex;flex-wrap:wrap;gap:8px 18px;margin-top:8px;font-size:14px}
Shared from Sia

%s

-
Stored on the Sia network and shared with Pithos.
+
-`, html.EscapeString(l.Name), html.EscapeString(l.Name), rows.String()) +`, html.EscapeString(l.Name), html.EscapeString(l.Name), rows.String(), pageLinks) } diff --git a/sia-share/main_test.go b/sia-share/main_test.go index f119cad..9e7b61d 100644 --- a/sia-share/main_test.go +++ b/sia-share/main_test.go @@ -1,6 +1,9 @@ package main -import "testing" +import ( + "net/http/httptest" + "testing" +) func TestParseRange(t *testing.T) { cases := []struct { @@ -35,3 +38,30 @@ func TestCleanName(t *testing.T) { } } } + +func TestWantsPage(t *testing.T) { + cases := []struct { + url, dest, accept string + want bool + }{ + {"/sia/x", "document", "", true}, + {"/sia/x", "image", "", false}, + {"/sia/x", "iframe", "", false}, + {"/sia/x?raw=1", "document", "", false}, + {"/sia/x?dl=1", "document", "", false}, + {"/sia/x", "", "text/html,application/xhtml+xml", true}, + {"/sia/x", "", "*/*", false}, + } + for _, c := range cases { + r := httptest.NewRequest("GET", c.url, nil) + if c.dest != "" { + r.Header.Set("Sec-Fetch-Dest", c.dest) + } + if c.accept != "" { + r.Header.Set("Accept", c.accept) + } + if got := wantsPage(r); got != c.want { + t.Errorf("%s dest=%q accept=%q: got %v want %v", c.url, c.dest, c.accept, got, c.want) + } + } +} diff --git a/ui/app.css b/ui/app.css index 5100fde..8948b71 100644 --- a/ui/app.css +++ b/ui/app.css @@ -489,3 +489,13 @@ a.brand:hover > span:first-of-type { color: var(--accent); } /* Toasts with actions (Share: Open / Manage) */ .toast.action { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; } .toast.action > span { flex: 1 1 auto; } + +/* share menu */ +.menu.share-menu { width: min(21rem, calc(100vw - 16px)); padding: 0.5rem; } +.share-busy { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem; color: var(--muted); } +.share-link { display: flex; gap: 0.375rem; padding: 0.125rem 0.125rem 0.375rem; } +.share-link input { flex: 1; min-width: 0; font-size: 0.75rem; padding: 0.3125rem 0.5rem; } +.share-sep { height: 1px; background: var(--border); margin: 0.25rem 0; } +.share-menu button[role=menuitem] { display: flex; align-items: center; gap: 0.625rem; width: 100%; } +.share-ic { width: 1.125rem; text-align: center; color: var(--muted); } +.share-note { margin: 0.375rem 0.5rem 0.125rem; } diff --git a/ui/app.js b/ui/app.js index f3d85de..a2d0891 100644 --- a/ui/app.js +++ b/ui/app.js @@ -1343,6 +1343,26 @@ function viewSwitch(page, current, onChange) { onclick: () => { try { localStorage.setItem(`pithos.view.${page}`, key); } catch {} onChange(key); } }, icon))); } +// A pop-up under (or above) an anchor; closes on an outside click or Escape. +// Returns { menu, done, place } so the caller can refill and re-place it. +function popMenu(anchor, children, cls = '') { + document.querySelectorAll('.menu').forEach((m) => m.remove()); + const menu = h('div', { class: `menu ${cls}`.trim(), role: 'menu' }, children); + function done() { menu.remove(); document.removeEventListener('mousedown', away, true); document.removeEventListener('keydown', esc, true); } + function away(ev) { if (!menu.contains(ev.target) && !anchor.contains(ev.target)) done(); } + function esc(ev) { if (ev.key === 'Escape') { ev.stopPropagation(); done(); anchor.focus(); } } + function place() { + const r = anchor.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'; + } + document.body.append(menu); + place(); + setTimeout(() => { document.addEventListener('mousedown', away, true); document.addEventListener('keydown', esc, true); }, 0); + return { menu, done, place }; +} + // "⋯" menu for one item: [label, action, kind?] rows; closes on any outside click. function itemMenu(items) { const btn = h('button', { type: 'button', class: 'btn small more', title: 'More', 'aria-label': 'More actions', 'aria-haspopup': 'menu' }, '⋯'); @@ -1373,7 +1393,7 @@ const ICONS = { share: '', }; function iconButton(name, label, run) { - const b = h('button', { type: 'button', class: 'btn small icon', title: label, 'aria-label': label, onclick: (e) => { e.stopPropagation(); run(); } }); + const b = h('button', { type: 'button', class: 'btn small icon', title: label, 'aria-label': label, onclick: (e) => { e.stopPropagation(); run(e); } }); b.innerHTML = ICONS[name]; // fixed markup above, never user data return b; } @@ -1741,7 +1761,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { } catch { put(accessChip, 'Access'); } } paintAccess(); - const shareHere = () => (isHosted() ? folderShareDialog(user, bucket, prefix).then(paintAccess) : quickShare({ prefix })); + const shareHere = () => (isHosted() ? folderShareDialog(user, bucket, prefix).then(paintAccess) : quickShare({ prefix }, shareBtn)); 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()), @@ -1793,7 +1813,7 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { // 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', 'Copy public link', () => quickShare({ key: o.key }))); + iconButton('share', 'Share', (e) => quickShare({ key: o.key }, e.currentTarget))); if (mode === 'details') { put(card, h('table', { class: 'files' }, @@ -1810,7 +1830,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', title: 'Copy a public link', onclick: () => quickShare({ key: o.key }) }, 'Share'), ' '), + h('button', { class: 'btn small', title: 'Share a public link', 'aria-haspopup': 'menu', onclick: (e) => { e.stopPropagation(); quickShare({ key: o.key }, e.currentTarget); } }, 'Share'), ' '), quickIcons(o), fileMenu(o)))))), more); } else if (mode === 'list') { @@ -2048,11 +2068,22 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { // 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 }) { + // With an anchor (a Share button) it opens the share menu instead. + async function quickShare({ key, prefix }, anchor) { 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)); + if (anchor) { + const getLink = async () => { + let links = []; + try { links = await api('GET', '/api/sia-links'); } catch {} + return links.filter(mine).filter((l) => l.until * 1000 > Date.now()).sort((a, b) => b.until - a.until)[0] + || api('POST', `${base}/sia-link`, { key, prefix, days: 3650 }); + }; + const title = String(key ?? prefix ?? '').replace(/\/+$/, '').split('/').pop() || bucket; + return shareMenu(anchor, getLink, { title, manage, folder }); + } const busy = h('div', { class: 'toast' }, h('span', { class: 'spinner' }), ' Making the public link…'); $('#toasts').append(busy); try { @@ -2072,6 +2103,43 @@ function objectBrowser(head, body, user, bucket, prefix, userSel) { } } + // Share menu: the public link with Copy, then a few places to send it. + // The link is made (or the existing one reused) behind a spinner. + function shareMenu(anchor, getLink, { title, manage, folder }) { + const pop = popMenu(anchor, [h('div', { class: 'share-busy' }, h('span', { class: 'spinner' }), ' Making the public link…')], 'share-menu'); + const options = () => { pop.done(); manage(); }; + getLink().then((link) => { + if (!pop.menu.isConnected) return; + const url = link.url; + const text = `${title} on Pithos`; + const enc = encodeURIComponent; + const field = h('input', { type: 'text', readonly: true, value: url, 'aria-label': 'Public link', onfocus: (e) => e.target.select() }); + const copyBtn = h('button', { type: 'button', class: 'btn small primary', onclick: async () => { + try { await navigator.clipboard.writeText(url); copyBtn.textContent = 'Copied ✓'; setTimeout(() => { copyBtn.textContent = 'Copy'; }, 1800); } + catch { field.focus(); field.select(); } + } }, 'Copy'); + const send = (href) => () => { pop.done(); openLink(href); }; + const row = (icon, label, run) => h('button', { type: 'button', role: 'menuitem', onclick: run }, h('span', { class: 'share-ic', 'aria-hidden': 'true' }, icon), label); + put(pop.menu, + h('div', { class: 'share-link' }, field, copyBtn), + h('div', { class: 'share-sep', role: 'separator' }), + row('↗', 'Open link', send(url)), + row('𝕏', 'Post on X', send(`https://x.com/intent/post?text=${enc(text)}&url=${enc(url)}`)), + row('✈', 'Send on Telegram', send(`https://t.me/share/url?url=${enc(url)}&text=${enc(text)}`)), + row('✆', 'Send on WhatsApp', send(`https://wa.me/?text=${enc(`${text}: ${url}`)}`)), + h('div', { class: 'share-sep', role: 'separator' }), + row('⚙', folder ? 'Manage public links…' : 'Link options…', options), + h('p', { class: 'small muted share-note' }, 'Anyone with the link can open it, for 10 years unless you switch it off.')); + pop.place(); + copyBtn.focus(); + }).catch((err) => { + if (!pop.menu.isConnected) return; + const msg = folder && /a file is required/.test(err.message) ? 'Pithos is still running its previous engine. Restart Theseus to finish the update, then try again.' : err.message; + put(pop.menu, h('p', { class: 'error share-note' }, msg), h('button', { type: 'button', role: 'menuitem', onclick: options }, 'Link options…')); + pop.place(); + }); + } + // 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 }) {