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
+14 -6
View File
@@ -48,6 +48,7 @@ type Paste struct {
DeletedAt *int64 `json:"deleted_at,omitempty"`
ExpiresAt *int64 `json:"expires_at,omitempty"`
ViewCount int `json:"view_count"`
DeletionToken string `json:"-"`
}
type PasteRow struct {
@@ -65,6 +66,7 @@ type PasteRow struct {
CreatedAt int64
DeletedAt sql.NullInt64
ViewCount int
DeletionToken sql.NullString
}
type CanRow struct {
@@ -109,7 +111,8 @@ func (s *Store) migrate() error {
can_id TEXT,
created_at INTEGER NOT NULL,
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_expires ON pastes(expires_at) WHERE expires_at IS NOT NULL;
@@ -125,6 +128,7 @@ func (s *Store) migrate() error {
expires_at INTEGER
);
`)
s.db.Exec(`ALTER TABLE pastes ADD COLUMN deletion_token TEXT`) // ignore if exists
return err
}
@@ -199,10 +203,11 @@ func (s *Store) CreatePaste(p *Paste) (*Paste, error) {
if p.CustomSlug != nil && *p.CustomSlug != "" {
slugVal = p.CustomSlug
}
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)
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
id, slugVal, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now)
(id, custom_slug, content, content_type, language, title, password_hash, expires_at, burn_after_read, visibility, created_at, deletion_token)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`,
id, slugVal, p.Content, contentType, p.Language, p.Title, pwHash, expiresAt, boolToInt(p.BurnAfterRead), visibility, now, p.DeletionToken)
if err != nil {
return nil, err
}
@@ -214,10 +219,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
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)
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 {
return nil, nil
}
@@ -319,6 +324,7 @@ func (a *apiServer) routes() http.Handler {
r.Post("/pastes", a.handleCreatePaste)
r.Get("/pastes/{id}", a.handleGetPaste)
r.Delete("/pastes/{id}", a.handleDeletePaste)
r.Delete("/pastes/{id}/redeem", a.handleRedeemDeletion)
r.Get("/public", a.handleListPublic)
r.Post("/pastes/can", a.handleCreateCan)
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{
"id": created.ID,
"deletion_token": created.DeletionToken,
"url": "/" + created.ID,
"raw_url": "/raw/" + created.ID,
"api_url": "/api/pastes/" + created.ID,
@@ -402,6 +409,7 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
}
return nil
}
a.store.maybeBurn(row)
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,