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 | | | |---|---| -| ![Editor in midnight](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmidnight-new.png) | ![Paste view in pastel-peach](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fpastel-peach-paste.png) | -| ![History in pastel-lavender](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fpastel-lavender-history.png) | ![Saved in pastel-cloud](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fpastel-cloud-mine.png) | +| ![Editor in midnight (dark)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-editor-new.png) | ![Paste view in pastel-peach (light)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-paste-pastel-peach-light.png) | +| ![Paste view in midnight (dark)](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-paste-midnight-dark.png) | ![Settings and theme picker](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-settings-themes.png) | +| ![Public pastes list](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fdesktop-public.png) | ![Editor at mobile width](https://git.archfox.org/poslop/palette/wiki/raw/palette-previews%2Fmobile-editor-new.png) | + +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, "
- + + + {{end}} diff --git a/internal/web/templates/mine.html b/internal/web/templates/mine.html index f5def5b..5ef7d9e 100644 --- a/internal/web/templates/mine.html +++ b/internal/web/templates/mine.html @@ -8,18 +8,19 @@
- + - - - - - - + + + + + + +
PasteLanguageSizeCreatedURLIDPasteTypeSizeCreatedURLID
- +
@@ -27,51 +28,5 @@
- + {{template "foot" .}} diff --git a/internal/web/templates/new.html b/internal/web/templates/new.html index 529168b..ef90b5f 100644 --- a/internal/web/templates/new.html +++ b/internal/web/templates/new.html @@ -4,7 +4,10 @@
- +
+ +
+
+ +
1
+
-
Ctrl+Enter to create -
+
@@ -41,337 +46,48 @@ -

Protection

- + - +
-

Custom URL

- -
Stays reserved while the paste exists
+

Attachment

+
+ Drop a file here, press Ctrl+V, or click to browse +
+ +
+
-

Can contents

- - +

Custom URL

+
- - + {{template "foot" .}} diff --git a/internal/web/templates/notfound.html b/internal/web/templates/notfound.html new file mode 100644 index 0000000..eda487f --- /dev/null +++ b/internal/web/templates/notfound.html @@ -0,0 +1,13 @@ +{{template "head" .}} +{{template "topbar" .}} +
+
+
+
+

Paste ID not found

+

The paste /{{.ID}} does not exist, has expired, or was burned.

+ Create a new paste +
+
+
+{{template "foot" .}} diff --git a/internal/web/templates/paste.html b/internal/web/templates/paste.html index cfca561..74456cd 100644 --- a/internal/web/templates/paste.html +++ b/internal/web/templates/paste.html @@ -6,20 +6,22 @@

{{if .Title}}{{.Title}}{{else}}Untitled paste{{end}}

{{if .CustomSlug}}/{{.CustomSlug}}{{end}}
- raw - copy - {{if .DeletionToken}}delete{{end}} + + Raw + Link + Copy + {{if .DeletionToken}}Delete{{end}}
- {{if .JustCreated}} +
Paste Created
+ {{end}} + {{if .Attachment}}
-
- Paste created. Link copied to clipboard: {{.Host}}/{{.ID}} - {{if .DeletionToken}} · deletion token: {{.DeletionToken}}{{end}} +
+ {{if .AttachmentImage}} +
{{.Attachment.Filename}}
+ {{end}} + + {{.Attachment.Filename}} + {{.Attachment.SizeHuman}} +
{{end}} + {{if not .AttachmentImage}}
-
{{.Gutter}}
{{.ContentHTML}}
+
{{.Gutter}}
{{.ContentHTML}}
+ {{end}}
- + + {{template "foot" .}} diff --git a/internal/web/templates/settings.html b/internal/web/templates/settings.html index b4a8629..dff3571 100644 --- a/internal/web/templates/settings.html +++ b/internal/web/templates/settings.html @@ -6,55 +6,18 @@

Settings

+

Theme

+

Editor

+
- + {{template "foot" .}} diff --git a/internal/web/templates/unlock.html b/internal/web/templates/unlock.html index 24182ce..f869088 100644 --- a/internal/web/templates/unlock.html +++ b/internal/web/templates/unlock.html @@ -18,12 +18,5 @@
Created {{.CreatedAgo}}
- + {{template "foot" .}} diff --git a/internal/web/web.go b/internal/web/web.go index 6d164ae..cbd71a7 100644 --- a/internal/web/web.go +++ b/internal/web/web.go @@ -24,6 +24,44 @@ import ( //go:embed templates/*.html var tmplFS embed.FS +// DefaultDark is the server-configured default dark mode state for visitors +// without stored preferences (#127). Set via PALETTE_DEFAULT_DARK. +var DefaultDark = true + +// SetDefaultDark applies the PALETTE_DEFAULT_DARK env var: unset = dark on, +// "false"/"0"/"off" = dark off. +func SetDefaultDark(v string) { + switch strings.ToLower(strings.TrimSpace(v)) { + case "false", "0", "off": + DefaultDark = false + default: + DefaultDark = true + } +} + +// ResolvePreset maps a theme pair id (or explicit variant id) plus dark state +// to a concrete preset id. Returns the defaults when pair is empty or unknown. +// Precedence handled by callers: URL ?theme= > stored pair+dark > defaults. +func ResolvePreset(pair string, dark bool) string { + variants := map[string][2]string{ // pair -> {light, dark} + "midnight": {"midnight-light", "midnight"}, + "smooth": {"smooth", "smooth-dark"}, + "pastel-lavender": {"pastel-lavender", "pastel-lavender-dark"}, + "pastel-peach": {"pastel-peach", "pastel-peach-dark"}, + "pastel-cloud": {"pastel-cloud", "pastel-cloud-dark"}, + } + if v, ok := variants[pair]; ok { + if dark { + return v[1] + } + return v[0] + } + if dark { + return "midnight" + } + return "midnight-light" +} + //go:embed static var staticFS embed.FS @@ -33,8 +71,9 @@ type UI struct { func New() (*UI, error) { funcs := template.FuncMap{ - "humanSize": humanSize, - "version": func() string { return Version }, // #93: topbar version label + "humanSize": humanSize, + "version": func() string { return Version }, // #93: topbar version label + "defaultDark": func() bool { return DefaultDark }, // #127: server-configured default } t, err := template.New("").Funcs(funcs).ParseFS(tmplFS, "templates/*.html") if err != nil { @@ -65,6 +104,13 @@ func (h *Handlers) renderPage(w http.ResponseWriter, name string, data any) { } } +// RenderNotFoundPage is the exported not-found renderer used by the api +// package (#173): the router's NotFound handler renders the main UI with a +// friendly "Paste ID not found" message, still with HTTP 404. +func (h *Handlers) RenderNotFoundPage(w http.ResponseWriter, r *http.Request) { + h.renderNotFound(w, r, r.URL.Path) +} + // RenderPage is the exported wrapper used by the api package (#4 can pages). func (h *Handlers) RenderPage(w http.ResponseWriter, name string, data any) { h.renderPage(w, name, data) @@ -161,6 +207,44 @@ func (h *Handlers) writeRateLimited(w http.ResponseWriter, retryAfterSecs int) { w.Write([]byte(`{"error":"rate limit exceeded"}`)) } +// renderNotFound serves the friendly not-found page (#173): the main UI +// chrome (topbar, centered card) with a "Paste ID not found" message in the +// result card, instead of a bare text 404. Still returns HTTP 404 so +// crawlers/validators see the correct status. +func (h *Handlers) renderNotFound(w http.ResponseWriter, r *http.Request, id string) { + h.renderPageStatus(w, "notfound.html", http.StatusNotFound, map[string]any{"Page": "notfound", "ID": id}) +} + +// HandleNotFoundPage serves the friendly not-found page for unknown routes +// (#173): main UI chrome with a "Paste ID not found" message. Called from the +// chi NotFound handler in the api package. +func (h *Handlers) HandleNotFoundPage(w http.ResponseWriter, r *http.Request) { + h.renderNotFound(w, r, r.URL.Path) +} + +// renderPageStatus renders a template with an explicit HTTP status code. +func (h *Handlers) renderPageStatus(w http.ResponseWriter, name string, status int, data any) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(status) + if err := h.UI.tmpl.ExecuteTemplate(w, name, data); err != nil { + http.Error(w, "template error: "+err.Error(), 500) + } +} + +// attachmentExt returns the lowercase file extension (without dot) of a +// filename, or "" when the name has none. Used by the Type display (#235). +func attachmentExt(filename string) string { + name := filename + if i := strings.LastIndexByte(name, '/'); i >= 0 { + name = name[i+1:] + } + i := strings.LastIndexByte(name, '.') + if i <= 0 || i == len(name)-1 { + return "" + } + return strings.ToLower(name[i+1:]) +} + func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justCreated bool, deletionToken string, readsRemaining *int) { lines := strings.Count(row.Content, "\n") + 1 gutter := "" @@ -175,21 +259,62 @@ func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justC if lang == "" { lang = "text" } - summary := fmt.Sprintf("%s · %s · %d views · %s", lang, humanSize(len(row.Content)), row.ViewCount, agoString(row.CreatedAt)) + // #38: one optional file attachment per paste; nil when none. + attachment, err := h.Store.GetAttachmentForPaste(row.ID) + if err != nil { + http.Error(w, "db error", 500) + return + } + // #221: for attachment pastes the stored text content is empty (the file + // replaced it), so the summary size must come from the attachment blob, + // not len(row.Content), or the summary shows "0 B". + summarySize := len(row.Content) + if attachment != nil { + summarySize = int(attachment.Size) + } + // #235: column renamed to "Type". Text pastes keep the detected + // language; attachment pastes show the file extension instead. + attExt := "" + typeLabel := lang + if attachment != nil { + if ext := attachmentExt(attachment.Filename); ext != "" { + attExt = ext + typeLabel = ext + } + } + summary := fmt.Sprintf("%s · %s · %d views · %s", typeLabel, humanSize(summarySize), row.ViewCount, agoString(row.CreatedAt)) + // #221: image attachments render the image, not a text/code box. Size + // comes from the attachment's actual file size, not the text content. + attImage := false + if attachment != nil { + switch attachment.Mime { + case "image/png", "image/jpeg", "image/gif", "image/webp": + attImage = true + } + } + sizeHuman := humanSize(len(row.Content)) + lineCount := lines + if attachment != nil { + sizeHuman = attachment.SizeHuman + lineCount = 1 + } data := map[string]any{ "Page": "paste", "ID": row.ID, "Title": row.Title.String, - "Language": row.Language.String, + "Language": typeLabel, + "TypeLabel": typeLabel, + "HasAttachment": attachment != nil, + "AttachmentExt": attExt, "StatsSummary": summary, - "SizeHuman": humanSize(len(row.Content)), + "SizeHuman": sizeHuman, "HasPassword": row.PasswordHash.Valid, "BurnAfterRead": row.BurnAfterRead, "CustomSlug": row.CustomSlug.String, "ContentHTML": template.HTML(langpkg.HighlightCode(row.Content, row.Language.String)), // safe: HighlightCode escapes all non-span text "ContentAttr": row.Content, "Gutter": strings.TrimSuffix(gutter, "\n"), - "LineCount": lines, + "LineCount": lineCount, "SizeBytes": len(row.Content), "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt, @@ -202,6 +327,8 @@ func (h *Handlers) renderPaste(w http.ResponseWriter, row *store.PasteRow, justC "ReadsLeftN": readsRemaining, // *int: reads remaining after this view "ReadsTotal": int(row.ReadsLimit.Int64), "JustCreated": justCreated, + "Attachment": attachment, + "AttachmentImage": attImage, "Host": "this host", } h.renderPage(w, "paste.html", data) @@ -216,11 +343,14 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) { return } if row == nil { - http.NotFound(w, r) + // #173: a missing paste ID gets the main UI with a friendly message, + // not a bare text 404 page. + h.renderNotFound(w, r, id) return } if row.ExpiresAt.Valid && row.ExpiresAt.Int64 < time.Now().Unix() { - http.Error(w, "paste expired", 404) + // #173: expired pastes render the same friendly not-found UI. + h.renderNotFound(w, r, id) return } if row.PasswordHash.Valid { @@ -264,9 +394,15 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) { } justCreated := r.URL.Query().Get("created") == "1" - token := r.URL.Query().Get("token") + // #143: the deletion token is no longer round-tripped through the URL + // (?token= leaks into access logs and history). The create flow sets a + // short-lived tok_ cookie; the paste view reads it once from there. + token := "" + if c, err := r.Cookie("tok_" + row.ID); err == nil { + token = c.Value + } if justCreated && token != "" { - // one-time display of the deletion token via the created banner + // one-time display of the deletion token via sessionStorage (#143) http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode}) } // Count the view for real page renders, deduped per-viewer within the @@ -281,8 +417,8 @@ func (h *Handlers) HandlePasteView(w http.ResponseWriter, r *http.Request) { // Just-created first render does not count as a read for the creator. if !justCreated { rem, admitted := h.Store.RegisterRead(row, h.ViewerID(r), h.BurnWindowMin()) - if !admitted { // #58: lost the burn claim; do not render content - http.NotFound(w, r) + if !admitted { // #58: lost the burn claim; #173: friendly not-found UI + h.renderNotFound(w, r, row.ID) return } h.renderPaste(w, row, false, "", rem) @@ -331,8 +467,12 @@ func (u *UI) Handlers() *Handlers { return &Handlers{UI: u} } // #59: security headers for rendered HTML pages. Applied wherever the // response is text/html (page templates and the inline can page); JSON API -// responses and /raw content pass through untouched. script-src allows -// 'unsafe-inline' because the page templates carry inline scripts; CSP +// responses and /raw content pass through untouched. +// #139: script-src and style-src no longer allow 'unsafe-inline'. All +// previously-inline scripts moved to external files under static/ (page data +// reaches them via data-* attributes on the script tags), inline style +// attributes became CSS classes, and JS sets swatch colors via CSSOM. The +// img-src data: allowance stays: SVG data-URI backgrounds in app.css need it. // default-src 'self' still blocks external content and object/frame embeds, // and frame-ancestors 'none' closes the clickjacking gap flagged in the #34 // pentest. Runs after the handler so the Content-Type is already set. @@ -344,7 +484,7 @@ func SecurityHeaders(next http.Handler) http.Handler { // is harmless and arguably desirable. h := w.Header() h.Set("Content-Security-Policy", - "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'") + "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'") h.Set("Referrer-Policy", "no-referrer") h.Set("X-Content-Type-Options", "nosniff") next.ServeHTTP(w, r) diff --git a/internal/web/web_test.go b/internal/web/web_test.go new file mode 100644 index 0000000..56fc339 --- /dev/null +++ b/internal/web/web_test.go @@ -0,0 +1,48 @@ +package web + +import "testing" + +func TestResolvePreset(t *testing.T) { + cases := []struct { + pair string + dark bool + want string + }{ + {"", false, "midnight-light"}, + {"", true, "midnight"}, + {"midnight", false, "midnight-light"}, + {"midnight", true, "midnight"}, + {"smooth", false, "smooth"}, + {"smooth", true, "smooth-dark"}, + {"pastel-lavender", true, "pastel-lavender-dark"}, + {"pastel-peach", false, "pastel-peach"}, + {"pastel-cloud", true, "pastel-cloud-dark"}, + {"bogus", true, "midnight"}, + {"bogus", false, "midnight-light"}, + // explicit variant ids also resolve (URL ?theme= may pass them) + {"smooth-dark", true, "midnight"}, + } + for _, c := range cases { + if got := ResolvePreset(c.pair, c.dark); got != c.want { + t.Errorf("ResolvePreset(%q, %v) = %q, want %q", c.pair, c.dark, got, c.want) + } + } +} + +func TestSetDefaultDark(t *testing.T) { + for _, off := range []string{"false", "0", "off", "OFF", " false "} { + DefaultDark = true + SetDefaultDark(off) + if DefaultDark { + t.Errorf("SetDefaultDark(%q): want dark off", off) + } + } + for _, on := range []string{"true", "1", "on", "anything"} { + DefaultDark = false + SetDefaultDark(on) + if !DefaultDark { + t.Errorf("SetDefaultDark(%q): want dark on", on) + } + } + DefaultDark = true +}