From 505e4f472f81b7de8a1c6b718af08f39e4420d88 Mon Sep 17 00:00:00 2001 From: Thayol Date: Sun, 13 Sep 2026 00:17:37 +0200 Subject: [PATCH] Add share link UI after upload --- internal/server/pages.go | 1 + internal/server/server_test.go | 66 +++++++++++++++++ internal/server/upload.go | 5 +- token.go | 16 +++++ web/static/app.js | 125 ++++++++++++++++++++++----------- web/static/style.css | 21 ++++++ web/templates/layout.html | 1 - web/templates/result.html | 23 ++++-- 8 files changed, 211 insertions(+), 47 deletions(-) diff --git a/internal/server/pages.go b/internal/server/pages.go index e553afb..279859c 100644 --- a/internal/server/pages.go +++ b/internal/server/pages.go @@ -122,6 +122,7 @@ type objectPage struct { Size string Expires string URL string + InfoURL string DeleteToken string } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 1923fbf..2124556 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -957,3 +957,69 @@ func TestDownloadCSPStaysInert(t *testing.T) { } } } + +// The result page is the only place a link to the info page is ever offered, +// so losing it strands that page with no way to discover it. +func TestResultPageOffersBothLinksAndAWayBack(t *testing.T) { + h := newHarness(t, nil) + + var body bytes.Buffer + mw := multipart.NewWriter(&body) + fw, _ := mw.CreateFormFile("file", "thing.bin") + fw.Write([]byte("data")) + mw.Close() + + req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body) + req.Header.Set("Content-Type", mw.FormDataContentType()) + req.Header.Set("Accept", "text/html") + resp, err := h.ts.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + page := string(raw) + + id := h.store.List()[0].ID + for _, want := range []struct{ what, fragment string }{ + {"the info page link", "/i/" + id}, + {"the direct download link", "/d/" + id}, + {"a way to upload another file", `href="/">Upload another file`}, + {"the script that enables the copy buttons", "static/app.js"}, + } { + if !strings.Contains(page, want.fragment) { + t.Errorf("the result page is missing %s (%q)", want.what, want.fragment) + } + } + + // Copy buttons ship hidden, so a reader without JavaScript never sees a + // button that does nothing. + if strings.Count(page, `class="copy"`) != strings.Count(page, `hidden>Copy<`) { + t.Error("a copy button is not hidden by default") + } +} + +// The JSON reply has to carry the same two links, since the script builds the +// result card from it alone. +func TestUploadJSONCarriesBothLinks(t *testing.T) { + h := newHarness(t, nil) + res := decode[uploadResult](t, h.upload(t, []byte("x"), nil)) + + if res.URL == "" || !strings.Contains(res.URL, "/d/"+res.ID) { + t.Errorf("url = %q, want the direct download", res.URL) + } + if res.InfoURL == "" || !strings.Contains(res.InfoURL, "/i/"+res.ID) { + t.Errorf("info_url = %q, want the info page", res.InfoURL) + } + // Both must actually resolve. + for _, u := range []string{res.URL, res.InfoURL} { + get, err := h.ts.Client().Get(u) + if err != nil { + t.Fatal(err) + } + get.Body.Close() + if get.StatusCode != http.StatusOK { + t.Errorf("GET %s => %s", u, get.Status) + } + } +} diff --git a/internal/server/upload.go b/internal/server/upload.go index 125737c..21d4820 100644 --- a/internal/server/upload.go +++ b/internal/server/upload.go @@ -426,11 +426,14 @@ func (s *Server) respondUploaded(w http.ResponseWriter, r *http.Request, m *stor // Rendered directly rather than redirected: a 303 would have to carry the // delete token in the URL, where it would end up in logs and history. s.render(w, http.StatusOK, "result.html", objectPage{ - page: s.page("Uploaded", false), + // The script is loaded here only to enable the copy buttons, which stay + // hidden without it rather than sitting there dead. + page: s.page("Uploaded", true), Meta: m, Size: config.FormatSize(m.Size), Expires: describeExpiry(m.Expires, s.now()), URL: url, + InfoURL: s.absBase(r) + "i/" + m.ID, DeleteToken: secret, }) } diff --git a/token.go b/token.go index 043d11a..76d4b27 100644 --- a/token.go +++ b/token.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" "os" + "path/filepath" "text/tabwriter" "time" @@ -73,6 +74,7 @@ func tokenCommand(args []string) error { if err != nil { return err } + warnIfUnusedDataDir(dataDir, tokensPath) switch sub { case "add": @@ -96,6 +98,20 @@ func tokenCommand(args []string) error { } } +// warnIfUnusedDataDir flags the most likely mistake with this command: pointing +// --data somewhere the server does not read, so a freshly minted token is never +// seen and every request comes back 401. A data directory the server has opened +// always has an objects/ subdirectory. +func warnIfUnusedDataDir(dataDir, tokensPath string) { + if _, err := os.Stat(filepath.Join(dataDir, "objects")); err == nil { + return + } + fmt.Fprintf(os.Stderr, + "note: %s has no objects/ directory, so no server has used it.\n"+ + " Tokens written to %s are only read by a server started with --data %s\n\n", + dataDir, tokensPath, dataDir) +} + func tokenAdd(file *auth.File, name, maxSize, maxExpiry, defExpiry string, vanity, admin bool) error { secret, err := store.NewSecret() if err != nil { diff --git a/web/static/app.js b/web/static/app.js index c440311..4365775 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -4,6 +4,30 @@ 'use strict'; var base = document.body.dataset.base || '/'; + + // Copy buttons appear on the server-rendered result page too, which has no + // upload form, so they are wired before anything form-specific bails out. + // They ship hidden so that without this script there is no dead button. + function wireCopy(root) { + var buttons = root.querySelectorAll('button.copy[data-copy]'); + Array.prototype.forEach.call(buttons, function (button) { + var target = document.getElementById(button.dataset.copy); + if (!target) return; + button.hidden = false; + button.addEventListener('click', function () { + target.select(); + var done = function () { + var was = button.textContent; + button.textContent = 'Copied'; + setTimeout(function () { button.textContent = was; }, 1500); + }; + if (navigator.clipboard) navigator.clipboard.writeText(target.value).then(done, function () {}); + else { document.execCommand('copy'); done(); } + }); + }); + } + wireCopy(document); + var form = document.getElementById('upload'); if (!form) return; @@ -174,58 +198,79 @@ return Math.floor(m / 60) + 'h ' + (m % 60) + 'm'; } + // Mirrors web/templates/result.html, so the JavaScript and no-JavaScript + // paths show the same thing. function showResult(r) { - var card = document.createElement('section'); - card.className = 'card result'; + var card = el('section', 'card result'); + card.appendChild(el('h1', null, 'Uploaded')); + card.appendChild(el('p', 'filename', r.filename)); - var h = document.createElement('h1'); - h.textContent = 'Uploaded'; - card.appendChild(h); - - var field = document.createElement('label'); - field.className = 'field'; - var span = document.createElement('span'); - span.textContent = 'Download link'; - var input = document.createElement('input'); - input.type = 'text'; - input.readOnly = true; - input.value = r.url; - field.appendChild(span); - field.appendChild(input); - card.appendChild(field); - - var copy = document.createElement('button'); - copy.type = 'button'; - copy.textContent = 'Copy link'; - copy.addEventListener('click', function () { - input.select(); - var done = function () { copy.textContent = 'Copied'; }; - if (navigator.clipboard) navigator.clipboard.writeText(r.url).then(done, function () {}); - else { document.execCommand('copy'); done(); } - }); - card.appendChild(copy); + var links = el('div', 'links'); + links.appendChild(linkField( + 'Share link', 'a page showing size, expiry and a download button', + 'link-info', r.info_url)); + links.appendChild(linkField( + 'Direct download', 'the file itself, for curl, wget or a browser', + 'link-file', r.url)); + card.appendChild(links); var dl = document.createElement('dl'); + addRow(dl, 'Size', formatSize(r.size)); addRow(dl, 'Expires', r.expires || 'never'); addRow(dl, 'SHA-256', r.sha256, 'mono wrap'); card.appendChild(dl); - var warn = document.createElement('div'); - warn.className = 'warn'; - var wh = document.createElement('h2'); - wh.textContent = 'Delete token'; - var wp = document.createElement('p'); - wp.textContent = 'Shown once. Keep it if you want to remove the file early.'; - var wt = document.createElement('p'); - wt.className = 'mono wrap'; - wt.textContent = r.delete_token; - warn.appendChild(wh); - warn.appendChild(wp); - warn.appendChild(wt); + var warn = el('div', 'warn'); + warn.appendChild(el('h2', null, 'Delete token')); + warn.appendChild(el('p', null, 'Shown once. Keep it if you want to remove the file before it expires.')); + warn.appendChild(el('p', 'mono wrap', r.delete_token)); card.appendChild(warn); + var actions = el('p', 'actions'); + var again = document.createElement('a'); + again.className = 'button'; + again.href = base; + again.textContent = 'Upload another file'; + actions.appendChild(again); + card.appendChild(actions); + form.parentNode.insertBefore(card, form); form.hidden = true; + wireCopy(card); + card.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + + function el(tag, cls, text) { + var node = document.createElement(tag); + if (cls) node.className = cls; + if (text !== undefined && text !== null) node.textContent = text; + return node; + } + + // One labelled, read-only URL with a copy button beside it. + function linkField(label, hint, id, value) { + var field = el('div', 'field'); + var span = document.createElement('span'); + span.appendChild(document.createTextNode(label + ' ')); + span.appendChild(el('em', null, hint)); + field.appendChild(span); + + var row = el('div', 'copyrow'); + var input = document.createElement('input'); + input.type = 'text'; + input.id = id; + input.readOnly = true; + input.value = value; + row.appendChild(input); + + var button = el('button', 'copy', 'Copy'); + button.type = 'button'; + button.hidden = true; + button.dataset.copy = id; + row.appendChild(button); + + field.appendChild(row); + return field; } function addRow(dl, label, value, cls) { diff --git a/web/static/style.css b/web/static/style.css index 7f78e98..5c7d6a7 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -166,3 +166,24 @@ button.link { text-decoration: underline; cursor: pointer; } + +/* Result card ------------------------------------------------------------ */ + +.result .links { margin: 1.25rem 0 0; } +.result .links .field { margin-bottom: 1rem; } + +/* A read-only URL with its copy button alongside. */ +.copyrow { display: flex; gap: .5rem; align-items: stretch; } +.copyrow input { flex: 1 1 auto; min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .8125rem; } +.copyrow .copy { flex: 0 0 auto; } + +/* Keep the detail list clear of the copy buttons above it. */ +.result dl { margin-top: 1.75rem; padding-top: 1.25rem; border-top: 1px solid var(--line); } +.result dl dd { margin-bottom: .25rem; } + +.result .actions { margin: 1.5rem 0 0; } + +@media (max-width: 26rem) { + .copyrow { flex-wrap: wrap; } + .copyrow input { flex-basis: 100%; } +} diff --git a/web/templates/layout.html b/web/templates/layout.html index 8dd7c17..9b616b5 100644 --- a/web/templates/layout.html +++ b/web/templates/layout.html @@ -13,7 +13,6 @@
{{template "content" .}}
- {{if .Script}}{{end}} {{end}} diff --git a/web/templates/result.html b/web/templates/result.html index 178ec4c..f238fcc 100644 --- a/web/templates/result.html +++ b/web/templates/result.html @@ -3,10 +3,23 @@

Uploaded

{{.Meta.Filename}}

- +
Size
{{.Size}}
@@ -24,6 +37,6 @@ -

Upload another

+

Upload another file

{{end}}