Fix delete color and contrast

This commit is contained in:
2026-09-13 10:08:04 +02:00
parent 9c5aae5386
commit 600e66f2af
9 changed files with 42 additions and 36 deletions
+6 -6
View File
@@ -61,12 +61,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handler.S
func (s *Server) routes() http.Handler { func (s *Server) routes() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("GET /{$}", s.handleIndex) mux.HandleFunc("GET /{$}", s.handleIndex)
mux.HandleFunc("POST /api/upload", s.handleUpload) mux.HandleFunc("POST /upload", s.handleUpload)
mux.HandleFunc("GET /api/limits", s.handleLimits) mux.HandleFunc("GET /api/limits", s.handleLimits)
mux.HandleFunc("GET /admin", s.handleAdmin) mux.HandleFunc("GET /admin", s.handleAdmin)
mux.HandleFunc("GET /d/{id}", s.handleDownload) mux.HandleFunc("GET /d/{id}", s.handleDownload)
mux.HandleFunc("GET /i/{id}", s.handleInfo) mux.HandleFunc("GET /i/{id}", s.handleInfo)
mux.HandleFunc("POST /api/d/{id}/delete", s.handleDelete) mux.HandleFunc("POST /d/{id}/delete", s.handleDelete)
mux.HandleFunc("GET /login", s.handleLoginPage) mux.HandleFunc("GET /login", s.handleLoginPage)
mux.HandleFunc("POST /login", s.handleLogin) mux.HandleFunc("POST /login", s.handleLogin)
mux.HandleFunc("POST /logout", s.handleLogout) mux.HandleFunc("POST /logout", s.handleLogout)
@@ -104,10 +104,10 @@ func (s *Server) staticHandler() http.Handler {
// appCSP locks the application pages down to their own origin. The frontend has // appCSP locks the application pages down to their own origin. The frontend has
// no inline script and no third-party anything, so this can be strict. // no inline script and no third-party anything, so this can be strict.
// //
// connect-src is not optional here: the upload page talks to /api/upload and // connect-src is not optional here: the upload page posts to /upload over
// /api/limits over XMLHttpRequest, and every fetch-directive left unlisted // XMLHttpRequest to draw a progress bar, and every fetch-directive left
// falls back to default-src, so omitting it makes the browser block every // unlisted falls back to default-src, so omitting it makes the browser block
// upload before it reaches the network. See TestAppCSPAllowsWhatThePageDoes. // every upload before it reaches the network. See TestAppCSPAllowsWhatThePageDoes.
const appCSP = "default-src 'none'; script-src 'self'; style-src 'self'; " + const appCSP = "default-src 'none'; script-src 'self'; style-src 'self'; " +
"img-src 'self' data:; connect-src 'self'; form-action 'self'; " + "img-src 'self' data:; connect-src 'self'; form-action 'self'; " +
"base-uri 'none'; frame-ancestors 'none'" "base-uri 'none'; frame-ancestors 'none'"
+21 -21
View File
@@ -110,7 +110,7 @@ func (h *harness) upload(t *testing.T, body []byte, headers map[string]string) *
func (h *harness) uploadReader(t *testing.T, body io.Reader, headers map[string]string) *http.Response { func (h *harness) uploadReader(t *testing.T, body io.Reader, headers map[string]string) *http.Response {
t.Helper() t.Helper()
req, err := http.NewRequest("POST", h.ts.URL+"/api/upload", body) req, err := http.NewRequest("POST", h.ts.URL+"/upload", body)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -416,7 +416,7 @@ func TestPathTraversalIsRejected(t *testing.T) {
func TestReservedNamesAreRejected(t *testing.T) { func TestReservedNamesAreRejected(t *testing.T) {
h := newHarness(t, nil) h := newHarness(t, nil)
for _, name := range []string{"api", "static", "d", "i", "robots.txt"} { for _, name := range []string{"api", "static", "d", "i", "upload", "robots.txt"} {
resp := h.upload(t, []byte("x"), map[string]string{ resp := h.upload(t, []byte("x"), map[string]string{
"Vanity": name, "Vanity": name,
"Authorization": "Bearer " + h.token, "Authorization": "Bearer " + h.token,
@@ -434,7 +434,7 @@ func TestDeleteRequiresTheRightToken(t *testing.T) {
del := func(token string) int { del := func(token string) int {
form := strings.NewReader("token=" + token) form := strings.NewReader("token=" + token)
req, _ := http.NewRequest("POST", h.ts.URL+"/api/d/"+res.ID+"/delete", form) req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
resp, err := h.ts.Client().Do(req) resp, err := h.ts.Client().Do(req)
@@ -465,7 +465,7 @@ func TestAdminMayDeleteAnything(t *testing.T) {
h := newHarness(t, nil) h := newHarness(t, nil)
res := decode[uploadResult](t, h.upload(t, []byte("someone else's"), nil)) res := decode[uploadResult](t, h.upload(t, []byte("someone else's"), nil))
req, _ := http.NewRequest("POST", h.ts.URL+"/api/d/"+res.ID+"/delete", nil) req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", nil)
req.Header.Set("Authorization", "Bearer "+h.admin) req.Header.Set("Authorization", "Bearer "+h.admin)
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
resp, err := h.ts.Client().Do(req) resp, err := h.ts.Client().Do(req)
@@ -494,7 +494,7 @@ func TestMultipartUpload(t *testing.T) {
fw.Write([]byte("hello from a browser")) fw.Write([]byte("hello from a browser"))
mw.Close() mw.Close()
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body) req, _ := http.NewRequest("POST", h.ts.URL+"/upload", &body)
req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
resp, err := h.ts.Client().Do(req) resp, err := h.ts.Client().Do(req)
@@ -523,7 +523,7 @@ func TestFormPostRendersHTML(t *testing.T) {
fw.Write([]byte("data")) fw.Write([]byte("data"))
mw.Close() mw.Close()
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body) req, _ := http.NewRequest("POST", h.ts.URL+"/upload", &body)
req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Accept", "text/html,application/xhtml+xml") req.Header.Set("Accept", "text/html,application/xhtml+xml")
resp, err := h.ts.Client().Do(req) resp, err := h.ts.Client().Do(req)
@@ -693,7 +693,7 @@ func TestLoginStoresTheToken(t *testing.T) {
} }
// The session alone is now enough to claim a custom name. // The session alone is now enough to claim a custom name.
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", strings.NewReader("two")) req, _ := http.NewRequest("POST", h.ts.URL+"/upload", strings.NewReader("two"))
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Vanity", "session-upload") req.Header.Set("Vanity", "session-upload")
req.AddCookie(cookie) req.AddCookie(cookie)
@@ -885,7 +885,7 @@ func TestCookieDoesNotShadowTheDeleteToken(t *testing.T) {
res := decode[uploadResult](t, h.upload(t, []byte("x"), nil)) res := decode[uploadResult](t, h.upload(t, []byte("x"), nil))
form := strings.NewReader("token=" + res.DeleteToken) form := strings.NewReader("token=" + res.DeleteToken)
req, _ := http.NewRequest("POST", h.ts.URL+"/api/d/"+res.ID+"/delete", form) req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token}) req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token})
@@ -929,7 +929,7 @@ func (h *harness) formUploadWith(t *testing.T, cookie *http.Cookie, fields map[s
fw.Write([]byte(content)) fw.Write([]byte(content))
mw.Close() mw.Close()
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body) req, _ := http.NewRequest("POST", h.ts.URL+"/upload", &body)
req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
if cookie != nil { if cookie != nil {
@@ -1050,7 +1050,7 @@ func TestResultPageOffersBothLinksAndAWayBack(t *testing.T) {
fw.Write([]byte("data")) fw.Write([]byte("data"))
mw.Close() mw.Close()
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body) req, _ := http.NewRequest("POST", h.ts.URL+"/upload", &body)
req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Accept", "text/html") req.Header.Set("Accept", "text/html")
resp, err := h.ts.Client().Do(req) resp, err := h.ts.Client().Do(req)
@@ -1223,7 +1223,7 @@ func TestAdminDeleteReturnsToTheTable(t *testing.T) {
client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } client.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
form := strings.NewReader("from=admin") form := strings.NewReader("from=admin")
req, _ := http.NewRequest("POST", h.ts.URL+"/api/d/"+res.ID+"/delete", form) req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "text/html") req.Header.Set("Accept", "text/html")
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.admin}) req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.admin})
@@ -1250,7 +1250,7 @@ func TestAdminDeleteStillRequiresAdmin(t *testing.T) {
res := decode[uploadResult](t, h.upload(t, []byte("not yours"), nil)) res := decode[uploadResult](t, h.upload(t, []byte("not yours"), nil))
form := strings.NewReader("from=admin") form := strings.NewReader("from=admin")
req, _ := http.NewRequest("POST", h.ts.URL+"/api/d/"+res.ID+"/delete", form) req, _ := http.NewRequest("POST", h.ts.URL+"/d/"+res.ID+"/delete", form)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token}) req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token})
@@ -1305,7 +1305,7 @@ func TestMultipartHonoursTheHeaderForm(t *testing.T) {
fw.Write([]byte("payload")) fw.Write([]byte("payload"))
mw.Close() mw.Close()
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body) req, _ := http.NewRequest("POST", h.ts.URL+"/upload", &body)
req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+h.token) req.Header.Set("Authorization", "Bearer "+h.token)
@@ -1339,7 +1339,7 @@ func TestFormFieldsOverrideTheHeaders(t *testing.T) {
fw.Write([]byte("payload")) fw.Write([]byte("payload"))
mw.Close() mw.Close()
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", &body) req, _ := http.NewRequest("POST", h.ts.URL+"/upload", &body)
req.Header.Set("Content-Type", mw.FormDataContentType()) req.Header.Set("Content-Type", mw.FormDataContentType())
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+h.token) req.Header.Set("Authorization", "Bearer "+h.token)
@@ -1399,7 +1399,7 @@ func TestInfoPageAcceptsTheDeleteToken(t *testing.T) {
t.Fatal("the info page leaks the delete token to anyone holding the link") t.Fatal("the info page leaks the delete token to anyone holding the link")
} }
resp := h.postForm(t, "/api/d/"+res.ID+"/delete", resp := h.postForm(t, "/d/"+res.ID+"/delete",
url.Values{"from": {"info"}, "token": {res.DeleteToken}}, nil) url.Values{"from": {"info"}, "token": {res.DeleteToken}}, nil)
resp.Body.Close() resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
@@ -1417,7 +1417,7 @@ func TestWrongDeleteTokenReturnsToTheInfoPage(t *testing.T) {
res := decode[uploadResult](t, h.upload(t, []byte("x"), map[string]string{ res := decode[uploadResult](t, h.upload(t, []byte("x"), map[string]string{
"Content-Disposition": `attachment; filename="keepme.bin"`})) "Content-Disposition": `attachment; filename="keepme.bin"`}))
resp := h.postForm(t, "/api/d/"+res.ID+"/delete", resp := h.postForm(t, "/d/"+res.ID+"/delete",
url.Values{"from": {"info"}, "token": {"wrong"}}, nil) url.Values{"from": {"info"}, "token": {"wrong"}}, nil)
raw, _ := io.ReadAll(resp.Body) raw, _ := io.ReadAll(resp.Body)
resp.Body.Close() resp.Body.Close()
@@ -1473,7 +1473,7 @@ func TestInfoPageOffersADirectButtonToAnOwner(t *testing.T) {
} }
// And that button actually works with no token field at all. // And that button actually works with no token field at all.
resp := h.postForm(t, "/api/d/"+res.ID+"/delete", resp := h.postForm(t, "/d/"+res.ID+"/delete",
url.Values{"from": {"info"}}, &http.Cookie{Name: tokenCookie, Value: h.token}) url.Values{"from": {"info"}}, &http.Cookie{Name: tokenCookie, Value: h.token})
resp.Body.Close() resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
@@ -1494,7 +1494,7 @@ func TestFailedDeletesAreThrottledAndSuccessIsNot(t *testing.T) {
if last != nil { if last != nil {
last.Body.Close() last.Body.Close()
} }
last = h.postForm(t, "/api/d/"+res.ID+"/delete", last = h.postForm(t, "/d/"+res.ID+"/delete",
url.Values{"from": {"info"}, "token": {"guess"}}, nil) url.Values{"from": {"info"}, "token": {"guess"}}, nil)
} }
if last.StatusCode != http.StatusTooManyRequests { if last.StatusCode != http.StatusTooManyRequests {
@@ -1503,7 +1503,7 @@ func TestFailedDeletesAreThrottledAndSuccessIsNot(t *testing.T) {
last.Body.Close() last.Body.Close()
// The real token still works, having consumed nothing from the bucket. // The real token still works, having consumed nothing from the bucket.
resp := h.postForm(t, "/api/d/"+res.ID+"/delete", resp := h.postForm(t, "/d/"+res.ID+"/delete",
url.Values{"from": {"info"}, "token": {res.DeleteToken}}, nil) url.Values{"from": {"info"}, "token": {res.DeleteToken}}, nil)
resp.Body.Close() resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
@@ -1520,7 +1520,7 @@ func TestFailedDeletesAreThrottledAndSuccessIsNot(t *testing.T) {
func TestCrossOriginPostsAreRejected(t *testing.T) { func TestCrossOriginPostsAreRejected(t *testing.T) {
h := newHarness(t, nil) h := newHarness(t, nil)
paths := []string{"/login", "/logout", "/api/upload", "/api/d/anything/delete"} paths := []string{"/login", "/logout", "/upload", "/d/anything/delete"}
hostile := []map[string]string{ hostile := []map[string]string{
{"Origin": "https://evil.example.com"}, {"Origin": "https://evil.example.com"},
{"Sec-Fetch-Site": "cross-site"}, {"Sec-Fetch-Site": "cross-site"},
@@ -1746,7 +1746,7 @@ func TestChosenPassphraseWorksEndToEnd(t *testing.T) {
t.Fatal("no session was started") t.Fatal("no session was started")
} }
req, _ := http.NewRequest("POST", h.ts.URL+"/api/upload", strings.NewReader("x")) req, _ := http.NewRequest("POST", h.ts.URL+"/upload", strings.NewReader("x"))
req.Header.Set("Accept", "application/json") req.Header.Set("Accept", "application/json")
req.Header.Set("Vanity", "chosen-upload") req.Header.Set("Vanity", "chosen-upload")
req.AddCookie(cookie) req.AddCookie(cookie)
+1 -1
View File
@@ -405,7 +405,7 @@ func (s *Server) respondUploaded(w http.ResponseWriter, r *http.Request, m *stor
URL: url, URL: url,
InfoURL: s.absBase(r) + "i/" + m.ID, InfoURL: s.absBase(r) + "i/" + m.ID,
DeleteToken: secret, DeleteToken: secret,
DeleteURL: s.absBase(r) + "api/d/" + m.ID + "/delete", DeleteURL: s.absBase(r) + "d/" + m.ID + "/delete",
}) })
return return
} }
+1 -1
View File
@@ -17,7 +17,7 @@ var vanityRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{1,63}$`)
// allowed into the object namespace. // allowed into the object namespace.
var reserved = map[string]bool{ var reserved = map[string]bool{
"d": true, "i": true, "api": true, "static": true, "admin": true, "d": true, "i": true, "api": true, "static": true, "admin": true,
"login": true, "logout": true, "login": true, "logout": true, "upload": true,
"favicon.ico": true, "robots.txt": true, "index.html": true, "favicon.ico": true, "robots.txt": true, "index.html": true,
"sitemap.xml": true, "tokens.json": true, "objects": true, "sitemap.xml": true, "tokens.json": true, "objects": true,
} }
+7 -1
View File
@@ -8,7 +8,11 @@
--line: #dcdcd5; --line: #dcdcd5;
--accent: #2f6f4f; --accent: #2f6f4f;
--accent-fg: #ffffff; --accent-fg: #ffffff;
/* Red reads as text and as a button surface, and one value cannot do both:
see the pair below, kept in step per theme. */
--danger: #9b2c2c; --danger: #9b2c2c;
--danger-bg: #991b1b;
--danger-fg: #ffffff;
--warn-bg: #fdf6e3; --warn-bg: #fdf6e3;
--radius: 10px; --radius: 10px;
} }
@@ -23,6 +27,8 @@
--accent: #5fae86; --accent: #5fae86;
--accent-fg: #10241a; --accent-fg: #10241a;
--danger: #e07070; --danger: #e07070;
--danger-bg: #d32f2f;
--danger-fg: #ffffff;
--warn-bg: #2a2418; --warn-bg: #2a2418;
} }
} }
@@ -112,7 +118,7 @@ button, .button {
text-decoration: none; text-decoration: none;
} }
button:disabled { opacity: .55; cursor: default; } button:disabled { opacity: .55; cursor: default; }
button.danger { background: var(--danger); color: #fff; } button.danger { background: var(--danger-bg); color: var(--danger-fg); }
.progress .bar { .progress .bar {
height: .5rem; height: .5rem;
+1 -1
View File
@@ -36,7 +36,7 @@
<td data-label="Uploaded" class="nowrap" title="{{.Created}}">{{.CreatedAgo}}</td> <td data-label="Uploaded" class="nowrap" title="{{.Created}}">{{.CreatedAgo}}</td>
<td data-label="Expires" class="nowrap" title="{{.Expires}}">{{.ExpiresIn}}</td> <td data-label="Expires" class="nowrap" title="{{.Expires}}">{{.ExpiresIn}}</td>
<td class="actions-cell"> <td class="actions-cell">
<form method="post" action="{{$.Base}}api/d/{{.ID}}/delete"> <form method="post" action="{{$.Base}}d/{{.ID}}/delete">
<input type="hidden" name="from" value="admin"> <input type="hidden" name="from" value="admin">
<button type="submit" class="danger small" <button type="submit" class="danger small"
data-confirm="Delete {{.Filename}}? This cannot be undone.">Delete</button> data-confirm="Delete {{.Filename}}? This cannot be undone.">Delete</button>
+2 -2
View File
@@ -3,7 +3,7 @@
<p class="notice">Your login is no longer valid, that token has been removed. You have been logged out.</p> <p class="notice">Your login is no longer valid, that token has been removed. You have been logged out.</p>
{{end}} {{end}}
<form id="upload" class="card" method="post" action="{{.Base}}api/upload" <form id="upload" class="card" method="post" action="{{.Base}}upload"
enctype="multipart/form-data" data-max-size="{{.MaxSizeBytes}}"> enctype="multipart/form-data" data-max-size="{{.MaxSizeBytes}}">
<!-- Field order is load-bearing: the server streams this body rather than <!-- Field order is load-bearing: the server streams this body rather than
@@ -68,6 +68,6 @@
-H 'Authorization: Bearer &lt;token&gt;' \ -H 'Authorization: Bearer &lt;token&gt;' \
-H 'Vanity: my-file' \ -H 'Vanity: my-file' \
-H 'Expiry: 3d' \ -H 'Expiry: 3d' \
{{.AbsBase}}api/upload</code></pre> {{.AbsBase}}upload</code></pre>
</section> </section>
{{end}} {{end}}
+2 -2
View File
@@ -26,7 +26,7 @@
{{if .CanDelete}} {{if .CanDelete}}
<p class="hint">Your token can remove this file.</p> <p class="hint">Your token can remove this file.</p>
<form method="post" action="{{.Base}}api/d/{{.Meta.ID}}/delete"> <form method="post" action="{{.Base}}d/{{.Meta.ID}}/delete">
<input type="hidden" name="from" value="info"> <input type="hidden" name="from" value="info">
<button type="submit" class="danger" <button type="submit" class="danger"
data-confirm="Delete {{.Meta.Filename}}? This cannot be undone.">Delete this file</button> data-confirm="Delete {{.Meta.Filename}}? This cannot be undone.">Delete this file</button>
@@ -36,7 +36,7 @@
Paste the delete token you were given when this file was uploaded. 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. It is the only way to remove a file early without an owning token.
</p> </p>
<form method="post" action="{{.Base}}api/d/{{.Meta.ID}}/delete"> <form method="post" action="{{.Base}}d/{{.Meta.ID}}/delete">
<input type="hidden" name="from" value="info"> <input type="hidden" name="from" value="info">
<label class="field"> <label class="field">
<span>Delete token</span> <span>Delete token</span>
+1 -1
View File
@@ -35,7 +35,7 @@
expires. expires.
</p> </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}}d/{{.Meta.ID}}/delete">
<input type="hidden" name="token" value="{{.DeleteToken}}"> <input type="hidden" name="token" value="{{.DeleteToken}}">
<button type="submit" class="danger" <button type="submit" class="danger"
data-confirm="Delete {{.Meta.Filename}}? This cannot be undone.">Delete it now</button> data-confirm="Delete {{.Meta.Filename}}? This cannot be undone.">Delete it now</button>