feat: discussions (comments) on pastes with optional nickname and E2E encryption
This commit is contained in:
parent
c8d78c7d61
commit
89e194a435
113
app.py
113
app.py
|
|
@ -127,17 +127,21 @@ def init_db():
|
||||||
cursor.execute("DROP TABLE pastes")
|
cursor.execute("DROP TABLE pastes")
|
||||||
cursor.execute('''
|
cursor.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS pastes (
|
CREATE TABLE IF NOT EXISTS pastes (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
encrypted_data TEXT NOT NULL,
|
encrypted_data TEXT NOT NULL,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
expires_at TIMESTAMP,
|
expires_at TIMESTAMP,
|
||||||
views INTEGER DEFAULT 0,
|
views INTEGER DEFAULT 0,
|
||||||
deletion_token TEXT
|
deletion_token TEXT,
|
||||||
|
discussions_enabled INTEGER DEFAULT 0
|
||||||
)
|
)
|
||||||
''')
|
''')
|
||||||
# Migration: add deletion_token column to existing table if missing
|
# Migration: add deletion_token column to existing table if missing
|
||||||
if columns and 'id' in columns and 'deletion_token' not in columns:
|
if columns and 'id' in columns and 'deletion_token' not in columns:
|
||||||
cursor.execute('ALTER TABLE pastes ADD COLUMN deletion_token TEXT')
|
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('''
|
cursor.execute('''
|
||||||
CREATE TABLE IF NOT EXISTS rate_limits (
|
CREATE TABLE IF NOT EXISTS rate_limits (
|
||||||
ip_address TEXT,
|
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 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()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
@ -279,6 +292,8 @@ def create_paste():
|
||||||
if not data:
|
if not data:
|
||||||
return jsonify({'error': 'JSON body required'}), 400
|
return jsonify({'error': 'JSON body required'}), 400
|
||||||
|
|
||||||
|
discussions_enabled = bool(data.get('discussions', False))
|
||||||
|
|
||||||
if 'encrypted_data' in data:
|
if 'encrypted_data' in data:
|
||||||
# Encrypted path — always accepted regardless of encrypt_pastes setting
|
# Encrypted path — always accepted regardless of encrypt_pastes setting
|
||||||
store_data = data.get('encrypted_data', '')
|
store_data = data.get('encrypted_data', '')
|
||||||
|
|
@ -299,7 +314,7 @@ def create_paste():
|
||||||
title = title.strip()[:MAX_TITLE_BYTES] or 'Untitled'
|
title = title.strip()[:MAX_TITLE_BYTES] or 'Untitled'
|
||||||
if not isinstance(language, str) or not _LANGUAGE_RE.match(language):
|
if not isinstance(language, str) or not _LANGUAGE_RE.match(language):
|
||||||
language = _pastes.get('default_language', 'text')
|
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:
|
else:
|
||||||
return jsonify({'error': 'Provide either encrypted_data or content'}), 400
|
return jsonify({'error': 'Provide either encrypted_data or content'}), 400
|
||||||
|
|
||||||
|
|
@ -333,8 +348,8 @@ def create_paste():
|
||||||
paste_id = generate_paste_id()
|
paste_id = generate_paste_id()
|
||||||
try:
|
try:
|
||||||
conn.execute(
|
conn.execute(
|
||||||
'INSERT INTO pastes (id, encrypted_data, expires_at, deletion_token) VALUES (?, ?, ?, ?)',
|
'INSERT INTO pastes (id, encrypted_data, expires_at, deletion_token, discussions_enabled) VALUES (?, ?, ?, ?, ?)',
|
||||||
(paste_id, store_data, expires_at, deletion_token)
|
(paste_id, store_data, expires_at, deletion_token, 1 if discussions_enabled else 0)
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
break
|
break
|
||||||
|
|
@ -490,6 +505,86 @@ def recent_pastes():
|
||||||
conn.close()
|
conn.close()
|
||||||
return render_template('recent.html', pastes=pastes)
|
return render_template('recent.html', pastes=pastes)
|
||||||
|
|
||||||
|
# ── Comments API ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_MAX_COMMENT_BYTES = 10240
|
||||||
|
_MAX_COMMENTS_PER_PASTE = 500
|
||||||
|
|
||||||
|
@app.route('/api/comments/<string:paste_id>')
|
||||||
|
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/<string:paste_id>', 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 ───────────────────────────────────────────────────────────────
|
# ── Admin Panel ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def is_admin():
|
def is_admin():
|
||||||
|
|
|
||||||
|
|
@ -398,3 +398,112 @@ pre[class*="language-"], code[class*="language-"] {
|
||||||
.btn-delete-small:hover { background: var(--danger); color: #fff; }
|
.btn-delete-small:hover { background: var(--danger); color: #fff; }
|
||||||
|
|
||||||
.table-responsive { overflow-x: auto; }
|
.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; }
|
||||||
|
|
|
||||||
|
|
@ -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.
|
* Encrypt a plaintext string.
|
||||||
* Returns a string in the format: base64url(iv):base64url(ciphertext)
|
* Returns a string in the format: base64url(iv):base64url(ciphertext)
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,14 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||||
expirySelect.addEventListener('change', () =>
|
expirySelect.addEventListener('change', () =>
|
||||||
localStorage.setItem('preferred_expiry', expirySelect.value));
|
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 ──────────────────────────────────────────────────
|
// ── Ctrl/Cmd+S shortcut ──────────────────────────────────────────────────
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
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).
|
// 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.
|
// By the time the user clicks Save, window.PBCFG is guaranteed to be populated.
|
||||||
const E2E = window.PBCFG?.features?.encrypt_pastes ?? true;
|
const E2E = window.PBCFG?.features?.encrypt_pastes ?? true;
|
||||||
|
const discussions = document.getElementById('allowDiscussions')?.checked ?? false;
|
||||||
|
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.textContent = '…';
|
submitBtn.textContent = '…';
|
||||||
|
|
@ -92,10 +101,10 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||||
const keyLen = window.PBCFG?.pastes?.encryption_key_bits ?? 128;
|
const keyLen = window.PBCFG?.pastes?.encryption_key_bits ?? 128;
|
||||||
const key = await PasteCrypto.generateKey(keyLen);
|
const key = await PasteCrypto.generateKey(keyLen);
|
||||||
keyBase64 = await PasteCrypto.exportKey(key);
|
keyBase64 = await PasteCrypto.exportKey(key);
|
||||||
const plain = JSON.stringify({ title, content, language });
|
const plain = JSON.stringify({ title, content, language, discussions });
|
||||||
postBody = { encrypted_data: await PasteCrypto.encrypt(plain, key), expires_in };
|
postBody = { encrypted_data: await PasteCrypto.encrypt(plain, key), expires_in, discussions };
|
||||||
} else {
|
} else {
|
||||||
postBody = { title, content, language, expires_in };
|
postBody = { title, content, language, expires_in, discussions };
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = await fetch('/create', {
|
const resp = await fetch('/create', {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@
|
||||||
* is needed in this external file.
|
* is needed in this external file.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let _decryptedPaste = null;
|
let _decryptedPaste = null;
|
||||||
|
let _pasteIsEncrypted = false;
|
||||||
|
let _keyBase64 = null;
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
let rawPayload;
|
let rawPayload;
|
||||||
|
|
@ -38,6 +40,8 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
const key = await PasteCrypto.importKey(keyBase64);
|
const key = await PasteCrypto.importKey(keyBase64);
|
||||||
const plaintext = await PasteCrypto.decrypt(rawPayload, key);
|
const plaintext = await PasteCrypto.decrypt(rawPayload, key);
|
||||||
_decryptedPaste = JSON.parse(plaintext);
|
_decryptedPaste = JSON.parse(plaintext);
|
||||||
|
_pasteIsEncrypted = true;
|
||||||
|
_keyBase64 = keyBase64;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError('Decryption Failed', 'Wrong key or tampered data.');
|
showError('Decryption Failed', 'Wrong key or tampered data.');
|
||||||
return;
|
return;
|
||||||
|
|
@ -58,6 +62,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
initPasteActions();
|
initPasteActions();
|
||||||
initLineHighlight(); // register Prism hook before initLineNumbers triggers Prism
|
initLineHighlight(); // register Prism hook before initLineNumbers triggers Prism
|
||||||
initLineNumbers();
|
initLineNumbers();
|
||||||
|
initDiscussions();
|
||||||
initDeletion();
|
initDeletion();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -332,3 +337,158 @@ function _updateLineUrl(start, end) {
|
||||||
url.searchParams.set('L', start === end ? String(start) : `${start}-${end}`);
|
url.searchParams.set('L', start === end ? String(start) : `${start}-${end}`);
|
||||||
history.replaceState(null, '', url.toString());
|
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 = '<div class="comments-loading">Loading\u2026</div>';
|
||||||
|
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 = '<div class="comments-empty">Could not load comments.</div>'; 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 = '<div class="comments-empty">Could not load comments.</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function renderComments(comments) {
|
||||||
|
const list = document.getElementById('commentsList');
|
||||||
|
if (!list) return;
|
||||||
|
if (!comments.length) {
|
||||||
|
list.innerHTML = '<div class="comments-empty">No comments yet. Be the first!</div>';
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,9 @@
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
<button id="clearBtn" class="nav-btn">Clear</button>
|
<button id="clearBtn" class="nav-btn">Clear</button>
|
||||||
|
<label class="nav-label" title="Allow discussions on this paste">
|
||||||
|
<input type="checkbox" id="allowDiscussions"> Discuss
|
||||||
|
</label>
|
||||||
<button id="submitBtn" class="nav-btn nav-btn-save">Save</button>
|
<button id="submitBtn" class="nav-btn nav-btn-save">Save</button>
|
||||||
{% if cfg.theme.allow_user_toggle %}
|
{% if cfg.theme.allow_user_toggle %}
|
||||||
<button id="themeToggle" class="theme-toggle">🌙</button>
|
<button id="themeToggle" class="theme-toggle">🌙</button>
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,22 @@
|
||||||
<div class="view-full" id="viewFull" style="display:none">
|
<div class="view-full" id="viewFull" style="display:none">
|
||||||
<pre id="viewPre"><code id="codeBlock"></code></pre>
|
<pre id="viewPre"><code id="codeBlock"></code></pre>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="discussionsPanel" class="discussions-panel" style="display:none">
|
||||||
|
<div class="discussions-header" id="discussionsToggle">
|
||||||
|
<span class="discussions-title">💬 Discussions (<span id="discussionsCount">0</span>)</span>
|
||||||
|
<span class="discussions-chevron" id="discussionsChevron">▼</span>
|
||||||
|
</div>
|
||||||
|
<div class="discussions-body">
|
||||||
|
<div id="commentsList" class="comments-list"></div>
|
||||||
|
<div class="comment-form-wrap">
|
||||||
|
<div class="comment-form-fields">
|
||||||
|
<input type="text" id="commentNick" class="comment-nick" placeholder="Nickname (optional)" maxlength="32" autocomplete="off">
|
||||||
|
<textarea id="commentInput" class="comment-input" placeholder="Add a comment… (Ctrl+Enter to post)" rows="2"></textarea>
|
||||||
|
</div>
|
||||||
|
<button id="commentSubmit" class="nav-btn nav-btn-save">Post</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<script type="application/json" id="encryptedPayload">{{ paste.encrypted_data | tojson }}</script>
|
<script type="application/json" id="encryptedPayload">{{ paste.encrypted_data | tojson }}</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue