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}}