Comprehensive security & reliability audit: hardened CSP, fixed vulnerabilities, improved theme management, and added line numbers toggle.

This commit is contained in:
ComputerTech 2026-03-27 15:22:53 +00:00
parent 6d05b40021
commit 0bb85da6bb
13 changed files with 495 additions and 281 deletions

189
app.py
View File

@ -1,11 +1,16 @@
import json import json
import os import os
import re import re
import secrets
import sqlite3 import sqlite3
import sys import sys
import threading
import uuid import uuid
import datetime import datetime
from flask import Flask, render_template, request, jsonify, abort, Response from flask import Flask, render_template, request, jsonify, abort, Response, g
_UTC = datetime.timezone.utc
# ── Load configuration ──────────────────────────────────────────────────────── # ── Load configuration ────────────────────────────────────────────────────────
@ -31,13 +36,28 @@ _ui = CFG['ui']
# ── Flask app setup ─────────────────────────────────────────────────────────── # ── Flask app setup ───────────────────────────────────────────────────────────
app = Flask(__name__) app = Flask(__name__)
app.config['SECRET_KEY'] = _server.get('secret_key', 'change-me')
# Prefer SECRET_KEY from the environment; fall back to config.json.
# Refuse to start if the key is still the default placeholder.
_secret_key = os.environ.get('SECRET_KEY') or _server.get('secret_key', '')
_DEFAULT_KEYS = {'', 'change-me', 'change-this-to-a-long-random-secret'}
if _secret_key in _DEFAULT_KEYS:
raise RuntimeError(
'SECRET_KEY is not set or is still the default placeholder. '
'Set a long random value in the SECRET_KEY environment variable '
'or in config.json before starting the server.'
)
app.config['SECRET_KEY'] = _secret_key
if _server.get('debug', False): if _server.get('debug', False):
print('WARNING: debug=true is set in config.json — never use debug mode in production!', print('WARNING: debug=true is set in config.json — never use debug mode in production!',
file=sys.stderr, flush=True) file=sys.stderr, flush=True)
DATABASE = _db_cfg.get('path', 'bastebin.db') _BASE_DIR = os.path.dirname(os.path.abspath(__file__))
_raw_db_path = _db_cfg.get('path', 'bastebin.db')
DATABASE = os.path.realpath(os.path.join(_BASE_DIR, _raw_db_path))
if not DATABASE.startswith(_BASE_DIR):
raise RuntimeError(f"Database path '{DATABASE}' must be within the project directory.")
MAX_ENCRYPTED_BYTES = _pastes.get('max_size_bytes', 2 * 1024 * 1024) MAX_ENCRYPTED_BYTES = _pastes.get('max_size_bytes', 2 * 1024 * 1024)
MAX_TITLE_BYTES = 200 MAX_TITLE_BYTES = 200
_ENCRYPTED_RE = re.compile(r'^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$') _ENCRYPTED_RE = re.compile(r'^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$')
@ -46,14 +66,20 @@ _PASTE_ID_RE = re.compile(r'^[0-9a-f]{4,32}$')
# ── Security headers ───────────────────────────────────────────────────────── # ── Security headers ─────────────────────────────────────────────────────────
@app.before_request
def _generate_csp_nonce():
"""Generate a unique nonce per request for the Content-Security-Policy."""
g.csp_nonce = secrets.token_urlsafe(16)
@app.after_request @app.after_request
def set_security_headers(response): def set_security_headers(response):
response.headers['X-Frame-Options'] = 'DENY' nonce = getattr(g, 'csp_nonce', '')
response.headers['X-Content-Type-Options'] = 'nosniff' response.headers['X-Frame-Options'] = 'DENY'
response.headers['Referrer-Policy'] = 'same-origin' response.headers['X-Content-Type-Options'] = 'nosniff'
response.headers['Content-Security-Policy'] = ( response.headers['Referrer-Policy'] = 'same-origin'
response.headers['Content-Security-Policy'] = (
"default-src 'self'; " "default-src 'self'; "
"script-src 'self' https://cdnjs.cloudflare.com 'unsafe-inline'; " f"script-src 'self' https://cdnjs.cloudflare.com 'nonce-{nonce}'; "
"style-src 'self' https://cdnjs.cloudflare.com 'unsafe-inline'; " "style-src 'self' https://cdnjs.cloudflare.com 'unsafe-inline'; "
"img-src 'self' data:; " "img-src 'self' data:; "
"connect-src 'self' blob:; " "connect-src 'self' blob:; "
@ -67,7 +93,7 @@ def set_security_headers(response):
@app.context_processor @app.context_processor
def inject_config(): def inject_config():
return {'cfg': CFG} return {'cfg': CFG, 'csp_nonce': getattr(g, 'csp_nonce', '')}
# ── Database ────────────────────────────────────────────────────────────────── # ── Database ──────────────────────────────────────────────────────────────────
@ -77,26 +103,35 @@ def get_db_connection():
conn.execute('PRAGMA journal_mode=WAL') conn.execute('PRAGMA journal_mode=WAL')
return conn return conn
_db_init_lock = threading.Lock()
_db_initialized = False
def init_db(): def init_db():
conn = sqlite3.connect(DATABASE) """Initialise the database. Thread-safe and idempotent: runs DDL only once per process."""
try: global _db_initialized
cursor = conn.cursor() with _db_init_lock:
cursor.execute("PRAGMA table_info(pastes)") if _db_initialized:
columns = {row[1] for row in cursor.fetchall()} return
if columns and 'content' in columns and 'encrypted_data' not in columns: conn = sqlite3.connect(DATABASE)
cursor.execute("DROP TABLE pastes") try:
cursor.execute(''' cursor = conn.cursor()
CREATE TABLE IF NOT EXISTS pastes ( cursor.execute("PRAGMA table_info(pastes)")
id TEXT PRIMARY KEY, columns = {row[1] for row in cursor.fetchall()}
encrypted_data TEXT NOT NULL, if columns and 'content' in columns and 'encrypted_data' not in columns:
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, cursor.execute("DROP TABLE pastes")
expires_at TIMESTAMP, cursor.execute('''
views INTEGER DEFAULT 0 CREATE TABLE IF NOT EXISTS pastes (
) id TEXT PRIMARY KEY,
''') encrypted_data TEXT NOT NULL,
conn.commit() created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
finally: expires_at TIMESTAMP,
conn.close() views INTEGER DEFAULT 0
)
''')
conn.commit()
finally:
conn.close()
_db_initialized = True
# ── Helpers ─────────────────────────────────────────────────────────────────── # ── Helpers ───────────────────────────────────────────────────────────────────
@ -107,20 +142,25 @@ def generate_paste_id():
def validate_encrypted_data(value): def validate_encrypted_data(value):
if not isinstance(value, str): if not isinstance(value, str):
return False return False
if len(value) > MAX_ENCRYPTED_BYTES: # Use byte length for a consistent limit regardless of character encoding
if len(value.encode('utf-8')) > MAX_ENCRYPTED_BYTES:
return False return False
return bool(_ENCRYPTED_RE.match(value)) return bool(_ENCRYPTED_RE.match(value))
def _get_paste_or_abort(paste_id): def _get_paste_or_abort(paste_id):
conn = get_db_connection() conn = get_db_connection()
paste = conn.execute('SELECT * FROM pastes WHERE id = ?', (paste_id,)).fetchone() try:
conn.close() paste = conn.execute('SELECT * FROM pastes WHERE id = ?', (paste_id,)).fetchone()
if not paste: if not paste:
abort(404) abort(404)
if paste['expires_at']: if paste['expires_at']:
expires_at = datetime.datetime.fromisoformat(paste['expires_at']) expires_at = datetime.datetime.fromisoformat(paste['expires_at']).replace(tzinfo=_UTC)
if expires_at < datetime.datetime.utcnow(): if expires_at < datetime.datetime.now(_UTC):
abort(410) conn.execute('DELETE FROM pastes WHERE id = ?', (paste_id,))
conn.commit()
abort(410)
finally:
conn.close()
return paste return paste
# ── Routes ──────────────────────────────────────────────────────────────────── # ── Routes ────────────────────────────────────────────────────────────────────
@ -172,7 +212,12 @@ def create_paste():
'1week': datetime.timedelta(weeks=1), '1week': datetime.timedelta(weeks=1),
'1month': datetime.timedelta(days=30), '1month': datetime.timedelta(days=30),
} }
expires_at = datetime.datetime.utcnow() + delta_map[expires_in] delta = delta_map.get(expires_in)
if delta is None:
# Config lists an expiry option not mapped in delta_map — treat as never
expires_in = 'never'
else:
expires_at = datetime.datetime.now(_UTC) + delta
paste_id = None paste_id = None
conn = get_db_connection() conn = get_db_connection()
@ -198,15 +243,10 @@ def create_paste():
def view_paste(paste_id): 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)
# Increment view count in a separate connection after expiry check.
conn = get_db_connection() conn = get_db_connection()
try: try:
paste = conn.execute('SELECT * FROM pastes WHERE id = ?', (paste_id,)).fetchone()
if not paste:
abort(404)
if paste['expires_at']:
expires = datetime.datetime.fromisoformat(paste['expires_at'])
if expires < datetime.datetime.utcnow():
abort(410)
conn.execute('UPDATE pastes SET views = views + 1 WHERE id = ?', (paste_id,)) conn.execute('UPDATE pastes SET views = views + 1 WHERE id = ?', (paste_id,))
conn.commit() conn.commit()
finally: finally:
@ -243,20 +283,67 @@ def get_languages():
@app.route('/api/config') @app.route('/api/config')
def get_client_config(): def get_client_config():
"""Expose the browser-safe portion of config to JavaScript.""" """Expose a whitelisted subset of config to the browser. Never forward entire blobs."""
return jsonify({ return jsonify({
'site': _site, 'site': {
'theme': _theme, 'name': _site.get('name', 'Bastebin'),
'features': _feat, 'tagline': _site.get('tagline', ''),
'ui': _ui, 'brand_icon': _site.get('brand_icon', ''),
'footer_text': _site.get('footer_text', ''),
},
'theme': {
'default': _theme.get('default', 'auto'),
'allow_user_toggle': _theme.get('allow_user_toggle', True),
'light': _theme.get('light', {}),
'dark': _theme.get('dark', {}),
},
'features': {
'encrypt_pastes': _feat.get('encrypt_pastes', True),
'show_recent': _feat.get('show_recent', False),
'show_view_count': _feat.get('show_view_count', True),
'show_e2e_banner': _feat.get('show_e2e_banner', True),
'allow_raw_api': _feat.get('allow_raw_api', True),
'auto_save_draft': _feat.get('auto_save_draft', True),
'draft_max_age_days': _feat.get('draft_max_age_days', 7),
'keyboard_shortcuts': _feat.get('keyboard_shortcuts', True),
},
'ui': {
'code_font_family': _ui.get('code_font_family', 'monospace'),
'code_font_size': _ui.get('code_font_size', '0.875rem'),
'code_line_height': _ui.get('code_line_height', '1.6'),
'textarea_rows': _ui.get('textarea_rows', 20),
'border_radius': _ui.get('border_radius', '8px'),
'animation_speed': _ui.get('animation_speed', '0.2s'),
},
'pastes': { 'pastes': {
'default_language': _pastes.get('default_language', 'text'), 'default_language': _pastes.get('default_language', 'text'),
'default_expiry': _pastes.get('default_expiry', 'never'), 'default_expiry': _pastes.get('default_expiry', 'never'),
'allow_expiry_options': _pastes.get('allow_expiry_options', []), 'allow_expiry_options': _pastes.get('allow_expiry_options', []),
'expiry_labels': _pastes.get('expiry_labels', {}), 'expiry_labels': _pastes.get('expiry_labels', {}),
} },
}) })
@app.route('/recent')
def recent_pastes():
"""Show recently created pastes (only available when show_recent is enabled in config)."""
if not _feat.get('show_recent', False):
abort(404)
limit = int(_pastes.get('recent_limit', 50))
now = datetime.datetime.now(_UTC).isoformat()
conn = get_db_connection()
try:
pastes = conn.execute(
'''
SELECT id, created_at, expires_at, views FROM pastes
WHERE expires_at IS NULL OR expires_at > ?
ORDER BY created_at DESC LIMIT ?
''',
(now, limit)
).fetchall()
finally:
conn.close()
return render_template('recent.html', pastes=pastes)
# ── Error handlers ──────────────────────────────────────────────────────────── # ── Error handlers ────────────────────────────────────────────────────────────
@app.errorhandler(404) @app.errorhandler(404)

