Burn after read + deletion tokens: single-read pastes, hard-delete via token redeem
This commit is contained in:
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user