Add delete token UI for info page
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+14
-12
@@ -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
|
||||
|
||||
@@ -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, "<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
@@ -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);
|
||||
|
||||
|
||||
@@ -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; }
|
||||
|
||||
+41
-1
@@ -6,6 +6,46 @@
|
||||
<dt>Expires</dt><dd>{{.Expires}}</dd>
|
||||
<dt>SHA-256</dt><dd class="mono wrap">{{.Meta.SHA256}}</dd>
|
||||
</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>
|
||||
{{end}}
|
||||
|
||||
@@ -29,11 +29,16 @@
|
||||
|
||||
<div class="warn">
|
||||
<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>
|
||||
<form method="post" action="{{.Base}}api/d/{{.Meta.ID}}/delete">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user