View File

@ -12,8 +12,8 @@
"server": { "server": {
"host": "0.0.0.0", "host": "0.0.0.0",
"port": 5000, "port": 5000,
"debug": true, "debug": false,
"secret_key": "change-this-to-a-long-random-secret" "secret_key": "ce81867ddcc16729b42d7ae0564140e6bcc83bcf5dbbe77b7e5b6c5aa4199347"
}, },
"database": { "database": {

1
gunicorn.pid Normal file
View File

@ -0,0 +1 @@
26810

View File

@ -68,30 +68,32 @@ def cmd_start(host: str, port: int, workers: int) -> None:
return return
gunicorn = _gunicorn_bin() gunicorn = _gunicorn_bin()
log = open(LOG_FILE, 'a')
proc = subprocess.Popen( log = open(LOG_FILE, 'a')
[ try:
gunicorn, proc = subprocess.Popen(
'--config', CONF_FILE, [
'--bind', f'{host}:{port}', gunicorn,
'--workers', str(workers), '--config', CONF_FILE,
'--pid', PID_FILE, '--bind', f'{host}:{port}',
'wsgi:app', '--workers', str(workers),
], '--pid', PID_FILE,
stdout=log, 'wsgi:app',
stderr=log, ],
cwd=BASE_DIR, stdout=log,
start_new_session=True, stderr=log,
) cwd=BASE_DIR,
start_new_session=True,
)
except Exception:
log.close()
raise
# Wait briefly to confirm the process stayed alive. # Wait briefly to confirm the process stayed alive.
time.sleep(1.5) time.sleep(1.5)
log.close() # Gunicorn has inherited the fd; safe to close our end.
if proc.poll() is not None: if proc.poll() is not None:
log.close()
sys.exit(f'Gunicorn exited immediately. Check {LOG_FILE} for details.') sys.exit(f'Gunicorn exited immediately. Check {LOG_FILE} for details.')
log.close()
# Gunicorn writes its own PID file; confirm it appeared. # Gunicorn writes its own PID file; confirm it appeared.
pid = _read_pid() pid = _read_pid()
print(f'Started (PID {pid or proc.pid}) → http://{host}:{port}') print(f'Started (PID {pid or proc.pid}) → http://{host}:{port}')

View File

@ -1,3 +1,3 @@
Flask==3.0.0 Flask==3.1.0
Werkzeug==3.0.1 Werkzeug==3.1.3
gunicorn==25.2.0 gunicorn==23.0.0

View File

@ -139,6 +139,29 @@ body {
flex-shrink: 1; flex-shrink: 1;
} }
.nav-label {
display: flex;
align-items: center;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--text-sub);
cursor: pointer;
white-space: nowrap;
user-select: none;
padding: 0 0.2rem;
}
.nav-label:hover { color: var(--primary); }
.nav-label input { cursor: pointer; }
/* ── Prism Line Numbers ────────────────────────────────────────────────── */
.line-numbers .line-numbers-rows {
border-right: 1px solid var(--border) !important;
padding-right: 0.5rem !important;
}
.line-numbers-rows > span:before {
color: var(--text-muted) !important;
}
.theme-toggle { .theme-toggle {
background: none; background: none;
border: none; border: none;

View File

@ -22,17 +22,25 @@ const _UI_VAR_MAP = {
border_radius: '--radius', border_radius: '--radius',
}; };
// Fetch config then initialise theme immediately (before DOMContentLoaded) // Fetch config and initialise application when ready
(async function loadConfig() { document.addEventListener('DOMContentLoaded', async () => {
try { try {
const resp = await fetch('/api/config'); const resp = await fetch('/api/config');
if (resp.ok) window.PBCFG = await resp.json(); if (resp.ok) window.PBCFG = await resp.json();
} catch (e) { } catch (e) {
console.warn('Could not load /api/config, using CSS fallbacks.', e); console.warn('Could not load /api/config, using CSS fallbacks.', e);
} }
initialiseTheme(); initialiseTheme();
applyUiVars(); applyUiVars();
})(); initAutoSave();
// Attach theme toggle listener (fixing CSP blocking of inline onclick)
const toggleBtn = document.getElementById('themeToggle');
if (toggleBtn) {
toggleBtn.addEventListener('click', toggleTheme);
}
});
// ── Theme Management ──────────────────────────────────────────────────────── // ── Theme Management ────────────────────────────────────────────────────────
@ -233,6 +241,4 @@ document.addEventListener('keydown', function (e) {
} }
}); });
// ── Initialise auto-save once DOM is ready ────────────────────────────────── // Removed redundant DOMContentLoaded at the bottom — handled by the single listener at the top.
document.addEventListener('DOMContentLoaded', initAutoSave);

