From d5a47b1a31865b9effd0390eac53d444cf331b61 Mon Sep 17 00:00:00 2001 From: poslop Date: Tue, 8 Sep 2026 23:54:03 -0500 Subject: [PATCH] Burn after N reads: reads_limit/reads_used, per-viewer 15min dedupe via paste_views, reads_remaining in API+stats pill, raw counts as read (#49) --- burn.go | 63 ++++++++++++-- burnreads_test.go | 179 +++++++++++++++++++++++++++++++++++++++ main.go | 45 ++++++++-- web.go | 19 +++-- web/templates/paste.html | 2 +- 5 files changed, 287 insertions(+), 21 deletions(-) create mode 100644 burnreads_test.go diff --git a/burn.go b/burn.go index 8dff1fc..9d02ea1 100644 --- a/burn.go +++ b/burn.go @@ -3,8 +3,10 @@ package main import ( "crypto/rand" "crypto/subtle" + "database/sql" "encoding/base64" "net/http" + "time" "github.com/go-chi/chi/v5" ) @@ -16,14 +18,61 @@ func genDeletionToken() string { return base64.RawURLEncoding.EncodeToString(b) } -// maybeBurn marks a paste soft-deleted if burn_after_read is set. -// Returns true if this read consumed the paste. -func (s *Store) maybeBurn(row *PasteRow) bool { - if !row.BurnAfterRead { - return false +// readWindowMinutes is the per-viewer dedupe window for burn-after-N-reads +// (#49): the same viewer cookie returning within 15 minutes does not count +// as a new read. See the decision comment on issue #49. +const readWindowMinutes = 15 + +// timeNow is overridable in tests to inject the clock. +var timeNow = time.Now + +// registerRead applies the burn-after-read budget for one view (#49). +// For pastes with reads_limit set: the viewer's paste_views row is checked; +// a view within readWindowMinutes of the viewer's last view is deduped +// (count=false). Otherwise reads_used is incremented, and the paste is +// soft-deleted (burned) once reads_used reaches reads_limit. Viewers without +// a cookie (plain API clients) count as their own viewer id "". +// For legacy plain burn_after_read pastes (no reads_limit), any read burns. +// Returns the number of reads remaining (0 when burned), or nil when no +// budget is set. view_count is tracked separately and unaffected. +func (s *Store) registerRead(row *PasteRow, viewerID string) (remaining *int, count bool) { + if !row.ReadsLimit.Valid { + if row.BurnAfterRead { + s.SoftDelete(row.ID) + r := 0 + return &r, true + } + return nil, false } - s.SoftDelete(row.ID) - return true + now := timeNow().Unix() + var last sql.NullInt64 + s.db.QueryRow(`SELECT last_viewed FROM paste_views WHERE paste_id=? AND viewer_id=?`, + row.ID, viewerID).Scan(&last) + if last.Valid && now-last.Int64 < readWindowMinutes*60 { + r := int(row.ReadsLimit.Int64) - row.ReadsUsed + if r < 0 { + r = 0 + } + return &r, false + } + s.db.Exec(`INSERT INTO paste_views (paste_id, viewer_id, last_viewed) VALUES (?,?,?) + ON CONFLICT(paste_id, viewer_id) DO UPDATE SET last_viewed = excluded.last_viewed`, + row.ID, viewerID, now) + used := row.ReadsUsed + 1 + s.db.Exec(`UPDATE pastes SET reads_used=? WHERE id=?`, used, row.ID) + if int64(used) >= row.ReadsLimit.Int64 { + s.SoftDelete(row.ID) + } + r := int(row.ReadsLimit.Int64) - int(used) + if r < 0 { + r = 0 + } + return &r, true +} + +// burned reports whether a read-limited paste has exhausted its budget. +func (row *PasteRow) burned() bool { + return row.ReadsLimit.Valid && int64(row.ReadsUsed) >= row.ReadsLimit.Int64 } func deletionTokenEqual(stored, given string) bool { diff --git a/burnreads_test.go b/burnreads_test.go new file mode 100644 index 0000000..2eb6f6a --- /dev/null +++ b/burnreads_test.go @@ -0,0 +1,179 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +type anyHandler interface { + ServeHTTP(http.ResponseWriter, *http.Request) +} + +// createBurnReads creates a burn-after-N-reads paste and returns its id. +func createBurnReads(t *testing.T, h anyHandler, reads int) string { + t.Helper() + body, _ := json.Marshal(map[string]any{"content": "limited", "burn_after_read": true, "burn_after_reads": reads}) + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(string(body))) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 201 { + t.Fatalf("create burn_after_reads=%d: %d %s", reads, rec.Code, rec.Body.String()) + } + var created struct { + ID string `json:"id"` + } + json.Unmarshal(rec.Body.Bytes(), &created) + if created.ID == "" { + t.Fatal("no id in create response") + } + return created.ID +} + +func getWithCookie(t *testing.T, h anyHandler, id, viewer string) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest("GET", "/api/pastes/"+id, nil) + if viewer != "" { + req.AddCookie(&http.Cookie{Name: "vwr", Value: viewer}) + } + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + return rec +} + +func TestBurnAfterNReadsDistinctViewers(t *testing.T) { + s := testServer(t) + h := s.routes() + id := createBurnReads(t, h, 2) + + // viewer A: ok (read 1) + if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 { + t.Fatalf("read 1 (viewer A): %d %s", rec.Code, rec.Body.String()) + } + // viewer B: ok (read 2) + if rec := getWithCookie(t, h, id, "bbb"); rec.Code != 200 { + t.Fatalf("read 2 (viewer B): %d %s", rec.Code, rec.Body.String()) + } + // viewer C: burned -> 404 + if rec := getWithCookie(t, h, id, "ccc"); rec.Code != 404 { + t.Fatalf("read 3 expected 404, got %d", rec.Code) + } +} + +func TestBurnReadsSameViewerWithinWindowNoDecrement(t *testing.T) { + s := testServer(t) + h := s.routes() + id := createBurnReads(t, h, 2) + + // same viewer reads twice within the window: second is deduped + if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 { + t.Fatalf("read 1: %d", rec.Code) + } + if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 { + t.Fatalf("deduped re-read expected 200, got %d", rec.Code) + } + // another viewer still gets read 2 (budget not consumed by re-reads) + if rec := getWithCookie(t, h, id, "bbb"); rec.Code != 200 { + t.Fatalf("read 2: %d", rec.Code) + } +} + +func TestBurnReadsWindowExpiryRecounts(t *testing.T) { + s := testServer(t) + h := s.routes() + id := createBurnReads(t, h, 2) + + base := time.Now() + timeNow = func() time.Time { return base } + t.Cleanup(func() { timeNow = time.Now }) + + if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 { + t.Fatalf("read 1: %d", rec.Code) + } + // 10 minutes later: still within window, deduped + timeNow = func() time.Time { return base.Add(10 * time.Minute) } + if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 { + t.Fatalf("re-read within window: %d", rec.Code) + } + // 20 minutes after first read: window expired, counts as read 2 + timeNow = func() time.Time { return base.Add(20 * time.Minute) } + if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 200 { + t.Fatalf("re-read after window expected 200, got %d", rec.Code) + } + // budget exhausted -> 404 even for the same viewer + if rec := getWithCookie(t, h, id, "aaa"); rec.Code != 404 { + t.Fatalf("after budget expected 404, got %d", rec.Code) + } +} + +func TestBurnReadsDefaultOne(t *testing.T) { + s := testServer(t) + h := s.routes() + // burn_after_read without burn_after_reads defaults to 1 read + req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"one","burn_after_read":true}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + var created struct { + ID string `json:"id"` + } + json.Unmarshal(rec.Body.Bytes(), &created) + + if rec := getWithCookie(t, h, created.ID, "aaa"); rec.Code != 200 { + t.Fatalf("read 1: %d", rec.Code) + } + if rec := getWithCookie(t, h, created.ID, "bbb"); rec.Code != 404 { + t.Fatalf("read 2 expected 404, got %d", rec.Code) + } +} + +func TestBurnReadsPageViewCounts(t *testing.T) { + if webUIInstance == nil { + ui, err := NewWebUI() + if err != nil { + t.Fatal(err) + } + webUIInstance = ui + } + s := testServer(t) + h := s.routes() + id := createBurnReads(t, h, 2) + + // HTML page view counts as a read too (documented decision) + req := httptest.NewRequest("GET", "/"+id, nil) + req.AddCookie(&http.Cookie{Name: "vwr", Value: "aaa"}) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("page view 1: %d", rec.Code) + } + // re-view within window: deduped + req = httptest.NewRequest("GET", "/"+id, nil) + req.AddCookie(&http.Cookie{Name: "vwr", Value: "aaa"}) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("page re-view: %d", rec.Code) + } + // distinct viewer: read 2, page renders with reads remaining + req = httptest.NewRequest("GET", "/"+id, nil) + req.AddCookie(&http.Cookie{Name: "vwr", Value: "bbb"}) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("page view 2: %d", rec.Code) + } + if !strings.Contains(rec.Body.String(), "Reads left") { + t.Fatal("stats pill missing 'Reads left'") + } + // third viewer: burned + req = httptest.NewRequest("GET", "/"+id, nil) + req.AddCookie(&http.Cookie{Name: "vwr", Value: "ccc"}) + rec = httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != 404 { + t.Fatalf("page view 3 expected 404, got %d", rec.Code) + } +} diff --git a/main.go b/main.go index 3b61cd8..aa670e3 100644 --- a/main.go +++ b/main.go @@ -44,12 +44,14 @@ type Paste struct { Password *string `json:"password,omitempty"` ExpiresIn *string `json:"expires_in,omitempty"` BurnAfterRead bool `json:"burn_after_read,omitempty"` + BurnAfterReads *int `json:"burn_after_reads,omitempty"` // #49: readable N times (default 1) Visibility string `json:"visibility"` CanID *string `json:"can_id,omitempty"` CreatedAt int64 `json:"created_at"` DeletedAt *int64 `json:"deleted_at,omitempty"` ExpiresAt *int64 `json:"expires_at,omitempty"` ViewerID string `json:"-"` // set from vwr cookie server-side (#37) + readsLimit *int64 // #49: resolved read budget, not serialized ViewCount int `json:"view_count"` DeletionToken string `json:"-"` } @@ -64,6 +66,8 @@ type PasteRow struct { PasswordHash sql.NullString ExpiresAt sql.NullInt64 BurnAfterRead bool + ReadsLimit sql.NullInt64 + ReadsUsed int Visibility string CanID sql.NullString CreatedAt int64 @@ -135,6 +139,14 @@ func (s *Store) migrate() error { `) s.db.Exec(`ALTER TABLE pastes ADD COLUMN deletion_token TEXT`) // ignore if exists s.db.Exec(`ALTER TABLE pastes ADD COLUMN viewer_id TEXT`) // ignore if exists (#37) + s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_limit INTEGER`) // ignore if exists (#49) + s.db.Exec(`ALTER TABLE pastes ADD COLUMN reads_used INTEGER DEFAULT 0`) // ignore if exists (#49) + s.db.Exec(`CREATE TABLE IF NOT EXISTS paste_views ( + paste_id TEXT NOT NULL, + viewer_id TEXT NOT NULL, + last_viewed INTEGER NOT NULL, + PRIMARY KEY (paste_id, viewer_id) + )`) // #49: per-viewer read dedupe window return err } @@ -192,6 +204,15 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) { } } + // #49: burn-after-read pastes carry a read budget (default 1 read) + if p.BurnAfterRead { + limit := int64(1) + if p.BurnAfterReads != nil && *p.BurnAfterReads > 0 { + limit = int64(*p.BurnAfterReads) + } + p.readsLimit = &limit + } + visibility := p.Visibility if visibility == "" { visibility = "public" @@ -211,9 +232,9 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) { } p.DeletionToken = genDeletionToken() _, err := s.db.Exec(`INSERT INTO pastes - (id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, created_at, deletion_token, viewer_id) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`, - id, slugVal, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now, p.DeletionToken, p.ViewerID) + (id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, created_at, deletion_token, viewer_id, reads_limit) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + id, slugVal, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now, p.DeletionToken, p.ViewerID, p.readsLimit) if err != nil { return nil, err } @@ -225,10 +246,10 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) { } func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) { - row := s.db.QueryRow(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, can_id, created_at, deleted_at, view_count, deletion_token, viewer_id + row := s.db.QueryRow(`SELECT id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, can_id, created_at, deleted_at, view_count, deletion_token, viewer_id, reads_limit, COALESCE(reads_used, 0) FROM pastes WHERE (id = ? OR custom_slug = ?) AND deleted_at IS NULL`, idOrSlug, idOrSlug) var r PasteRow - err := row.Scan(&r.ID, &r.CustomSlug, &r.Content, &r.ContentType, &r.Language, &r.Title, &r.PasswordHash, &r.ExpiresAt, &r.BurnAfterRead, &r.Visibility, &r.CanID, &r.CreatedAt, &r.DeletedAt, &r.ViewCount, &r.DeletionToken, &r.ViewerID) + err := row.Scan(&r.ID, &r.CustomSlug, &r.Content, &r.ContentType, &r.Language, &r.Title, &r.PasswordHash, &r.ExpiresAt, &r.BurnAfterRead, &r.Visibility, &r.CanID, &r.CreatedAt, &r.DeletedAt, &r.ViewCount, &r.DeletionToken, &r.ViewerID, &r.ReadsLimit, &r.ReadsUsed) if err == sql.ErrNoRows { return nil, nil } @@ -521,6 +542,10 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) { writeErr(w, 404, "paste expired") return } + if row.burned() { // #49: read budget exhausted + writeErr(w, 404, "paste not found") + return + } if row.PasswordHash.Valid { // require password via header or query pw := r.Header.Get("X-Paste-Password") @@ -538,11 +563,12 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) { } return nil } - a.store.maybeBurn(row) + rem, _ := a.store.registerRead(row, currentViewerID(r)) // #49 (also covers legacy burn) writeJSON(w, 200, map[string]any{ "id": row.ID, "content": row.Content, "content_type": row.ContentType, "language": nullPtr(row.Language), "title": nullPtr(row.Title), "created_at": row.CreatedAt, "view_count": row.ViewCount, "visibility": row.Visibility, + "reads_remaining": rem, }) } @@ -635,6 +661,13 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) { http.Error(w, "password required", 401) return } + if row.burned() { // #49: read budget exhausted + http.Error(w, "not found", 404) + return + } + // #49 decision: raw reads count against the read budget too, with the + // same per-viewer 15-minute dedupe window as page views. + a.store.registerRead(row, currentViewerID(r)) w.Header().Set("Content-Type", row.ContentType) a.store.IncrementViews(row.ID) w.Write([]byte(row.Content)) diff --git a/web.go b/web.go index 52ac612..519ec1e 100644 --- a/web.go +++ b/web.go @@ -100,7 +100,7 @@ func expiryString(expiresAt int64) string { } } -func (a *apiServer) renderPaste(w http.ResponseWriter, row *PasteRow, justCreated bool, deletionToken string) { +func (a *apiServer) renderPaste(w http.ResponseWriter, row *PasteRow, justCreated bool, deletionToken string, readsRemaining *int) { lines := strings.Count(row.Content, "\n") + 1 gutter := "" for i := 1; i <= lines; i++ { @@ -137,6 +137,9 @@ func (a *apiServer) renderPaste(w http.ResponseWriter, row *PasteRow, justCreate "ExpiresAt": row.ExpiresAt.Valid, "ExpiresIn": expIn, "DeletionToken": deletionToken, + "ReadsLimit": row.ReadsLimit.Valid, + "ReadsLeftN": readsRemaining, // *int: reads remaining after this view + "ReadsTotal": int(row.ReadsLimit.Int64), "JustCreated": justCreated, "Host": "this host", } @@ -181,7 +184,7 @@ func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) { return } } - a.renderPaste(w, row, false, "") + a.renderPaste(w, row, false, "", nil) return } renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": true, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt}) @@ -195,19 +198,21 @@ func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) { } } - a.store.IncrementViews(row.ID) justCreated := r.URL.Query().Get("created") == "1" token := r.URL.Query().Get("token") if justCreated && token != "" { // 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}) } - // only pass the token to the template right after creation - if justCreated { - a.renderPaste(w, row, true, token) + // #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 { + rem, _ := a.store.registerRead(row, currentViewerID(r)) + a.renderPaste(w, row, false, "", rem) return } - a.renderPaste(w, row, false, "") + // only pass the token to the template right after creation + a.renderPaste(w, row, true, token, nil) } var _ = strconv.Itoa diff --git a/web/templates/paste.html b/web/templates/paste.html index 1464145..cfca561 100644 --- a/web/templates/paste.html +++ b/web/templates/paste.html @@ -25,7 +25,7 @@ Created{{.CreatedAgo}} {{if .ExpiresAt}}Expiresin {{.ExpiresIn}}{{end}} Password{{if .HasPassword}}protected{{else}}none{{end}} - {{if .BurnAfterRead}}Burnburn after read{{end}} + {{if .BurnAfterRead}}{{if .ReadsLimit}}{{with .ReadsLeftN}}Reads left{{.}} of {{$.ReadsTotal}}{{end}}{{else}}Burnburn after read{{end}}{{end}} {{if .CustomSlug}}Custom URL/{{.CustomSlug}}{{end}} Visibility{{.Visibility}}