diff --git a/app.py b/app.py index ad932ab..c645f2a 100644 --- a/app.py +++ b/app.py @@ -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('//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/', 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 @@ -623,33 +678,66 @@ def admin_logout(): def admin_dashboard(): if not is_admin(): return redirect(url_for('admin_login')) - + now = datetime.datetime.now(_UTC).isoformat() conn = get_db_connection() try: 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/', methods=['POST']) def admin_delete_paste(paste_id): if not is_admin(): abort(403) - + if not _is_same_origin(): abort(403) - + 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) diff --git a/gunicorn.pid b/gunicorn.pid deleted file mode 100644 index 30fabb3..0000000 --- a/gunicorn.pid +++ /dev/null @@ -1 +0,0 @@ -32002 diff --git a/static/css/style.css b/static/css/style.css index e63334a..45c1c24 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -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); } +} + diff --git a/static/js/crypto.js b/static/js/crypto.js index e505873..6f50c18 100644 --- a/static/js/crypto.js +++ b/static/js/crypto.js @@ -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; } }; })(); diff --git a/static/js/paste_create.js b/static/js/paste_create.js index 79bd8ae..105e30c 100644 --- a/static/js/paste_create.js +++ b/static/js/paste_create.js @@ -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') { @@ -90,7 +154,8 @@ document.addEventListener('DOMContentLoaded', function () { // Read E2E flag from the already-loaded config (fetched by app.js at startup). // 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 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:" — no raw key in the URL. + const { key: derivedKey, saltBase64url } = await PasteCrypto.deriveKeyFromPassword(_pendingPassword); + key = derivedKey; + fragmentValue = 'p:' + saltBase64url; + } else { + // 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 }; + postBody = { title, content, language, expires_in, discussions, burn_after_read }; } const resp = await fetch('/create', { diff --git a/static/js/paste_view.js b/static/js/paste_view.js index 59c3198..cc84224 100644 --- a/static/js/paste_view.js +++ b/static/js/paste_view.js @@ -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,22 +36,45 @@ 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; } - try { - const key = await PasteCrypto.importKey(keyBase64); - const plaintext = await PasteCrypto.decrypt(rawPayload, key); - _decryptedPaste = JSON.parse(plaintext); - _pasteIsEncrypted = true; - _keyBase64 = keyBase64; - } catch (e) { - showError('Decryption Failed', 'Wrong key or tampered data.'); - return; + + if (fragment.startsWith('p:')) { + // Password-protected paste — show password prompt + const saltBase64url = fragment.slice(2); + showPasswordPrompt(async (password) => { + try { + const key = await PasteCrypto.deriveKeyFromPasswordAndSalt(password, saltBase64url); + const plaintext = await PasteCrypto.decrypt(rawPayload, key); + _decryptedPaste = JSON.parse(plaintext); + _pasteIsEncrypted = true; + _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. @@ -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 = '🔥 This paste will self-destruct 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; diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html index bc610cc..614de6e 100644 --- a/templates/admin_dashboard.html +++ b/templates/admin_dashboard.html @@ -12,13 +12,19 @@

Global Paste Management

-

Total Pastes: {{ pastes|length }}

+

Active Pastes: {{ active_count }}  |  Total in DB (inc. expired): {{ pastes|length }}

- + +
+ + 0 selected +
+
- +
+ @@ -29,6 +35,7 @@ {% for paste in pastes %} + @@ -44,4 +51,64 @@
ID Created Expires
{{ paste.id }} {{ paste.created_at }} {{ paste.expires_at or 'Never' }}
+ + {% endblock %} diff --git a/templates/index.html b/templates/index.html index f1e0f7c..bfe19ad 100644 --- a/templates/index.html +++ b/templates/index.html @@ -17,6 +17,10 @@ + + {% if cfg.theme.allow_user_toggle %} @@ -24,6 +28,26 @@ {% endblock %} {% block content %} +