98
static/js/paste_create.js Normal file
View File

@ -0,0 +1,98 @@
'use strict';
/**
* paste_create.js Logic for the paste creation page.
* Depends on: crypto.js (PasteCrypto), app.js (clearDraft, window.PBCFG)
*/
document.addEventListener('DOMContentLoaded', function () {
const textarea = document.getElementById('content');
const submitBtn = document.getElementById('submitBtn');
const langSelect = document.getElementById('language');
// ── Load languages from API ──────────────────────────────────────────────
fetch('/api/languages')
.then(r => r.json())
.then(langs => {
langSelect.innerHTML = '';
langs.forEach(l => {
const o = document.createElement('option');
o.value = l.value;
o.textContent = l.name;
langSelect.appendChild(o);
});
// Restore last-used language preference
const saved = localStorage.getItem('preferred_language');
if (saved) langSelect.value = saved;
})
.catch(() => {});
langSelect.addEventListener('change', () =>
localStorage.setItem('preferred_language', langSelect.value));
// ── Restore expiry preference ────────────────────────────────────────────
const expirySelect = document.getElementById('expires_in');
const savedExpiry = localStorage.getItem('preferred_expiry');
if (savedExpiry) expirySelect.value = savedExpiry;
expirySelect.addEventListener('change', () =>
localStorage.setItem('preferred_expiry', expirySelect.value));
// ── Ctrl/Cmd+S shortcut ──────────────────────────────────────────────────
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault();
submitPaste();
}
});
submitBtn.addEventListener('click', submitPaste);
// ── Submit ───────────────────────────────────────────────────────────────
async function submitPaste() {
const content = textarea.value;
const title = document.getElementById('title').value || 'Untitled';
const language = langSelect.value;
const expires_in = expirySelect.value;
if (!content.trim()) { textarea.focus(); return; }
// Read E2E flag from the already-loaded config (fetched by app.js at startup).
// By the time the user clicks Save, window.PBCFG is guaranteed to be populated.
const E2E = window.PBCFG?.features?.encrypt_pastes ?? true;
submitBtn.disabled = true;
submitBtn.textContent = '…';
try {
let postBody, keyBase64 = null;
if (E2E) {
const key = await PasteCrypto.generateKey();
keyBase64 = await PasteCrypto.exportKey(key);
const plain = JSON.stringify({ title, content, language });
postBody = { encrypted_data: await PasteCrypto.encrypt(plain, key), expires_in };
} else {
postBody = { title, content, language, expires_in };
}
const resp = await fetch('/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(postBody),
});
const result = await resp.json();
if (result.error) {
alert('Error: ' + result.error);
submitBtn.disabled = false;
submitBtn.textContent = 'Save';
} else {
clearDraft();
window.location.href = result.url + (keyBase64 ? '#' + keyBase64 : '');
}
} catch (err) {
console.error(err);
alert('Failed to create paste. Try again.');
submitBtn.disabled = false;
submitBtn.textContent = 'Save';
}
}
});

