package store import ( "crypto/subtle" "database/sql" "encoding/base64" "time" ) // genDeletionToken returns a 32-char url-safe random token func genDeletionToken() string { b := make([]byte, 24) cryptoRead(b) return base64.RawURLEncoding.EncodeToString(b) } // 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 the burn viewer window 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. // #58: admission is atomic. Returns (remaining, admitted). admitted is true // only when this caller may serve the content: for legacy burn pastes the // caller wins exactly when its conditional soft delete flipped deleted_at // (RowsAffected), and for read-budget pastes the caller wins exactly when its // conditional UPDATE (reads_used < reads_limit) incremented the counter - so // concurrent readers can never both consume the last read. Losing callers // must treat the paste as gone. view_count is tracked separately and // unaffected. func (s *Store) RegisterRead(row *PasteRow, viewerID string, burnWindowMinutes int) (remaining *int, admitted bool) { if !row.ReadsLimit.Valid { if row.BurnAfterRead { // #58: atomic claim - only the caller whose UPDATE actually // flips deleted_at from NULL may serve the content. ok, err := s.SoftDelete(row.ID) r := 0 if err != nil || !ok { return &r, false } return &r, true } return nil, true } 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 < int64(burnWindowMinutes)*60 { r := int(row.ReadsLimit.Int64) - row.ReadsUsed if r < 0 { r = 0 } return &r, true } 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) // #58: conditional increment - only succeeds while budget remains, so // concurrent readers cannot both consume the final read. res, err := s.db.Exec(`UPDATE pastes SET reads_used = reads_used + 1 WHERE id = ? AND deleted_at IS NULL AND reads_used < ?`, row.ID, row.ReadsLimit.Int64) if err != nil { r := 0 return &r, false } if n, _ := res.RowsAffected(); n == 0 { // Lost the race: budget exhausted (or paste already burned). r := 0 return &r, false } var used int64 s.db.QueryRow(`SELECT reads_used FROM pastes WHERE id = ?`, row.ID).Scan(&used) if used >= row.ReadsLimit.Int64 { // Atomic burn; either way the paste is gone for future readers. 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 }