From 03600b2ed55733e01738f8109b0b9379b0f382ce Mon Sep 17 00:00:00 2001 From: poslop Date: Wed, 9 Sep 2026 00:34:24 -0500 Subject: [PATCH] sweep: fix missing view_count on HTML views, API expiry bounds, search ignoring custom slug (#33) --- main.go | 16 +++++++ sweep33_test.go | 114 ++++++++++++++++++++++++++++++++++++++++++++ web.go | 7 +++ web/static/table.js | 3 +- 4 files changed, 139 insertions(+), 1 deletion(-) create mode 100644 sweep33_test.go diff --git a/main.go b/main.go index 74c486a..bceaffa 100644 --- a/main.go +++ b/main.go @@ -167,6 +167,19 @@ func cryptorandRead(b []byte) (int, error) { return cryptoRead(b) } +// validExpiry reports whether an expires_in duration is in the accepted +// window. The UI restricts presets to 1 minute - 1 year (#48); the API must +// enforce the same bounds, otherwise negative/zero/absurd durations create +// pastes that are born expired (or effectively permanent). +const ( + minExpiry = time.Minute + maxExpiry = 366 * 24 * time.Hour // 1 year (+ leap day headroom) +) + +func validExpiry(d time.Duration) bool { + return d >= minExpiry && d <= maxExpiry +} + func (s *Store) CreatePaste(p *Paste) (*Paste, error) { id := genSlug(6) now := time.Now().Unix() @@ -177,6 +190,9 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) { if err != nil { return nil, fmt.Errorf("invalid expires_in: %w", err) } + if !validExpiry(d) { + return nil, fmt.Errorf("expires_in must be between 1 minute and 1 year") + } t := now + int64(d.Seconds()) expiresAt = &t } diff --git a/sweep33_test.go b/sweep33_test.go new file mode 100644 index 0000000..cf29b6c --- /dev/null +++ b/sweep33_test.go @@ -0,0 +1,114 @@ +package main + +import ( + "net/http/httptest" + "strings" + "testing" +) + +// #33 sweep: the create API must enforce the same expiry window as the UI +// (1 minute .. 1 year). Previously -1h, 0s, 1ns and 30000h were all accepted, +// producing pastes that were born expired or effectively permanent. +func TestCreatePasteExpiryBounds(t *testing.T) { + s := testServer(t) + h := s.routes() + + cases := []struct { + expiresIn string + wantCode int + }{ + {"-1h", 400}, + {"-0s", 400}, + {"0s", 400}, + {"1ns", 400}, + {"59s", 400}, + {"1m", 201}, + {"90s", 201}, + {"8760h", 201}, // exactly 1 year + {"8785h", 400}, // 1 year + 1 day: over the max + {"30000h", 400}, // ~3.4 years, over the max + } + globalLimiter = newLimiter() // one fresh bucket for the whole table + for _, c := range cases { + globalLimiter = newLimiter() // avoid create rate limit between cases + req := httptest.NewRequest("POST", "/api/pastes", + strings.NewReader(`{"content":"x","expires_in":"`+c.expiresIn+`"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != c.wantCode { + t.Errorf("expires_in %q: got %d want %d (%s)", + c.expiresIn, rec.Code, c.wantCode, rec.Body.String()) + } + } +} + +// #33 sweep: HTML paste views must increment view_count. The increment was +// missing from handlePasteView, so the counter only moved on /raw. +func TestPasteViewIncrementsViewCount(t *testing.T) { + s := testServer(t) + if webUIInstance == nil { + ui, err := NewWebUI() + if err != nil { + t.Fatal(err) + } + webUIInstance = ui + } + h := s.routes() + + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"vc"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + id := jsonField(t, rec.Body.String(), "id") + + // first render counts (no ?created=1 here: that's the just-created banner case) + req = httptest.NewRequest("GET", "/"+id, nil) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("view: got %d", rec.Code) + } + req = httptest.NewRequest("GET", "/"+id, nil) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + + req = httptest.NewRequest("GET", "/api/pastes/"+id, nil) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("api get: got %d", rec.Code) + } + body := rec.Body.String() + if !strings.Contains(body, `"view_count":2`) { + t.Fatalf("expected view_count 2 after two HTML views, got: %s", body) + } +} + +// #33 sweep: the just-created banner render (?created=1) must NOT count as a +// view for the creator. +func TestJustCreatedViewDoesNotCount(t *testing.T) { + s := testServer(t) + if webUIInstance == nil { + ui, err := NewWebUI() + if err != nil { + t.Fatal(err) + } + webUIInstance = ui + } + h := s.routes() + + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"jc"}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + id := jsonField(t, rec.Body.String(), "id") + + req = httptest.NewRequest("GET", "/"+id+"?created=1&token=t", nil) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + + req = httptest.NewRequest("GET", "/api/pastes/"+id, nil) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if strings.Contains(rec.Body.String(), `"view_count":1`) { + t.Fatalf("just-created render counted as a view: %s", rec.Body.String()) + } +} diff --git a/web.go b/web.go index aefeadb..3689590 100644 --- a/web.go +++ b/web.go @@ -237,6 +237,13 @@ func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) { // one-time display of the deletion token via the created banner http.SetCookie(w, &http.Cookie{Name: "tok_" + row.ID, Value: token, Path: "/", MaxAge: 60, HttpOnly: true, SameSite: http.SameSiteLaxMode}) } + // Count the view for every real page render. Raw views increment in + // handleRaw; the HTML path was missing its increment, so view_count only + // ever moved via /raw and the API-stored count stayed at 0 (#33). + // The just-created banner render does not count as a view. + if !justCreated { + a.store.IncrementViews(row.ID) + } // #49: burn-after-N-reads budget (per-viewer, 15-minute dedupe window). // Just-created first render does not count as a read for the creator. if !justCreated { diff --git a/web/static/table.js b/web/static/table.js index bc3fc1f..971114c 100644 --- a/web/static/table.js +++ b/web/static/table.js @@ -44,7 +44,8 @@ const PaletteTable = (() => { function matches(it) { if (!state.filter) return true; const f = state.filter.toLowerCase(); - return (it.title || '').toLowerCase().includes(f) || (it.id || '').toLowerCase().includes(f); + return (it.title || '').toLowerCase().includes(f) || (it.id || '').toLowerCase().includes(f) || + (it.custom_slug || '').toLowerCase().includes(f); } function renderSortIndicators() {