diff --git a/internal/server/server.go b/internal/server/server.go index eb203b8..397d3f6 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -61,12 +61,12 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.handler.S func (s *Server) routes() http.Handler { mux := http.NewServeMux() 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 /admin", s.handleAdmin) mux.HandleFunc("GET /d/{id}", s.handleDownload) 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("POST /login", s.handleLogin) 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 // 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 -// /api/limits over XMLHttpRequest, and every fetch-directive left unlisted -// falls back to default-src, so omitting it makes the browser block every -// upload before it reaches the network. See TestAppCSPAllowsWhatThePageDoes. +// connect-src is not optional here: the upload page posts to /upload over +// XMLHttpRequest to draw a progress bar, and every fetch-directive left +// unlisted falls back to default-src, so omitting it makes the browser block +// every upload before it reaches the network. See TestAppCSPAllowsWhatThePageDoes. const appCSP = "default-src 'none'; script-src 'self'; style-src 'self'; " + "img-src 'self' data:; connect-src 'self'; form-action 'self'; " + "base-uri 'none'; frame-ancestors 'none'" diff --git a/internal/server/server_test.go b/internal/server/server_test.go index f4916e3..a1d63d7 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -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 { 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 { t.Fatal(err) } @@ -416,7 +416,7 @@ func TestPathTraversalIsRejected(t *testing.T) { func TestReservedNamesAreRejected(t *testing.T) { 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{ "Vanity": name, "Authorization": "Bearer " + h.token, @@ -434,7 +434,7 @@ func TestDeleteRequiresTheRightToken(t *testing.T) { del := func(token string) int { 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("Accept", "application/json") resp, err := h.ts.Client().Do(req) @@ -465,7 +465,7 @@ func TestAdminMayDeleteAnything(t *testing.T) { h := newHarness(t, 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("Accept", "application/json") resp, err := h.ts.Client().Do(req) @@ -494,7 +494,7 @@ func TestMultipartUpload(t *testing.T) { fw.Write([]byte("hello from a browser")) 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("Accept", "application/json") resp, err := h.ts.Client().Do(req) @@ -523,7 +523,7 @@ func TestFormPostRendersHTML(t *testing.T) { fw.Write([]byte("data")) 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("Accept", "text/html,application/xhtml+xml") 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. - 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("Vanity", "session-upload") req.AddCookie(cookie) @@ -885,7 +885,7 @@ func TestCookieDoesNotShadowTheDeleteToken(t *testing.T) { res := decode[uploadResult](t, h.upload(t, []byte("x"), nil)) 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("Accept", "application/json") 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)) 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("Accept", "application/json") if cookie != nil { @@ -1050,7 +1050,7 @@ func TestResultPageOffersBothLinksAndAWayBack(t *testing.T) { fw.Write([]byte("data")) 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("Accept", "text/html") 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 } 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("Accept", "text/html") 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)) 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("Accept", "application/json") req.AddCookie(&http.Cookie{Name: tokenCookie, Value: h.token}) @@ -1305,7 +1305,7 @@ func TestMultipartHonoursTheHeaderForm(t *testing.T) { fw.Write([]byte("payload")) 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("Accept", "application/json") req.Header.Set("Authorization", "Bearer "+h.token) @@ -1339,7 +1339,7 @@ func TestFormFieldsOverrideTheHeaders(t *testing.T) { fw.Write([]byte("payload")) 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("Accept", "application/json") 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") } - 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) resp.Body.Close() 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{ "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) raw, _ := io.ReadAll(resp.Body) resp.Body.Close() @@ -1473,7 +1473,7 @@ func TestInfoPageOffersADirectButtonToAnOwner(t *testing.T) { } // 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}) resp.Body.Close() if resp.StatusCode != http.StatusOK { @@ -1494,7 +1494,7 @@ func TestFailedDeletesAreThrottledAndSuccessIsNot(t *testing.T) { if last != nil { 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) } if last.StatusCode != http.StatusTooManyRequests { @@ -1503,7 +1503,7 @@ func TestFailedDeletesAreThrottledAndSuccessIsNot(t *testing.T) { last.Body.Close() // 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) resp.Body.Close() if resp.StatusCode != http.StatusOK { @@ -1520,7 +1520,7 @@ func TestFailedDeletesAreThrottledAndSuccessIsNot(t *testing.T) { func TestCrossOriginPostsAreRejected(t *testing.T) { 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{ {"Origin": "https://evil.example.com"}, {"Sec-Fetch-Site": "cross-site"}, @@ -1746,7 +1746,7 @@ func TestChosenPassphraseWorksEndToEnd(t *testing.T) { 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("Vanity", "chosen-upload") req.AddCookie(cookie) diff --git a/internal/server/upload.go b/internal/server/upload.go index 2b996ff..4e9fddb 100644 --- a/internal/server/upload.go +++ b/internal/server/upload.go @@ -405,7 +405,7 @@ func (s *Server) respondUploaded(w http.ResponseWriter, r *http.Request, m *stor URL: url, InfoURL: s.absBase(r) + "i/" + m.ID, DeleteToken: secret, - DeleteURL: s.absBase(r) + "api/d/" + m.ID + "/delete", + DeleteURL: s.absBase(r) + "d/" + m.ID + "/delete", }) return } diff --git a/internal/store/id.go b/internal/store/id.go index 4315bce..41e5f0d 100644 --- a/internal/store/id.go +++ b/internal/store/id.go @@ -17,7 +17,7 @@ var vanityRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{1,63}$`) // allowed into the object namespace. var reserved = map[string]bool{ "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, "sitemap.xml": true, "tokens.json": true, "objects": true, } diff --git a/web/static/style.css b/web/static/style.css index fae7f5b..5e372a1 100644 --- a/web/static/style.css +++ b/web/static/style.css @@ -8,7 +8,11 @@ --line: #dcdcd5; --accent: #2f6f4f; --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-bg: #991b1b; + --danger-fg: #ffffff; --warn-bg: #fdf6e3; --radius: 10px; } @@ -23,6 +27,8 @@ --accent: #5fae86; --accent-fg: #10241a; --danger: #e07070; + --danger-bg: #d32f2f; + --danger-fg: #ffffff; --warn-bg: #2a2418; } } @@ -112,7 +118,7 @@ button, .button { text-decoration: none; } 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 { height: .5rem; diff --git a/web/templates/admin.html b/web/templates/admin.html index e51bd85..5f6591c 100644 --- a/web/templates/admin.html +++ b/web/templates/admin.html @@ -36,7 +36,7 @@ {{.CreatedAgo}} {{.ExpiresIn}} -
+ diff --git a/web/templates/index.html b/web/templates/index.html index 5b80b9b..bb4c191 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -3,7 +3,7 @@

Your login is no longer valid, that token has been removed. You have been logged out.

{{end}} -