diff --git a/.gitignore b/.gitignore
index a1acada..fef4f42 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@ palette.db-shm
palette.db-wal
admin-key
settings.json
+:memory:.files/
diff --git a/README.md b/README.md
index 0c15dbd..342092c 100644
--- a/README.md
+++ b/README.md
@@ -1,7 +1,7 @@
# Palette
Palette is a fast, self-hosted pastebin. One Go binary, a SQLite database, and
-a web UI for sharing text and small files
+a web UI for sharing text and small files.
> [!NOTE]
>
@@ -10,22 +10,27 @@ a web UI for sharing text and small files
## Features
-- Multiple files in one paste
+- Text pastes and cans (multiple items in one share)
+- File attachments (one file per paste, up to 25 MB)
- Password protected pastes
-- Expir after a specified time
+- Expire after a specified time
- Burn after a number of views
- Custom URLs
- Syntax highlighting with language auto-detection (go-enry)
-- Local cookie based submission history
-- Cookie based settings
-- Themes!
+- Public paste listing with search, sort and pagination
+- Cookie based saved pastes and settings
+- Five base themes (midnight, smooth, pastel-lavender, pastel-peach, pastel-cloud), each with a dark and light variant
+- Dark mode toggle in the topbar and settings, with a configurable default
## Screenshots
| | |
|---|---|
-|  |  |
-|  |  |
+|  |  |
+|  |  |
+|  |  |
+
+Mobile previews (375x812): [paste view](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-paste-midnight-dark.png), [public list](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-public.png), [settings](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-settings.png).
## Get Started
@@ -63,9 +68,11 @@ go build -o palette ./cmd/palette
| `PALETTE_MAX_TEXT` | `5242880` | Max paste size in bytes (5 MB) |
| `PALETTE_MAX_ITEM` | `26214400` | Max can item size in bytes (25 MB) |
| `PALETTE_ADMIN_KEY` | generated | Admin key; if unset a 32-char hex key is generated and persisted to `/admin-key` (0600) |
+| `PALETTE_DEFAULT_DARK` | dark on | Default dark mode for new visitors. Set `false`, `0`, or `off` to default to light mode. Visitors who toggle dark mode keep their choice in their browser. |
+| `PALETTE_UNLOCK_SECRET` | random per start | HMAC secret for password-unlock cookies. Set a fixed value to keep unlock sessions across restarts or across replicas. |
An `/admin` page exists for runtime settings, protected by a key set at
-install (`PALETTE_ADMIN_KEY` env var) and resettable locally — see
+install (`PALETTE_ADMIN_KEY` env var) and resettable locally. See
[API](https://git.archfox.org/poslop/palette/wiki/API) and the [design docs](https://git.archfox.org/poslop/palette/wiki/Home) in the wiki for details.
## API
diff --git a/cmd/palette/main.go b/cmd/palette/main.go
index 2807582..11f494b 100644
--- a/cmd/palette/main.go
+++ b/cmd/palette/main.go
@@ -42,6 +42,7 @@ func main() {
if err != nil {
log.Fatal(err)
}
+ web.SetDefaultDark(os.Getenv("PALETTE_DEFAULT_DARK"))
srv := api.NewServer(st, cfg, ui, ss, adminKey)
log.Printf("palette listening on %s", cfg.Addr)
log.Fatal(http.ListenAndServe(cfg.Addr, srv.Routes()))
diff --git a/internal/api/admin.go b/internal/api/admin.go
index b88d9dc..01c3246 100644
--- a/internal/api/admin.go
+++ b/internal/api/admin.go
@@ -160,13 +160,12 @@ func HandleResetAdminKey(dbPath string) {
}
// adminKeyOK reports whether the request carries the correct admin key via
-// X-Admin-Key header or ?key=. Constant-time compare; failures and successes
-// are both logged (#40).
+// the X-Admin-Key header only. The ?key= query fallback was removed (#137):
+// query strings land in access logs, browser history, and Referer headers,
+// so accepting the key there leaked the admin secret. Constant-time compare;
+// failures and successes are both logged (#40).
func (a *apiServer) adminKeyOK(r *http.Request, key string) bool {
given := r.Header.Get("X-Admin-Key")
- if given == "" {
- given = r.URL.Query().Get("key")
- }
return subtle.ConstantTimeCompare([]byte(given), []byte(key)) == 1
}
diff --git a/internal/api/admin_test.go b/internal/api/admin_test.go
index 8dc2dbc..67900d0 100644
--- a/internal/api/admin_test.go
+++ b/internal/api/admin_test.go
@@ -37,11 +37,12 @@ func TestAdminAuth(t *testing.T) {
t.Fatalf("wrong key: expected 401, got %d", rec.Code)
}
+ // #137: the ?key= query fallback was removed; keys must go via header.
req = httptest.NewRequest("GET", "/admin/api/settings?key=test-admin-key", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
- if rec.Code != 200 {
- t.Fatalf("query key: expected 200, got %d", rec.Code)
+ if rec.Code != 401 {
+ t.Fatalf("query key: expected 401 after #137 removal, got %d", rec.Code)
}
req = httptest.NewRequest("GET", "/admin/api/settings", nil)
diff --git a/internal/api/attachments.go b/internal/api/attachments.go
new file mode 100644
index 0000000..63fca22
--- /dev/null
+++ b/internal/api/attachments.go
@@ -0,0 +1,363 @@
+package api
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/go-chi/chi/v5"
+
+ "palette/internal/store"
+)
+
+// #38: file attachments, iteration 1: one file per paste. A paste either has
+// text content OR one attached file. Multipart create + /f/ serving route.
+
+const (
+ MaxAttachmentBytes = 25 << 20 // 25 MB per file
+ maxFileBytesHard = MaxAttachmentBytes + 1<<20 // sniff headroom; over this reject before reading it all
+)
+
+// sniffMime runs http.DetectContentType on the first 512 bytes (and any
+// remainder of the head) of r, returning the sniffed mime and a reader that
+// replays the full stream. Mime is NEVER taken from the client.
+func sniffMime(r io.Reader) (string, io.Reader, error) {
+ head := make([]byte, 512)
+ n, err := io.ReadFull(r, head)
+ if err != nil && err != io.ErrUnexpectedEOF && err != io.EOF {
+ return "", nil, err
+ }
+ head = head[:n]
+ mime := http.DetectContentType(head)
+ return mime, io.MultiReader(bytes.NewReader(head), r), nil
+}
+
+// sanitizeMimeForServing maps the stored (sniffed) mime to the Content-Type
+// used on /f/. Active-content types (html, svg, xml...) are forced to
+// text/plain — same rule as the /raw #34 fix — so a malicious upload can
+// never execute on this origin.
+func serveContentType(mime string) string {
+ base := mime
+ if i := strings.IndexByte(mime, ';'); i >= 0 {
+ base = strings.TrimSpace(mime[:i])
+ }
+ base = strings.ToLower(base)
+ switch base {
+ case "text/html", "image/svg+xml", "application/xhtml+xml", "text/xml",
+ "application/xml", "application/xhtml", "image/xml+svg":
+ return "text/plain; charset=utf-8"
+ }
+ return mime
+}
+
+// inlineable reports whether the sniffed mime is safe to render inline
+// (Content-Disposition: inline); everything else downloads as an attachment.
+func inlineable(mime string) bool {
+ base := mime
+ if i := strings.IndexByte(mime, ';'); i >= 0 {
+ base = strings.TrimSpace(mime[:i])
+ }
+ base = strings.ToLower(base)
+ switch {
+ case strings.HasPrefix(base, "image/"), base == "application/pdf":
+ return true
+ }
+ return false
+}
+
+// mime/multipart parts are fully read during parsing (the multipart reader
+// closes each part when advancing) and the mime is sniffed from bytes.
+
+// limitAttachment rejects reads past the 25 MB per-file cap server-side.
+type limitReader struct {
+ r io.Reader
+ n int64
+ max int64
+}
+
+func (l *limitReader) Read(p []byte) (int, error) {
+ if l.n > l.max {
+ return 0, store.ErrFileTooLarge
+ }
+ n, err := l.r.Read(p)
+ l.n += int64(n)
+ if l.n > l.max && err == nil {
+ err = store.ErrFileTooLarge
+ }
+ return n, err
+}
+
+// isImageMime reports whether the sniffed mime is a raster image the viewer
+// can render inline (#221). SVG is excluded: it is forced to text/plain on
+// serving by the active-content rule and must never render as an image.
+func isImageMime(mime string) bool {
+ switch mime {
+ case "image/png", "image/jpeg", "image/gif", "image/webp":
+ return true
+ }
+ return false
+}
+
+// handleCreatePasteMultipart implements POST /api/pastes with
+// multipart/form-data (#38). Fields mirror the JSON create path; a 'file'
+// part makes the paste a file paste (1 file = 1 paste: if text content is
+// also present, the file wins and the text is ignored — simplest correct
+// behavior, documented in the PR).
+func (a *apiServer) handleCreatePasteMultipart(w http.ResponseWriter, r *http.Request, s Settings) {
+ blobs := a.store.Blobs()
+ if blobs == nil {
+ writeErr(w, 500, "blob storage unavailable")
+ return
+ }
+ // guard the raw body: 25 MB file + multipart overhead headroom
+ mr, err := r.MultipartReader()
+ if err != nil {
+ writeErr(w, 400, "invalid multipart body")
+ return
+ }
+ var (
+ p store.Paste
+ fileSeen bool
+ att store.Attachment
+ fileBody io.Reader
+ )
+ for {
+ part, err := mr.NextPart()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ if isBodyTooLarge(err) {
+ writeErrCode(w, http.StatusRequestEntityTooLarge, "content_too_large", "request body too large")
+ return
+ }
+ writeErr(w, 400, "invalid multipart body")
+ return
+ }
+ name := part.FormName()
+ if name == "file" {
+ if fileSeen {
+ writeErrCode(w, 400, "one_file_only", "Choose either text or a file for now. Only one file per paste.")
+ part.Close()
+ return
+ }
+ // The part must be fully read during parsing: the multipart
+ // reader closes it as soon as the next part is fetched. Read it
+ // here into memory (bounded by the 25 MB cap) and sniff the mime
+ // from the content, never from client headers.
+ limited := io.LimitReader(part, MaxAttachmentBytes+1)
+ raw, err := io.ReadAll(limited)
+ part.Close()
+ if err != nil {
+ writeErr(w, 400, "invalid file part")
+ return
+ }
+ if int64(len(raw)) > MaxAttachmentBytes {
+ writeErrCode(w, http.StatusRequestEntityTooLarge, "file_too_large",
+ "File is too large. The limit is 25 MB.")
+ return
+ }
+ if len(raw) == 0 {
+ writeErrCode(w, 400, "content_empty", "The file is empty.")
+ return
+ }
+ filename := store.SanitizeFilename(part.FileName())
+ mime := http.DetectContentType(raw[:min(512, len(raw))])
+ att = store.Attachment{PasteID: "pending", Filename: filename, Mime: mime}
+ fileBody = bytes.NewReader(raw)
+ fileSeen = true
+ continue
+ }
+ val, err := io.ReadAll(io.LimitReader(part, 1<<16))
+ part.Close()
+ if err != nil {
+ writeErr(w, 400, "invalid multipart field")
+ return
+ }
+ v := string(val)
+ switch name {
+ case "content":
+ p.Content = v
+ case "title":
+ p.Title = &v
+ case "language":
+ p.Language = &v
+ case "custom_slug":
+ p.CustomSlug = &v
+ case "password":
+ p.Password = &v
+ case "expires_in":
+ p.ExpiresIn = &v
+ case "visibility":
+ p.Visibility = v
+ case "burn_after_read":
+ p.BurnAfterRead = v == "true" || v == "1" || v == "on"
+ case "burn_after_reads":
+ if n, err := strconv.Atoi(v); err == nil {
+ p.BurnAfterReads = &n
+ }
+ }
+ }
+
+ if fileSeen {
+ // 1 file = 1 paste: the file replaces text content.
+ p.Content = ""
+ } else if status, msg := checkContent(p.Content, s.MaxContentBytes); status != 0 {
+ if status == http.StatusRequestEntityTooLarge {
+ writeErrCode(w, status, "content_too_large", msg)
+ } else {
+ writeErrCode(w, status, "content_empty", msg)
+ }
+ return
+ }
+ // #86 metadata bounds + default expiry: same rules as the JSON path
+ if p.Title != nil {
+ t, err := checkTitle(*p.Title)
+ if err != nil {
+ writeErr(w, 400, err.Error())
+ return
+ }
+ p.Title = &t
+ }
+ if p.Language != nil {
+ l, err := checkLanguage(*p.Language)
+ if err != nil {
+ writeErr(w, 400, err.Error())
+ return
+ }
+ if l == "" {
+ p.Language = nil
+ } else {
+ p.Language = &l
+ }
+ }
+ if p.BurnAfterReads != nil {
+ if err := parseBurnAfterReads(*p.BurnAfterReads); err != nil {
+ writeErr(w, 400, err.Error())
+ return
+ }
+ }
+ if (p.ExpiresIn == nil || *p.ExpiresIn == "") && s.DefaultExpiry != "" {
+ def := s.DefaultExpiry
+ p.ExpiresIn = &def
+ }
+ p.ViewerID = currentViewerID(r)
+
+ created, err := a.store.CreatePaste(&p)
+ if err != nil {
+ writeErrCode(w, 400, createErrCode(err), err.Error())
+ return
+ }
+
+ setDeletionTokenCookie(w, created.ID, created.DeletionToken) // #143
+ resp := map[string]any{
+ "id": created.ID,
+ "deletion_token": created.DeletionToken,
+ "url": "/" + created.ID,
+ "raw_url": "/raw/" + created.ID,
+ "api_url": "/api/pastes/" + created.ID,
+ "expires_at": created.ExpiresAt,
+ "created_at": created.CreatedAt,
+ "rate_limit": map[string]int{"create_per_sec": 1, "burst": 5},
+ }
+
+ if fileSeen {
+ att.PasteID = created.ID
+ // size pre-check happens inside the limited read; re-run with limit
+ // enforced so oversized uploads fail before the blob is stored.
+ err := a.store.CreateAttachment(&att, fileBody, blobs)
+ if err == store.ErrFileTooLarge {
+ a.store.SoftDelete(created.ID)
+ writeErrCode(w, http.StatusRequestEntityTooLarge, "file_too_large",
+ "File is too large. The limit is 25 MB.")
+ return
+ }
+ if err != nil {
+ a.store.SoftDelete(created.ID)
+ writeErr(w, 500, "could not store file")
+ return
+ }
+ resp["attachment"] = map[string]any{
+ "id": att.ID,
+ "filename": att.Filename,
+ "mime": att.Mime,
+ "size": att.Size,
+ "sha256": att.SHA256,
+ "url": "/f/" + att.ID + "/" + att.Filename,
+ }
+ }
+ writeJSON(w, 201, resp)
+}
+
+// handleServeAttachment serves GET /f/{attachment-id}/{filename} with the
+// stored (server-sniffed) mime, nosniff, and a safe Content-Disposition.
+// The filename path segment is decorative; lookups key on the attachment id.
+func (a *apiServer) handleServeAttachment(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "aid")
+ att, err := a.store.GetAttachment(id)
+ if err != nil {
+ writeErr(w, 500, "db error")
+ return
+ }
+ if att == nil {
+ writeErr(w, 404, "attachment not found")
+ return
+ }
+ // attachment inherits the paste's lifecycle: gone if the paste is gone
+ row, err := a.store.GetPaste(att.PasteID)
+ if err != nil || row == nil {
+ writeErr(w, 404, "attachment not found")
+ return
+ }
+ if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() {
+ writeErr(w, 404, "attachment not found")
+ return
+ }
+ if row.Burned() {
+ writeErr(w, 404, "attachment not found")
+ return
+ }
+
+ blobs := a.store.Blobs()
+ if blobs == nil {
+ writeErr(w, 500, "blob storage unavailable")
+ return
+ }
+ blob, err := blobs.Get(att.PasteID + "/" + att.SHA256)
+ if err != nil {
+ writeErr(w, 404, "attachment not found")
+ return
+ }
+ defer blob.Close()
+
+ ct := serveContentType(att.Mime)
+ w.Header().Set("Content-Type", ct)
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ disposition := "attachment"
+ if inlineable(att.Mime) {
+ disposition = "inline"
+ }
+ w.Header().Set("Content-Disposition",
+ fmt.Sprintf(`%s; filename="%s"`, disposition, asciiFilename(att.Filename)))
+ w.Header().Set("Content-Length", fmt.Sprintf("%d", att.Size))
+ http.ServeContent(w, r, "", time.Unix(att.CreatedAt, 0), blob)
+}
+
+// asciiFilename quotes a filename for the Content-Disposition header,
+// escaping quotes and backslashes and dropping non-ASCII bytes.
+func asciiFilename(name string) string {
+ var b strings.Builder
+ for _, r := range name {
+ if r < 128 && r != '"' && r != '\\' && r > 31 {
+ b.WriteRune(r)
+ }
+ }
+ if b.Len() == 0 {
+ return "file"
+ }
+ return b.String()
+}
diff --git a/internal/api/attachments_test.go b/internal/api/attachments_test.go
new file mode 100644
index 0000000..c9840cc
--- /dev/null
+++ b/internal/api/attachments_test.go
@@ -0,0 +1,308 @@
+package api
+
+import (
+ "bytes"
+ "encoding/json"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "palette/internal/store"
+)
+
+// multipartCreate posts a multipart create to the routes handler; extra
+// fields are appended as text parts. Returns recorder and parsed response.
+func multipartCreate(t *testing.T, h http.Handler, filename string, content []byte, fields map[string]string) (*httptest.ResponseRecorder, map[string]any) {
+ t.Helper()
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ if filename != "" {
+ fw, _ := mw.CreateFormFile("file", filename)
+ fw.Write(content)
+ }
+ for k, v := range fields {
+ mw.WriteField(k, v)
+ }
+ mw.Close()
+ req := httptest.NewRequest("POST", "/api/pastes", &buf)
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ var resp map[string]any
+ json.Unmarshal(rec.Body.Bytes(), &resp)
+ return rec, resp
+}
+
+func TestMultipartAttachmentCreateAndServe(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+
+ png := append([]byte("\x89PNG\r\n\x1a\n"), bytes.Repeat([]byte{0, 1, 2, 3}, 32)...)
+ rec, resp := multipartCreate(t, h, "shot.png", png, map[string]string{"title": "with file"})
+ if rec.Code != 201 {
+ t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
+ }
+ att, _ := resp["attachment"].(map[string]any)
+ if att == nil {
+ t.Fatalf("no attachment in response: %v", resp)
+ }
+ id, _ := att["id"].(string)
+ url, _ := att["url"].(string)
+ if url != "/f/"+id+"/shot.png" {
+ t.Fatalf("attachment url = %q", url)
+ }
+ if att["mime"] != "image/png" {
+ t.Fatalf("sniffed mime = %v want image/png", att["mime"])
+ }
+
+ // serve: image mime -> inline, nosniff, stored bytes
+ req := httptest.NewRequest("GET", url, nil)
+ rec2 := httptest.NewRecorder()
+ h.ServeHTTP(rec2, req)
+ if rec2.Code != 200 {
+ t.Fatalf("serve: %d %s", rec2.Code, rec2.Body.String())
+ }
+ if got := rec2.Header().Get("Content-Type"); got != "image/png" {
+ t.Fatalf("Content-Type = %q", got)
+ }
+ if got := rec2.Header().Get("X-Content-Type-Options"); got != "nosniff" {
+ t.Fatalf("nosniff = %q", got)
+ }
+ if got := rec2.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "inline") {
+ t.Fatalf("Content-Disposition = %q", got)
+ }
+ if !bytes.Equal(rec2.Body.Bytes(), png) {
+ t.Fatal("served bytes differ from upload")
+ }
+}
+
+func TestMultipartFileReplacesText(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+ rec, resp := multipartCreate(t, h, "notes.txt", []byte("file body"), map[string]string{"content": "some text"})
+ if rec.Code != 201 {
+ t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
+ }
+ id, _ := resp["id"].(string)
+ req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
+ rec2 := httptest.NewRecorder()
+ h.ServeHTTP(rec2, req)
+ var got map[string]any
+ json.Unmarshal(rec2.Body.Bytes(), &got)
+ if got["content"] != "" {
+ t.Fatalf("content should be empty when file provided, got %v", got["content"])
+ }
+}
+
+func TestMultipartSecondFileRejected(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ for _, name := range []string{"a.txt", "b.txt"} {
+ fw, _ := mw.CreateFormFile("file", name)
+ fw.Write([]byte("x"))
+ }
+ mw.Close()
+ req := httptest.NewRequest("POST", "/api/pastes", &buf)
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != 400 {
+ t.Fatalf("two files: got %d want 400", rec.Code)
+ }
+ if !strings.Contains(rec.Body.String(), "one_file_only") {
+ t.Fatalf("error code missing: %s", rec.Body.String())
+ }
+}
+
+func TestMultipartHtmlUploadServesAsPlainText(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+ html := []byte("")
+ rec, resp := multipartCreate(t, h, "page.html", html, nil)
+ if rec.Code != 201 {
+ t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
+ }
+ url, _ := resp["attachment"].(map[string]any)["url"].(string)
+ req := httptest.NewRequest("GET", url, nil)
+ rec2 := httptest.NewRecorder()
+ h.ServeHTTP(rec2, req)
+ if got := rec2.Header().Get("Content-Type"); got != "text/plain; charset=utf-8" {
+ t.Fatalf("html served as %q, want text/plain", got)
+ }
+ if got := rec2.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "attachment") {
+ t.Fatalf("html Content-Disposition = %q, want attachment", got)
+ }
+}
+
+func TestMultipartSvgUploadServesAsPlainText(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+ svg := []byte(``)
+ rec, resp := multipartCreate(t, h, "evil.svg", svg, nil)
+ if rec.Code != 201 {
+ t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
+ }
+ url, _ := resp["attachment"].(map[string]any)["url"].(string)
+ req := httptest.NewRequest("GET", url, nil)
+ rec2 := httptest.NewRecorder()
+ h.ServeHTTP(rec2, req)
+ ct := rec2.Header().Get("Content-Type")
+ if strings.Contains(ct, "svg") || strings.Contains(ct, "html") {
+ t.Fatalf("svg served as %q", ct)
+ }
+ if ct != "text/plain; charset=utf-8" {
+ t.Fatalf("svg Content-Type = %q", ct)
+ }
+}
+
+func TestMultipartClientMimeIgnored(t *testing.T) {
+ // client claims image/png; server must sniff the real type (text)
+ s := testServer(t)
+ h := s.routes()
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ fw, _ := mw.CreateFormFile("file", "fake.png")
+ fw.Write([]byte("just plain text, definitely not a png"))
+ // note: CreateFormFile sets Content-Type: application/octet-stream; the
+ // sniffed type for text content is text/plain either way.
+ mw.Close()
+ req := httptest.NewRequest("POST", "/api/pastes", &buf)
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != 201 {
+ t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
+ }
+ var resp map[string]any
+ json.Unmarshal(rec.Body.Bytes(), &resp)
+ att := resp["attachment"].(map[string]any)
+ if att["mime"] != "text/plain; charset=utf-8" && att["mime"] != "text/plain" {
+ t.Fatalf("mime = %v, want sniffed text/plain", att["mime"])
+ }
+}
+
+func TestMultipartOversizeRejected(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+ big := bytes.Repeat([]byte("A"), MaxAttachmentBytes+1024)
+ rec, _ := multipartCreate(t, h, "big.bin", big, nil)
+ if rec.Code != http.StatusRequestEntityTooLarge {
+ t.Fatalf("oversize: got %d want 413", rec.Code)
+ }
+}
+
+func TestMultipartExactlyAtLimitAccepted(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+ exact := bytes.Repeat([]byte("A"), MaxAttachmentBytes)
+ rec, resp := multipartCreate(t, h, "exact.bin", exact, nil)
+ if rec.Code != 201 {
+ t.Fatalf("at-limit: got %d %s", rec.Code, rec.Body.String())
+ }
+ att := resp["attachment"].(map[string]any)
+ if att["size"].(float64) != float64(MaxAttachmentBytes) {
+ t.Fatalf("size = %v", att["size"])
+ }
+}
+
+func TestMultipartEmptyFileRejected(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+ rec, _ := multipartCreate(t, h, "empty.txt", nil, nil)
+ if rec.Code != 400 {
+ t.Fatalf("empty file: got %d want 400", rec.Code)
+ }
+}
+
+func TestServeAttachment404Missing(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+ req := httptest.NewRequest("GET", "/f/zzzzzzzz/nonexistent.txt", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != 404 {
+ t.Fatalf("missing attachment: got %d want 404", rec.Code)
+ }
+}
+
+func TestServeAttachmentUnknownPaste404(t *testing.T) {
+ // attachment row referencing a paste that doesn't exist must 404, not leak
+ s := testServer(t)
+ h := s.routes()
+ s.store.CreatePaste(&store.Paste{Content: "x"})
+ att := store.Attachment{PasteID: "ghost00", Filename: "f.txt", Mime: "text/plain"}
+ blobs := s.store.Blobs()
+ if err := s.store.CreateAttachment(&att, strings.NewReader("hello"), blobs); err != nil {
+ t.Fatal(err)
+ }
+ req := httptest.NewRequest("GET", "/f/"+att.ID+"/f.txt", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != 404 {
+ t.Fatalf("orphan attachment: got %d want 404", rec.Code)
+ }
+}
+
+func TestServeAttachmentPdfInline(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+ pdf := []byte("%PDF-1.4\n%fake pdf body\n")
+ rec, resp := multipartCreate(t, h, "doc.pdf", pdf, nil)
+ if rec.Code != 201 {
+ t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
+ }
+ url, _ := resp["attachment"].(map[string]any)["url"].(string)
+ req := httptest.NewRequest("GET", url, nil)
+ rec2 := httptest.NewRecorder()
+ h.ServeHTTP(rec2, req)
+ if got := rec2.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/pdf") {
+ t.Fatalf("pdf Content-Type = %q", got)
+ }
+ if got := rec2.Header().Get("Content-Disposition"); !strings.HasPrefix(got, "inline") {
+ t.Fatalf("pdf Content-Disposition = %q", got)
+ }
+}
+
+func TestServeAttachmentBurnedPaste404(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+ rec, resp := multipartCreate(t, h, "burn.txt", []byte("burn me"), nil)
+ if rec.Code != 201 {
+ t.Fatalf("create: %d", rec.Code)
+ }
+ att := resp["attachment"].(map[string]any)
+ url, _ := att["url"].(string)
+ pid, _ := resp["id"].(string)
+ // burn the paste via API read (burn_after_read default off here, so force)
+ s.store.SoftDelete(pid)
+ req := httptest.NewRequest("GET", url, nil)
+ rec2 := httptest.NewRecorder()
+ h.ServeHTTP(rec2, req)
+ if rec2.Code != 404 {
+ t.Fatalf("deleted paste attachment: got %d want 404", rec2.Code)
+ }
+}
+
+func TestMultipartPasswordFieldAccepted(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+ rec, resp := multipartCreate(t, h, "secret.txt", []byte("top secret"),
+ map[string]string{"password": "hunter2", "expires_in": "1h"})
+ if rec.Code != 201 {
+ t.Fatalf("create: %d %s", rec.Code, rec.Body.String())
+ }
+ if resp["attachment"] == nil {
+ t.Fatal("attachment missing")
+ }
+ id, _ := resp["id"].(string)
+ req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
+ rec2 := httptest.NewRecorder()
+ h.ServeHTTP(rec2, req)
+ if rec2.Code != 401 {
+ t.Fatalf("paste should require password, got %d", rec2.Code)
+ }
+}
diff --git a/internal/api/burn.go b/internal/api/burn.go
index a425ac2..2e108a6 100644
--- a/internal/api/burn.go
+++ b/internal/api/burn.go
@@ -20,10 +20,12 @@ func (a *apiServer) burnViewerWindow() int {
}
// handleRedeemDeletion lets a holder of the deletion token hard-delete immediately.
-// DELETE /api/pastes/{id}/redeem?token=...
+// DELETE /api/pastes/{id}/redeem with the token in the Authorization header
+// (#143: the ?token= query path was removed so the secret stays out of
+// access logs and browser history).
func (a *apiServer) handleRedeemDeletion(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
- token := r.URL.Query().Get("token")
+ token := deletionAuthorization(r)
if token == "" {
writeErr(w, 400, "token required")
return
diff --git a/internal/api/burn_test.go b/internal/api/burn_test.go
index cdaa2ef..e67c309 100644
--- a/internal/api/burn_test.go
+++ b/internal/api/burn_test.go
@@ -53,8 +53,9 @@ func TestDeletionTokenRedeem(t *testing.T) {
t.Fatal("no deletion token in create response")
}
- // wrong token
- req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID+"/redeem?token=wrong", nil)
+ // wrong token (#143: token goes in the Authorization header, not the URL)
+ req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID+"/redeem", nil)
+ req.Header.Set("Authorization", "Bearer wrong")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 403 {
@@ -62,7 +63,8 @@ func TestDeletionTokenRedeem(t *testing.T) {
}
// right token: hard delete
- req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID+"/redeem?token="+created.DeletionToken, nil)
+ req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID+"/redeem", nil)
+ req.Header.Set("Authorization", "Bearer "+created.DeletionToken)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
diff --git a/internal/api/cans.go b/internal/api/cans.go
index 9107bc3..0583dfd 100644
--- a/internal/api/cans.go
+++ b/internal/api/cans.go
@@ -206,9 +206,6 @@ func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) {
}
if can.PasswordHash.Valid {
pw := r.Header.Get("X-Paste-Password")
- if pw == "" {
- pw = r.URL.Query().Get("password")
- }
if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) {
writeErr(w, 401, "password required")
return
@@ -256,9 +253,6 @@ func (a *apiServer) handleCanItem(w http.ResponseWriter, r *http.Request) {
can, _ := a.store.GetCan(row.CanID.String)
if can != nil && can.PasswordHash.Valid {
pw := r.Header.Get("X-Paste-Password")
- if pw == "" {
- pw = r.URL.Query().Get("password")
- }
if pw == "" || !store.CheckPassword(can.PasswordHash.String, pw) {
// fall back to the browser's unlock cookie for this can
c, cerr := r.Cookie("pw_" + can.ID)
diff --git a/internal/api/cans_flow_test.go b/internal/api/cans_flow_test.go
index 308587d..44c3c8f 100644
--- a/internal/api/cans_flow_test.go
+++ b/internal/api/cans_flow_test.go
@@ -294,7 +294,8 @@ func TestCanItemCookieParity(t *testing.T) {
}
// item id from API (with password query)
- req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"?password=pw123", nil)
+ req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
+ req.Header.Set("X-Paste-Password", "pw123")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
var can struct {
diff --git a/internal/api/cans_test.go b/internal/api/cans_test.go
index e6ac0ec..4723f4e 100644
--- a/internal/api/cans_test.go
+++ b/internal/api/cans_test.go
@@ -112,7 +112,8 @@ func TestCanPasswordInheritedByItems(t *testing.T) {
}
// get item id with pw
- req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"?password=pw123", nil)
+ req = httptest.NewRequest("GET", "/api/cans/"+created.ID, nil)
+ req.Header.Set("X-Paste-Password", "pw123")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
var can struct {
@@ -130,7 +131,8 @@ func TestCanPasswordInheritedByItems(t *testing.T) {
}
// item with pw -> 200
- req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID+"?password=pw123", nil)
+ req = httptest.NewRequest("GET", "/api/cans/"+created.ID+"/items/"+itemID, nil)
+ req.Header.Set("X-Paste-Password", "pw123")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
diff --git a/internal/api/cookie_set_test.go b/internal/api/cookie_set_test.go
new file mode 100644
index 0000000..9da605e
--- /dev/null
+++ b/internal/api/cookie_set_test.go
@@ -0,0 +1,105 @@
+package api
+
+// #143: create responses must set the short-lived tok_ HttpOnly cookie
+// that the paste view reads for the one-time created banner.
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestCreateSetsDeletionTokenCookie(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+
+ // JSON create
+ body := `{"content":"hello #143 cookie"}`
+ req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != 201 {
+ t.Fatalf("json create: got %d", rec.Code)
+ }
+ found := false
+ for _, c := range rec.Result().Cookies() {
+ if strings.HasPrefix(c.Name, "tok_") && c.Value != "" {
+ found = true
+ if !c.HttpOnly {
+ t.Error("tok_ cookie not HttpOnly")
+ }
+ if c.MaxAge != 60 {
+ t.Errorf("tok_ cookie MaxAge = %d, want 60", c.MaxAge)
+ }
+ }
+ }
+ if !found {
+ t.Error("json create did not set tok_ cookie (#143)")
+ }
+
+ // multipart create
+ var buf strings.Builder
+ boundary := "----qa143"
+ buf.WriteString("--" + boundary + "\r\n")
+ buf.WriteString("Content-Disposition: form-data; name=\"content\"\r\n\r\n")
+ buf.WriteString("multipart #143\r\n")
+ buf.WriteString("--" + boundary + "--\r\n")
+ req2 := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(buf.String()))
+ req2.Header.Set("Content-Type", "multipart/form-data; boundary="+boundary)
+ rec2 := httptest.NewRecorder()
+ h.ServeHTTP(rec2, req2)
+ if rec2.Code != 201 {
+ t.Fatalf("multipart create: got %d body=%s", rec2.Code, rec2.Body.String())
+ }
+ found = false
+ for _, c := range rec2.Result().Cookies() {
+ if strings.HasPrefix(c.Name, "tok_") && c.Value != "" {
+ found = true
+ }
+ }
+ if !found {
+ t.Error("multipart create did not set tok_ cookie (#143)")
+ }
+}
+
+func TestCreatedBannerViaCookie(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+ body := `{"content":"banner flow #143"}`
+ req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(body))
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != 201 {
+ t.Fatalf("create: got %d", rec.Code)
+ }
+ var id, tok string
+ for _, c := range rec.Result().Cookies() {
+ if strings.HasPrefix(c.Name, "tok_") {
+ id = strings.TrimPrefix(c.Name, "tok_")
+ tok = c.Value
+ }
+ }
+ if id == "" || tok == "" {
+ t.Fatal("no tok_ cookie from create")
+ }
+ // follow the redirect the browser would make: GET /?created=1 with the cookie
+ req2 := httptest.NewRequest("GET", "/"+id+"?created=1", nil)
+ req2.AddCookie(&http.Cookie{Name: "tok_" + id, Value: tok})
+ rec2 := httptest.NewRecorder()
+ h.ServeHTTP(rec2, req2)
+ if rec2.Code != 200 {
+ t.Fatalf("paste view: got %d", rec2.Code)
+ }
+ // #168: the created pill no longer prints the deletion token; the token
+ // still arrives via the one-time cookie flow (re-set on the view response).
+ var got bool
+ for _, c := range rec2.Result().Cookies() {
+ if c.Name == "tok_"+id && c.Value == tok {
+ got = true
+ }
+ }
+ if !got {
+ t.Error("created view did not re-set the deletion token cookie (#143 cookie flow broken)")
+ }
+}
diff --git a/internal/api/delete_auth_test.go b/internal/api/delete_auth_test.go
index 9581b1d..a2cddde 100644
--- a/internal/api/delete_auth_test.go
+++ b/internal/api/delete_auth_test.go
@@ -1,9 +1,10 @@
package api
-// Regression tests for #63: DELETE /api/pastes/{id} must require the
-// deletion token (Authorization header or ?token= query param, constant-time
-// compare). Without a token, or with a wrong token, the paste must survive
-// and the response must be 403.
+// Regression tests for #63 and #143: DELETE /api/pastes/{id} must require the
+// deletion token in the Authorization header (constant-time compare). The
+// ?token= query parameter is NOT accepted (#143): URL-carried tokens leak
+// into access logs and browser history. Without a token, or with a wrong
+// token, the paste must survive and the response must be 403.
import (
"encoding/json"
@@ -68,10 +69,14 @@ func TestDeleteWithWrongTokenForbidden(t *testing.T) {
h := s.routes()
id, _ := createTestPaste(t, h)
- // query param
- rec := doReq(t, h, "DELETE", "/api/pastes/"+id+"?token=wrong-token", "", "")
+ // query param: even the CORRECT token must be rejected now (#143)
+ id2, tok2 := createTestPaste(t, h)
+ rec := doReq(t, h, "DELETE", "/api/pastes/"+id2+"?token="+tok2, "", "")
if rec.Code != http.StatusForbidden {
- t.Fatalf("delete with wrong token (query): got %d want 403", rec.Code)
+ t.Fatalf("delete with correct token in query: got %d want 403 (#143)", rec.Code)
+ }
+ if !pasteExists(t, h, id2) {
+ t.Fatal("paste was deleted via ?token= query param (#143 regression)")
}
// header
req := httptest.NewRequest("DELETE", "/api/pastes/"+id, nil)
@@ -103,14 +108,43 @@ func TestDeleteWithCorrectToken(t *testing.T) {
t.Fatal("paste still exists after authorized delete")
}
- // via query param
+ // query param: even with the correct token the delete must fail (#143)
id, tok = createTestPaste(t, h)
rec = doReq(t, h, "DELETE", "/api/pastes/"+id+"?token="+tok, "", "")
+ if rec.Code != http.StatusForbidden {
+ t.Fatalf("delete with correct token (query): got %d want 403 (#143)", rec.Code)
+ }
+ if !pasteExists(t, h, id) {
+ t.Fatal("paste was deleted via ?token= query param (#143 regression)")
+ }
+}
+
+// #143: the deletion token must be accepted via the Authorization header on
+// the redeem (hard delete) endpoint too.
+func TestRedeemWithCorrectTokenHeader(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+ id, tok := createTestPaste(t, h)
+
+ req := httptest.NewRequest("DELETE", "/api/pastes/"+id+"/redeem", nil)
+ req.Header.Set("Authorization", "Bearer "+tok)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
if rec.Code != 200 {
- t.Fatalf("delete with correct token (query): got %d want 200", rec.Code)
+ t.Fatalf("redeem with correct token (header): got %d want 200: %s", rec.Code, rec.Body.String())
}
if pasteExists(t, h, id) {
- t.Fatal("paste still exists after authorized delete (query)")
+ t.Fatal("paste still exists after authorized redeem")
+ }
+
+ // query param must NOT work on redeem either
+ id, tok = createTestPaste(t, h)
+ rec = doReq(t, h, "DELETE", "/api/pastes/"+id+"/redeem?token="+tok, "", "")
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("redeem via ?token= query: got %d want 400 (#143)", rec.Code)
+ }
+ if !pasteExists(t, h, id) {
+ t.Fatal("paste was hard-deleted via ?token= query param (#143 regression)")
}
}
@@ -161,8 +195,8 @@ func TestDeletionAuthorizationExtract(t *testing.T) {
{"bearer tok", "", "tok"},
{"Token tok", "", "tok"},
{"tok", "", "tok"},
- {"", "?token=q", "q"},
- {"Bearer hdr", "?token=q", "hdr"}, // header wins
+ {"", "?token=q", ""}, // #143: query tokens are never accepted
+ {"Bearer hdr", "?token=q", "hdr"}, // header only
}
for _, c := range cases {
if got := deletionAuthorization(mk(c.hdr, c.q)); got != c.want {
diff --git a/internal/api/issue138_cookie_test.go b/internal/api/issue138_cookie_test.go
new file mode 100644
index 0000000..d5e8ca2
--- /dev/null
+++ b/internal/api/issue138_cookie_test.go
@@ -0,0 +1,67 @@
+package api
+
+// #138: the vwr viewer cookie must carry the Secure attribute. Tests inspect
+// the Set-Cookie header directly rather than relying on cookie round-tripping,
+// because Go's HTTP client (and browsers) drop Secure cookies over plain HTTP,
+// which is how tests and local dev run.
+
+import (
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "palette/internal/store"
+ "palette/internal/web"
+)
+
+func newTestServer138(t *testing.T) *httptest.ResponseRecorder {
+ t.Helper()
+ st, err := store.OpenStore(":memory:")
+ if err != nil {
+ t.Fatal(err)
+ }
+ ui, err := web.New()
+ if err != nil {
+ t.Fatal(err)
+ }
+ cfg := Config{MaxTextBytes: 5 * 1024 * 1024}
+ ss := NewTestSettingsStore(t, cfg)
+ globalSettingsFn = ss.get
+ t.Cleanup(func() { globalSettingsFn = nil })
+ a := &apiServer{store: st, cfg: cfg, ui: ui, settings: ss, adminKey: "test-admin-key"}
+ req := httptest.NewRequest("GET", "/history", nil)
+ rec := httptest.NewRecorder()
+ a.routes().ServeHTTP(rec, req)
+ return rec
+}
+
+func TestViewerCookieAttributes(t *testing.T) {
+ rec := newTestServer138(t)
+ var vwr *string
+ for _, c := range rec.Result().Cookies() {
+ if c.Name == "vwr" {
+ cc := c
+ vwr = &cc.Value
+ if !c.Secure {
+ t.Error("vwr cookie missing Secure attribute (#138)")
+ }
+ if !c.HttpOnly {
+ t.Error("vwr cookie missing HttpOnly attribute")
+ }
+ if c.Path != "/" {
+ t.Errorf("vwr cookie Path = %q, want /", c.Path)
+ }
+ if c.SameSite != 2 { // http.SameSiteLaxMode
+ t.Errorf("vwr cookie SameSite = %v, want Lax", c.SameSite)
+ }
+ }
+ }
+ if vwr == nil {
+ t.Fatal("no vwr cookie set")
+ }
+ // also confirm the raw header form spells out Secure
+ sc := rec.Header().Get("Set-Cookie")
+ if !strings.Contains(sc, "Secure") {
+ t.Errorf("Set-Cookie header %q lacks Secure", sc)
+ }
+}
diff --git a/internal/api/issue235_type_test.go b/internal/api/issue235_type_test.go
new file mode 100644
index 0000000..2164fd8
--- /dev/null
+++ b/internal/api/issue235_type_test.go
@@ -0,0 +1,40 @@
+package api
+
+import "testing"
+
+// #235: Type column. Text pastes show the language, attachment pastes show
+// the file extension (lowercase, no dot).
+func TestDisplayType(t *testing.T) {
+ lang := "python"
+ cases := []struct {
+ lang *string
+ att string
+ want string
+ }{
+ {nil, "", "text"},
+ {&lang, "", "python"},
+ {&lang, "report.pdf", "pdf"},
+ {nil, "photo.PNG", "png"},
+ {&lang, "archive.tar.gz", "gz"},
+ {&lang, "noext", "python"}, // no extension: fall back to language
+ {&lang, ".hidden", "python"}, // dotfile: no extension
+ {&lang, "dir/name.txt", "txt"}, // path component only
+ }
+ for _, c := range cases {
+ if got := displayType(c.lang, c.att); got != c.want {
+ t.Errorf("displayType(%v, %q) = %q, want %q", c.lang, c.att, got, c.want)
+ }
+ }
+}
+
+func TestAttachmentExtName(t *testing.T) {
+ cases := map[string]string{
+ "a.txt": "txt", "A.PNG": "png", "noext": "", ".hidden": "",
+ "x.": "", "dir/b.md": "md", "": "",
+ }
+ for in, want := range cases {
+ if got := attachmentExtName(in); got != want {
+ t.Errorf("attachmentExtName(%q) = %q, want %q", in, got, want)
+ }
+ }
+}
diff --git a/internal/api/issue81_password_ratelimit_test.go b/internal/api/issue81_password_ratelimit_test.go
index f33d91f..8ec49bf 100644
--- a/internal/api/issue81_password_ratelimit_test.go
+++ b/internal/api/issue81_password_ratelimit_test.go
@@ -2,7 +2,7 @@ package api
// #81: ALL password verification attempts (GET query param, header, POST
// form) must go through the per-IP unlock limiter. Regression: N wrong
-// passwords via GET ?password= must eventually yield 429.
+// passwords via X-Paste-Password must eventually yield 429.
import (
"encoding/json"
@@ -26,7 +26,7 @@ func createPasswordPaste(t *testing.T, s *apiServer, pw string) string {
}
// TestRateLimitGetPasswordQuery: repeated wrong passwords via GET
-// ?password= must eventually return 429 (unlock limiter: burst 5).
+// X-Paste-Password wrong attempts must eventually return 429 (unlock limiter: burst 5).
func TestRateLimitGetPasswordQuery(t *testing.T) {
s := testServer(t)
h := s.routes()
@@ -35,7 +35,8 @@ func TestRateLimitGetPasswordQuery(t *testing.T) {
var saw429 bool
// more attempts than the unlock burst (5)
for i := 0; i < 10; i++ {
- req := httptest.NewRequest("GET", "/api/pastes/"+id+"?password=wrong"+string(rune('a'+i)), nil)
+ req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
+ req.Header.Set("X-Paste-Password", "wrong"+string(rune('a'+i)))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == 429 {
@@ -47,7 +48,7 @@ func TestRateLimitGetPasswordQuery(t *testing.T) {
}
}
if !saw429 {
- t.Fatal("expected 429 after repeated wrong ?password= attempts, never got one")
+ t.Fatal("expected 429 after repeated wrong password attempts, never got one")
}
}
@@ -83,7 +84,8 @@ func TestRateLimitGetPasswordCorrectStillAllowed(t *testing.T) {
h := s.routes()
id := createPasswordPaste(t, s, "hunter2")
- req := httptest.NewRequest("GET", "/api/pastes/"+id+"?password=hunter2", nil)
+ req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
+ req.Header.Set("X-Paste-Password", "hunter2")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
diff --git a/internal/api/main_test.go b/internal/api/main_test.go
index 55011d2..ab41402 100644
--- a/internal/api/main_test.go
+++ b/internal/api/main_test.go
@@ -89,7 +89,8 @@ func TestPasswordProtection(t *testing.T) {
}
// with password -> 200
- req = httptest.NewRequest("GET", "/api/pastes/"+created.ID+"?password=hunter2", nil)
+ req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil)
+ req.Header.Set("X-Paste-Password", "hunter2")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
@@ -97,7 +98,8 @@ func TestPasswordProtection(t *testing.T) {
}
// wrong password -> 401
- req = httptest.NewRequest("GET", "/api/pastes/"+created.ID+"?password=nope", nil)
+ req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil)
+ req.Header.Set("X-Paste-Password", "nope")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 401 {
@@ -297,3 +299,27 @@ func TestNotFound(t *testing.T) {
t.Fatalf("expected 404, got %d", rec.Code)
}
}
+
+// #173: a missing paste ID on the UI route (/p/{id}, i.e. /{id} HTML view)
+// should render the main UI page with a friendly "Paste ID not found"
+// message, not a bare text 404. Status stays 404.
+func TestPasteViewNotFoundFriendly(t *testing.T) {
+ s := testServer(t)
+ h := s.routes()
+ req := httptest.NewRequest("GET", "/zzzzzz", nil)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusNotFound {
+ t.Fatalf("expected 404 status, got %d", rec.Code)
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "Paste ID not found") {
+ t.Fatalf("expected friendly message in body, got: %.200s", body)
+ }
+ if !strings.Contains(body, "