forked from ComputerTech/bastebin
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:
parent
d5a9efd285
commit
b4706ae101
42
app.py
42
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)
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue