Add delete token UI for info page

This commit is contained in:
2026-09-13 00:32:51 +02:00
parent ca34f1506d
commit 74bfdbfd8a
9 changed files with 293 additions and 25 deletions
+10 -1
View File
@@ -141,7 +141,7 @@ file is accepted.
| Route | | | Route | |
|---|---| |---|---|
| `GET /d/{id}` | the file, as an attachment; supports resuming | | `GET /d/{id}` | the file, as an attachment; supports resuming |
| `GET /i/{id}` | a page showing name, size, expiry and digest | | `GET /i/{id}` | a page showing name, size, expiry, digest — and where a delete token is used |
| `POST /api/d/{id}/delete` | delete, with `token=` in the form or `Authorization: Bearer` | | `POST /api/d/{id}/delete` | delete, with `token=` in the form or `Authorization: Bearer` |
| `POST /api/forget` | clear a remembered token | | `POST /api/forget` | clear a remembered token |
| `GET /admin` | administration page; admin tokens only | | `GET /admin` | administration page; admin tokens only |
@@ -149,6 +149,15 @@ file is accepted.
Deleting accepts the object's delete token, the token that uploaded it, or any Deleting accepts the object's delete token, the token that uploaded it, or any
admin token. admin token.
The delete token is shown once, when the file is uploaded. To use it later,
open the file's info page and expand **Remove this file** — that page is the
link worth keeping, since it holds everything about the file including the way
to withdraw it. Anyone whose own token already owns the file, or who is an
admin, gets a plain button there instead of a field. A wrong token returns to
the same page with the reason rather than to a generic error, and repeated
failures are throttled per address; a correct token is never delayed by
someone else's guessing.
## Administration ## Administration
An admin token adds a page at `/admin`, linked from the header whenever the An admin token adds a page at `/admin`, linked from the header whenever the
+25 -3
View File
@@ -27,12 +27,23 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
} }
presented := s.deleteCredentials(w, r) presented := s.deleteCredentials(w, r)
from := r.PostFormValue("from")
if len(presented) == 0 { if len(presented) == 0 {
s.fail(w, r, http.StatusUnauthorized, "A delete token or an owning token is required.") s.refuse(w, r, m, from, http.StatusUnauthorized,
"A delete token or an owning token is required.")
return return
} }
if !s.authorised(m, presented) { if !s.authorised(m, presented) {
s.fail(w, r, http.StatusForbidden, "That token cannot delete this file.") // Only failures are throttled, so a correct token is never delayed.
// The info page is publicly shareable and now carries a credential
// field, which is reason enough not to let it be hammered freely.
if !s.deleteLimiter.allow(clientIP(r, s.cfg), s.now()) {
s.refuse(w, r, m, from, http.StatusTooManyRequests,
"Too many failed attempts; try again shortly.")
return
}
s.refuse(w, r, m, from, http.StatusForbidden, "That delete token is not correct.")
return return
} }
@@ -50,7 +61,7 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
// Deleting from the administration table goes back to it. The destination // Deleting from the administration table goes back to it. The destination
// is built from configuration, never from the request, so this cannot be // is built from configuration, never from the request, so this cannot be
// turned into an open redirect. // turned into an open redirect.
if r.PostFormValue("from") == "admin" { if from == "admin" {
http.Redirect(w, r, s.cfg.BasePath+"admin", http.StatusSeeOther) http.Redirect(w, r, s.cfg.BasePath+"admin", http.StatusSeeOther)
return return
} }
@@ -61,6 +72,17 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) {
}) })
} }
// refuse reports a rejected deletion. A failed attempt from the file's own page
// lands back on that page with the reason, rather than on a generic error page
// that has thrown away what the reader typed.
func (s *Server) refuse(w http.ResponseWriter, r *http.Request, m *store.Meta, from string, status int, msg string) {
if from == "info" && !wantsJSON(r) {
s.renderInfo(w, r, m, status, msg)
return
}
s.fail(w, r, status, msg)
}
// deleteCredentials collects every secret the request carries. // deleteCredentials collects every secret the request carries.
// //
// Three can legitimately arrive at once — the object's delete token in the // Three can legitimately arrive at once — the object's delete token in the
+17 -2
View File
@@ -124,6 +124,11 @@ type objectPage struct {
URL string URL string
InfoURL string InfoURL string
DeleteToken string DeleteToken string
// CanDelete is set when the viewer's own token already authorises removing
// this file, so they are offered a button instead of a token field.
CanDelete bool
Error string
} }
func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) { func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
@@ -137,11 +142,21 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) {
s.fail(w, r, http.StatusNotFound, "No such file.") s.fail(w, r, http.StatusNotFound, "No such file.")
return return
} }
s.render(w, http.StatusOK, "info.html", objectPage{ s.renderInfo(w, r, m, http.StatusOK, "")
page: s.page(r, m.Filename, false), }
// renderInfo draws the file's page, optionally with an error from a failed
// delete attempt, so a mistyped token lands back on the form rather than on a
// dead end.
func (s *Server) renderInfo(w http.ResponseWriter, r *http.Request, m *store.Meta, status int, errMsg string) {
s.render(w, status, "info.html", objectPage{
page: s.page(r, m.Filename, 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: s.objectURL(r, m.ID),
CanDelete: s.mayDelete(m, credential(r)),
Error: errMsg,
}) })
} }
+2
View File
@@ -27,6 +27,7 @@ type Server struct {
pages map[string]*template.Template pages map[string]*template.Template
handler http.Handler handler http.Handler
limiter *limiter limiter *limiter
deleteLimiter *limiter // consumed only by failed deletions
slots chan struct{} // bounds uploads in flight slots chan struct{} // bounds uploads in flight
now func() time.Time // swappable in tests now func() time.Time // swappable in tests
@@ -44,6 +45,7 @@ func New(cfg *config.Config, st *store.Store, tokens *auth.File, log *slog.Logge
log: log, log: log,
pages: pages, pages: pages,
limiter: newLimiter(cfg.UploadRate, cfg.UploadBurst), limiter: newLimiter(cfg.UploadRate, cfg.UploadBurst),
deleteLimiter: newLimiter(120, 20),
slots: make(chan struct{}, cfg.MaxConcurrent), slots: make(chan struct{}, cfg.MaxConcurrent),
now: time.Now, now: time.Now,
} }
+150
View File
@@ -1273,3 +1273,153 @@ func TestFormFieldsOverrideTheHeaders(t *testing.T) {
t.Errorf("id = %q, want the form field to win", res.ID) t.Errorf("id = %q, want the form field to win", res.ID)
} }
} }
// --- deleting from the info page -----------------------------------------
func (h *harness) postForm(t *testing.T, path string, form url.Values, cookie *http.Cookie) *http.Response {
t.Helper()
req, _ := http.NewRequest("POST", h.ts.URL+path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "text/html")
if cookie != nil {
req.AddCookie(cookie)
}
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
return resp
}
// The delete token is shown once and then has to be usable somewhere. The info
// page is the link an uploader would have kept, so the form lives there.
func TestInfoPageAcceptsTheDeleteToken(t *testing.T) {
h := newHarness(t, nil)
res := decode[uploadResult](t, h.upload(t, []byte("x"), nil))
info := h.get(t, "/i/"+res.ID, "")
raw, _ := io.ReadAll(info.Body)
info.Body.Close()
page := string(raw)
if !strings.Contains(page, "Remove this file") {
t.Error("the info page offers no way to use a delete token")
}
if !strings.Contains(page, `name="token"`) {
t.Error("the info page has no field for the delete token")
}
if strings.Contains(page, res.DeleteToken) {
t.Fatal("the info page leaks the delete token to anyone holding the link")
}
resp := h.postForm(t, "/api/d/"+res.ID+"/delete",
url.Values{"from": {"info"}, "token": {res.DeleteToken}}, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("deleting with the right token => %s", resp.Status)
}
if _, err := h.store.Get(res.ID, h.now); err == nil {
t.Error("the file was not deleted")
}
}
// A mistyped token must land back on the file's page with the reason, not on a
// generic error page that has thrown the form away.
func TestWrongDeleteTokenReturnsToTheInfoPage(t *testing.T) {
h := newHarness(t, nil)
res := decode[uploadResult](t, h.upload(t, []byte("x"), map[string]string{
"Content-Disposition": `attachment; filename="keepme.bin"`}))
resp := h.postForm(t, "/api/d/"+res.ID+"/delete",
url.Values{"from": {"info"}, "token": {"wrong"}}, nil)
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
page := string(raw)
if resp.StatusCode != http.StatusForbidden {
t.Errorf("status = %s, want 403", resp.Status)
}
if !strings.Contains(page, "keepme.bin") {
t.Error("the response is not the file's own page")
}
if !strings.Contains(page, "not correct") {
t.Error("the page does not say what went wrong")
}
if !strings.Contains(page, "<details open>") {
t.Error("the delete section is collapsed, hiding the error")
}
if _, err := h.store.Get(res.ID, h.now); err != nil {
t.Error("the file was deleted despite a wrong token")
}
}
// Someone whose own token already authorises removal gets a button, not a
// field asking for a token they do not have.
func TestInfoPageOffersADirectButtonToAnOwner(t *testing.T) {
h := newHarness(t, nil)
res := decode[uploadResult](t, h.upload(t, []byte("x"), map[string]string{
"Authorization": "Bearer " + h.token}))
for _, c := range []struct {
who string
token string
expectBtn bool
}{
{"the owner", h.token, true},
{"an admin", h.admin, true},
{"a stranger", "", false},
} {
req, _ := http.NewRequest("GET", h.ts.URL+"/i/"+res.ID, nil)
if c.token != "" {
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: c.token})
}
resp, err := h.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
raw, _ := io.ReadAll(resp.Body)
resp.Body.Close()
got := strings.Contains(string(raw), "Your token can remove this file")
if got != c.expectBtn {
t.Errorf("direct delete button shown to %s = %v, want %v", c.who, got, c.expectBtn)
}
}
// And that button actually works with no token field at all.
resp := h.postForm(t, "/api/d/"+res.ID+"/delete",
url.Values{"from": {"info"}}, &http.Cookie{Name: tokenCookie, Value: h.token})
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("owner delete => %s", resp.Status)
}
}
// Guessing is throttled, but only the guessing: a correct token is never
// delayed by someone else's failed attempts.
func TestFailedDeletesAreThrottledAndSuccessIsNot(t *testing.T) {
h := newHarness(t, nil)
h.deleteLimiter = newLimiter(1, 3)
res := decode[uploadResult](t, h.upload(t, []byte("x"), nil))
var last *http.Response
for range 5 {
if last != nil {
last.Body.Close()
}
last = h.postForm(t, "/api/d/"+res.ID+"/delete",
url.Values{"from": {"info"}, "token": {"guess"}}, nil)
}
if last.StatusCode != http.StatusTooManyRequests {
t.Fatalf("repeated guesses => %s, want 429", last.Status)
}
last.Body.Close()
// The real token still works, having consumed nothing from the bucket.
resp := h.postForm(t, "/api/d/"+res.ID+"/delete",
url.Values{"from": {"info"}, "token": {res.DeleteToken}}, nil)
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("the correct token was throttled: %s", resp.Status)
}
}
+3 -1
View File
@@ -230,7 +230,9 @@
var warn = el('div', 'warn'); var warn = el('div', 'warn');
warn.appendChild(el('h2', null, 'Delete token')); 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', null,
'Shown once. Keep it alongside the share link: pasting it under ' +
'"Remove this file" on that page deletes the file before it expires.'));
warn.appendChild(el('p', 'mono wrap', r.delete_token)); warn.appendChild(el('p', 'mono wrap', r.delete_token));
card.appendChild(warn); card.appendChild(warn);
+23
View File
@@ -212,3 +212,26 @@ table.admin form { margin: 0; }
button.small { padding: .2rem .5rem; font-size: .75rem; } button.small { padding: .2rem .5rem; font-size: .75rem; }
.cli code, .hint code { background: var(--bg); padding: .1rem .3rem; border-radius: 4px; } .cli code, .hint code { background: var(--bg); padding: .1rem .3rem; border-radius: 4px; }
/* Disclosure sections and inline errors ----------------------------------- */
details > summary {
cursor: pointer;
font-weight: 500;
padding: .125rem 0;
}
details[open] > summary { margin-bottom: .75rem; }
details .field { margin-top: .75rem; max-width: 28rem; }
p.error {
margin: 0 0 .75rem;
padding: .5rem .75rem;
color: var(--danger);
background: var(--warn-bg);
border: 1px solid var(--danger);
border-radius: 6px;
font-size: .875rem;
}
.card .actions { margin: 1.25rem 0; }
.card > .field:last-child { margin-bottom: 0; }
+41 -1
View File
@@ -6,6 +6,46 @@
<dt>Expires</dt><dd>{{.Expires}}</dd> <dt>Expires</dt><dd>{{.Expires}}</dd>
<dt>SHA-256</dt><dd class="mono wrap">{{.Meta.SHA256}}</dd> <dt>SHA-256</dt><dd class="mono wrap">{{.Meta.SHA256}}</dd>
</dl> </dl>
<p><a class="button" href="{{.Base}}d/{{.Meta.ID}}">Download</a></p>
<p class="actions"><a class="button" href="{{.Base}}d/{{.Meta.ID}}">Download</a></p>
<div class="field">
<span>Direct link</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>
</section>
<section class="card">
<details{{if .Error}} open{{end}}>
<summary>Remove this file</summary>
{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
{{if .CanDelete}}
<p class="hint">Your token can remove this file.</p>
<form method="post" action="{{.Base}}api/d/{{.Meta.ID}}/delete">
<input type="hidden" name="from" value="info">
<button type="submit" class="danger"
data-confirm="Delete {{.Meta.Filename}}? This cannot be undone.">Delete this file</button>
</form>
{{else}}
<p class="hint">
Paste the delete token you were given when this file was uploaded.
It is the only way to remove a file early without an owning token.
</p>
<form method="post" action="{{.Base}}api/d/{{.Meta.ID}}/delete">
<input type="hidden" name="from" value="info">
<label class="field">
<span>Delete token</span>
<input type="password" name="token" autocomplete="off" required>
</label>
<button type="submit" class="danger"
data-confirm="Delete {{.Meta.Filename}}? This cannot be undone.">Delete this file</button>
</form>
{{end}}
</details>
</section> </section>
{{end}} {{end}}
+7 -2
View File
@@ -29,11 +29,16 @@
<div class="warn"> <div class="warn">
<h2>Delete token</h2> <h2>Delete token</h2>
<p>Shown once. Keep it if you want to remove the file before it expires.</p> <p>
Shown once. Keep it alongside the share link: pasting it under
<strong>Remove this file</strong> on that page deletes the file before it
expires.
</p>
<p class="mono wrap">{{.DeleteToken}}</p> <p class="mono wrap">{{.DeleteToken}}</p>
<form method="post" action="{{.Base}}api/d/{{.Meta.ID}}/delete"> <form method="post" action="{{.Base}}api/d/{{.Meta.ID}}/delete">
<input type="hidden" name="token" value="{{.DeleteToken}}"> <input type="hidden" name="token" value="{{.DeleteToken}}">
<button type="submit" class="danger">Delete it now</button> <button type="submit" class="danger"
data-confirm="Delete {{.Meta.Filename}}? This cannot be undone.">Delete it now</button>
</form> </form>
</div> </div>