fix: atomic burn-after-read claim (#58)
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).
This commit is contained in:
+19
-9
@@ -29,11 +29,11 @@ type Config struct {
|
||||
}
|
||||
|
||||
type apiServer struct {
|
||||
store *store.Store
|
||||
cfg Config
|
||||
ui *web.UI
|
||||
settings *settingsStore
|
||||
adminKey string
|
||||
store *store.Store
|
||||
cfg Config
|
||||
ui *web.UI
|
||||
settings *settingsStore
|
||||
adminKey string
|
||||
}
|
||||
|
||||
func NewServer(st *store.Store, cfg Config, ui *web.UI, ss *settingsStore, adminKey string) *apiServer {
|
||||
@@ -216,7 +216,12 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
}
|
||||
rem, _ := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow()) // #49 (also covers legacy burn)
|
||||
// #58: only the reader that wins the atomic burn claim may see content.
|
||||
rem, admitted := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
|
||||
if !admitted {
|
||||
writeErr(w, 404, "paste not found")
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"id": row.ID, "content": row.Content, "content_type": row.ContentType,
|
||||
"language": store.NullStrPtr(row.Language), "title": store.NullStrPtr(row.Title), "created_at": row.CreatedAt,
|
||||
@@ -240,7 +245,7 @@ func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 403, "not your paste")
|
||||
return
|
||||
}
|
||||
if err := a.store.SoftDelete(row.ID); err != nil {
|
||||
if _, err := a.store.SoftDelete(row.ID); err != nil {
|
||||
writeErr(w, 500, "db error")
|
||||
return
|
||||
}
|
||||
@@ -319,8 +324,13 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
// #49 decision: raw reads count against the read budget too, with the
|
||||
// same per-viewer dedupe window as page views.
|
||||
a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
|
||||
// same per-viewer dedupe window as page views. #58: a reader that loses
|
||||
// the burn claim must not receive the content.
|
||||
_, admitted := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
|
||||
if !admitted {
|
||||
http.Error(w, "not found", 404)
|
||||
return
|
||||
}
|
||||
// #34: content_type is attacker-controlled via the create API. Serving it
|
||||
// verbatim let a paste be stored with text/html (or image/svg+xml) and
|
||||
// render as active content on this origin when fetched from /raw —
|
||||
|
||||
Reference in New Issue
Block a user