From 89e194a435d299e948cb7d25f41e6e7fd3f0254a Mon Sep 17 00:00:00 2001 From: ComputerTech Date: Mon, 6 Apr 2026 20:50:07 +0100 Subject: [PATCH] feat: discussions (comments) on pastes with optional nickname and E2E encryption --- app.py | 113 +++++++++++++++++++++++--- static/css/style.css | 109 +++++++++++++++++++++++++ static/js/crypto.js | 13 +++ static/js/paste_create.js | 15 +++- static/js/paste_view.js | 162 +++++++++++++++++++++++++++++++++++++- templates/index.html | 3 + templates/view.html | 16 ++++ 7 files changed, 418 insertions(+), 13 deletions(-) diff --git a/app.py b/app.py index cfe316b..ad932ab 100644 --- a/app.py +++ b/app.py @@ -127,17 +127,21 @@ def init_db(): cursor.execute("DROP TABLE pastes") cursor.execute(''' CREATE TABLE IF NOT EXISTS pastes ( - id TEXT PRIMARY KEY, - encrypted_data TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - expires_at TIMESTAMP, - views INTEGER DEFAULT 0, - deletion_token TEXT + id TEXT PRIMARY KEY, + encrypted_data TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP, + views INTEGER DEFAULT 0, + deletion_token TEXT, + discussions_enabled INTEGER DEFAULT 0 ) ''') # Migration: add deletion_token column to existing table if missing if columns and 'id' in columns and 'deletion_token' not in columns: cursor.execute('ALTER TABLE pastes ADD COLUMN deletion_token TEXT') + # 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') cursor.execute(''' CREATE TABLE IF NOT EXISTS rate_limits ( ip_address TEXT, @@ -146,6 +150,15 @@ def init_db(): ) ''') cursor.execute('CREATE INDEX IF NOT EXISTS idx_rate_limit_ts ON rate_limits(timestamp)') + cursor.execute(''' + CREATE TABLE IF NOT EXISTS comments ( + id TEXT PRIMARY KEY, + paste_id TEXT NOT NULL, + content TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + ''') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_comments_paste ON comments(paste_id)') conn.commit() finally: conn.close() @@ -279,6 +292,8 @@ def create_paste(): if not data: return jsonify({'error': 'JSON body required'}), 400 + discussions_enabled = bool(data.get('discussions', False)) + if 'encrypted_data' in data: # Encrypted path — always accepted regardless of encrypt_pastes setting store_data = data.get('encrypted_data', '') @@ -299,7 +314,7 @@ def create_paste(): title = title.strip()[:MAX_TITLE_BYTES] or 'Untitled' if not isinstance(language, str) or not _LANGUAGE_RE.match(language): language = _pastes.get('default_language', 'text') - store_data = json.dumps({'title': title, 'content': content, 'language': language}) + store_data = json.dumps({'title': title, 'content': content, 'language': language, 'discussions': discussions_enabled}) else: return jsonify({'error': 'Provide either encrypted_data or content'}), 400 @@ -333,8 +348,8 @@ def create_paste(): paste_id = generate_paste_id() try: conn.execute( - 'INSERT INTO pastes (id, encrypted_data, expires_at, deletion_token) VALUES (?, ?, ?, ?)', - (paste_id, store_data, expires_at, deletion_token) + '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) ) conn.commit() break @@ -490,6 +505,86 @@ def recent_pastes(): conn.close() return render_template('recent.html', pastes=pastes) +# ── Comments API ────────────────────────────────────────────────────────────── + +_MAX_COMMENT_BYTES = 10240 +_MAX_COMMENTS_PER_PASTE = 500 + +@app.route('/api/comments/') +def get_comments(paste_id): + if not _PASTE_ID_RE.match(paste_id): + abort(404) + conn = get_db_connection() + try: + paste = conn.execute( + 'SELECT id, discussions_enabled, expires_at FROM pastes WHERE id = ?', (paste_id,) + ).fetchone() + if not paste: + abort(404) + if paste['expires_at']: + exp = datetime.datetime.fromisoformat(paste['expires_at']).replace(tzinfo=_UTC) + if exp < datetime.datetime.now(_UTC): + abort(410) + if not paste['discussions_enabled']: + return jsonify({'error': 'Discussions not enabled for this paste'}), 403 + comments = conn.execute( + 'SELECT id, content, created_at FROM comments WHERE paste_id = ? ORDER BY created_at ASC', + (paste_id,) + ).fetchall() + finally: + conn.close() + return jsonify({ + 'discussions_enabled': True, + 'comments': [dict(c) for c in comments], + }) + +@app.route('/api/comments/', methods=['POST']) +def post_comment(paste_id): + if not _PASTE_ID_RE.match(paste_id): + abort(404) + if not _is_same_origin(): + return jsonify({'error': 'Cross-Origin request blocked'}), 403 + if not _check_rate_limit(request.remote_addr, key_prefix='comment', window=600, limit=20): + return jsonify({'error': 'Rate limit exceeded. Please wait a few minutes.'}), 429 + data = request.get_json(silent=True) + if not data: + return jsonify({'error': 'JSON body required'}), 400 + content = data.get('content', '') + if not content or not isinstance(content, str): + return jsonify({'error': 'content required'}), 400 + if len(content.encode('utf-8')) > _MAX_COMMENT_BYTES: + return jsonify({'error': 'Comment too large'}), 413 + conn = get_db_connection() + try: + paste = conn.execute( + 'SELECT id, discussions_enabled, expires_at FROM pastes WHERE id = ?', (paste_id,) + ).fetchone() + if not paste: + abort(404) + if paste['expires_at']: + exp = datetime.datetime.fromisoformat(paste['expires_at']).replace(tzinfo=_UTC) + if exp < datetime.datetime.now(_UTC): + abort(410) + if not paste['discussions_enabled']: + return jsonify({'error': 'Discussions not enabled for this paste'}), 403 + count = conn.execute( + 'SELECT COUNT(*) FROM comments WHERE paste_id = ?', (paste_id,) + ).fetchone()[0] + if count >= _MAX_COMMENTS_PER_PASTE: + return jsonify({'error': 'Comment limit reached for this paste'}), 429 + comment_id = secrets.token_hex(8) + conn.execute( + 'INSERT INTO comments (id, paste_id, content) VALUES (?, ?, ?)', + (comment_id, paste_id, content) + ) + conn.commit() + comment = conn.execute( + 'SELECT id, created_at FROM comments WHERE id = ?', (comment_id,) + ).fetchone() + finally: + conn.close() + return jsonify({'id': comment['id'], 'created_at': comment['created_at']}), 201 + # ── Admin Panel ─────────────────────────────────────────────────────────────── def is_admin(): diff --git a/static/css/style.css b/static/css/style.css index 2f3ee6d..f73db7f 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -398,3 +398,112 @@ pre[class*="language-"], code[class*="language-"] { .btn-delete-small:hover { background: var(--danger); color: #fff; } .table-responsive { overflow-x: auto; } + +/* ── Discussions panel ────────────────────────────────────────────────── */ +.discussions-panel { + flex-shrink: 0; + flex-direction: column; + border-top: 1px solid var(--border); + background: var(--surface); + max-height: 45vh; +} +.discussions-panel.collapsed { + max-height: none; +} +.discussions-panel.collapsed .discussions-body { + display: none; +} +.discussions-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.35rem 1rem; + cursor: pointer; + font-size: 0.8rem; + color: var(--text-sub); + flex-shrink: 0; + user-select: none; + border-bottom: 1px solid var(--border); +} +.discussions-header:hover { background: var(--bg); } +.discussions-title { font-weight: 600; } +.discussions-chevron { font-size: 0.7rem; color: var(--text-muted); } +.discussions-body { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-height: 0; +} +.comments-list { + flex: 1; + overflow-y: auto; + padding: 0.25rem 1rem; + min-height: 0; +} +.comment-item { + padding: 0.45rem 0; + border-bottom: 1px solid var(--border); +} +.comment-item:last-child { border-bottom: none; } +.comment-meta { + font-size: 0.7rem; + color: var(--text-muted); + margin-bottom: 0.2rem; +} +.comment-content { + font-size: 0.85rem; + color: var(--text); + white-space: pre-wrap; + word-break: break-word; +} +.comments-loading, .comments-empty { + font-size: 0.8rem; + color: var(--text-muted); + padding: 0.5rem 0; +} +.comment-form-wrap { + display: flex; + gap: 0.5rem; + padding: 0.4rem 1rem; + border-top: 1px solid var(--border); + align-items: flex-start; + flex-shrink: 0; + background: var(--surface); +} +.comment-form-fields { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.3rem; + min-width: 0; +} +.comment-nick { + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-family: inherit; + font-size: 0.78rem; + padding: 0.25rem 0.6rem; + outline: none; + width: 160px; +} +.comment-nick:focus { border-color: var(--primary); } +.comment-input { + flex: 1; + background: var(--bg); + border: 1px solid var(--border); + border-radius: var(--radius); + color: var(--text); + font-family: inherit; + font-size: 0.8rem; + padding: 0.35rem 0.6rem; + resize: none; + min-height: 2.2rem; + max-height: 8rem; + outline: none; + line-height: 1.4; +} +.comment-input:focus { border-color: var(--primary); } +.comment-meta.has-nick { color: var(--primary); font-weight: 600; } diff --git a/static/js/crypto.js b/static/js/crypto.js index 56fecd5..e505873 100644 --- a/static/js/crypto.js +++ b/static/js/crypto.js @@ -72,6 +72,19 @@ const PasteCrypto = (function () { ); }, + /** Import a base64url key for both encryption AND decryption (used for comment posting). */ + async importKeyBidirectional(keyBase64url) { + const keyBytes = base64urlToArrayBuffer(keyBase64url); + const keyLength = keyBytes.byteLength * 8; + return window.crypto.subtle.importKey( + 'raw', + keyBytes, + { name: 'AES-GCM', length: keyLength }, + false, + ['encrypt', 'decrypt'] + ); + }, + /** * Encrypt a plaintext string. * Returns a string in the format: base64url(iv):base64url(ciphertext) diff --git a/static/js/paste_create.js b/static/js/paste_create.js index b1dbb98..79bd8ae 100644 --- a/static/js/paste_create.js +++ b/static/js/paste_create.js @@ -36,6 +36,14 @@ document.addEventListener('DOMContentLoaded', function () { expirySelect.addEventListener('change', () => localStorage.setItem('preferred_expiry', expirySelect.value)); + // ── Restore discussions preference ─────────────────────────────────────── + const discussCheck = document.getElementById('allowDiscussions'); + if (discussCheck) { + discussCheck.checked = localStorage.getItem('preferred_discussions') === 'true'; + discussCheck.addEventListener('change', () => + localStorage.setItem('preferred_discussions', discussCheck.checked)); + } + // ── Ctrl/Cmd+S shortcut ────────────────────────────────────────────────── document.addEventListener('keydown', e => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { @@ -82,6 +90,7 @@ 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; submitBtn.disabled = true; submitBtn.textContent = '…'; @@ -92,10 +101,10 @@ document.addEventListener('DOMContentLoaded', function () { 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 }); - postBody = { encrypted_data: await PasteCrypto.encrypt(plain, key), expires_in }; + const plain = JSON.stringify({ title, content, language, discussions }); + postBody = { encrypted_data: await PasteCrypto.encrypt(plain, key), expires_in, discussions }; } else { - postBody = { title, content, language, expires_in }; + postBody = { title, content, language, expires_in, discussions }; } const resp = await fetch('/create', { diff --git a/static/js/paste_view.js b/static/js/paste_view.js index cf2b0fb..0664d53 100644 --- a/static/js/paste_view.js +++ b/static/js/paste_view.js @@ -7,7 +7,9 @@ * is needed in this external file. */ -let _decryptedPaste = null; +let _decryptedPaste = null; +let _pasteIsEncrypted = false; +let _keyBase64 = null; document.addEventListener('DOMContentLoaded', async () => { let rawPayload; @@ -38,6 +40,8 @@ document.addEventListener('DOMContentLoaded', async () => { 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; @@ -58,6 +62,7 @@ document.addEventListener('DOMContentLoaded', async () => { initPasteActions(); initLineHighlight(); // register Prism hook before initLineNumbers triggers Prism initLineNumbers(); + initDiscussions(); initDeletion(); }); @@ -332,3 +337,158 @@ function _updateLineUrl(start, end) { url.searchParams.set('L', start === end ? String(start) : `${start}-${end}`); history.replaceState(null, '', url.toString()); } + +// ── Discussions ─────────────────────────────────────────────────────────────── + +function initDiscussions() { + if (!_decryptedPaste?.discussions) return; + const panel = document.getElementById('discussionsPanel'); + if (!panel) return; + panel.style.display = 'flex'; + + const pasteId = window.location.pathname.replace(/^\//, '').split('/')[0]; + loadComments(pasteId); + + // Collapse / expand via header click + const header = document.getElementById('discussionsToggle'); + if (header) { + header.addEventListener('click', () => { + panel.classList.toggle('collapsed'); + const chevron = document.getElementById('discussionsChevron'); + if (chevron) chevron.textContent = panel.classList.contains('collapsed') ? '▲' : '▼'; + }); + } + + const submitBtn = document.getElementById('commentSubmit'); + if (submitBtn) submitBtn.addEventListener('click', () => postComment(pasteId)); + + const input = document.getElementById('commentInput'); + if (input) { + input.addEventListener('keydown', e => { + if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { + e.preventDefault(); + postComment(pasteId); + } + }); + } + + // Restore saved nick + const nickInput = document.getElementById('commentNick'); + if (nickInput) { + const savedNick = localStorage.getItem('comment_nick') || ''; + nickInput.value = savedNick; + nickInput.addEventListener('change', () => + localStorage.setItem('comment_nick', nickInput.value.trim())); + } +} + +async function loadComments(pasteId) { + const list = document.getElementById('commentsList'); + const panel = document.getElementById('discussionsPanel'); + if (!list) return; + list.innerHTML = '
Loading\u2026
'; + try { + const resp = await fetch(`/api/comments/${pasteId}`); + if (resp.status === 403 || resp.status === 404) { + // Discussions disabled or route unknown — hide the panel entirely + if (panel) panel.style.display = 'none'; + return; + } + if (!resp.ok) { list.innerHTML = '
Could not load comments.
'; return; } + const data = await resp.json(); + await renderComments(data.comments || []); + const countEl = document.getElementById('discussionsCount'); + if (countEl) countEl.textContent = (data.comments || []).length; + } catch (e) { + list.innerHTML = '
Could not load comments.
'; + } +} + +async function renderComments(comments) { + const list = document.getElementById('commentsList'); + if (!list) return; + if (!comments.length) { + list.innerHTML = '
No comments yet. Be the first!
'; + return; + } + // Import key once for all comments if paste is encrypted + let decryptKey = null; + if (_pasteIsEncrypted && _keyBase64) { + try { decryptKey = await PasteCrypto.importKey(_keyBase64); } catch (e) {} + } + list.innerHTML = ''; + const frag = document.createDocumentFragment(); + for (const c of comments) { + let text, nick; + if (decryptKey) { + try { + const plain = await PasteCrypto.decrypt(c.content, decryptKey); + const parsed = JSON.parse(plain); + text = parsed.content || ''; + nick = parsed.nick || ''; + } catch (e) { text = '[Could not decrypt comment]'; nick = ''; } + } else { + try { + const parsed = JSON.parse(c.content); + text = parsed.content || ''; + nick = parsed.nick || ''; + } catch (e) { text = c.content; nick = ''; } + } + const item = document.createElement('div'); + item.className = 'comment-item'; + const meta = document.createElement('div'); + meta.className = 'comment-meta'; + const timeStr = new Date(c.created_at.replace(' ', 'T') + 'Z').toLocaleString(); + meta.textContent = nick ? `${nick} · ${timeStr}` : timeStr; + if (nick) meta.querySelector ? null : (meta.dataset.nick = '1'); + if (nick) meta.classList.add('has-nick'); + const body = document.createElement('div'); + body.className = 'comment-content'; + body.textContent = text; + item.appendChild(meta); + item.appendChild(body); + frag.appendChild(item); + } + list.appendChild(frag); +} + +async function postComment(pasteId) { + const input = document.getElementById('commentInput'); + const nickInput = document.getElementById('commentNick'); + const btn = document.getElementById('commentSubmit'); + if (!input || !btn) return; + const text = input.value.trim(); + if (!text) { input.focus(); return; } + const nick = (nickInput?.value.trim() || '').slice(0, 32); + if (nick) localStorage.setItem('comment_nick', nick); + btn.disabled = true; + btn.textContent = '\u2026'; + try { + let content; + const payload = { content: text, ...(nick ? { nick } : {}) }; + if (_pasteIsEncrypted && _keyBase64) { + const key = await PasteCrypto.importKeyBidirectional(_keyBase64); + content = await PasteCrypto.encrypt(JSON.stringify(payload), key); + } else { + content = JSON.stringify(payload); + } + const resp = await fetch(`/api/comments/${pasteId}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ content }), + }); + let result; + try { result = await resp.json(); } catch (_) { result = {}; } + if (!resp.ok || result.error) { + alert('Error: ' + (result.error || `Server error ${resp.status}`)); + } else { + input.value = ''; + await loadComments(pasteId); + } + } catch (e) { + alert('Failed to post comment: ' + e.message); + } finally { + btn.disabled = false; + btn.textContent = 'Post'; + } +} diff --git a/templates/index.html b/templates/index.html index 407de5e..f1e0f7c 100644 --- a/templates/index.html +++ b/templates/index.html @@ -14,6 +14,9 @@ {% endfor %} + {% if cfg.theme.allow_user_toggle %} diff --git a/templates/view.html b/templates/view.html index 07c07ea..823a945 100644 --- a/templates/view.html +++ b/templates/view.html @@ -25,6 +25,22 @@ + {% endblock %}