diff --git a/burn.go b/burn.go new file mode 100644 index 0000000..8dff1fc --- /dev/null +++ b/burn.go @@ -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"}) +} diff --git a/burn_test.go b/burn_test.go new file mode 100644 index 0000000..d9f9331 --- /dev/null +++ b/burn_test.go @@ -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) + } + } +} diff --git a/main.go b/main.go index bd2a2af..2fde6c5 100644 --- a/main.go +++ b/main.go @@ -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, diff --git a/palette b/palette index 077ee0c..6edafc0 100755 Binary files a/palette and b/palette differ diff --git a/palette.db-shm b/palette.db-shm index ddcf9b9..1e288e7 100644 Binary files a/palette.db-shm and b/palette.db-shm differ diff --git a/palette.db-wal b/palette.db-wal index 92b7450..043d6f2 100644 Binary files a/palette.db-wal and b/palette.db-wal differ