From b4706ae10105f810e768af274cc508de6849bf38 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Sat, 16 May 2026 12:55:11 +0100 Subject: [PATCH] fix: enforce single-view on burn-after-read pastes server-side MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 / 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. --- app.py | 42 +++++++++++++++++++++++++++++++++--------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/app.py b/app.py index c645f2a..365c798 100644 --- a/app.py +++ b/app.py @@ -136,7 +136,8 @@ def init_db(): deletion_token TEXT, discussions_enabled 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 @@ -145,11 +146,13 @@ def init_db(): # Migration: add discussions_enabled column to existing table if missing if columns and 'id' in columns and 'discussions_enabled' not in columns: 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: 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: 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(''' CREATE TABLE IF NOT EXISTS rate_limits ( ip_address TEXT, @@ -413,15 +416,36 @@ def view_paste(paste_id): if not _PASTE_ID_RE.match(paste_id): abort(404) 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_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, burn_after_read=burn_after_read, burn_token=burn_token)