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}}