forked from ComputerTech/bastebin
Compare commits
11 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
b4706ae101 | |
|
|
d5a9efd285 | |
|
|
3c15f1e2c7 | |
|
|
9aeed2af50 | |
|
|
24d70aeb08 | |
|
|
89e194a435 | |
|
|
c8d78c7d61 | |
|
|
f93d232769 | |
|
|
be6b102d16 | |
|
|
cc9b184277 | |
|
|
636d4c919e |
|
|
@ -60,5 +60,9 @@ Thumbs.db
|
||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
|
||||||
# User uploads (if you add file upload functionality)
|
# Gunicorn
|
||||||
uploads/gunicorn.pid
|
gunicorn.pid
|
||||||
|
|
||||||
|
# Runtime config — contains secrets (SECRET_KEY, admin password hash).
|
||||||
|
# Copy config.example.json → config.json and fill in your values.
|
||||||
|
config.json
|
||||||
|
|
|
||||||
279
app.py
279
app.py
|
|
@ -1,3 +1,4 @@
|
||||||
|
import hmac
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
|
@ -127,17 +128,31 @@ 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,
|
||||||
|
burn_after_read INTEGER DEFAULT 0,
|
||||||
|
burn_token TEXT,
|
||||||
|
burn_claimed 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')
|
||||||
|
# Migration: add burn_after_read / burn_token / burn_claimed 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')
|
||||||
|
if columns and 'id' in columns and 'burn_claimed' not in columns:
|
||||||
|
cursor.execute('ALTER TABLE pastes ADD COLUMN burn_claimed 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 +161,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()
|
||||||
|
|
@ -209,15 +233,32 @@ def _is_same_origin():
|
||||||
return referer.startswith(base_url)
|
return referer.startswith(base_url)
|
||||||
return False
|
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):
|
def _check_rate_limit(remote_ip, key_prefix='rl', window=600, limit=10):
|
||||||
"""Generic rate limiting via SQLite. Window is in seconds."""
|
"""Generic rate limiting via SQLite. Window is in seconds."""
|
||||||
now_ts = time.time()
|
now_ts = time.time()
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
try:
|
try:
|
||||||
count = conn.execute(
|
try:
|
||||||
'SELECT COUNT(*) FROM rate_limits WHERE ip_address = ? AND timestamp > ?',
|
count = conn.execute(
|
||||||
(f"{key_prefix}:{remote_ip}", now_ts - window)
|
'SELECT COUNT(*) FROM rate_limits WHERE ip_address = ? AND timestamp > ?',
|
||||||
).fetchone()[0]
|
(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:
|
if count >= limit:
|
||||||
return False
|
return False
|
||||||
|
|
@ -254,7 +295,6 @@ def index():
|
||||||
def create_paste():
|
def create_paste():
|
||||||
# ── Rate limiting ────────────────────────────────────────────────────────
|
# ── 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)
|
||||||
# 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):
|
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
|
return jsonify({'error': 'Rate limit exceeded. Please wait a few minutes.'}), 429
|
||||||
|
|
||||||
|
|
@ -262,6 +302,9 @@ 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))
|
||||||
|
burn_after_read = bool(data.get('burn_after_read', 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', '')
|
||||||
|
|
@ -282,7 +325,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
|
||||||
|
|
||||||
|
|
@ -309,6 +352,7 @@ def create_paste():
|
||||||
expires_at = datetime.datetime.now(_UTC) + delta
|
expires_at = datetime.datetime.now(_UTC) + delta
|
||||||
|
|
||||||
deletion_token = secrets.token_urlsafe(32)
|
deletion_token = secrets.token_urlsafe(32)
|
||||||
|
burn_token = secrets.token_urlsafe(32) if burn_after_read else None
|
||||||
paste_id = None
|
paste_id = None
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
try:
|
try:
|
||||||
|
|
@ -316,8 +360,13 @@ 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 '
|
||||||
(paste_id, store_data, expires_at, deletion_token)
|
'(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()
|
conn.commit()
|
||||||
break
|
break
|
||||||
|
|
@ -350,10 +399,11 @@ def delete_paste(paste_id):
|
||||||
if not paste:
|
if not paste:
|
||||||
return jsonify({'error': 'Paste not found'}), 404
|
return jsonify({'error': 'Paste not found'}), 404
|
||||||
|
|
||||||
# Verify token
|
# Verify token — constant-time comparison to prevent timing attacks
|
||||||
if paste['deletion_token'] != token:
|
if not hmac.compare_digest(paste['deletion_token'] or '', token):
|
||||||
return jsonify({'error': 'Invalid deletion token'}), 403
|
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.execute('DELETE FROM pastes WHERE id = ?', (paste_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -366,14 +416,38 @@ def view_paste(paste_id):
|
||||||
if not _PASTE_ID_RE.match(paste_id):
|
if not _PASTE_ID_RE.match(paste_id):
|
||||||
abort(404)
|
abort(404)
|
||||||
paste = _get_paste_or_abort(paste_id)
|
paste = _get_paste_or_abort(paste_id)
|
||||||
# Increment view count in a separate connection after expiry check.
|
|
||||||
conn = get_db_connection()
|
burn_after_read = bool(paste['burn_after_read'])
|
||||||
try:
|
burn_token = paste['burn_token'] if burn_after_read else None
|
||||||
conn.execute('UPDATE pastes SET views = views + 1 WHERE id = ?', (paste_id,))
|
|
||||||
conn.commit()
|
if burn_after_read:
|
||||||
finally:
|
# Atomically claim this burn slot. Only the first request where
|
||||||
conn.close()
|
# burn_claimed=0 succeeds; any concurrent or subsequent request
|
||||||
return render_template('view.html', paste=paste)
|
# finds rowcount=0 and gets a 410. SQLite serialises writes so
|
||||||
|
# two simultaneous GETs cannot both claim the slot.
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
res = conn.execute(
|
||||||
|
'UPDATE pastes SET burn_claimed=1, views=views+1 WHERE id=? AND burn_claimed=0',
|
||||||
|
(paste_id,)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
claimed = res.rowcount > 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if not claimed:
|
||||||
|
abort(410) # Already viewed — paste is gone
|
||||||
|
else:
|
||||||
|
# Normal paste — just increment view count.
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
conn.execute('UPDATE pastes SET views = views + 1 WHERE id = ?', (paste_id,))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return render_template('view.html', paste=paste,
|
||||||
|
burn_after_read=burn_after_read, burn_token=burn_token)
|
||||||
|
|
||||||
@app.route('/<string:paste_id>/raw')
|
@app.route('/<string:paste_id>/raw')
|
||||||
def view_paste_raw(paste_id):
|
def view_paste_raw(paste_id):
|
||||||
|
|
@ -473,6 +547,123 @@ def recent_pastes():
|
||||||
conn.close()
|
conn.close()
|
||||||
return render_template('recent.html', pastes=pastes)
|
return render_template('recent.html', pastes=pastes)
|
||||||
|
|
||||||
|
# ── Burn-after-read API ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_BURN_TOKEN_RE = re.compile(r'^[A-Za-z0-9_-]{20,64}$')
|
||||||
|
|
||||||
|
@app.route('/api/burn/<string:paste_id>', methods=['POST'])
|
||||||
|
def burn_paste(paste_id):
|
||||||
|
"""Called by the client after successful decryption to destroy a burn-after-read paste."""
|
||||||
|
if not _PASTE_ID_RE.match(paste_id):
|
||||||
|
abort(404)
|
||||||
|
if not _is_same_origin():
|
||||||
|
return jsonify({'error': 'Cross-Origin request blocked'}), 403
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
token = data.get('burn_token', '')
|
||||||
|
if not token or not _BURN_TOKEN_RE.match(str(token)):
|
||||||
|
return jsonify({'error': 'burn_token required'}), 400
|
||||||
|
|
||||||
|
conn = get_db_connection()
|
||||||
|
try:
|
||||||
|
paste = conn.execute(
|
||||||
|
'SELECT burn_after_read, burn_token FROM pastes WHERE id = ?', (paste_id,)
|
||||||
|
).fetchone()
|
||||||
|
if not paste:
|
||||||
|
# Already burned or never existed — treat as success (idempotent)
|
||||||
|
return jsonify({'burned': True})
|
||||||
|
if not paste['burn_after_read']:
|
||||||
|
return jsonify({'error': 'This paste is not flagged for burn-after-read'}), 400
|
||||||
|
# Constant-time comparison prevents timing oracle
|
||||||
|
if not hmac.compare_digest(paste['burn_token'] or '', token):
|
||||||
|
return jsonify({'error': 'Invalid burn token'}), 403
|
||||||
|
conn.execute('DELETE FROM comments WHERE paste_id = ?', (paste_id,))
|
||||||
|
conn.execute('DELETE FROM pastes WHERE id = ?', (paste_id,))
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return jsonify({'burned': True})
|
||||||
|
|
||||||
|
# ── Comments API ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_MAX_COMMENT_BYTES = 10240
|
||||||
|
_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():
|
||||||
|
|
@ -518,9 +709,12 @@ def admin_dashboard():
|
||||||
pastes = conn.execute(
|
pastes = conn.execute(
|
||||||
'SELECT id, created_at, expires_at, views FROM pastes ORDER BY created_at DESC'
|
'SELECT id, created_at, expires_at, views FROM pastes ORDER BY created_at DESC'
|
||||||
).fetchall()
|
).fetchall()
|
||||||
|
active_count = conn.execute(
|
||||||
|
'SELECT COUNT(*) FROM pastes WHERE expires_at IS NULL OR expires_at > ?', (now,)
|
||||||
|
).fetchone()[0]
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return render_template('admin_dashboard.html', pastes=pastes)
|
return render_template('admin_dashboard.html', pastes=pastes, active_count=active_count)
|
||||||
|
|
||||||
@app.route('/admin/delete/<string:paste_id>', methods=['POST'])
|
@app.route('/admin/delete/<string:paste_id>', methods=['POST'])
|
||||||
def admin_delete_paste(paste_id):
|
def admin_delete_paste(paste_id):
|
||||||
|
|
@ -532,12 +726,42 @@ def admin_delete_paste(paste_id):
|
||||||
|
|
||||||
conn = get_db_connection()
|
conn = get_db_connection()
|
||||||
try:
|
try:
|
||||||
|
conn.execute('DELETE FROM comments WHERE paste_id = ?', (paste_id,))
|
||||||
conn.execute('DELETE FROM pastes WHERE id = ?', (paste_id,))
|
conn.execute('DELETE FROM pastes WHERE id = ?', (paste_id,))
|
||||||
conn.commit()
|
conn.commit()
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
return redirect(url_for('admin_dashboard'))
|
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 ────────────────────────────────────────────────────────────
|
# ── Error handlers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@app.errorhandler(404)
|
@app.errorhandler(404)
|
||||||
|
|
@ -550,8 +774,11 @@ def gone(error):
|
||||||
|
|
||||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
# ── 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__':
|
if __name__ == '__main__':
|
||||||
init_db()
|
|
||||||
app.run(
|
app.run(
|
||||||
debug=_server.get('debug', False),
|
debug=_server.get('debug', False),
|
||||||
host=_server.get('host', '0.0.0.0'),
|
host=_server.get('host', '0.0.0.0'),
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
{
|
{
|
||||||
"_comment": "Bastebin configuration. Restart the server after changing values.",
|
"_comment": "Bastebin configuration. Copy this file to config.json, fill in your values, and restart the server.",
|
||||||
|
|
||||||
"site": {
|
"site": {
|
||||||
"name": "Bastebin",
|
"name": "Bastebin",
|
||||||
|
|
@ -13,11 +13,13 @@
|
||||||
"host": "0.0.0.0",
|
"host": "0.0.0.0",
|
||||||
"port": 5500,
|
"port": 5500,
|
||||||
"debug": false,
|
"debug": false,
|
||||||
"secret_key": "ce81867ddcc16729b42d7ae0564140e6bcc83bcf5dbbe77b7e5b6c5aa4199347"
|
"use_https": true,
|
||||||
|
"_secret_key_note": "Prefer the SECRET_KEY environment variable. This fallback is used only if the env var is not set.",
|
||||||
|
"secret_key": "CHANGE-ME-use-a-long-random-string-at-least-32-chars"
|
||||||
},
|
},
|
||||||
|
|
||||||
"database": {
|
"database": {
|
||||||
"path": "pastebin.db"
|
"path": "bastebin.db"
|
||||||
},
|
},
|
||||||
|
|
||||||
"pastes": {
|
"pastes": {
|
||||||
|
|
@ -133,9 +135,12 @@
|
||||||
{"value": "toml", "name": "TOML"},
|
{"value": "toml", "name": "TOML"},
|
||||||
{"value": "ini", "name": "INI / Config"}
|
{"value": "ini", "name": "INI / Config"}
|
||||||
],
|
],
|
||||||
|
|
||||||
"admin": {
|
"admin": {
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
"user": "admin",
|
"_user_note": "Set a strong username — not 'admin'.",
|
||||||
"pass": "scrypt:32768:8:1$WKz9I6qE4hh0paUQ$6fd34e7f0195280f81301a92f5bac26d247f95d64744cb2c6e44108a3d8420eba5343b7b2ba657f39404f4ef102ce2e62a689e7797a43f3169fd69dca7b5b3c7"
|
"user": "your-admin-username",
|
||||||
|
"_pass_note": "Generate with: python generate_hash.py yourpassword",
|
||||||
|
"pass": "REPLACE-WITH-SCRYPT-HASH-FROM-generate_hash.py"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
32002
|
|
||||||
|
|
@ -0,0 +1,104 @@
|
||||||
|
# ── Bastebin — Nginx reverse-proxy configuration ─────────────────────────────
|
||||||
|
#
|
||||||
|
# Copy to /etc/nginx/sites-available/bastebin and symlink:
|
||||||
|
# sudo ln -s /etc/nginx/sites-available/bastebin /etc/nginx/sites-enabled/
|
||||||
|
# sudo nginx -t && sudo systemctl reload nginx
|
||||||
|
#
|
||||||
|
# Assumes:
|
||||||
|
# - Gunicorn is bound to a Unix socket at /run/bastebin/gunicorn.sock
|
||||||
|
# - Your TLS certificates are managed by Certbot / Let's Encrypt
|
||||||
|
# - Static files live at /path/to/bastebin/static/
|
||||||
|
#
|
||||||
|
# Replace every occurrence of:
|
||||||
|
# bastebin.com → your actual domain
|
||||||
|
# /path/to/bastebin → absolute path to your project directory
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name bastebin.com www.bastebin.com;
|
||||||
|
|
||||||
|
# Redirect all HTTP → HTTPS
|
||||||
|
return 301 https://$host$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
listen [::]:443 ssl http2;
|
||||||
|
server_name bastebin.com www.bastebin.com;
|
||||||
|
|
||||||
|
# ── TLS (managed by Certbot — do not edit manually) ──────────────────────
|
||||||
|
ssl_certificate /etc/letsencrypt/live/bastebin.com/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/bastebin.com/privkey.pem;
|
||||||
|
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||||
|
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||||
|
|
||||||
|
# ── Security headers (supplement Flask's own headers) ────────────────────
|
||||||
|
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||||
|
add_header X-Robots-Tag "noindex, nofollow" always;
|
||||||
|
|
||||||
|
# ── Block access to sensitive paths ──────────────────────────────────────
|
||||||
|
# These should NEVER be served, even accidentally.
|
||||||
|
location ~* \.(py|json|db|sqlite|sqlite3|log|cfg|ini|toml|env|sh|conf|bak|orig)$ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Block dot-files / hidden files (e.g. .env, .git)
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Block __pycache__ and similar Python internals
|
||||||
|
location ~* /__pycache__/ {
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Static files served directly by Nginx (fast path) ───────────────────
|
||||||
|
# Nginx serves these without hitting Gunicorn at all.
|
||||||
|
location /static/ {
|
||||||
|
alias /path/to/bastebin/static/;
|
||||||
|
|
||||||
|
# No directory listings
|
||||||
|
autoindex off;
|
||||||
|
|
||||||
|
# Cache busting — serve with long expiry; filename includes hash in prod
|
||||||
|
expires 7d;
|
||||||
|
add_header Cache-Control "public, max-age=604800, immutable";
|
||||||
|
|
||||||
|
# Only allow known file extensions; block anything else
|
||||||
|
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||||
|
# Passthrough — let the alias serve it
|
||||||
|
}
|
||||||
|
location / {
|
||||||
|
# Anything under /static/ that doesn't match above is denied
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Gunicorn (Flask application) ─────────────────────────────────────────
|
||||||
|
location / {
|
||||||
|
proxy_pass http://unix:/run/bastebin/gunicorn.sock;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
|
||||||
|
# Timeouts
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_read_timeout 60s;
|
||||||
|
proxy_send_timeout 60s;
|
||||||
|
|
||||||
|
# Body size limit — match config.json max_size_bytes (2 MB) + overhead
|
||||||
|
client_max_body_size 3m;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Logging ───────────────────────────────────────────────────────────────
|
||||||
|
access_log /var/log/nginx/bastebin.access.log;
|
||||||
|
error_log /var/log/nginx/bastebin.error.log warn;
|
||||||
|
}
|
||||||
|
|
@ -183,6 +183,14 @@ body {
|
||||||
min-height: 0;
|
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 ───────────────────────────────────────────────────── */
|
/* ── Editor textarea ───────────────────────────────────────────────────── */
|
||||||
.full-editor {
|
.full-editor {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
@ -202,10 +210,8 @@ body {
|
||||||
|
|
||||||
/* ── View: full-page code ──────────────────────────────────────────────── */
|
/* ── View: full-page code ──────────────────────────────────────────────── */
|
||||||
.view-full {
|
.view-full {
|
||||||
flex: 1;
|
min-height: calc(100vh - 42px); /* at least full viewport below navbar */
|
||||||
overflow: auto;
|
|
||||||
background: var(--code-bg);
|
background: var(--code-bg);
|
||||||
min-height: 0;
|
|
||||||
}
|
}
|
||||||
.view-full pre {
|
.view-full pre {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -218,6 +224,12 @@ body {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.6;
|
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) ──────────────────────────────────────────── */
|
/* ── Inline error (view page) ──────────────────────────────────────────── */
|
||||||
.error-inline {
|
.error-inline {
|
||||||
|
|
@ -285,6 +297,41 @@ pre[class*="language-"], code[class*="language-"] {
|
||||||
background: transparent !important;
|
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 ────────────────────────────────────────────────────────── */
|
/* ── Responsive ────────────────────────────────────────────────────────── */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.nav-input { width: 90px; }
|
.nav-input { width: 90px; }
|
||||||
|
|
@ -363,3 +410,287 @@ 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; }
|
||||||
|
|
||||||
|
/* ── 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;
|
||||||
|
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); }
|
||||||
|
|
||||||
|
/* ── 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); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Burn share panel (shown after creating a burn paste) ─────────────── */
|
||||||
|
.burn-share-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--bg);
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
.burn-share-inner {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.burn-share-icon {
|
||||||
|
font-size: 2rem;
|
||||||
|
color: #d97706;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .burn-share-icon {
|
||||||
|
color: #f59e0b;
|
||||||
|
}
|
||||||
|
.burn-share-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.burn-share-desc {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-sub);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.burn-share-link-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
.burn-share-url {
|
||||||
|
flex: 1;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: var(--mono);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
outline: none;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.burn-share-url:focus {
|
||||||
|
border-color: #d97706;
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .burn-share-url:focus {
|
||||||
|
border-color: #f59e0b;
|
||||||
|
}
|
||||||
|
.burn-share-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -102,6 +115,35 @@ const PasteCrypto = (function () {
|
||||||
ciphertext
|
ciphertext
|
||||||
);
|
);
|
||||||
return new TextDecoder().decode(decrypted);
|
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;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,67 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||||
const submitBtn = document.getElementById('submitBtn');
|
const submitBtn = document.getElementById('submitBtn');
|
||||||
const langSelect = document.getElementById('language');
|
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 ──────────────────────────────────────────────
|
// ── Load languages from API ──────────────────────────────────────────────
|
||||||
fetch('/api/languages')
|
fetch('/api/languages')
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
|
|
@ -36,92 +97,16 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||||
expirySelect.addEventListener('change', () =>
|
expirySelect.addEventListener('change', () =>
|
||||||
localStorage.setItem('preferred_expiry', expirySelect.value));
|
localStorage.setItem('preferred_expiry', expirySelect.value));
|
||||||
|
|
||||||
// ── Indent controls (Tabs / Spaces + size) ───────────────────────────────
|
// ── Restore discussions preference ───────────────────────────────────────
|
||||||
const indentType = document.getElementById('indent_type');
|
const discussCheck = document.getElementById('allowDiscussions');
|
||||||
const indentSize = document.getElementById('indent_size');
|
if (discussCheck) {
|
||||||
|
discussCheck.checked = localStorage.getItem('preferred_discussions') === 'true';
|
||||||
// Restore saved preferences
|
discussCheck.addEventListener('change', () =>
|
||||||
const savedIndentType = localStorage.getItem('preferred_indent_type');
|
localStorage.setItem('preferred_discussions', discussCheck.checked));
|
||||||
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', () => {
|
// Burn-after-read — intentionally NOT persisted to localStorage.
|
||||||
localStorage.setItem('preferred_indent_type', indentType.value);
|
const burnCheck = document.getElementById('burnAfterRead');
|
||||||
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 ──────────────────────────────────────────────────
|
// ── Ctrl/Cmd+S shortcut ──────────────────────────────────────────────────
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
|
|
@ -169,6 +154,8 @@ 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;
|
||||||
|
const burn_after_read = burnCheck?.checked ?? false;
|
||||||
|
|
||||||
submitBtn.disabled = true;
|
submitBtn.disabled = true;
|
||||||
submitBtn.textContent = '…';
|
submitBtn.textContent = '…';
|
||||||
|
|
@ -176,13 +163,25 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||||
try {
|
try {
|
||||||
let postBody, keyBase64 = null;
|
let postBody, keyBase64 = null;
|
||||||
if (E2E) {
|
if (E2E) {
|
||||||
const keyLen = window.PBCFG?.pastes?.encryption_key_bits ?? 128;
|
let key, fragmentValue;
|
||||||
const key = await PasteCrypto.generateKey(keyLen);
|
if (_pendingPassword) {
|
||||||
keyBase64 = await PasteCrypto.exportKey(key);
|
// Password-based: derive key from password via PBKDF2.
|
||||||
const plain = JSON.stringify({ title, content, language });
|
// Fragment is "p:<salt>" — no raw key in the URL.
|
||||||
postBody = { encrypted_data: await PasteCrypto.encrypt(plain, key), expires_in };
|
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 {
|
} else {
|
||||||
postBody = { title, content, language, expires_in };
|
postBody = { title, content, language, expires_in, discussions, burn_after_read };
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = await fetch('/create', {
|
const resp = await fetch('/create', {
|
||||||
|
|
@ -203,7 +202,16 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||||
if (result.deletion_token) {
|
if (result.deletion_token) {
|
||||||
localStorage.setItem('del_' + result.paste_id, result.deletion_token);
|
localStorage.setItem('del_' + result.paste_id, result.deletion_token);
|
||||||
}
|
}
|
||||||
window.location.href = result.url + (keyBase64 ? '#' + keyBase64 : '');
|
|
||||||
|
const shareUrl = window.location.origin + result.url + (keyBase64 ? '#' + keyBase64 : '');
|
||||||
|
|
||||||
|
if (burn_after_read) {
|
||||||
|
// Don't navigate — creator must NOT open the paste or it burns.
|
||||||
|
// Show a share panel so they can copy the link and hand it to the recipient.
|
||||||
|
showBurnSharePanel(shareUrl);
|
||||||
|
} else {
|
||||||
|
window.location.href = shareUrl;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|
@ -212,4 +220,36 @@ document.addEventListener('DOMContentLoaded', function () {
|
||||||
submitBtn.textContent = 'Save';
|
submitBtn.textContent = 'Save';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showBurnSharePanel(url) {
|
||||||
|
// Hide the editor so the user can't accidentally re-submit
|
||||||
|
const editorEl = document.getElementById('content');
|
||||||
|
if (editorEl) editorEl.style.display = 'none';
|
||||||
|
// Hide nav controls (keep theme toggle visible)
|
||||||
|
document.querySelectorAll('.nav-links > *:not(.theme-toggle)').forEach(el => {
|
||||||
|
el.style.visibility = 'hidden';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Populate and show the panel
|
||||||
|
const panel = document.getElementById('burnSharePanel');
|
||||||
|
const urlEl = document.getElementById('burnShareUrl');
|
||||||
|
const copyEl = document.getElementById('burnShareCopy');
|
||||||
|
if (!panel || !urlEl || !copyEl) return;
|
||||||
|
|
||||||
|
urlEl.value = url;
|
||||||
|
panel.style.display = 'flex';
|
||||||
|
|
||||||
|
// Auto-select the URL for easy manual copying
|
||||||
|
urlEl.focus();
|
||||||
|
urlEl.select();
|
||||||
|
|
||||||
|
copyEl.addEventListener('click', async () => {
|
||||||
|
const ok = typeof copyToClipboard === 'function'
|
||||||
|
? await copyToClipboard(url)
|
||||||
|
: await navigator.clipboard?.writeText(url).then(() => true).catch(() => false);
|
||||||
|
const prev = copyEl.textContent;
|
||||||
|
copyEl.textContent = ok ? 'Copied!' : 'Failed';
|
||||||
|
setTimeout(() => { copyEl.textContent = prev; }, 1800);
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,10 @@
|
||||||
* is needed in this external file.
|
* is needed in this external file.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let _decryptedPaste = null;
|
let _decryptedPaste = null;
|
||||||
|
let _pasteIsEncrypted = false;
|
||||||
|
let _keyBase64 = null;
|
||||||
|
let _burnMeta = { burn_after_read: false, burn_token: null };
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
let rawPayload;
|
let rawPayload;
|
||||||
|
|
@ -20,6 +23,12 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
return;
|
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.
|
// Detect format from the data itself.
|
||||||
// Encrypted pastes: "base64url:base64url" string.
|
// Encrypted pastes: "base64url:base64url" string.
|
||||||
// Plaintext pastes: a JSON object.
|
// Plaintext pastes: a JSON object.
|
||||||
|
|
@ -27,20 +36,45 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
/^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$/.test(rawPayload);
|
/^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$/.test(rawPayload);
|
||||||
|
|
||||||
if (isEncrypted) {
|
if (isEncrypted) {
|
||||||
const keyBase64 = window.location.hash.slice(1);
|
const fragment = window.location.hash.slice(1);
|
||||||
if (!keyBase64) {
|
if (!fragment) {
|
||||||
showError('No Key',
|
showError('No Key',
|
||||||
'The decryption key is missing from the URL. ' +
|
'The decryption key is missing from the URL. ' +
|
||||||
'Use the full link including the # part.');
|
'Use the full link including the # part.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
const key = await PasteCrypto.importKey(keyBase64);
|
if (fragment.startsWith('p:')) {
|
||||||
const plaintext = await PasteCrypto.decrypt(rawPayload, key);
|
// Password-protected paste — show password prompt
|
||||||
_decryptedPaste = JSON.parse(plaintext);
|
const saltBase64url = fragment.slice(2);
|
||||||
} catch (e) {
|
showPasswordPrompt(async (password) => {
|
||||||
showError('Decryption Failed', 'Wrong key or tampered data.');
|
try {
|
||||||
return;
|
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 {
|
} else {
|
||||||
// Plaintext paste — rawPayload is already the parsed JSON object.
|
// Plaintext paste — rawPayload is already the parsed JSON object.
|
||||||
|
|
@ -52,13 +86,54 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||||
showError('Bad Data', 'Could not parse paste data.');
|
showError('Bad Data', 'Could not parse paste data.');
|
||||||
return;
|
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);
|
renderPaste(_decryptedPaste);
|
||||||
initPasteActions();
|
initPasteActions();
|
||||||
|
initLineHighlight();
|
||||||
initLineNumbers();
|
initLineNumbers();
|
||||||
|
initWordWrap();
|
||||||
|
initDiscussions();
|
||||||
initDeletion();
|
initDeletion();
|
||||||
});
|
}
|
||||||
|
|
||||||
function initPasteActions() {
|
function initPasteActions() {
|
||||||
const rawBtn = document.getElementById('rawBtn');
|
const rawBtn = document.getElementById('rawBtn');
|
||||||
|
|
@ -86,27 +161,22 @@ function initLineNumbers() {
|
||||||
viewPre.classList.add('line-numbers');
|
viewPre.classList.add('line-numbers');
|
||||||
} else {
|
} else {
|
||||||
viewPre.classList.remove('line-numbers');
|
viewPre.classList.remove('line-numbers');
|
||||||
|
const existing = viewPre.querySelector('.line-numbers-rows');
|
||||||
|
if (existing) existing.remove();
|
||||||
}
|
}
|
||||||
localStorage.setItem('show_line_numbers', checked);
|
localStorage.setItem('show_line_numbers', checked);
|
||||||
|
|
||||||
// Re-highlight if a language is selected to force Prism to update the numbers span
|
// Always re-highlight so Prism's line-numbers plugin runs (works for plain text too)
|
||||||
const code = document.getElementById('codeBlock');
|
const code = document.getElementById('codeBlock');
|
||||||
if (code && (code.className.includes('language-') || viewPre.className.includes('language-'))) {
|
if (code) {
|
||||||
// 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);
|
Prism.highlightElement(code);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
toggle.addEventListener('change', updateLines);
|
toggle.addEventListener('change', updateLines);
|
||||||
|
|
||||||
// Initial state
|
// Apply initial state (calls Prism so line numbers render without needing a toggle)
|
||||||
if (isEnabled) {
|
updateLines();
|
||||||
viewPre.classList.add('line-numbers');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPaste(paste) {
|
function renderPaste(paste) {
|
||||||
|
|
@ -137,6 +207,41 @@ function showError(title, detail) {
|
||||||
document.getElementById('errorState').style.display = 'block';
|
document.getElementById('errorState').style.display = 'block';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Burn-after-read ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function showBurnBanner() {
|
||||||
|
const existing = document.getElementById('burnBanner');
|
||||||
|
if (existing) return;
|
||||||
|
const banner = document.createElement('div');
|
||||||
|
banner.id = 'burnBanner';
|
||||||
|
banner.className = 'burn-banner';
|
||||||
|
banner.innerHTML = '🔥 <strong>This paste will self-destruct</strong> after you view it. Copy what you need now.';
|
||||||
|
// Insert at the top of main
|
||||||
|
const main = document.querySelector('main');
|
||||||
|
if (main) main.insertBefore(banner, main.firstChild);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scheduleBurn() {
|
||||||
|
if (!_burnMeta?.burn_after_read || !_burnMeta?.burn_token) return;
|
||||||
|
showBurnBanner();
|
||||||
|
const pasteId = window.location.pathname.split('/').pop();
|
||||||
|
const token = _burnMeta.burn_token;
|
||||||
|
// Give the user a moment to see the content before the request fires.
|
||||||
|
// The paste is already rendered; a brief delay is user-friendly but not a security gate.
|
||||||
|
await new Promise(r => setTimeout(r, 1500));
|
||||||
|
try {
|
||||||
|
await fetch(`/api/burn/${pasteId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ burn_token: token }),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Fire-and-forget — if it fails (e.g. offline), the server-side
|
||||||
|
// cleanup cron will eventually remove it, or the next viewer burns it.
|
||||||
|
console.warn('[Burn] Request failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function rawView() {
|
function rawView() {
|
||||||
const key = window.location.hash;
|
const key = window.location.hash;
|
||||||
const url = window.location.href.split('?')[0].split('#')[0] + '/raw' + key;
|
const url = window.location.href.split('?')[0].split('#')[0] + '/raw' + key;
|
||||||
|
|
@ -213,3 +318,297 @@ 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,13 +12,19 @@
|
||||||
<div class="admin-container">
|
<div class="admin-container">
|
||||||
<div class="admin-header">
|
<div class="admin-header">
|
||||||
<h1>Global Paste Management</h1>
|
<h1>Global Paste Management</h1>
|
||||||
<p>Total Pastes: {{ pastes|length }}</p>
|
<p>Active Pastes: <strong>{{ active_count }}</strong> | Total in DB (inc. expired): {{ pastes|length }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-bulk-bar">
|
||||||
|
<button class="btn-bulk-delete" id="bulkDeleteBtn" disabled>Delete Selected</button>
|
||||||
|
<span class="admin-bulk-count" id="bulkCount">0 selected</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="admin-table">
|
<table class="admin-table" id="adminTable">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="check-col"><input type="checkbox" id="selectAll" title="Select all"></th>
|
||||||
<th>ID</th>
|
<th>ID</th>
|
||||||
<th>Created</th>
|
<th>Created</th>
|
||||||
<th>Expires</th>
|
<th>Expires</th>
|
||||||
|
|
@ -29,6 +35,7 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for paste in pastes %}
|
{% for paste in pastes %}
|
||||||
<tr>
|
<tr>
|
||||||
|
<td class="check-col"><input type="checkbox" class="row-check" value="{{ paste.id }}"></td>
|
||||||
<td><a href="{{ url_for('view_paste', paste_id=paste.id) }}" target="_blank"><code>{{ paste.id }}</code></a></td>
|
<td><a href="{{ url_for('view_paste', paste_id=paste.id) }}" target="_blank"><code>{{ paste.id }}</code></a></td>
|
||||||
<td>{{ paste.created_at }}</td>
|
<td>{{ paste.created_at }}</td>
|
||||||
<td>{{ paste.expires_at or 'Never' }}</td>
|
<td>{{ paste.expires_at or 'Never' }}</td>
|
||||||
|
|
@ -44,4 +51,64 @@
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script nonce="{{ csp_nonce }}">
|
||||||
|
(function () {
|
||||||
|
const selectAll = document.getElementById('selectAll');
|
||||||
|
const bulkDeleteBtn = document.getElementById('bulkDeleteBtn');
|
||||||
|
const bulkCount = document.getElementById('bulkCount');
|
||||||
|
|
||||||
|
function getChecked() {
|
||||||
|
return [...document.querySelectorAll('.row-check:checked')];
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBulkBar() {
|
||||||
|
const checked = getChecked();
|
||||||
|
bulkCount.textContent = checked.length + ' selected';
|
||||||
|
bulkDeleteBtn.disabled = checked.length === 0;
|
||||||
|
const all = document.querySelectorAll('.row-check');
|
||||||
|
selectAll.indeterminate = checked.length > 0 && checked.length < all.length;
|
||||||
|
selectAll.checked = all.length > 0 && checked.length === all.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('.row-check').forEach(cb =>
|
||||||
|
cb.addEventListener('change', updateBulkBar)
|
||||||
|
);
|
||||||
|
|
||||||
|
selectAll.addEventListener('change', function () {
|
||||||
|
document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);
|
||||||
|
updateBulkBar();
|
||||||
|
});
|
||||||
|
|
||||||
|
bulkDeleteBtn.addEventListener('click', async function () {
|
||||||
|
const checked = getChecked();
|
||||||
|
if (!checked.length) return;
|
||||||
|
if (!confirm('Delete ' + checked.length + ' paste(s) permanently?')) return;
|
||||||
|
|
||||||
|
bulkDeleteBtn.disabled = true;
|
||||||
|
bulkDeleteBtn.textContent = 'Deleting…';
|
||||||
|
|
||||||
|
const ids = checked.map(cb => cb.value);
|
||||||
|
try {
|
||||||
|
const resp = await fetch('{{ url_for("admin_bulk_delete") }}', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ ids }),
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
window.location.reload();
|
||||||
|
} else {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
alert('Error: ' + (data.error || resp.status));
|
||||||
|
bulkDeleteBtn.disabled = false;
|
||||||
|
bulkDeleteBtn.textContent = 'Delete Selected';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('Request failed. Please try again.');
|
||||||
|
bulkDeleteBtn.disabled = false;
|
||||||
|
bulkDeleteBtn.textContent = 'Delete Selected';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
|
||||||
|
|
@ -6,15 +6,6 @@
|
||||||
{% block nav_actions %}
|
{% block nav_actions %}
|
||||||
<input type="text" id="title" placeholder="Title (optional)" class="nav-input" maxlength="100" autocomplete="off">
|
<input type="text" id="title" placeholder="Title (optional)" class="nav-input" maxlength="100" autocomplete="off">
|
||||||
<select id="language" class="nav-select"></select>
|
<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">
|
<select id="expires_in" class="nav-select">
|
||||||
{% for opt in cfg.pastes.allow_expiry_options %}
|
{% for opt in cfg.pastes.allow_expiry_options %}
|
||||||
<option value="{{ opt }}" {% if opt == cfg.pastes.default_expiry %}selected{% endif %}>
|
<option value="{{ opt }}" {% if opt == cfg.pastes.default_expiry %}selected{% endif %}>
|
||||||
|
|
@ -23,6 +14,13 @@
|
||||||
{% 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>
|
||||||
|
<label class="nav-label" title="Delete this paste the moment it is first opened">
|
||||||
|
<input type="checkbox" id="burnAfterRead"> 🔥 Burn
|
||||||
|
</label>
|
||||||
|
<button id="passwordBtn" class="nav-btn" title="Set a password for this paste">🔒 Lock</button>
|
||||||
<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>
|
||||||
|
|
@ -30,6 +28,26 @@
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div id="passwordModal" class="modal-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="passwordModalTitle">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h2 id="passwordModalTitle" class="modal-title">🔒 Password Protect</h2>
|
||||||
|
<p class="modal-desc">The password is never sent to the server. Anyone viewing this paste will need it to decrypt.</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="pastePassword">Password</label>
|
||||||
|
<input type="password" id="pastePassword" class="modal-input" placeholder="Enter password…" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="pastePasswordConfirm">Confirm Password</label>
|
||||||
|
<input type="password" id="pastePasswordConfirm" class="modal-input" placeholder="Confirm password…" autocomplete="new-password">
|
||||||
|
</div>
|
||||||
|
<div id="passwordModalError" class="modal-error" style="display:none"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="passwordModalClear" class="btn">Remove Lock</button>
|
||||||
|
<button id="passwordModalCancel" class="btn">Cancel</button>
|
||||||
|
<button id="passwordModalSave" class="btn btn-primary">Set Password</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
id="content"
|
id="content"
|
||||||
class="full-editor"
|
class="full-editor"
|
||||||
|
|
@ -38,6 +56,20 @@
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
autocorrect="off"
|
autocorrect="off"
|
||||||
autocapitalize="off"></textarea>
|
autocapitalize="off"></textarea>
|
||||||
|
<div id="burnSharePanel" class="burn-share-panel" style="display:none" aria-live="polite">
|
||||||
|
<div class="burn-share-inner">
|
||||||
|
<div class="burn-share-icon">■</div>
|
||||||
|
<h2 class="burn-share-title">Burn link ready</h2>
|
||||||
|
<p class="burn-share-desc">This link can only be opened <strong>once</strong>. Share it with the intended recipient — do not open it yourself.</p>
|
||||||
|
<div class="burn-share-link-row">
|
||||||
|
<input type="text" id="burnShareUrl" class="burn-share-url" readonly>
|
||||||
|
<button id="burnShareCopy" class="nav-btn nav-btn-save">Copy</button>
|
||||||
|
</div>
|
||||||
|
<div class="burn-share-actions">
|
||||||
|
<a href="/" class="btn btn-primary">Create another</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,16 @@
|
||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block title %}{{ cfg.site.name }}{% endblock %}
|
{% block title %}{{ cfg.site.name }}{% endblock %}
|
||||||
{% block main_class %}full-page{% endblock %}
|
{% block main_class %}full-page view-page{% endblock %}
|
||||||
|
|
||||||
{% block nav_actions %}
|
{% block nav_actions %}
|
||||||
<span id="navPasteTitle" class="nav-paste-title"></span>
|
<span id="navPasteTitle" class="nav-paste-title"></span>
|
||||||
<label class="nav-label" title="Toggle line numbers">
|
<label class="nav-label" title="Toggle line numbers">
|
||||||
<input type="checkbox" id="lineNoToggle" checked> Lines
|
<input type="checkbox" id="lineNoToggle" checked> Lines
|
||||||
</label>
|
</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="rawBtn" class="nav-btn">Raw</button>
|
||||||
<button id="copyBtn" class="nav-btn">Copy</button>
|
<button id="copyBtn" class="nav-btn">Copy</button>
|
||||||
<button id="downloadBtn" class="nav-btn">Download</button>
|
<button id="downloadBtn" class="nav-btn">Download</button>
|
||||||
|
|
@ -18,14 +21,44 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}<div id="passwordPrompt" class="modal-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="ppTitle">
|
||||||
<div id="errorState" class="error-inline" style="display:none">
|
<div class="modal-box">
|
||||||
|
<h2 id="ppTitle" class="modal-title">🔒 Password Required</h2>
|
||||||
|
<p class="modal-desc">This paste is password protected. Enter the password to view it.</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="viewPassword">Password</label>
|
||||||
|
<input type="password" id="viewPassword" class="modal-input" placeholder="Enter password…" autocomplete="current-password">
|
||||||
|
</div>
|
||||||
|
<div id="ppError" class="modal-error" style="display:none">Incorrect password or corrupted data.</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<a href="/" class="btn">Cancel</a>
|
||||||
|
<button id="ppSubmit" class="btn btn-primary">Decrypt</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div><div id="errorState" class="error-inline" style="display:none">
|
||||||
🔐 <strong id="errorTitle">Decryption Failed</strong> — <span id="errorDetail"></span>
|
🔐 <strong id="errorTitle">Decryption Failed</strong> — <span id="errorDetail"></span>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
||||||
|
<script type="application/json" id="burnMeta">{{ {'burn_after_read': burn_after_read, 'burn_token': burn_token} | tojson }}</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue