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"`
|
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,
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user