Compare commits
1 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
f073f41984 |
125
app.py
125
app.py
|
|
@ -132,16 +132,12 @@ def init_db():
|
|||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
views INTEGER DEFAULT 0,
|
||||
deletion_token TEXT,
|
||||
discussions_enabled INTEGER DEFAULT 0
|
||||
deletion_token TEXT
|
||||
)
|
||||
''')
|
||||
# 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,
|
||||
|
|
@ -150,15 +146,6 @@ 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()
|
||||
|
|
@ -222,32 +209,15 @@ def _is_same_origin():
|
|||
return referer.startswith(base_url)
|
||||
return False
|
||||
|
||||
def _ensure_rate_limits_table(conn):
|
||||
"""Create the rate_limits table if it doesn't exist (migration safety net)."""
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS rate_limits (
|
||||
ip_address TEXT,
|
||||
timestamp REAL,
|
||||
PRIMARY KEY (ip_address, timestamp)
|
||||
)
|
||||
''')
|
||||
conn.execute('CREATE INDEX IF NOT EXISTS idx_rate_limit_ts ON rate_limits(timestamp)')
|
||||
conn.commit()
|
||||
|
||||
def _check_rate_limit(remote_ip, key_prefix='rl', window=600, limit=10):
|
||||
"""Generic rate limiting via SQLite. Window is in seconds."""
|
||||
now_ts = time.time()
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
try:
|
||||
count = conn.execute(
|
||||
'SELECT COUNT(*) FROM rate_limits WHERE ip_address = ? AND timestamp > ?',
|
||||
(f"{key_prefix}:{remote_ip}", now_ts - window)
|
||||
).fetchone()[0]
|
||||
except sqlite3.OperationalError:
|
||||
# Table missing (pre-migration DB) — create it and proceed.
|
||||
_ensure_rate_limits_table(conn)
|
||||
count = 0
|
||||
|
||||
if count >= limit:
|
||||
return False
|
||||
|
|
@ -292,8 +262,6 @@ 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', '')
|
||||
|
|
@ -314,7 +282,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, 'discussions': discussions_enabled})
|
||||
store_data = json.dumps({'title': title, 'content': content, 'language': language})
|
||||
else:
|
||||
return jsonify({'error': 'Provide either encrypted_data or content'}), 400
|
||||
|
||||
|
|
@ -348,8 +316,8 @@ 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) VALUES (?, ?, ?, ?)',
|
||||
(paste_id, store_data, expires_at, deletion_token)
|
||||
)
|
||||
conn.commit()
|
||||
break
|
||||
|
|
@ -505,86 +473,6 @@ 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/<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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
def is_admin():
|
||||
|
|
@ -662,11 +550,8 @@ def gone(error):
|
|||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
# Always initialise the DB when the module is imported (works under Gunicorn
|
||||
# and other WSGI servers that import app directly, not just via wsgi.py).
|
||||
init_db()
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_db()
|
||||
app.run(
|
||||
debug=_server.get('debug', False),
|
||||
host=_server.get('host', '0.0.0.0'),
|
||||
|
|
|
|||
|
|
@ -183,14 +183,6 @@ body {
|
|||
min-height: 0;
|
||||
}
|
||||
|
||||
/* On the view page, override to allow the discussions to extend below */
|
||||
.full-page.view-page {
|
||||
overflow: visible;
|
||||
overflow-y: auto;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Editor textarea ───────────────────────────────────────────────────── */
|
||||
.full-editor {
|
||||
flex: 1;
|
||||
|
|
@ -210,8 +202,10 @@ body {
|
|||
|
||||
/* ── View: full-page code ──────────────────────────────────────────────── */
|
||||
.view-full {
|
||||
min-height: calc(100vh - 42px); /* at least full viewport below navbar */
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
background: var(--code-bg);
|
||||
min-height: 0;
|
||||
}
|
||||
.view-full pre {
|
||||
margin: 0;
|
||||
|
|
@ -224,12 +218,6 @@ body {
|
|||
font-size: 0.875rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
/* Word wrap toggle */
|
||||
.view-full.wrap-lines pre,
|
||||
.view-full.wrap-lines code {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
/* ── Inline error (view page) ──────────────────────────────────────────── */
|
||||
.error-inline {
|
||||
|
|
@ -297,41 +285,6 @@ pre[class*="language-"], code[class*="language-"] {
|
|||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* ── Line highlight (paste view) ───────────────────────────────────────── */
|
||||
.view-full pre { position: relative; }
|
||||
.view-full code { position: relative; z-index: 1; }
|
||||
|
||||
/* Overlay bar behind code text */
|
||||
.line-hl {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
border-left: 3px solid var(--primary);
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
[data-theme="dark"] .line-hl {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
/* Gutter span for highlighted lines */
|
||||
.line-numbers-rows > span.hl-active::before {
|
||||
color: var(--primary) !important;
|
||||
font-weight: 700;
|
||||
}
|
||||
/* Gutter spans are clickable when line numbers are shown */
|
||||
.line-numbers-rows {
|
||||
pointer-events: auto !important;
|
||||
}
|
||||
.line-numbers-rows > span {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.line-numbers-rows > span:hover::before {
|
||||
color: var(--text-sub) !important;
|
||||
}
|
||||
|
||||
/* ── Responsive ────────────────────────────────────────────────────────── */
|
||||
@media (max-width: 600px) {
|
||||
.nav-input { width: 90px; }
|
||||
|
|
@ -410,107 +363,3 @@ 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-direction: column;
|
||||
border-top: 2px solid var(--border);
|
||||
background: var(--surface);
|
||||
/* no max-height — extends the page naturally */
|
||||
}
|
||||
.discussions-panel.collapsed .discussions-body {
|
||||
display: none;
|
||||
}
|
||||
.discussions-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1.5rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-sub);
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 42px; /* stick just below navbar */
|
||||
background: var(--surface);
|
||||
z-index: 5;
|
||||
}
|
||||
.discussions-header:hover { background: var(--bg); }
|
||||
.discussions-title { font-weight: 600; }
|
||||
.discussions-chevron { font-size: 0.7rem; color: var(--text-muted); }
|
||||
.discussions-body {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
.comments-list {
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
.comment-item {
|
||||
padding: 0.6rem 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.comment-item:last-child { border-bottom: none; }
|
||||
.comment-meta {
|
||||
font-size: 0.72rem;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.comment-meta.has-nick {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.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.75rem 0;
|
||||
}
|
||||
.comment-form-wrap {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
align-items: flex-start;
|
||||
margin-top: 0.25rem;
|
||||
background: var(--surface);
|
||||
}
|
||||
.comment-form-fields {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
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.8rem;
|
||||
padding: 0.3rem 0.6rem;
|
||||
outline: none;
|
||||
width: 180px;
|
||||
}
|
||||
.comment-nick:focus { border-color: var(--primary); }
|
||||
.comment-input {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
color: var(--text);
|
||||
font-family: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.4rem 0.6rem;
|
||||
resize: vertical;
|
||||
min-height: 4rem;
|
||||
outline: none;
|
||||
line-height: 1.5;
|
||||
width: 100%;
|
||||
}
|
||||
.comment-input:focus { border-color: var(--primary); }
|
||||
|
|
|
|||
|
|
@ -72,19 +72,6 @@ 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)
|
||||
|
|
|
|||
|
|
@ -36,13 +36,92 @@ 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));
|
||||
// ── Indent controls (Tabs / Spaces + size) ───────────────────────────────
|
||||
const indentType = document.getElementById('indent_type');
|
||||
const indentSize = document.getElementById('indent_size');
|
||||
|
||||
// Restore saved preferences
|
||||
const savedIndentType = localStorage.getItem('preferred_indent_type');
|
||||
const savedIndentSize = localStorage.getItem('preferred_indent_size');
|
||||
if (savedIndentType) indentType.value = savedIndentType;
|
||||
if (savedIndentSize) indentSize.value = savedIndentSize;
|
||||
|
||||
// Hide size selector when Tabs is chosen (tabs have no width setting in the input)
|
||||
function syncIndentUI() {
|
||||
indentSize.style.display = indentType.value === 'tabs' ? 'none' : '';
|
||||
}
|
||||
syncIndentUI();
|
||||
|
||||
indentType.addEventListener('change', () => {
|
||||
localStorage.setItem('preferred_indent_type', indentType.value);
|
||||
syncIndentUI();
|
||||
});
|
||||
indentSize.addEventListener('change', () =>
|
||||
localStorage.setItem('preferred_indent_size', indentSize.value));
|
||||
|
||||
// ── Tab key handler ──────────────────────────────────────────────────────
|
||||
textarea.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Tab') return;
|
||||
e.preventDefault();
|
||||
|
||||
const useTab = indentType.value === 'tabs';
|
||||
const indent = useTab ? '\t' : ' '.repeat(parseInt(indentSize.value, 10));
|
||||
|
||||
const start = this.selectionStart;
|
||||
const end = this.selectionEnd;
|
||||
const value = this.value;
|
||||
|
||||
if (start === end) {
|
||||
// No selection — insert indent at cursor
|
||||
this.value = value.slice(0, start) + indent + value.slice(end);
|
||||
this.selectionStart = this.selectionEnd = start + indent.length;
|
||||
} else {
|
||||
// Multi-line selection — indent / unindent each line
|
||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||
const selectedText = value.slice(lineStart, end);
|
||||
const lines = selectedText.split('\n');
|
||||
|
||||
let newText;
|
||||
if (e.shiftKey) {
|
||||
// Unindent: remove one level of indent from the start of each line
|
||||
newText = lines.map(line => {
|
||||
if (useTab) return line.startsWith('\t') ? line.slice(1) : line;
|
||||
const spaces = parseInt(indentSize.value, 10);
|
||||
return line.startsWith(' '.repeat(spaces))
|
||||
? line.slice(spaces)
|
||||
: line.replace(/^ +/, m => m.slice(Math.min(m.length, spaces)));
|
||||
}).join('\n');
|
||||
} else {
|
||||
// Indent: prepend indent to each line
|
||||
newText = lines.map(line => indent + line).join('\n');
|
||||
}
|
||||
|
||||
const removed = selectedText.length - newText.length; // negative when indenting
|
||||
this.value = value.slice(0, lineStart) + newText + value.slice(end);
|
||||
this.selectionStart = lineStart;
|
||||
this.selectionEnd = lineStart + newText.length;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Enter key: auto-indent to match previous line ────────────────────────
|
||||
textarea.addEventListener('keydown', function (e) {
|
||||
if (e.key !== 'Enter') return;
|
||||
e.preventDefault();
|
||||
|
||||
const start = this.selectionStart;
|
||||
const value = this.value;
|
||||
|
||||
// Find the beginning of the current line
|
||||
const lineStart = value.lastIndexOf('\n', start - 1) + 1;
|
||||
const currentLine = value.slice(lineStart, start);
|
||||
|
||||
// Grab the leading whitespace (spaces or tabs) of the current line
|
||||
const leadingWhitespace = currentLine.match(/^(\s*)/)[1];
|
||||
|
||||
const insert = '\n' + leadingWhitespace;
|
||||
this.value = value.slice(0, start) + insert + value.slice(this.selectionEnd);
|
||||
this.selectionStart = this.selectionEnd = start + insert.length;
|
||||
});
|
||||
|
||||
// ── Ctrl/Cmd+S shortcut ──────────────────────────────────────────────────
|
||||
document.addEventListener('keydown', e => {
|
||||
|
|
@ -90,7 +169,6 @@ 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 = '…';
|
||||
|
|
@ -101,10 +179,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, discussions });
|
||||
postBody = { encrypted_data: await PasteCrypto.encrypt(plain, key), expires_in, discussions };
|
||||
const plain = JSON.stringify({ title, content, language });
|
||||
postBody = { encrypted_data: await PasteCrypto.encrypt(plain, key), expires_in };
|
||||
} else {
|
||||
postBody = { title, content, language, expires_in, discussions };
|
||||
postBody = { title, content, language, expires_in };
|
||||
}
|
||||
|
||||
const resp = await fetch('/create', {
|
||||
|
|
|
|||
|
|
@ -8,8 +8,6 @@
|
|||
*/
|
||||
|
||||
let _decryptedPaste = null;
|
||||
let _pasteIsEncrypted = false;
|
||||
let _keyBase64 = null;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
let rawPayload;
|
||||
|
|
@ -40,8 +38,6 @@ 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;
|
||||
|
|
@ -60,10 +56,7 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
|
||||
renderPaste(_decryptedPaste);
|
||||
initPasteActions();
|
||||
initLineHighlight(); // register Prism hook before initLineNumbers triggers Prism
|
||||
initLineNumbers();
|
||||
initWordWrap();
|
||||
initDiscussions();
|
||||
initDeletion();
|
||||
});
|
||||
|
||||
|
|
@ -93,22 +86,27 @@ function initLineNumbers() {
|
|||
viewPre.classList.add('line-numbers');
|
||||
} else {
|
||||
viewPre.classList.remove('line-numbers');
|
||||
const existing = viewPre.querySelector('.line-numbers-rows');
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
localStorage.setItem('show_line_numbers', checked);
|
||||
|
||||
// Always re-highlight so Prism's line-numbers plugin runs (works for plain text too)
|
||||
// Re-highlight if a language is selected to force Prism to update the numbers span
|
||||
const code = document.getElementById('codeBlock');
|
||||
if (code) {
|
||||
if (code && (code.className.includes('language-') || viewPre.className.includes('language-'))) {
|
||||
// Prism's line-numbers plugin needs to clean up if turning off
|
||||
if (!checked) {
|
||||
const existing = viewPre.querySelector('.line-numbers-rows');
|
||||
if (existing) existing.remove();
|
||||
}
|
||||
Prism.highlightElement(code);
|
||||
}
|
||||
};
|
||||
|
||||
toggle.addEventListener('change', updateLines);
|
||||
|
||||
// Apply initial state (calls Prism so line numbers render without needing a toggle)
|
||||
updateLines();
|
||||
// Initial state
|
||||
if (isEnabled) {
|
||||
viewPre.classList.add('line-numbers');
|
||||
}
|
||||
}
|
||||
|
||||
function renderPaste(paste) {
|
||||
|
|
@ -215,297 +213,3 @@ function initDeletion() {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Line highlight / linking ──────────────────────────────────────────────────
|
||||
|
||||
let _hlAnchor = null; // anchor line for shift-click range
|
||||
let _hlStart = null;
|
||||
let _hlEnd = null;
|
||||
let _hlScrolled = false;
|
||||
|
||||
function initLineHighlight() {
|
||||
// Parse ?L=5 or ?L=5-12 from the URL
|
||||
const lParam = new URLSearchParams(window.location.search).get('L');
|
||||
if (lParam) {
|
||||
const m = lParam.match(/^(\d+)(?:-(\d+))?$/);
|
||||
if (m) {
|
||||
_hlStart = parseInt(m[1], 10);
|
||||
_hlEnd = m[2] ? parseInt(m[2], 10) : _hlStart;
|
||||
_hlAnchor = _hlStart;
|
||||
}
|
||||
}
|
||||
|
||||
// After every Prism highlight, wire gutter clicks and redraw highlight bars.
|
||||
// initLineNumbers() calls Prism, which fires this hook.
|
||||
Prism.hooks.add('complete', function (env) {
|
||||
if (env.element && env.element.id === 'codeBlock') {
|
||||
_wireLineClicks();
|
||||
if (_hlStart !== null) {
|
||||
_drawHighlight(_hlStart, _hlEnd);
|
||||
if (!_hlScrolled) {
|
||||
_scrollToLine(_hlStart);
|
||||
_hlScrolled = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function _wireLineClicks() {
|
||||
const viewPre = document.getElementById('viewPre');
|
||||
if (!viewPre) return;
|
||||
viewPre.querySelectorAll('.line-numbers-rows > span').forEach((span, i) => {
|
||||
if (span.dataset.lineWired) return;
|
||||
span.dataset.lineWired = '1';
|
||||
const lineNum = i + 1;
|
||||
span.title = `Line ${lineNum}`;
|
||||
span.addEventListener('click', (e) => {
|
||||
if (e.shiftKey && _hlAnchor !== null) {
|
||||
const a = Math.min(_hlAnchor, lineNum);
|
||||
const b = Math.max(_hlAnchor, lineNum);
|
||||
_hlStart = a; _hlEnd = b;
|
||||
_drawHighlight(a, b);
|
||||
_updateLineUrl(a, b);
|
||||
} else if (_hlStart === lineNum && _hlEnd === lineNum) {
|
||||
// Clicking the sole highlighted line → clear
|
||||
_hlAnchor = null; _hlStart = null; _hlEnd = null;
|
||||
_clearHighlight();
|
||||
_clearLineUrl();
|
||||
} else {
|
||||
_hlAnchor = lineNum;
|
||||
_hlStart = lineNum;
|
||||
_hlEnd = lineNum;
|
||||
_drawHighlight(lineNum, lineNum);
|
||||
_updateLineUrl(lineNum, lineNum);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _clearHighlight() {
|
||||
const viewPre = document.getElementById('viewPre');
|
||||
if (!viewPre) return;
|
||||
viewPre.querySelectorAll('.line-hl').forEach(el => el.remove());
|
||||
viewPre.querySelectorAll('.line-numbers-rows > span').forEach(
|
||||
s => s.classList.remove('hl-active')
|
||||
);
|
||||
}
|
||||
|
||||
function _clearLineUrl() {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.delete('L');
|
||||
history.replaceState(null, '', url.toString());
|
||||
}
|
||||
|
||||
function _drawHighlight(start, end) {
|
||||
const viewPre = document.getElementById('viewPre');
|
||||
const code = document.getElementById('codeBlock');
|
||||
if (!viewPre || !code) return;
|
||||
|
||||
// Remove old bars and gutter classes
|
||||
viewPre.querySelectorAll('.line-hl').forEach(el => el.remove());
|
||||
viewPre.querySelectorAll('.line-numbers-rows > span').forEach(
|
||||
(s, i) => s.classList.toggle('hl-active', i + 1 >= start && i + 1 <= end)
|
||||
);
|
||||
|
||||
const lineH = parseFloat(getComputedStyle(code).lineHeight) || 22.4;
|
||||
const padTop = parseFloat(getComputedStyle(viewPre).paddingTop) || 16;
|
||||
|
||||
const frag = document.createDocumentFragment();
|
||||
for (let i = start; i <= end; i++) {
|
||||
const bar = document.createElement('div');
|
||||
bar.className = 'line-hl';
|
||||
bar.style.top = (padTop + (i - 1) * lineH) + 'px';
|
||||
bar.style.height = lineH + 'px';
|
||||
frag.appendChild(bar);
|
||||
}
|
||||
// Insert before <code> so bars sit below code text in z-order
|
||||
viewPre.insertBefore(frag, viewPre.firstChild);
|
||||
}
|
||||
|
||||
function _scrollToLine(lineNum) {
|
||||
const code = document.getElementById('codeBlock');
|
||||
const viewPre = document.getElementById('viewPre');
|
||||
const scroller = document.querySelector('.view-full');
|
||||
if (!code || !viewPre || !scroller) return;
|
||||
const lineH = parseFloat(getComputedStyle(code).lineHeight) || 22.4;
|
||||
const padTop = parseFloat(getComputedStyle(viewPre).paddingTop) || 16;
|
||||
scroller.scrollTop = Math.max(0, padTop + (lineNum - 1) * lineH - 80);
|
||||
}
|
||||
|
||||
function _updateLineUrl(start, end) {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set('L', start === end ? String(start) : `${start}-${end}`);
|
||||
history.replaceState(null, '', url.toString());
|
||||
}
|
||||
|
||||
// ── Discussions ───────────────────────────────────────────────────────────────
|
||||
|
||||
function initWordWrap() {
|
||||
const toggle = document.getElementById('wrapToggle');
|
||||
const viewDiv = document.getElementById('viewFull');
|
||||
if (!toggle || !viewDiv) return;
|
||||
|
||||
const stored = localStorage.getItem('wrap_lines');
|
||||
const enabled = stored === null ? true : stored === 'true';
|
||||
toggle.checked = enabled;
|
||||
viewDiv.classList.toggle('wrap-lines', enabled);
|
||||
|
||||
toggle.addEventListener('change', () => {
|
||||
viewDiv.classList.toggle('wrap-lines', toggle.checked);
|
||||
localStorage.setItem('wrap_lines', toggle.checked);
|
||||
});
|
||||
}
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,15 @@
|
|||
{% block nav_actions %}
|
||||
<input type="text" id="title" placeholder="Title (optional)" class="nav-input" maxlength="100" autocomplete="off">
|
||||
<select id="language" class="nav-select"></select>
|
||||
<select id="indent_type" class="nav-select">
|
||||
<option value="spaces">Spaces</option>
|
||||
<option value="tabs">Tabs</option>
|
||||
</select>
|
||||
<select id="indent_size" class="nav-select">
|
||||
<option value="2">2</option>
|
||||
<option value="4">4</option>
|
||||
<option value="8">8</option>
|
||||
</select>
|
||||
<select id="expires_in" class="nav-select">
|
||||
{% for opt in cfg.pastes.allow_expiry_options %}
|
||||
<option value="{{ opt }}" {% if opt == cfg.pastes.default_expiry %}selected{% endif %}>
|
||||
|
|
@ -14,9 +23,6 @@
|
|||
{% endfor %}
|
||||
</select>
|
||||
<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>
|
||||
{% if cfg.theme.allow_user_toggle %}
|
||||
<button id="themeToggle" class="theme-toggle">🌙</button>
|
||||
|
|
|
|||
|
|
@ -1,16 +1,13 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ cfg.site.name }}{% endblock %}
|
||||
{% block main_class %}full-page view-page{% endblock %}
|
||||
{% block main_class %}full-page{% endblock %}
|
||||
|
||||
{% block nav_actions %}
|
||||
<span id="navPasteTitle" class="nav-paste-title"></span>
|
||||
<label class="nav-label" title="Toggle line numbers">
|
||||
<input type="checkbox" id="lineNoToggle" checked> Lines
|
||||
</label>
|
||||
<label class="nav-label" title="Toggle word wrap">
|
||||
<input type="checkbox" id="wrapToggle" checked> Wrap
|
||||
</label>
|
||||
<button id="rawBtn" class="nav-btn">Raw</button>
|
||||
<button id="copyBtn" class="nav-btn">Copy</button>
|
||||
<button id="downloadBtn" class="nav-btn">Download</button>
|
||||
|
|
@ -28,22 +25,6 @@
|
|||
<div class="view-full" id="viewFull" style="display:none">
|
||||
<pre id="viewPre"><code id="codeBlock"></code></pre>
|
||||
</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>
|
||||
{% endblock %}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue