SoftDelete now reports whether it performed the delete (conditional UPDATE ... WHERE deleted_at IS NULL checked via RowsAffected). RegisterRead returns an admitted flag: legacy burn pastes admit exactly one reader (the atomic soft-delete winner), and burn-after-N pastes increment reads_used via a conditional UPDATE guarded on reads_used < reads_limit, so concurrent readers cannot both consume the final read. API, HTML, and raw read paths return 404 when the reader loses the burn claim; content is never served twice. OpenStore pins the SQLite pool to one connection: concurrent writes on separate pooled connections surfaced SQLITE_BUSY as spurious 500s instead of serializing. Adds concurrency regression tests: 24 parallel readers of a burn paste (exactly one receives content, none of the others leak it) and 30 parallel readers vs a 3-read budget (exactly 3 admitted, then 404).
97 lines
3.4 KiB
Go
97 lines
3.4 KiB
Go
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
|
|
}
|