182
static/js/paste_view.js Normal file
View File

@ -0,0 +1,182 @@
'use strict';
/**
* paste_view.js Logic for the paste view page.
* Depends on: crypto.js (PasteCrypto), app.js (copyToClipboard)
*
* Reads the site name from <meta name="site-name"> so no Jinja injection
* is needed in this external file.
*/
let _decryptedPaste = null;
document.addEventListener('DOMContentLoaded', async () => {
let rawPayload;
try {
const island = document.getElementById('encryptedPayload');
if (!island) return; // Not a paste view page
rawPayload = JSON.parse(island.textContent);
} catch (e) {
showError('Bad Data', 'Could not read the paste payload.');
return;
}
// Detect format from the data itself.
// Encrypted pastes: "base64url:base64url" string.
// Plaintext pastes: a JSON object.
const isEncrypted = typeof rawPayload === 'string' &&
/^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$/.test(rawPayload);
if (isEncrypted) {
const keyBase64 = window.location.hash.slice(1);
if (!keyBase64) {
showError('No Key',
'The decryption key is missing from the URL. ' +
'Use the full link including the # part.');
return;
}
try {
const key = await PasteCrypto.importKey(keyBase64);
const plaintext = await PasteCrypto.decrypt(rawPayload, key);
_decryptedPaste = JSON.parse(plaintext);
} catch (e) {
showError('Decryption Failed', 'Wrong key or tampered data.');
return;
}
} else {
// Plaintext paste — rawPayload is already the parsed JSON object.
try {
_decryptedPaste = (typeof rawPayload === 'object')
? rawPayload
: JSON.parse(rawPayload);
} catch (e) {
showError('Bad Data', 'Could not parse paste data.');
return;
}
}
renderPaste(_decryptedPaste);
initPasteActions();
initLineNumbers();
});
function initPasteActions() {
const rawBtn = document.getElementById('rawBtn');
const copyBtn = document.getElementById('copyBtn');
const downloadBtn = document.getElementById('downloadBtn');
if (rawBtn) rawBtn.addEventListener('click', rawView);
if (copyBtn) copyBtn.addEventListener('click', copyPaste);
if (downloadBtn) downloadBtn.addEventListener('click', downloadPaste);
}
function initLineNumbers() {
const toggle = document.getElementById('lineNoToggle');
const viewPre = document.getElementById('viewPre');
if (!toggle || !viewPre) return;
// Load preference from localStorage (default to checked)
const stored = localStorage.getItem('show_line_numbers');
const isEnabled = (stored === null) ? true : (stored === 'true');
toggle.checked = isEnabled;
const updateLines = () => {
const checked = toggle.checked;
if (checked) {
viewPre.classList.add('line-numbers');
} else {
viewPre.classList.remove('line-numbers');
}
localStorage.setItem('show_line_numbers', checked);
// Re-highlight if a language is selected to force Prism to update the numbers span
const code = document.getElementById('codeBlock');
if (code && (code.className.includes('language-') || viewPre.className.includes('language-'))) {
// Prism's line-numbers plugin needs to clean up if turning off
if (!checked) {
const existing = viewPre.querySelector('.line-numbers-rows');
if (existing) existing.remove();
}
Prism.highlightElement(code);
}
};
toggle.addEventListener('change', updateLines);
// Initial state
if (isEnabled) {
viewPre.classList.add('line-numbers');
}
}
function renderPaste(paste) {
const siteName = document.querySelector('meta[name="site-name"]')?.content || '';
const title = paste.title || 'Untitled';
document.title = title + (siteName ? ' \u2014 ' + siteName : '');
document.getElementById('navPasteTitle').textContent = title;
const lang = paste.language || 'text';
const prismLangMap = { text: false, html: 'markup', xml: 'markup', docker: 'docker' };
const prismLang = (lang in prismLangMap) ? prismLangMap[lang] : lang;
const codeBlock = document.getElementById('codeBlock');
const viewPre = document.getElementById('viewPre');
codeBlock.textContent = paste.content || '';
if (prismLang) {
codeBlock.className = 'language-' + prismLang;
viewPre.className = 'language-' + prismLang;
Prism.highlightElement(codeBlock);
}
document.getElementById('viewFull').style.display = 'block';
}
function showError(title, detail) {
document.getElementById('errorTitle').textContent = title;
document.getElementById('errorDetail').textContent = detail;
document.getElementById('errorState').style.display = 'block';
}
function rawView() {
if (!_decryptedPaste) return;
const blob = new Blob([_decryptedPaste.content], { type: 'text/plain; charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'),
{ href: url, target: '_blank', rel: 'noopener noreferrer' });
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
async function copyPaste() {
if (!_decryptedPaste) return;
const ok = await copyToClipboard(_decryptedPaste.content);
const btn = document.getElementById('copyBtn');
if (btn) {
const t = btn.textContent;
btn.textContent = ok ? 'Copied!' : 'Failed';
setTimeout(() => btn.textContent = t, 1500);
}
}
function downloadPaste() {
if (!_decryptedPaste) return;
const { title = 'untitled', content = '', language = 'text' } = _decryptedPaste;
const extMap = {
javascript: '.js', typescript: '.ts', python: '.py', java: '.java',
c: '.c', cpp: '.cpp', csharp: '.cs', html: '.html',
css: '.css', scss: '.scss', sql: '.sql', json: '.json',
yaml: '.yaml',xml: '.xml', bash: '.sh', powershell: '.ps1',
php: '.php', ruby: '.rb', go: '.go', rust: '.rs',
swift: '.swift',kotlin: '.kt', markdown:'.md', diff: '.diff',
docker: '', nginx: '.conf', toml: '.toml', ini: '.ini',
};
const filename = title.replace(/[^a-z0-9.\-]/gi, '_') + (extMap[language] ?? '.txt');
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), { href: url, download: filename });
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}

