Burn after read + deletion tokens: single-read pastes, hard-delete via token redeem

This commit is contained in:
2026-09-08 16:12:25 -05:00
parent 7d158834fc
commit f66c0e777b
6 changed files with 166 additions and 6 deletions
+54
View File
@@ -0,0 +1,54 @@
package main
import (
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"net/http"
"github.com/go-chi/chi/v5"
)
// genDeletionToken returns a 32-char url-safe random token
func genDeletionToken() string {
b := make([]byte, 24)
rand.Read(b)
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
}
s.SoftDelete(row.ID)
return true
}
func deletionTokenEqual(stored, given string) bool {
return subtle.ConstantTimeCompare([]byte(stored), []byte(given)) == 1
}
// handleRedeemDeletion lets a holder of the deletion token hard-delete immediately.
// DELETE /api/pastes/{id}/redeem?token=...
func (a *apiServer) handleRedeemDeletion(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
token := r.URL.Query().Get("token")
if token == "" {
writeErr(w, 400, "token required")
return
}
row, err := a.store.GetPaste(id)
if err != nil || row == nil {
writeErr(w, 404, "paste not found")
return
}
if row.DeletionToken.String == "" || !deletionTokenEqual(row.DeletionToken.String, token) {
writeErr(w, 403, "invalid token")
return
}
// hard delete: pastes table row goes away entirely
a.store.db.Exec(`DELETE FROM pastes WHERE id = ?`, row.ID)
writeJSON(w, 200, map[string]string{"status": "deleted"})
}
+98
View File
@@ -0,0 +1,98 @@
package main
import (
"encoding/json"
"net/http/httptest"
"strings"
"testing"
)
func TestBurnAfterRead(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"vanish","burn_after_read":true}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 201 {
t.Fatalf("create: %d", rec.Code)
}
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
// first read ok
req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("first read: %d", rec.Code)
}
// second read gone
req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 404 {
t.Fatalf("second read expected 404, got %d", rec.Code)
}
}
func TestDeletionTokenRedeem(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"x"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct {
ID string `json:"id"`
DeletionToken string `json:"deletion_token"`
}
json.Unmarshal(rec.Body.Bytes(), &created)
if created.DeletionToken == "" {
t.Fatal("no deletion token in create response")
}
// wrong token
req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID+"/redeem?token=wrong", nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 403 {
t.Fatalf("wrong token: %d", rec.Code)
}
// right token: hard delete
req = httptest.NewRequest("DELETE", "/api/pastes/"+created.ID+"/redeem?token="+created.DeletionToken, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("redeem: %d", rec.Code)
}
// gone for good: even soft-deleted lookup returns nothing, and row count is 0
var n int
s.store.db.QueryRow(`SELECT COUNT(*) FROM pastes WHERE id=?`, created.ID).Scan(&n)
if n != 0 {
t.Fatal("row still exists after redeem")
}
}
func TestNonBurnPasteUnaffectedByRead(t *testing.T) {
s := testServer(t)
h := s.routes()
req := httptest.NewRequest("POST", "/api/pastes", strings.NewReader(`{"content":"normal"}`))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var created struct{ ID string `json:"id"` }
json.Unmarshal(rec.Body.Bytes(), &created)
for i := 0; i < 3; i++ {
req = httptest.NewRequest("GET", "/api/pastes/"+created.ID, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != 200 {
t.Fatalf("read %d: %d", i, rec.Code)
}
}
}
+14 -6
View File
@@ -48,6 +48,7 @@ type Paste struct {
DeletedAt *int64 `json:"deleted_at,omitempty"` DeletedAt *int64 `json:"deleted_at,omitempty"`
ExpiresAt *int64 `json:"expires_at,omitempty"` ExpiresAt *int64 `json:"expires_at,omitempty"`
ViewCount int `json:"view_count"` ViewCount int `json:"view_count"`
DeletionToken string `json:"-"`
} }
type PasteRow struct { type PasteRow struct {
@@ -65,6 +66,7 @@ type PasteRow struct {
CreatedAt int64 CreatedAt int64
DeletedAt sql.NullInt64 DeletedAt sql.NullInt64
ViewCount int ViewCount int
DeletionToken sql.NullString
} }
type CanRow struct { type CanRow struct {
@@ -109,7 +111,8 @@ func (s *Store) migrate() error {
can_id TEXT, can_id TEXT,
created_at INTEGER NOT NULL, created_at INTEGER NOT NULL,
deleted_at INTEGER, deleted_at INTEGER,
view_count INTEGER NOT NULL DEFAULT 0 view_count INTEGER NOT NULL DEFAULT 0,
deletion_token TEXT
); );
CREATE INDEX IF NOT EXISTS idx_pastes_visibility_created ON pastes(visibility, created_at DESC); CREATE INDEX IF NOT EXISTS idx_pastes_visibility_created ON pastes(visibility, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_pastes_expires ON pastes(expires_at) WHERE expires_at IS NOT NULL; CREATE INDEX IF NOT EXISTS idx_pastes_expires ON pastes(expires_at) WHERE expires_at IS NOT NULL;
@@ -125,6 +128,7 @@ func (s *Store) migrate() error {
expires_at INTEGER expires_at INTEGER
); );
`) `)
s.db.Exec(`ALTER TABLE pastes ADD COLUMN deletion_token TEXT`) // ignore if exists
return err return err
} }
@@ -199,10 +203,11 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
if p.CustomSlug != nil && *p.CustomSlug != "" { if p.CustomSlug != nil && *p.CustomSlug != "" {
slugVal = p.CustomSlug slugVal = p.CustomSlug
} }
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) (id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, created_at, deletion_token)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`, VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
id, slugVal, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now) id, slugVal, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now, p.DeletionToken)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -214,10 +219,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 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
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) 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)
if err == sql.ErrNoRows { if err == sql.ErrNoRows {
return nil, nil return nil, nil
} }
@@ -319,6 +324,7 @@ func (a *apiServer) routes() http.Handler {
r.Post("/pastes", a.handleCreatePaste) r.Post("/pastes", a.handleCreatePaste)
r.Get("/pastes/{id}", a.handleGetPaste) r.Get("/pastes/{id}", a.handleGetPaste)
r.Delete("/pastes/{id}", a.handleDeletePaste) r.Delete("/pastes/{id}", a.handleDeletePaste)
r.Delete("/pastes/{id}/redeem", a.handleRedeemDeletion)
r.Get("/public", a.handleListPublic) r.Get("/public", a.handleListPublic)
r.Post("/pastes/can", a.handleCreateCan) r.Post("/pastes/can", a.handleCreateCan)
r.Get("/cans/{id}", a.handleGetCan) r.Get("/cans/{id}", a.handleGetCan)
@@ -362,6 +368,7 @@ func (a *apiServer) handleCreatePaste(w http.ResponseWriter, r *http.Request) {
} }
writeJSON(w, 201, map[string]any{ writeJSON(w, 201, map[string]any{
"id": created.ID, "id": created.ID,
"deletion_token": created.DeletionToken,
"url": "/" + created.ID, "url": "/" + created.ID,
"raw_url": "/raw/" + created.ID, "raw_url": "/raw/" + created.ID,
"api_url": "/api/pastes/" + created.ID, "api_url": "/api/pastes/" + created.ID,
@@ -402,6 +409,7 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
} }
return nil return nil
} }
a.store.maybeBurn(row)
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,
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.