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)
CI / test (push) Successful in 21s
CI / docker (push) Skipped

This commit is contained in:
2026-09-08 23:54:03 -05:00
parent 127c12c79a
commit d5a47b1a31
5 changed files with 287 additions and 21 deletions
+56 -7
View File
@@ -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 {