View File

@ -3,8 +3,9 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="site-name" content="{{ cfg.site.name }}">
<title>{% block title %}{{ cfg.site.name }}{% endblock %}</title> <title>{% block title %}{{ cfg.site.name }}{% endblock %}</title>
<script> <script nonce="{{ csp_nonce }}">
(function(){ (function(){
var t = localStorage.getItem('theme'); var t = localStorage.getItem('theme');
if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches)) if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches))
@ -13,6 +14,7 @@
</script> </script>
<link id="prism-light" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" rel="stylesheet" integrity="sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==" crossorigin="anonymous"> <link id="prism-light" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" rel="stylesheet" integrity="sha512-tN7Ec6zAFaVSG3TpNAKtk4DOHNpSwKHxxrsiw4GHKESGPs5njn/0sMCUMl2svV4wo4BK/rCP7juYz+zx+l6oeQ==" crossorigin="anonymous">
<link id="prism-dark" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" rel="stylesheet" integrity="sha512-vswe+cgvic/XBoF1OcM/TeJ2FW0OofqAVdCZiEYkd6dwGXthvkSFWOoGGJgS2CW70VK5dQM5Oh+7ne47s74VTg==" crossorigin="anonymous" disabled> <link id="prism-dark" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" rel="stylesheet" integrity="sha512-vswe+cgvic/XBoF1OcM/TeJ2FW0OofqAVdCZiEYkd6dwGXthvkSFWOoGGJgS2CW70VK5dQM5Oh+7ne47s74VTg==" crossorigin="anonymous" disabled>
<link href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/line-numbers/prism-line-numbers.min.css" rel="stylesheet" crossorigin="anonymous">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}"> <link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
</head> </head>
@ -24,7 +26,7 @@
{% block nav_actions %} {% block nav_actions %}
<a href="{{ url_for('index') }}" class="nav-btn">New</a> <a href="{{ url_for('index') }}" class="nav-btn">New</a>
{% if cfg.theme.allow_user_toggle %} {% if cfg.theme.allow_user_toggle %}
<button class="theme-toggle" onclick="toggleTheme()">🌙</button> <button id="themeToggle" class="theme-toggle">🌙</button>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
</div> </div>
@ -64,6 +66,7 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-nginx.min.js" integrity="sha512-FiVqlerxsba+BjEKw8+ZL01f8XUZScGKfJpZYz9ptAdBSc787nTjepF7ie14lyUJ6/OMVp3FDJ5efvtvsqFXCw==" crossorigin="anonymous"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-nginx.min.js" integrity="sha512-FiVqlerxsba+BjEKw8+ZL01f8XUZScGKfJpZYz9ptAdBSc787nTjepF7ie14lyUJ6/OMVp3FDJ5efvtvsqFXCw==" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-toml.min.js" integrity="sha512-R9JG7uVdcjWlZvEWyP3KfxtexvT1uIlKUF/dYVmZRbvJyMobK6zGCpIM2gLVqYjLSYeL/zBjOVpP7vXxVtzfCw==" crossorigin="anonymous"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-toml.min.js" integrity="sha512-R9JG7uVdcjWlZvEWyP3KfxtexvT1uIlKUF/dYVmZRbvJyMobK6zGCpIM2gLVqYjLSYeL/zBjOVpP7vXxVtzfCw==" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-ini.min.js" integrity="sha512-SEHSxegRLtkgGiD1O0/CV0ycF85DmBRLaZm0hIq0zTIKqZWJYX8z3tXDH8uPBexdsvFazJQ3TIcxMqFos4BRTw==" crossorigin="anonymous"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-ini.min.js" integrity="sha512-SEHSxegRLtkgGiD1O0/CV0ycF85DmBRLaZm0hIq0zTIKqZWJYX8z3tXDH8uPBexdsvFazJQ3TIcxMqFos4BRTw==" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/plugins/line-numbers/prism-line-numbers.min.js" crossorigin="anonymous"></script>
<script src="{{ url_for('static', filename='js/crypto.js') }}"></script> <script src="{{ url_for('static', filename='js/crypto.js') }}"></script>
<script src="{{ url_for('static', filename='js/app.js') }}"></script> <script src="{{ url_for('static', filename='js/app.js') }}"></script>
{% block scripts %}{% endblock %} {% block scripts %}{% endblock %}

