Add share link UI after upload

This commit is contained in:
2026-09-13 00:17:37 +02:00
parent da36645aaf
commit 505e4f472f
8 changed files with 211 additions and 47 deletions
+1
View File
@@ -122,6 +122,7 @@ type objectPage struct {
Size string Size string
Expires string Expires string
URL string URL string
InfoURL string
DeleteToken string DeleteToken string
} }
+66
View File
@@ -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)
}
}
}
+4 -1
View File
@@ -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 // 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. // delete token in the URL, where it would end up in logs and history.
s.render(w, http.StatusOK, "result.html", objectPage{ 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, Meta: m,
Size: config.FormatSize(m.Size), Size: config.FormatSize(m.Size),
Expires: describeExpiry(m.Expires, s.now()), Expires: describeExpiry(m.Expires, s.now()),
URL: url, URL: url,
InfoURL: s.absBase(r) + "i/" + m.ID,
DeleteToken: secret, DeleteToken: secret,
}) })
} }
+16
View File
@@ -5,6 +5,7 @@ import (
"flag" "flag"
"fmt" "fmt"
"os" "os"
"path/filepath"
"text/tabwriter" "text/tabwriter"
"time" "time"
@@ -73,6 +74,7 @@ func tokenCommand(args []string) error {
if err != nil { if err != nil {
return err return err
} }
warnIfUnusedDataDir(dataDir, tokensPath)
switch sub { switch sub {
case "add": 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 { func tokenAdd(file *auth.File, name, maxSize, maxExpiry, defExpiry string, vanity, admin bool) error {
secret, err := store.NewSecret() secret, err := store.NewSecret()
if err != nil { if err != nil {
+85 -40
View File
@@ -4,6 +4,30 @@
'use strict'; 'use strict';
var base = document.body.dataset.base || '/'; 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'); var form = document.getElementById('upload');
if (!form) return; if (!form) return;
@@ -174,58 +198,79 @@
return Math.floor(m / 60) + 'h ' + (m % 60) + 'm'; 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) { function showResult(r) {
var card = document.createElement('section'); var card = el('section', 'card result');
card.className = 'card result'; card.appendChild(el('h1', null, 'Uploaded'));
card.appendChild(el('p', 'filename', r.filename));
var h = document.createElement('h1'); var links = el('div', 'links');
h.textContent = 'Uploaded'; links.appendChild(linkField(
card.appendChild(h); 'Share link', 'a page showing size, expiry and a download button',
'link-info', r.info_url));
var field = document.createElement('label'); links.appendChild(linkField(
field.className = 'field'; 'Direct download', 'the file itself, for curl, wget or a browser',
var span = document.createElement('span'); 'link-file', r.url));
span.textContent = 'Download link'; card.appendChild(links);
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 dl = document.createElement('dl'); var dl = document.createElement('dl');
addRow(dl, 'Size', formatSize(r.size));
addRow(dl, 'Expires', r.expires || 'never'); addRow(dl, 'Expires', r.expires || 'never');
addRow(dl, 'SHA-256', r.sha256, 'mono wrap'); addRow(dl, 'SHA-256', r.sha256, 'mono wrap');
card.appendChild(dl); card.appendChild(dl);
var warn = document.createElement('div'); var warn = el('div', 'warn');
warn.className = 'warn'; warn.appendChild(el('h2', null, 'Delete token'));
var wh = document.createElement('h2'); warn.appendChild(el('p', null, 'Shown once. Keep it if you want to remove the file before it expires.'));
wh.textContent = 'Delete token'; warn.appendChild(el('p', 'mono wrap', r.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);
card.appendChild(warn); 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.parentNode.insertBefore(card, form);
form.hidden = true; 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) { function addRow(dl, label, value, cls) {
+21
View File
@@ -166,3 +166,24 @@ button.link {
text-decoration: underline; text-decoration: underline;
cursor: pointer; 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%; }
}
-1
View File
@@ -13,7 +13,6 @@
<main> <main>
{{template "content" .}} {{template "content" .}}
</main> </main>
<footer>Files are removed automatically when they expire.</footer>
{{if .Script}}<script src="{{.Base}}static/app.js" defer></script>{{end}} {{if .Script}}<script src="{{.Base}}static/app.js" defer></script>{{end}}
</body> </body>
</html>{{end}} </html>{{end}}
+18 -5
View File
@@ -3,10 +3,23 @@
<h1>Uploaded</h1> <h1>Uploaded</h1>
<p class="filename">{{.Meta.Filename}}</p> <p class="filename">{{.Meta.Filename}}</p>
<label class="field"> <div class="links">
<span>Download link</span> <div class="field">
<input type="text" id="link" value="{{.URL}}" readonly> <span>Share link <em>a page showing size, expiry and a download button</em></span>
</label> <div class="copyrow">
<input type="text" id="link-info" value="{{.InfoURL}}" readonly>
<button type="button" class="copy" data-copy="link-info" hidden>Copy</button>
</div>
</div>
<div class="field">
<span>Direct download <em>the file itself, for curl, wget or a browser</em></span>
<div class="copyrow">
<input type="text" id="link-file" value="{{.URL}}" readonly>
<button type="button" class="copy" data-copy="link-file" hidden>Copy</button>
</div>
</div>
</div>
<dl> <dl>
<dt>Size</dt><dd>{{.Size}}</dd> <dt>Size</dt><dd>{{.Size}}</dd>
@@ -24,6 +37,6 @@
</form> </form>
</div> </div>
<p><a href="{{.Base}}">Upload another</a></p> <p class="actions"><a class="button" href="{{.Base}}">Upload another file</a></p>
</section> </section>
{{end}} {{end}}