sweep: fix missing view_count on HTML views, API expiry bounds, search ignoring custom slug (#33)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
+114
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+2
-1
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user