View File

@ -15,7 +15,7 @@
</select> </select>
<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 class="theme-toggle" onclick="toggleTheme()">🌙</button> <button id="themeToggle" class="theme-toggle">🌙</button>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@ -31,92 +31,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script src="{{ url_for('static', filename='js/paste_create.js') }}" nonce="{{ csp_nonce }}"></script>
document.addEventListener('DOMContentLoaded', function () {
const textarea = document.getElementById('content');
const submitBtn = document.getElementById('submitBtn');
const langSelect = document.getElementById('language');
// Load languages
fetch('/api/languages')
.then(r => r.json())
.then(langs => {
langSelect.innerHTML = '';
langs.forEach(l => {
const o = document.createElement('option');
o.value = l.value; o.textContent = l.name;
langSelect.appendChild(o);
});
// Restore saved preference
const saved = localStorage.getItem('preferred_language');
if (saved) langSelect.value = saved;
})
.catch(() => {});
langSelect.addEventListener('change', () =>
localStorage.setItem('preferred_language', langSelect.value));
const expirySelect = document.getElementById('expires_in');
const savedExpiry = localStorage.getItem('preferred_expiry');
if (savedExpiry) expirySelect.value = savedExpiry;
expirySelect.addEventListener('change', () =>
localStorage.setItem('preferred_expiry', expirySelect.value));
// Ctrl/Cmd+S to save
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
e.preventDefault(); submitPaste();
}
});
submitBtn.addEventListener('click', submitPaste);
const E2E = {{ cfg.features.encrypt_pastes | tojson }};
async function submitPaste() {
const content = textarea.value;
const title = document.getElementById('title').value || 'Untitled';
const language = langSelect.value;
const expires_in = expirySelect.value;
if (!content.trim()) { textarea.focus(); return; }
submitBtn.disabled = true;
submitBtn.textContent = '…';
try {
let postBody, keyBase64 = null;
if (E2E) {
const key = await PasteCrypto.generateKey();
keyBase64 = await PasteCrypto.exportKey(key);
const plain = JSON.stringify({ title, content, language });
postBody = { encrypted_data: await PasteCrypto.encrypt(plain, key), expires_in };
} else {
postBody = { title, content, language, expires_in };
}
const resp = await fetch('/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(postBody)
});
const result = await resp.json();
if (result.error) {
alert('Error: ' + result.error);
submitBtn.disabled = false;
submitBtn.textContent = 'Save';
} else {
clearDraft();
window.location.href = result.url + (keyBase64 ? '#' + keyBase64 : '');
}
} catch (err) {
console.error(err);
alert('Failed to create paste. Try again.');
submitBtn.disabled = false;
submitBtn.textContent = 'Save';
}
}
});
</script>
{% endblock %} {% endblock %}

