From 5b9be808ed7e7bb91159ef34a82ce120708ebdb7 Mon Sep 17 00:00:00 2001 From: poslop Date: Wed, 9 Sep 2026 16:36:55 -0500 Subject: [PATCH] Merge origin/main: keep can listing rows with #65 password-metadata leak guard --- internal/api/cans.go | 7 +++---- internal/store/store.go | 18 ++++++++++++++++-- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/internal/api/cans.go b/internal/api/cans.go index 1f330ab..836561c 100644 --- a/internal/api/cans.go +++ b/internal/api/cans.go @@ -1,11 +1,11 @@ package api import ( - "palette/internal/store" "encoding/json" "fmt" "io" "net/http" + "palette/internal/store" "strings" "time" @@ -43,7 +43,8 @@ func (a *apiServer) handleCreateCan(w http.ResponseWriter, r *http.Request) { writeErr(w, 400, "invalid expires_in") return } - // parity with pastes (#48): same expiry window is enforced + // #60/#48: clamp at the API boundary like the pastes API does - + // reject zero/negative and durations past the 1-year UI cap. if !store.ValidExpiry(d) { writeErr(w, 400, "expires_in must be between 1 minute and 1 year") return @@ -188,8 +189,6 @@ func detectContentType(name string, content []byte) string { return "text/plain" } - - func (a *apiServer) handleGetCan(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") can, err := a.store.GetCan(id) diff --git a/internal/store/store.go b/internal/store/store.go index 2cedf48..e836e04 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -30,6 +30,8 @@ type Paste struct { BurnAfterRead bool `json:"burn_after_read,omitempty"` BurnAfterReads *int `json:"burn_after_reads,omitempty"` // #49: readable N times (default 1) Visibility string `json:"visibility"` + // #83: accept "public": true/false as an alias for visibility. + Public *bool `json:"public,omitempty"` CanID *string `json:"can_id,omitempty"` CreatedAt int64 `json:"created_at"` DeletedAt *int64 `json:"deleted_at,omitempty"` @@ -219,6 +221,14 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) { } visibility := p.Visibility + // #83: "public": false -> unlisted, true -> public; overrides string field + if p.Public != nil { + if *p.Public { + visibility = "public" + } else { + visibility = "unlisted" + } + } if visibility == "" { visibility = "public" } @@ -261,10 +271,14 @@ func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) { return &r, err } +// ListPublic backs /api/public and the public listing page. Visibility rules +// mirror the history page: only non-deleted, non-expired, non-can pastes are +// listed, and password-protected pastes are excluded at the query level +// (#65) so their metadata (title, slug, existence) never leaks. func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) { rows, err := s.db.Query(`SELECT id, custom_slug, content_type, language, title, visibility, created_at, view_count, LENGTH(content), 0 FROM pastes - WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?) + WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND password_hash IS NULL AND (expires_at IS NULL OR expires_at > ?) UNION ALL SELECT id, NULL, 'text/plain', NULL, title, visibility, created_at, 0, 0, 1 FROM paste_cans @@ -289,7 +303,7 @@ func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) { out = append(out, r) } var total int - s.db.QueryRow(`SELECT (SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?)) + s.db.QueryRow(`SELECT (SELECT COUNT(*) FROM pastes WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND password_hash IS NULL AND (expires_at IS NULL OR expires_at > ?)) + (SELECT COUNT(*) FROM paste_cans WHERE visibility='public' AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?))`, time.Now().Unix(), time.Now().Unix()).Scan(&total) return out, total, nil