feat: burn-after-read (self-destruct) paste option

- Add 🔥 Burn checkbox on the create page (next to Discuss)
- New DB columns: burn_after_read (INTEGER) and burn_token (TEXT) with
  automatic migration for existing databases
- POST /api/burn/<paste_id> endpoint: validates a one-time burn_token
  (constant-time comparison), deletes paste + comments atomically
- burn_token embedded as a JSON island in view.html so paste_view.js
  can read it without Jinja in external scripts
- After successful decryption, scheduleBurn() fires 1.5 s later so
  the user has time to read/copy before the paste is destroyed
- showBurnBanner() inserts a warm amber warning strip (slides in with
  animation) informing the viewer the paste will self-destruct
- Burn is intentionally NOT persisted to localStorage — it's a
  deliberate per-paste choice, not a sticky preference
- Server responds 200 even if paste is already gone (idempotent)
This commit is contained in:
Antigravity 2026-05-16 12:31:47 +01:00
parent 9aeed2af50
commit 3c15f1e2c7
10 changed files with 555 additions and 41 deletions

104
app.py
View File

@ -1,3 +1,4 @@
import hmac
import json
import os
import re
@ -133,7 +134,9 @@ def init_db():
expires_at TIMESTAMP,
views INTEGER DEFAULT 0,
deletion_token TEXT,
discussions_enabled INTEGER DEFAULT 0
discussions_enabled INTEGER DEFAULT 0,
burn_after_read INTEGER DEFAULT 0,
burn_token TEXT
)
''')
# Migration: add deletion_token column to existing table if missing
@ -142,6 +145,11 @@ 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
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')
cursor.execute('''
CREATE TABLE IF NOT EXISTS rate_limits (
ip_address TEXT,
@ -284,7 +292,6 @@ def index():
def create_paste():
# ── Rate limiting ────────────────────────────────────────────────────────
# 10 pastes per 10 minutes per IP address (SQLite backed for worker safety)
# 10 pastes per 10 minutes per IP address (SQLite backed for worker safety)
if not _check_rate_limit(request.remote_addr, key_prefix='create', limit=10):
return jsonify({'error': 'Rate limit exceeded. Please wait a few minutes.'}), 429
@ -293,6 +300,7 @@ def create_paste():
return jsonify({'error': 'JSON body required'}), 400
discussions_enabled = bool(data.get('discussions', False))
burn_after_read = bool(data.get('burn_after_read', False))
if 'encrypted_data' in data:
# Encrypted path — always accepted regardless of encrypt_pastes setting
@ -341,6 +349,7 @@ def create_paste():
expires_at = datetime.datetime.now(_UTC) + delta
deletion_token = secrets.token_urlsafe(32)
burn_token = secrets.token_urlsafe(32) if burn_after_read else None
paste_id = None
conn = get_db_connection()
try:
@ -348,8 +357,13 @@ def create_paste():
paste_id = generate_paste_id()
try:
conn.execute(
'INSERT INTO pastes (id, encrypted_data, expires_at, deletion_token, discussions_enabled) VALUES (?, ?, ?, ?, ?)',
(paste_id, store_data, expires_at, deletion_token, 1 if discussions_enabled else 0)
'INSERT INTO pastes '
'(id, encrypted_data, expires_at, deletion_token, discussions_enabled, burn_after_read, burn_token) '
'VALUES (?, ?, ?, ?, ?, ?, ?)',
(paste_id, store_data, expires_at, deletion_token,
1 if discussions_enabled else 0,
1 if burn_after_read else 0,
burn_token)
)
conn.commit()
break
@ -382,10 +396,11 @@ def delete_paste(paste_id):
if not paste:
return jsonify({'error': 'Paste not found'}), 404
# Verify token
if paste['deletion_token'] != token:
# Verify token — constant-time comparison to prevent timing attacks
if not hmac.compare_digest(paste['deletion_token'] or '', token):
return jsonify({'error': 'Invalid deletion token'}), 403
conn.execute('DELETE FROM comments WHERE paste_id = ?', (paste_id,))
conn.execute('DELETE FROM pastes WHERE id = ?', (paste_id,))
conn.commit()
finally:
@ -405,7 +420,10 @@ def view_paste(paste_id):
conn.commit()
finally:
conn.close()
return render_template('view.html', paste=paste)
burn_after_read = bool(paste['burn_after_read'])
burn_token = paste['burn_token'] if burn_after_read else None
return render_template('view.html', paste=paste,
burn_after_read=burn_after_read, burn_token=burn_token)
@app.route('/<string:paste_id>/raw')
def view_paste_raw(paste_id):
@ -505,6 +523,43 @@ def recent_pastes():
conn.close()
return render_template('recent.html', pastes=pastes)
# ── Burn-after-read API ───────────────────────────────────────────────────────
_BURN_TOKEN_RE = re.compile(r'^[A-Za-z0-9_-]{20,64}$')
@app.route('/api/burn/<string:paste_id>', methods=['POST'])
def burn_paste(paste_id):
"""Called by the client after successful decryption to destroy a burn-after-read paste."""
if not _PASTE_ID_RE.match(paste_id):
abort(404)
if not _is_same_origin():
return jsonify({'error': 'Cross-Origin request blocked'}), 403
data = request.get_json(silent=True) or {}
token = data.get('burn_token', '')
if not token or not _BURN_TOKEN_RE.match(str(token)):
return jsonify({'error': 'burn_token required'}), 400
conn = get_db_connection()
try:
paste = conn.execute(
'SELECT burn_after_read, burn_token FROM pastes WHERE id = ?', (paste_id,)
).fetchone()
if not paste:
# Already burned or never existed — treat as success (idempotent)
return jsonify({'burned': True})
if not paste['burn_after_read']:
return jsonify({'error': 'This paste is not flagged for burn-after-read'}), 400
# Constant-time comparison prevents timing oracle
if not hmac.compare_digest(paste['burn_token'] or '', token):
return jsonify({'error': 'Invalid burn token'}), 403
conn.execute('DELETE FROM comments WHERE paste_id = ?', (paste_id,))
conn.execute('DELETE FROM pastes WHERE id = ?', (paste_id,))
conn.commit()
finally:
conn.close()
return jsonify({'burned': True})
# ── Comments API ──────────────────────────────────────────────────────────────
_MAX_COMMENT_BYTES = 10240
@ -630,9 +685,12 @@ def admin_dashboard():
pastes = conn.execute(
'SELECT id, created_at, expires_at, views FROM pastes ORDER BY created_at DESC'
).fetchall()
active_count = conn.execute(
'SELECT COUNT(*) FROM pastes WHERE expires_at IS NULL OR expires_at > ?', (now,)
).fetchone()[0]
finally:
conn.close()
return render_template('admin_dashboard.html', pastes=pastes)
return render_template('admin_dashboard.html', pastes=pastes, active_count=active_count)
@app.route('/admin/delete/<string:paste_id>', methods=['POST'])
def admin_delete_paste(paste_id):
@ -644,12 +702,42 @@ def admin_delete_paste(paste_id):
conn = get_db_connection()
try:
conn.execute('DELETE FROM comments WHERE paste_id = ?', (paste_id,))
conn.execute('DELETE FROM pastes WHERE id = ?', (paste_id,))
conn.commit()
finally:
conn.close()
return redirect(url_for('admin_dashboard'))
@app.route('/admin/bulk_delete', methods=['POST'])
def admin_bulk_delete():
if not is_admin():
abort(403)
if not _is_same_origin():
return jsonify({'error': 'Cross-Origin request blocked'}), 403
data = request.get_json(silent=True) or {}
ids = data.get('ids', [])
if not isinstance(ids, list) or not ids:
return jsonify({'error': 'ids must be a non-empty list'}), 400
# Cap at 500 to prevent abuse
if len(ids) > 500:
return jsonify({'error': 'Too many IDs in one request'}), 400
# Validate every ID to prevent injection
if not all(isinstance(i, str) and _PASTE_ID_RE.match(i) for i in ids):
return jsonify({'error': 'Invalid paste ID in list'}), 400
conn = get_db_connection()
try:
placeholders = ','.join('?' * len(ids))
conn.execute(f'DELETE FROM comments WHERE paste_id IN ({placeholders})', ids)
conn.execute(f'DELETE FROM pastes WHERE id IN ({placeholders})', ids)
conn.commit()
finally:
conn.close()
return jsonify({'deleted': len(ids)})
# ── Error handlers ────────────────────────────────────────────────────────────
@app.errorhandler(404)

View File

@ -1 +0,0 @@
32002

View File

@ -411,6 +411,97 @@ pre[class*="language-"], code[class*="language-"] {
.table-responsive { overflow-x: auto; }
/* ── Password lock button (active state) ─────────────────────────────── */
.nav-btn-locked {
border-color: var(--primary);
color: var(--primary);
font-weight: 600;
}
.nav-btn-locked:hover { background: var(--primary); color: #fff; }
/* ── Modal overlay ────────────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.55);
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-box {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.75rem 2rem;
width: 100%;
max-width: 380px;
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
}
.modal-title {
font-size: 1.1rem;
margin-bottom: 0.5rem;
}
.modal-desc {
font-size: 0.8rem;
color: var(--text-sub);
margin-bottom: 1.25rem;
line-height: 1.5;
}
.modal-input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--bg);
color: var(--text);
font-size: 0.875rem;
outline: none;
}
.modal-input:focus { border-color: var(--primary); }
.modal-error {
font-size: 0.8rem;
color: var(--danger);
margin-top: 0.5rem;
}
.modal-actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
margin-top: 1.25rem;
}
/* ── Admin bulk-delete controls ───────────────────────────────────────── */
.admin-bulk-bar {
display: flex;
align-items: center;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.admin-bulk-count {
font-size: 0.82rem;
color: var(--text-muted);
}
.btn-bulk-delete {
background: none;
border: 1px solid var(--danger);
color: var(--danger);
padding: 0.3rem 0.75rem;
border-radius: var(--radius);
cursor: pointer;
font-size: 0.8rem;
font-weight: 600;
}
.btn-bulk-delete:hover { background: var(--danger); color: #fff; }
.btn-bulk-delete:disabled { opacity: 0.4; cursor: default; }
.admin-table td.check-col, .admin-table th.check-col {
width: 2rem;
text-align: center;
padding-left: 0.25rem;
padding-right: 0.25rem;
}
/* ── Discussions panel ────────────────────────────────────────────────── */
.discussions-panel {
flex-direction: column;
@ -514,3 +605,27 @@ pre[class*="language-"], code[class*="language-"] {
width: 100%;
}
.comment-input:focus { border-color: var(--primary); }
/* ── Burn-after-read banner ───────────────────────────────────────────── */
.burn-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 1.5rem;
background: rgba(217, 119, 6, 0.12);
border-bottom: 1px solid rgba(217, 119, 6, 0.35);
color: #92400e;
font-size: 0.82rem;
line-height: 1.4;
animation: burnSlideIn 0.3s ease;
}
[data-theme="dark"] .burn-banner {
background: rgba(245, 158, 11, 0.12);
border-bottom-color: rgba(245, 158, 11, 0.3);
color: #fcd34d;
}
@keyframes burnSlideIn {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}

View File

@ -115,6 +115,35 @@ const PasteCrypto = (function () {
ciphertext
);
return new TextDecoder().decode(decrypted);
},
/**
* Derive an AES-GCM key from a password using PBKDF2.
* Returns { key, saltBase64url } store the salt in the URL fragment.
*/
async deriveKeyFromPassword(password, saltBytes = null) {
const salt = saltBytes || window.crypto.getRandomValues(new Uint8Array(16));
const enc = new TextEncoder().encode(password);
const baseKey = await window.crypto.subtle.importKey(
'raw', enc, 'PBKDF2', false, ['deriveKey']
);
const key = await window.crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt, iterations: 200000, hash: 'SHA-256' },
baseKey,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
return { key, saltBase64url: arrayBufferToBase64url(salt) };
},
/**
* Re-derive a key from a password and a known salt (base64url string).
*/
async deriveKeyFromPasswordAndSalt(password, saltBase64url) {
const salt = new Uint8Array(base64urlToArrayBuffer(saltBase64url));
const { key } = await this.deriveKeyFromPassword(password, salt);
return key;
}
};
})();

View File

@ -9,6 +9,67 @@ document.addEventListener('DOMContentLoaded', function () {
const submitBtn = document.getElementById('submitBtn');
const langSelect = document.getElementById('language');
// ── Password modal state ─────────────────────────────────────────────────
let _pendingPassword = null; // set when user locks paste
const passwordBtn = document.getElementById('passwordBtn');
const passwordModal = document.getElementById('passwordModal');
const ppInput = document.getElementById('pastePassword');
const ppConfirm = document.getElementById('pastePasswordConfirm');
const ppError = document.getElementById('passwordModalError');
const ppSave = document.getElementById('passwordModalSave');
const ppCancel = document.getElementById('passwordModalCancel');
const ppClear = document.getElementById('passwordModalClear');
function openPasswordModal() {
ppInput.value = '';
ppConfirm.value = '';
ppError.style.display = 'none';
passwordModal.style.display = 'flex';
ppInput.focus();
}
function closePasswordModal() {
passwordModal.style.display = 'none';
}
function updatePasswordBtn() {
if (_pendingPassword) {
passwordBtn.textContent = '\uD83D\uDD13 Locked';
passwordBtn.classList.add('nav-btn-locked');
} else {
passwordBtn.textContent = '\uD83D\uDD12 Lock';
passwordBtn.classList.remove('nav-btn-locked');
}
}
if (passwordBtn) passwordBtn.addEventListener('click', openPasswordModal);
if (ppCancel) ppCancel.addEventListener('click', closePasswordModal);
if (ppClear) {
ppClear.addEventListener('click', () => {
_pendingPassword = null;
updatePasswordBtn();
closePasswordModal();
});
}
if (ppSave) {
ppSave.addEventListener('click', () => {
const pw = ppInput.value;
const pw2 = ppConfirm.value;
if (!pw) { ppError.textContent = 'Password cannot be empty.'; ppError.style.display = 'block'; return; }
if (pw !== pw2) { ppError.textContent = 'Passwords do not match.'; ppError.style.display = 'block'; return; }
_pendingPassword = pw;
updatePasswordBtn();
closePasswordModal();
});
}
// Close modal on overlay click
if (passwordModal) {
passwordModal.addEventListener('click', (e) => { if (e.target === passwordModal) closePasswordModal(); });
}
// Allow Enter to confirm password modal
[ppInput, ppConfirm].forEach(el => {
if (el) el.addEventListener('keydown', e => { if (e.key === 'Enter') ppSave?.click(); });
});
// ── Load languages from API ──────────────────────────────────────────────
fetch('/api/languages')
.then(r => r.json())
@ -44,6 +105,9 @@ document.addEventListener('DOMContentLoaded', function () {
localStorage.setItem('preferred_discussions', discussCheck.checked));
}
// Burn-after-read — intentionally NOT persisted to localStorage.
const burnCheck = document.getElementById('burnAfterRead');
// ── Ctrl/Cmd+S shortcut ──────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
@ -91,6 +155,7 @@ document.addEventListener('DOMContentLoaded', function () {
// By the time the user clicks Save, window.PBCFG is guaranteed to be populated.
const E2E = window.PBCFG?.features?.encrypt_pastes ?? true;
const discussions = document.getElementById('allowDiscussions')?.checked ?? false;
const burn_after_read = burnCheck?.checked ?? false;
submitBtn.disabled = true;
submitBtn.textContent = '…';
@ -98,13 +163,25 @@ document.addEventListener('DOMContentLoaded', function () {
try {
let postBody, keyBase64 = null;
if (E2E) {
const keyLen = window.PBCFG?.pastes?.encryption_key_bits ?? 128;
const key = await PasteCrypto.generateKey(keyLen);
keyBase64 = await PasteCrypto.exportKey(key);
const plain = JSON.stringify({ title, content, language, discussions });
postBody = { encrypted_data: await PasteCrypto.encrypt(plain, key), expires_in, discussions };
let key, fragmentValue;
if (_pendingPassword) {
// Password-based: derive key from password via PBKDF2.
// Fragment is "p:<salt>" — no raw key in the URL.
const { key: derivedKey, saltBase64url } = await PasteCrypto.deriveKeyFromPassword(_pendingPassword);
key = derivedKey;
fragmentValue = 'p:' + saltBase64url;
} else {
postBody = { title, content, language, expires_in, discussions };
// Normal: random key, fragment is the key itself.
const keyLen = window.PBCFG?.pastes?.encryption_key_bits ?? 128;
key = await PasteCrypto.generateKey(keyLen);
keyBase64 = await PasteCrypto.exportKey(key);
fragmentValue = keyBase64;
}
const plain = JSON.stringify({ title, content, language, discussions, password_protected: !!_pendingPassword });
postBody = { encrypted_data: await PasteCrypto.encrypt(plain, key), expires_in, discussions, burn_after_read };
keyBase64 = fragmentValue;
} else {
postBody = { title, content, language, expires_in, discussions, burn_after_read };
}
const resp = await fetch('/create', {

View File

@ -10,6 +10,7 @@
let _decryptedPaste = null;
let _pasteIsEncrypted = false;
let _keyBase64 = null;
let _burnMeta = { burn_after_read: false, burn_token: null };
document.addEventListener('DOMContentLoaded', async () => {
let rawPayload;
@ -22,6 +23,12 @@ document.addEventListener('DOMContentLoaded', async () => {
return;
}
// Read burn-after-read metadata
try {
const burnIsland = document.getElementById('burnMeta');
if (burnIsland) _burnMeta = JSON.parse(burnIsland.textContent);
} catch (e) { /* non-fatal */ }
// Detect format from the data itself.
// Encrypted pastes: "base64url:base64url" string.
// Plaintext pastes: a JSON object.
@ -29,23 +36,46 @@ document.addEventListener('DOMContentLoaded', async () => {
/^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$/.test(rawPayload);
if (isEncrypted) {
const keyBase64 = window.location.hash.slice(1);
if (!keyBase64) {
const fragment = window.location.hash.slice(1);
if (!fragment) {
showError('No Key',
'The decryption key is missing from the URL. ' +
'Use the full link including the # part.');
return;
}
if (fragment.startsWith('p:')) {
// Password-protected paste — show password prompt
const saltBase64url = fragment.slice(2);
showPasswordPrompt(async (password) => {
try {
const key = await PasteCrypto.importKey(keyBase64);
const key = await PasteCrypto.deriveKeyFromPasswordAndSalt(password, saltBase64url);
const plaintext = await PasteCrypto.decrypt(rawPayload, key);
_decryptedPaste = JSON.parse(plaintext);
_pasteIsEncrypted = true;
_keyBase64 = keyBase64;
_keyBase64 = null; // no shareable key for password-protected pastes
finishRender();
scheduleBurn();
} catch (e) {
return false; // signal wrong password
}
return true;
});
} else {
// Normal key in fragment
try {
const key = await PasteCrypto.importKey(fragment);
const plaintext = await PasteCrypto.decrypt(rawPayload, key);
_decryptedPaste = JSON.parse(plaintext);
_pasteIsEncrypted = true;
_keyBase64 = fragment;
} catch (e) {
showError('Decryption Failed', 'Wrong key or tampered data.');
return;
}
finishRender();
scheduleBurn();
}
} else {
// Plaintext paste — rawPayload is already the parsed JSON object.
try {
@ -56,16 +86,54 @@ document.addEventListener('DOMContentLoaded', async () => {
showError('Bad Data', 'Could not parse paste data.');
return;
}
finishRender();
scheduleBurn();
}
});
function showPasswordPrompt(onSubmit) {
const overlay = document.getElementById('passwordPrompt');
const input = document.getElementById('viewPassword');
const submitBtn = document.getElementById('ppSubmit');
const errEl = document.getElementById('ppError');
if (!overlay) return;
overlay.style.display = 'flex';
input.value = '';
errEl.style.display = 'none';
setTimeout(() => input.focus(), 50);
async function attempt() {
const password = input.value;
if (!password) { input.focus(); return; }
submitBtn.disabled = true;
submitBtn.textContent = '…';
errEl.style.display = 'none';
const ok = await onSubmit(password);
if (ok) {
overlay.style.display = 'none';
} else {
errEl.style.display = 'block';
submitBtn.disabled = false;
submitBtn.textContent = 'Decrypt';
input.value = '';
input.focus();
}
}
submitBtn.addEventListener('click', attempt);
input.addEventListener('keydown', e => { if (e.key === 'Enter') attempt(); });
}
function finishRender() {
renderPaste(_decryptedPaste);
initPasteActions();
initLineHighlight(); // register Prism hook before initLineNumbers triggers Prism
initLineHighlight();
initLineNumbers();
initWordWrap();
initDiscussions();
initDeletion();
});
}
function initPasteActions() {
const rawBtn = document.getElementById('rawBtn');
@ -139,6 +207,41 @@ function showError(title, detail) {
document.getElementById('errorState').style.display = 'block';
}
// ── Burn-after-read ────────────────────────────────────────────────────────────────────
function showBurnBanner() {
const existing = document.getElementById('burnBanner');
if (existing) return;
const banner = document.createElement('div');
banner.id = 'burnBanner';
banner.className = 'burn-banner';
banner.innerHTML = '🔥 <strong>This paste will self-destruct</strong> after you view it. Copy what you need now.';
// Insert at the top of main
const main = document.querySelector('main');
if (main) main.insertBefore(banner, main.firstChild);
}
async function scheduleBurn() {
if (!_burnMeta?.burn_after_read || !_burnMeta?.burn_token) return;
showBurnBanner();
const pasteId = window.location.pathname.split('/').pop();
const token = _burnMeta.burn_token;
// Give the user a moment to see the content before the request fires.
// The paste is already rendered; a brief delay is user-friendly but not a security gate.
await new Promise(r => setTimeout(r, 1500));
try {
await fetch(`/api/burn/${pasteId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ burn_token: token }),
});
} catch (e) {
// Fire-and-forget — if it fails (e.g. offline), the server-side
// cleanup cron will eventually remove it, or the next viewer burns it.
console.warn('[Burn] Request failed:', e);
}
}
function rawView() {
const key = window.location.hash;
const url = window.location.href.split('?')[0].split('#')[0] + '/raw' + key;

View File

@ -12,13 +12,19 @@
<div class="admin-container">
<div class="admin-header">
<h1>Global Paste Management</h1>
<p>Total Pastes: {{ pastes|length }}</p>
<p>Active Pastes: <strong>{{ active_count }}</strong> &nbsp;|&nbsp; Total in DB (inc. expired): {{ pastes|length }}</p>
</div>
<div class="admin-bulk-bar">
<button class="btn-bulk-delete" id="bulkDeleteBtn" disabled>Delete Selected</button>
<span class="admin-bulk-count" id="bulkCount">0 selected</span>
</div>
<div class="table-responsive">
<table class="admin-table">
<table class="admin-table" id="adminTable">
<thead>
<tr>
<th class="check-col"><input type="checkbox" id="selectAll" title="Select all"></th>
<th>ID</th>
<th>Created</th>
<th>Expires</th>
@ -29,6 +35,7 @@
<tbody>
{% for paste in pastes %}
<tr>
<td class="check-col"><input type="checkbox" class="row-check" value="{{ paste.id }}"></td>
<td><a href="{{ url_for('view_paste', paste_id=paste.id) }}" target="_blank"><code>{{ paste.id }}</code></a></td>
<td>{{ paste.created_at }}</td>
<td>{{ paste.expires_at or 'Never' }}</td>
@ -44,4 +51,64 @@
</table>
</div>
</div>
<script nonce="{{ csp_nonce }}">
(function () {
const selectAll = document.getElementById('selectAll');
const bulkDeleteBtn = document.getElementById('bulkDeleteBtn');
const bulkCount = document.getElementById('bulkCount');
function getChecked() {
return [...document.querySelectorAll('.row-check:checked')];
}
function updateBulkBar() {
const checked = getChecked();
bulkCount.textContent = checked.length + ' selected';
bulkDeleteBtn.disabled = checked.length === 0;
const all = document.querySelectorAll('.row-check');
selectAll.indeterminate = checked.length > 0 && checked.length < all.length;
selectAll.checked = all.length > 0 && checked.length === all.length;
}
document.querySelectorAll('.row-check').forEach(cb =>
cb.addEventListener('change', updateBulkBar)
);
selectAll.addEventListener('change', function () {
document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);
updateBulkBar();
});
bulkDeleteBtn.addEventListener('click', async function () {
const checked = getChecked();
if (!checked.length) return;
if (!confirm('Delete ' + checked.length + ' paste(s) permanently?')) return;
bulkDeleteBtn.disabled = true;
bulkDeleteBtn.textContent = 'Deleting…';
const ids = checked.map(cb => cb.value);
try {
const resp = await fetch('{{ url_for("admin_bulk_delete") }}', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
if (resp.ok) {
window.location.reload();
} else {
const data = await resp.json().catch(() => ({}));
alert('Error: ' + (data.error || resp.status));
bulkDeleteBtn.disabled = false;
bulkDeleteBtn.textContent = 'Delete Selected';
}
} catch (e) {
alert('Request failed. Please try again.');
bulkDeleteBtn.disabled = false;
bulkDeleteBtn.textContent = 'Delete Selected';
}
});
})();
</script>
{% endblock %}

View File

@ -17,6 +17,10 @@
<label class="nav-label" title="Allow discussions on this paste">
<input type="checkbox" id="allowDiscussions"> Discuss
</label>
<label class="nav-label" title="Delete this paste the moment it is first opened">
<input type="checkbox" id="burnAfterRead"> 🔥 Burn
</label>
<button id="passwordBtn" class="nav-btn" title="Set a password for this paste">&#x1F512; Lock</button>
<button id="submitBtn" class="nav-btn nav-btn-save">Save</button>
{% if cfg.theme.allow_user_toggle %}
<button id="themeToggle" class="theme-toggle">🌙</button>
@ -24,6 +28,26 @@
{% endblock %}
{% block content %}
<div id="passwordModal" class="modal-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="passwordModalTitle">
<div class="modal-box">
<h2 id="passwordModalTitle" class="modal-title">&#x1F512; Password Protect</h2>
<p class="modal-desc">The password is never sent to the server. Anyone viewing this paste will need it to decrypt.</p>
<div class="form-group">
<label for="pastePassword">Password</label>
<input type="password" id="pastePassword" class="modal-input" placeholder="Enter password…" autocomplete="new-password">
</div>
<div class="form-group">
<label for="pastePasswordConfirm">Confirm Password</label>
<input type="password" id="pastePasswordConfirm" class="modal-input" placeholder="Confirm password…" autocomplete="new-password">
</div>
<div id="passwordModalError" class="modal-error" style="display:none"></div>
<div class="modal-actions">
<button id="passwordModalClear" class="btn">Remove Lock</button>
<button id="passwordModalCancel" class="btn">Cancel</button>
<button id="passwordModalSave" class="btn btn-primary">Set Password</button>
</div>
</div>
</div>
<textarea
id="content"
class="full-editor"

View File

@ -21,8 +21,21 @@
{% endif %}
{% endblock %}
{% block content %}
<div id="errorState" class="error-inline" style="display:none">
{% block content %}<div id="passwordPrompt" class="modal-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="ppTitle">
<div class="modal-box">
<h2 id="ppTitle" class="modal-title">&#x1F512; Password Required</h2>
<p class="modal-desc">This paste is password protected. Enter the password to view it.</p>
<div class="form-group">
<label for="viewPassword">Password</label>
<input type="password" id="viewPassword" class="modal-input" placeholder="Enter password…" autocomplete="current-password">
</div>
<div id="ppError" class="modal-error" style="display:none">Incorrect password or corrupted data.</div>
<div class="modal-actions">
<a href="/" class="btn">Cancel</a>
<button id="ppSubmit" class="btn btn-primary">Decrypt</button>
</div>
</div>
</div><div id="errorState" class="error-inline" style="display:none">
🔐 <strong id="errorTitle">Decryption Failed</strong><span id="errorDetail"></span>
</div>
<div class="view-full" id="viewFull" style="display:none">
@ -45,6 +58,7 @@
</div>
</div>
<script type="application/json" id="encryptedPayload">{{ paste.encrypted_data | tojson }}</script>
<script type="application/json" id="burnMeta">{{ {'burn_after_read': burn_after_read, 'burn_token': burn_token} | tojson }}</script>
{% endblock %}
{% block scripts %}

View File

@ -1,6 +1,4 @@
from app import app, init_db
init_db()
from app import app
if __name__ == '__main__':
app.run()