From 74bfdbfd8a00105a3fff75c91005148a70ee2adf Mon Sep 17 00:00:00 2001 From: Thayol Date: Sun, 13 Sep 2026 00:32:51 +0200 Subject: [PATCH] Add delete token UI for info page --- README.md | 11 ++- internal/server/delete.go | 28 +++++- internal/server/pages.go | 25 ++++-- internal/server/server.go | 26 +++--- internal/server/server_test.go | 150 +++++++++++++++++++++++++++++++++ web/static/app.js | 4 +- web/static/style.css | 23 +++++ web/templates/info.html | 42 ++++++++- web/templates/result.html | 9 +- 9 files changed, 293 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index c4dffd9..0c5109f 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ file is accepted. | Route | | |---|---| | `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/forget` | clear a remembered token | | `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 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 An admin token adds a page at `/admin`, linked from the header whenever the diff --git a/internal/server/delete.go b/internal/server/delete.go index ff8eb92..4f334f6 100644 --- a/internal/server/delete.go +++ b/internal/server/delete.go @@ -27,12 +27,23 @@ func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) { } presented := s.deleteCredentials(w, r) + from := r.PostFormValue("from") + 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 } 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 } @@ -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 // is built from configuration, never from the request, so this cannot be // turned into an open redirect. - if r.PostFormValue("from") == "admin" { + if from == "admin" { http.Redirect(w, r, s.cfg.BasePath+"admin", http.StatusSeeOther) 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. // // Three can legitimately arrive at once — the object's delete token in the diff --git a/internal/server/pages.go b/internal/server/pages.go index 5ca7824..6663ab6 100644 --- a/internal/server/pages.go +++ b/internal/server/pages.go @@ -124,6 +124,11 @@ type objectPage struct { URL string InfoURL 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) { @@ -137,11 +142,21 @@ func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) { s.fail(w, r, http.StatusNotFound, "No such file.") return } - s.render(w, http.StatusOK, "info.html", objectPage{ - page: s.page(r, m.Filename, false), - Meta: m, - Size: config.FormatSize(m.Size), - Expires: describeExpiry(m.Expires, s.now()), + s.renderInfo(w, r, m, http.StatusOK, "") +} + +// 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, + Size: config.FormatSize(m.Size), + Expires: describeExpiry(m.Expires, s.now()), + URL: s.objectURL(r, m.ID), + CanDelete: s.mayDelete(m, credential(r)), + Error: errMsg, }) } diff --git a/internal/server/server.go b/internal/server/server.go index d65e36c..0e9df3c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -24,10 +24,11 @@ type Server struct { tokens *auth.File log *slog.Logger - pages map[string]*template.Template - handler http.Handler - limiter *limiter - slots chan struct{} // bounds uploads in flight + pages map[string]*template.Template + handler http.Handler + limiter *limiter + deleteLimiter *limiter // consumed only by failed deletions + slots chan struct{} // bounds uploads in flight now func() time.Time // swappable in tests } @@ -38,14 +39,15 @@ func New(cfg *config.Config, st *store.Store, tokens *auth.File, log *slog.Logge return nil, err } s := &Server{ - cfg: cfg, - store: st, - tokens: tokens, - log: log, - pages: pages, - limiter: newLimiter(cfg.UploadRate, cfg.UploadBurst), - slots: make(chan struct{}, cfg.MaxConcurrent), - now: time.Now, + cfg: cfg, + store: st, + tokens: tokens, + log: log, + pages: pages, + limiter: newLimiter(cfg.UploadRate, cfg.UploadBurst), + deleteLimiter: newLimiter(120, 20), + slots: make(chan struct{}, cfg.MaxConcurrent), + now: time.Now, } s.handler = s.routes() return s, nil diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 79d9929..30131a5 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1273,3 +1273,153 @@ func TestFormFieldsOverrideTheHeaders(t *testing.T) { 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, "
") { + 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) + } +} diff --git a/web/static/app.js b/web/static/app.js index 76bb94c..4721da3 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -230,7 +230,9 @@ 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', 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)); card.appendChild(warn); diff --git a/web/static/style.css b/web/static/style.css index a6d749c..bdb9e77 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -212,3 +212,26 @@ table.admin form { margin: 0; } button.small { padding: .2rem .5rem; font-size: .75rem; } .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; } diff --git a/web/templates/info.html b/web/templates/info.html index ef09302..e1eae8f 100644 --- a/web/templates/info.html +++ b/web/templates/info.html @@ -6,6 +6,46 @@
Expires
{{.Expires}}
SHA-256
{{.Meta.SHA256}}
-

Download

+ +

Download

+ +
+ Direct link +
+ + +
+
+ + +
+ + Remove this file + + {{if .Error}}

{{.Error}}

{{end}} + + {{if .CanDelete}} +

Your token can remove this file.

+
+ + +
+ {{else}} +

+ 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. +

+
+ + + +
+ {{end}} +
{{end}} diff --git a/web/templates/result.html b/web/templates/result.html index f238fcc..818f1e2 100644 --- a/web/templates/result.html +++ b/web/templates/result.html @@ -29,11 +29,16 @@

Delete token

-

Shown once. Keep it if you want to remove the file before it expires.

+

+ Shown once. Keep it alongside the share link: pasting it under + Remove this file on that page deletes the file before it + expires. +

{{.DeleteToken}}

- +