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)
|
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) {
|
func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
|
||||||
id := genSlug(6)
|
id := genSlug(6)
|
||||||
now := time.Now().Unix()
|
now := time.Now().Unix()
|
||||||
@@ -177,6 +190,9 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("invalid expires_in: %w", err)
|
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())
|
t := now + int64(d.Seconds())
|
||||||
expiresAt = &t
|
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
|
// 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})
|
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).
|
// #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.
|
// Just-created first render does not count as a read for the creator.
|
||||||
if !justCreated {
|
if !justCreated {
|
||||||
|
|||||||
+2
-1
@@ -44,7 +44,8 @@ const PaletteTable = (() => {
|
|||||||
function matches(it) {
|
function matches(it) {
|
||||||
if (!state.filter) return true;
|
if (!state.filter) return true;
|
||||||
const f = state.filter.toLowerCase();
|
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() {
|
function renderSortIndicators() {
|
||||||
|
|||||||
Reference in New Issue
Block a user