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)
This commit is contained in:
@@ -3,8 +3,10 @@ package main
|
|||||||
import (
|
import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"database/sql"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
)
|
)
|
||||||
@@ -16,14 +18,61 @@ func genDeletionToken() string {
|
|||||||
return base64.RawURLEncoding.EncodeToString(b)
|
return base64.RawURLEncoding.EncodeToString(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
// maybeBurn marks a paste soft-deleted if burn_after_read is set.
|
// readWindowMinutes is the per-viewer dedupe window for burn-after-N-reads
|
||||||
// Returns true if this read consumed the paste.
|
// (#49): the same viewer cookie returning within 15 minutes does not count
|
||||||
func (s *Store) maybeBurn(row *PasteRow) bool {
|
// as a new read. See the decision comment on issue #49.
|
||||||
if !row.BurnAfterRead {
|
const readWindowMinutes = 15
|
||||||
return false
|
|
||||||
|
// 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)
|
now := timeNow().Unix()
|
||||||
return true
|
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 {
|
func deletionTokenEqual(stored, given string) bool {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -44,12 +44,14 @@ type Paste struct {
|
|||||||
Password *string `json:"password,omitempty"`
|
Password *string `json:"password,omitempty"`
|
||||||
ExpiresIn *string `json:"expires_in,omitempty"`
|
ExpiresIn *string `json:"expires_in,omitempty"`
|
||||||
BurnAfterRead bool `json:"burn_after_read,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"`
|
Visibility string `json:"visibility"`
|
||||||
CanID *string `json:"can_id,omitempty"`
|
CanID *string `json:"can_id,omitempty"`
|
||||||
CreatedAt int64 `json:"created_at"`
|
CreatedAt int64 `json:"created_at"`
|
||||||
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
DeletedAt *int64 `json:"deleted_at,omitempty"`
|
||||||
ExpiresAt *int64 `json:"expires_at,omitempty"`
|
ExpiresAt *int64 `json:"expires_at,omitempty"`
|
||||||
ViewerID string `json:"-"` // set from vwr cookie server-side (#37)
|
ViewerID string `json:"-"` // set from vwr cookie server-side (#37)
|
||||||
|
readsLimit *int64 // #49: resolved read budget, not serialized
|
||||||
ViewCount int `json:"view_count"`
|
ViewCount int `json:"view_count"`
|
||||||
DeletionToken string `json:"-"`
|
DeletionToken string `json:"-"`
|
||||||
}
|
}
|
||||||
@@ -64,6 +66,8 @@ type PasteRow struct {
|
|||||||
PasswordHash sql.NullString
|
PasswordHash sql.NullString
|
||||||
ExpiresAt sql.NullInt64
|
ExpiresAt sql.NullInt64
|
||||||
BurnAfterRead bool
|
BurnAfterRead bool
|
||||||
|
ReadsLimit sql.NullInt64
|
||||||
|
ReadsUsed int
|
||||||
Visibility string
|
Visibility string
|
||||||
CanID sql.NullString
|
CanID sql.NullString
|
||||||
CreatedAt int64
|
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 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 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
|
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
|
visibility := p.Visibility
|
||||||
if visibility == "" {
|
if visibility == "" {
|
||||||
visibility = "public"
|
visibility = "public"
|
||||||
@@ -211,9 +232,9 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
|
|||||||
}
|
}
|
||||||
p.DeletionToken = genDeletionToken()
|
p.DeletionToken = genDeletionToken()
|
||||||
_, err := s.db.Exec(`INSERT INTO pastes
|
_, 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)
|
(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 (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
id, slugVal, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now, p.DeletionToken, p.ViewerID)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -225,10 +246,10 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Store) GetPaste(idOrSlug string) (*PasteRow, 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)
|
FROM pastes WHERE (id = ? OR custom_slug = ?) AND deleted_at IS NULL`, idOrSlug, idOrSlug)
|
||||||
var r PasteRow
|
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 {
|
if err == sql.ErrNoRows {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
@@ -521,6 +542,10 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, 404, "paste expired")
|
writeErr(w, 404, "paste expired")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if row.burned() { // #49: read budget exhausted
|
||||||
|
writeErr(w, 404, "paste not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
if row.PasswordHash.Valid {
|
if row.PasswordHash.Valid {
|
||||||
// require password via header or query
|
// require password via header or query
|
||||||
pw := r.Header.Get("X-Paste-Password")
|
pw := r.Header.Get("X-Paste-Password")
|
||||||
@@ -538,11 +563,12 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
a.store.maybeBurn(row)
|
rem, _ := a.store.registerRead(row, currentViewerID(r)) // #49 (also covers legacy burn)
|
||||||
writeJSON(w, 200, map[string]any{
|
writeJSON(w, 200, map[string]any{
|
||||||
"id": row.ID, "content": row.Content, "content_type": row.ContentType,
|
"id": row.ID, "content": row.Content, "content_type": row.ContentType,
|
||||||
"language": nullPtr(row.Language), "title": nullPtr(row.Title), "created_at": row.CreatedAt,
|
"language": nullPtr(row.Language), "title": nullPtr(row.Title), "created_at": row.CreatedAt,
|
||||||
"view_count": row.ViewCount, "visibility": row.Visibility,
|
"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)
|
http.Error(w, "password required", 401)
|
||||||
return
|
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)
|
w.Header().Set("Content-Type", row.ContentType)
|
||||||
a.store.IncrementViews(row.ID)
|
a.store.IncrementViews(row.ID)
|
||||||
w.Write([]byte(row.Content))
|
w.Write([]byte(row.Content))
|
||||||
|
|||||||
@@ -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
|
lines := strings.Count(row.Content, "\n") + 1
|
||||||
gutter := ""
|
gutter := ""
|
||||||
for i := 1; i <= lines; i++ {
|
for i := 1; i <= lines; i++ {
|
||||||
@@ -137,6 +137,9 @@ func (a *apiServer) renderPaste(w http.ResponseWriter, row *PasteRow, justCreate
|
|||||||
"ExpiresAt": row.ExpiresAt.Valid,
|
"ExpiresAt": row.ExpiresAt.Valid,
|
||||||
"ExpiresIn": expIn,
|
"ExpiresIn": expIn,
|
||||||
"DeletionToken": deletionToken,
|
"DeletionToken": deletionToken,
|
||||||
|
"ReadsLimit": row.ReadsLimit.Valid,
|
||||||
|
"ReadsLeftN": readsRemaining, // *int: reads remaining after this view
|
||||||
|
"ReadsTotal": int(row.ReadsLimit.Int64),
|
||||||
"JustCreated": justCreated,
|
"JustCreated": justCreated,
|
||||||
"Host": "this host",
|
"Host": "this host",
|
||||||
}
|
}
|
||||||
@@ -181,7 +184,7 @@ func (a *apiServer) handlePasteView(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.renderPaste(w, row, false, "")
|
a.renderPaste(w, row, false, "", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
renderPage(w, "unlock.html", map[string]any{"Page": "unlock", "ID": row.ID, "Wrong": true, "CreatedAgo": agoString(row.CreatedAt), "CreatedAtUnix": row.CreatedAt})
|
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"
|
justCreated := r.URL.Query().Get("created") == "1"
|
||||||
token := r.URL.Query().Get("token")
|
token := r.URL.Query().Get("token")
|
||||||
if justCreated && token != "" {
|
if justCreated && token != "" {
|
||||||
// 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})
|
||||||
}
|
}
|
||||||
// only pass the token to the template right after creation
|
// #49: burn-after-N-reads budget (per-viewer, 15-minute dedupe window).
|
||||||
if justCreated {
|
// Just-created first render does not count as a read for the creator.
|
||||||
a.renderPaste(w, row, true, token)
|
if !justCreated {
|
||||||
|
rem, _ := a.store.registerRead(row, currentViewerID(r))
|
||||||
|
a.renderPaste(w, row, false, "", rem)
|
||||||
return
|
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
|
var _ = strconv.Itoa
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<span class="stats-k">Created</span><span class="stats-v" data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span>
|
<span class="stats-k">Created</span><span class="stats-v" data-ts="{{.CreatedAtUnix}}">{{.CreatedAgo}}</span>
|
||||||
{{if .ExpiresAt}}<span class="stats-k">Expires</span><span class="stats-v">in {{.ExpiresIn}}</span>{{end}}
|
{{if .ExpiresAt}}<span class="stats-k">Expires</span><span class="stats-v">in {{.ExpiresIn}}</span>{{end}}
|
||||||
<span class="stats-k">Password</span><span class="stats-v">{{if .HasPassword}}protected{{else}}none{{end}}</span>
|
<span class="stats-k">Password</span><span class="stats-v">{{if .HasPassword}}protected{{else}}none{{end}}</span>
|
||||||
{{if .BurnAfterRead}}<span class="stats-k">Burn</span><span class="stats-v">burn after read</span>{{end}}
|
{{if .BurnAfterRead}}{{if .ReadsLimit}}{{with .ReadsLeftN}}<span class="stats-k">Reads left</span><span class="stats-v">{{.}} of {{$.ReadsTotal}}</span>{{end}}{{else}}<span class="stats-k">Burn</span><span class="stats-v">burn after read</span>{{end}}{{end}}
|
||||||
{{if .CustomSlug}}<span class="stats-k">Custom URL</span><span class="stats-v">/{{.CustomSlug}}</span>{{end}}
|
{{if .CustomSlug}}<span class="stats-k">Custom URL</span><span class="stats-v">/{{.CustomSlug}}</span>{{end}}
|
||||||
<span class="stats-k">Visibility</span><span class="stats-v">{{.Visibility}}</span>
|
<span class="stats-k">Visibility</span><span class="stats-v">{{.Visibility}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user