package main import ( "crypto/rand" "crypto/subtle" "database/sql" "encoding/base64" "net/http" "time" "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) } // readWindowMinutes is the per-viewer dedupe window for burn-after-N-reads // (#49): the same viewer cookie returning within 15 minutes does not count // as a new read. See the decision comment on issue #49. const readWindowMinutes = 15 // timeNow is overridable in tests to inject the clock. var timeNow = time.Now // registerRead applies the burn-after-read budget for one view (#49). // For pastes with reads_limit set: the viewer's paste_views row is checked; // a view within readWindowMinutes of the viewer's last view is deduped // (count=false). Otherwise reads_used is incremented, and the paste is // soft-deleted (burned) once reads_used reaches reads_limit. Viewers without // a cookie (plain API clients) count as their own viewer id "". // For legacy plain burn_after_read pastes (no reads_limit), any read burns. // Returns the number of reads remaining (0 when burned), or nil when no // budget is set. view_count is tracked separately and unaffected. func (s *Store) registerRead(row *PasteRow, viewerID string) (remaining *int, count bool) { if !row.ReadsLimit.Valid { if row.BurnAfterRead { s.SoftDelete(row.ID) r := 0 return &r, true } return nil, false } now := timeNow().Unix() var last sql.NullInt64 s.db.QueryRow(`SELECT last_viewed FROM paste_views WHERE paste_id=? AND viewer_id=?`, row.ID, viewerID).Scan(&last) if last.Valid && now-last.Int64 < readWindowMinutes*60 { r := int(row.ReadsLimit.Int64) - row.ReadsUsed if r < 0 { r = 0 } return &r, false } s.db.Exec(`INSERT INTO paste_views (paste_id, viewer_id, last_viewed) VALUES (?,?,?) ON CONFLICT(paste_id, viewer_id) DO UPDATE SET last_viewed = excluded.last_viewed`, row.ID, viewerID, now) used := row.ReadsUsed + 1 s.db.Exec(`UPDATE pastes SET reads_used=? WHERE id=?`, used, row.ID) if int64(used) >= row.ReadsLimit.Int64 { s.SoftDelete(row.ID) } r := int(row.ReadsLimit.Int64) - int(used) if r < 0 { r = 0 } return &r, true } // burned reports whether a read-limited paste has exhausted its budget. func (row *PasteRow) burned() bool { return row.ReadsLimit.Valid && int64(row.ReadsUsed) >= row.ReadsLimit.Int64 } 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"}) }