View File

@ -1,6 +1,6 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Recent Pastes - PasteBin{% endblock %} {% block title %}Recent Pastes — {{ cfg.site.name }}{% endblock %}
{% block content %} {% block content %}
<div class="recent-pastes"> <div class="recent-pastes">

View File

@ -5,12 +5,15 @@
{% block nav_actions %} {% block nav_actions %}
<span id="navPasteTitle" class="nav-paste-title"></span> <span id="navPasteTitle" class="nav-paste-title"></span>
<button onclick="rawView()" class="nav-btn">Raw</button> <label class="nav-label" title="Toggle line numbers">
<button onclick="copyPaste()" class="nav-btn">Copy</button> <input type="checkbox" id="lineNoToggle" checked> Lines
<button onclick="downloadPaste()" class="nav-btn">Download</button> </label>
<button id="rawBtn" class="nav-btn">Raw</button>
<button id="copyBtn" class="nav-btn">Copy</button>
<button id="downloadBtn" class="nav-btn">Download</button>
<a href="{{ url_for('index') }}" class="nav-btn nav-btn-save">New</a> <a href="{{ url_for('index') }}" class="nav-btn nav-btn-save">New</a>
{% if cfg.theme.allow_user_toggle %} {% if cfg.theme.allow_user_toggle %}
<button class="theme-toggle" onclick="toggleTheme()">🌙</button> <button id="themeToggle" class="theme-toggle">🌙</button>
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@ -25,109 +28,5 @@
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script> <script src="{{ url_for('static', filename='js/paste_view.js') }}" nonce="{{ csp_nonce }}"></script>
let _decryptedPaste = null;
(async function () {
let rawPayload;
try {
rawPayload = JSON.parse(document.getElementById('encryptedPayload').textContent);
} catch (e) {
showError('Bad Data', 'Could not read the paste payload.');
return;
}
// Detect format from the data itself, not from the config flag.
// Encrypted pastes are stored as "base64url:base64url"; plaintext pastes
// are stored as a JSON object string.
const isEncrypted = typeof rawPayload === 'string' && /^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$/.test(rawPayload);
if (isEncrypted) {
const keyBase64 = window.location.hash.slice(1);
if (!keyBase64) {
showError('No Key', 'The decryption key is missing from the URL. Use the full link including the # part.');
return;
}
try {
const key = await PasteCrypto.importKey(keyBase64);
const plaintext = await PasteCrypto.decrypt(rawPayload, key);
_decryptedPaste = JSON.parse(plaintext);
} catch (e) {
showError('Decryption Failed', 'Wrong key or tampered data.');
return;
}
} else {
// Plaintext paste — rawPayload is already the parsed JSON object.
try {
_decryptedPaste = typeof rawPayload === 'object' ? rawPayload : JSON.parse(rawPayload);
} catch (e) {
showError('Bad Data', 'Could not parse paste data.');
return;
}
}
renderPaste(_decryptedPaste);
})();
function renderPaste(paste) {
const title = paste.title || 'Untitled';
document.title = title + ' — {{ cfg.site.name }}';
document.getElementById('navPasteTitle').textContent = title;
const lang = paste.language || 'text';
const prismLangMap = { text: false, html: 'markup', xml: 'markup', docker: 'docker' };
const prismLang = (lang in prismLangMap) ? prismLangMap[lang] : lang;
const codeBlock = document.getElementById('codeBlock');
const viewPre = document.getElementById('viewPre');
codeBlock.textContent = paste.content || '';
if (prismLang) {
codeBlock.className = 'language-' + prismLang;
viewPre.className = 'language-' + prismLang;
Prism.highlightElement(codeBlock);
}
document.getElementById('viewFull').style.display = 'block';
}
function showError(title, detail) {
document.getElementById('errorTitle').textContent = title;
document.getElementById('errorDetail').textContent = detail;
document.getElementById('errorState').style.display = 'block';
}
function rawView() {
if (!_decryptedPaste) return;
const blob = new Blob([_decryptedPaste.content], { type: 'text/plain; charset=utf-8' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank');
setTimeout(() => URL.revokeObjectURL(url), 10000);
}
async function copyPaste() {
if (!_decryptedPaste) return;
const ok = await copyToClipboard(_decryptedPaste.content);
const btn = document.querySelector('.nav-btn[onclick="copyPaste()"]');
if (btn) { const t = btn.textContent; btn.textContent = ok ? 'Copied!' : 'Failed'; setTimeout(() => btn.textContent = t, 1500); }
}
function downloadPaste() {
if (!_decryptedPaste) return;
const { title = 'untitled', content = '', language = 'text' } = _decryptedPaste;
const extMap = {
javascript: '.js', typescript: '.ts', python: '.py', java: '.java',
c: '.c', cpp: '.cpp', csharp: '.cs', html: '.html', css: '.css',
scss: '.scss', sql: '.sql', json: '.json', yaml: '.yaml', xml: '.xml',
bash: '.sh', powershell: '.ps1', php: '.php', ruby: '.rb', go: '.go',
rust: '.rs', swift: '.swift', kotlin: '.kt', markdown: '.md',
diff: '.diff', docker: '', nginx: '.conf', toml: '.toml', ini: '.ini',
};
const filename = title.replace(/[^a-z0-9.\-]/gi, '_') + (extMap[language] ?? '.txt');
const blob = new Blob([content], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement('a'), { href: url, download: filename });
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
}
</script>
{% endblock %} {% endblock %}