fix: enforce single-view on burn-after-read pastes server-side

Previously the burn was triggered client-side with a 1.5 s delay,
leaving a window where opening the paste in two tabs (or two quick
requests) would both successfully serve the content before either
burn request reached the server.

Fix: on GET /<paste_id> for a burn paste, the server now does an
atomic conditional UPDATE:

    UPDATE pastes SET burn_claimed=1, views=views+1
    WHERE id=? AND burn_claimed=0

SQLite serialises writes, so only one concurrent request can ever
see rowcount > 0. The first request claims the slot and gets the
page; every subsequent request immediately gets 410 Gone — the
encrypted blob is never served to them.

The client-side /api/burn call still fires after successful
decryption to physically delete the row, keeping the data lifetime
as short as possible.
This commit is contained in:
Antigravity 2026-05-16 12:55:11 +01:00
parent d5a9efd285
commit b4706ae101
1 changed files with 33 additions and 9 deletions

42
app.py
View File

@ -136,7 +136,8 @@ def init_db():
deletion_token TEXT, deletion_token TEXT,
discussions_enabled INTEGER DEFAULT 0, discussions_enabled INTEGER DEFAULT 0,
burn_after_read INTEGER DEFAULT 0, burn_after_read INTEGER DEFAULT 0,
burn_token TEXT burn_token TEXT,
burn_claimed INTEGER DEFAULT 0
) )
''') ''')
# Migration: add deletion_token column to existing table if missing # Migration: add deletion_token column to existing table if missing
@ -145,11 +146,13 @@ def init_db():
# Migration: add discussions_enabled column to existing table if missing # Migration: add discussions_enabled column to existing table if missing
if columns and 'id' in columns and 'discussions_enabled' not in columns: if columns and 'id' in columns and 'discussions_enabled' not in columns:
cursor.execute('ALTER TABLE pastes ADD COLUMN discussions_enabled INTEGER DEFAULT 0') cursor.execute('ALTER TABLE pastes ADD COLUMN discussions_enabled INTEGER DEFAULT 0')
# Migration: add burn_after_read / burn_token columns to existing table if missing # Migration: add burn_after_read / burn_token / burn_claimed columns to existing table if missing
if columns and 'id' in columns and 'burn_after_read' not in columns: if columns and 'id' in columns and 'burn_after_read' not in columns:
cursor.execute('ALTER TABLE pastes ADD COLUMN burn_after_read INTEGER DEFAULT 0') cursor.execute('ALTER TABLE pastes ADD COLUMN burn_after_read INTEGER DEFAULT 0')
if columns and 'id' in columns and 'burn_token' not in columns: if columns and 'id' in columns and 'burn_token' not in columns:
cursor.execute('ALTER TABLE pastes ADD COLUMN burn_token TEXT') cursor.execute('ALTER TABLE pastes ADD COLUMN burn_token TEXT')
if columns and 'id' in columns and 'burn_claimed' not in columns:
cursor.execute('ALTER TABLE pastes ADD COLUMN burn_claimed INTEGER DEFAULT 0')
cursor.execute(''' cursor.execute('''
CREATE TABLE IF NOT EXISTS rate_limits ( CREATE TABLE IF NOT EXISTS rate_limits (
ip_address TEXT, ip_address TEXT,
@ -413,15 +416,36 @@ def view_paste(paste_id):
if not _PASTE_ID_RE.match(paste_id): if not _PASTE_ID_RE.match(paste_id):
abort(404) abort(404)
paste = _get_paste_or_abort(paste_id) paste = _get_paste_or_abort(paste_id)
# Increment view count in a separate connection after expiry check.
conn = get_db_connection()
try:
conn.execute('UPDATE pastes SET views = views + 1 WHERE id = ?', (paste_id,))
conn.commit()
finally:
conn.close()
burn_after_read = bool(paste['burn_after_read']) burn_after_read = bool(paste['burn_after_read'])
burn_token = paste['burn_token'] if burn_after_read else None burn_token = paste['burn_token'] if burn_after_read else None
if burn_after_read:
# Atomically claim this burn slot. Only the first request where
# burn_claimed=0 succeeds; any concurrent or subsequent request
# finds rowcount=0 and gets a 410. SQLite serialises writes so
# two simultaneous GETs cannot both claim the slot.
conn = get_db_connection()
try:
res = conn.execute(
'UPDATE pastes SET burn_claimed=1, views=views+1 WHERE id=? AND burn_claimed=0',
(paste_id,)
)
conn.commit()
claimed = res.rowcount > 0
finally:
conn.close()
if not claimed:
abort(410) # Already viewed — paste is gone
else:
# Normal paste — just increment view count.
conn = get_db_connection()
try:
conn.execute('UPDATE pastes SET views = views + 1 WHERE id = ?', (paste_id,))
conn.commit()
finally:
conn.close()
return render_template('view.html', paste=paste, return render_template('view.html', paste=paste,
burn_after_read=burn_after_read, burn_token=burn_token) burn_after_read=burn_after_read, burn_token=burn_token)