From 0bb85da6bb9afd5fa7126ee9df976a6ca50192ab Mon Sep 17 00:00:00 2001 From: ComputerTech Date: Fri, 27 Mar 2026 15:22:53 +0000 Subject: [PATCH] Comprehensive security & reliability audit: hardened CSP, fixed vulnerabilities, improved theme management, and added line numbers toggle. --- app.py | 189 ++++++++++++++++++++++++++++---------- config.json | 4 +- gunicorn.pid | 1 + production.py | 38 ++++---- requirements.txt | 6 +- static/css/style.css | 23 +++++ static/js/app.js | 18 ++-- static/js/paste_create.js | 98 ++++++++++++++++++++ static/js/paste_view.js | 182 ++++++++++++++++++++++++++++++++++++ templates/base.html | 7 +- templates/index.html | 91 +----------------- templates/recent.html | 2 +- templates/view.html | 117 ++--------------------- 13 files changed, 495 insertions(+), 281 deletions(-) create mode 100644 gunicorn.pid create mode 100644 static/js/paste_create.js create mode 100644 static/js/paste_view.js diff --git a/app.py b/app.py index b2e39a0..ee7c35b 100644 --- a/app.py +++ b/app.py @@ -1,11 +1,16 @@ import json import os import re +import secrets import sqlite3 import sys +import threading import uuid 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 ──────────────────────────────────────────────────────── @@ -31,13 +36,28 @@ _ui = CFG['ui'] # ── Flask app setup ─────────────────────────────────────────────────────────── 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): print('WARNING: debug=true is set in config.json — never use debug mode in production!', 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_TITLE_BYTES = 200 _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 ───────────────────────────────────────────────────────── +@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 def set_security_headers(response): - response.headers['X-Frame-Options'] = 'DENY' - response.headers['X-Content-Type-Options'] = 'nosniff' - response.headers['Referrer-Policy'] = 'same-origin' - response.headers['Content-Security-Policy'] = ( + nonce = getattr(g, 'csp_nonce', '') + response.headers['X-Frame-Options'] = 'DENY' + response.headers['X-Content-Type-Options'] = 'nosniff' + response.headers['Referrer-Policy'] = 'same-origin' + response.headers['Content-Security-Policy'] = ( "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'; " "img-src 'self' data:; " "connect-src 'self' blob:; " @@ -67,7 +93,7 @@ def set_security_headers(response): @app.context_processor def inject_config(): - return {'cfg': CFG} + return {'cfg': CFG, 'csp_nonce': getattr(g, 'csp_nonce', '')} # ── Database ────────────────────────────────────────────────────────────────── @@ -77,26 +103,35 @@ def get_db_connection(): conn.execute('PRAGMA journal_mode=WAL') return conn +_db_init_lock = threading.Lock() +_db_initialized = False + def init_db(): - conn = sqlite3.connect(DATABASE) - try: - cursor = conn.cursor() - cursor.execute("PRAGMA table_info(pastes)") - columns = {row[1] for row in cursor.fetchall()} - if columns and 'content' in columns and 'encrypted_data' not in columns: - cursor.execute("DROP TABLE pastes") - cursor.execute(''' - CREATE TABLE IF NOT EXISTS pastes ( - id TEXT PRIMARY KEY, - encrypted_data TEXT NOT NULL, - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - expires_at TIMESTAMP, - views INTEGER DEFAULT 0 - ) - ''') - conn.commit() - finally: - conn.close() + """Initialise the database. Thread-safe and idempotent: runs DDL only once per process.""" + global _db_initialized + with _db_init_lock: + if _db_initialized: + return + conn = sqlite3.connect(DATABASE) + try: + cursor = conn.cursor() + cursor.execute("PRAGMA table_info(pastes)") + columns = {row[1] for row in cursor.fetchall()} + if columns and 'content' in columns and 'encrypted_data' not in columns: + cursor.execute("DROP TABLE pastes") + cursor.execute(''' + CREATE TABLE IF NOT EXISTS pastes ( + id TEXT PRIMARY KEY, + encrypted_data TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP, + views INTEGER DEFAULT 0 + ) + ''') + conn.commit() + finally: + conn.close() + _db_initialized = True # ── Helpers ─────────────────────────────────────────────────────────────────── @@ -107,20 +142,25 @@ def generate_paste_id(): def validate_encrypted_data(value): if not isinstance(value, str): 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 bool(_ENCRYPTED_RE.match(value)) def _get_paste_or_abort(paste_id): conn = get_db_connection() - paste = conn.execute('SELECT * FROM pastes WHERE id = ?', (paste_id,)).fetchone() - conn.close() - if not paste: - abort(404) - if paste['expires_at']: - expires_at = datetime.datetime.fromisoformat(paste['expires_at']) - if expires_at < datetime.datetime.utcnow(): - abort(410) + try: + paste = conn.execute('SELECT * FROM pastes WHERE id = ?', (paste_id,)).fetchone() + if not paste: + abort(404) + if paste['expires_at']: + expires_at = datetime.datetime.fromisoformat(paste['expires_at']).replace(tzinfo=_UTC) + if expires_at < datetime.datetime.now(_UTC): + conn.execute('DELETE FROM pastes WHERE id = ?', (paste_id,)) + conn.commit() + abort(410) + finally: + conn.close() return paste # ── Routes ──────────────────────────────────────────────────────────────────── @@ -172,7 +212,12 @@ def create_paste(): '1week': datetime.timedelta(weeks=1), '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 conn = get_db_connection() @@ -198,15 +243,10 @@ def create_paste(): def view_paste(paste_id): if not _PASTE_ID_RE.match(paste_id): abort(404) + paste = _get_paste_or_abort(paste_id) + # Increment view count in a separate connection after expiry check. conn = get_db_connection() 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.commit() finally: @@ -243,20 +283,67 @@ def get_languages(): @app.route('/api/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({ - 'site': _site, - 'theme': _theme, - 'features': _feat, - 'ui': _ui, + 'site': { + 'name': _site.get('name', 'Bastebin'), + 'tagline': _site.get('tagline', ''), + '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': { 'default_language': _pastes.get('default_language', 'text'), 'default_expiry': _pastes.get('default_expiry', 'never'), 'allow_expiry_options': _pastes.get('allow_expiry_options', []), '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 ──────────────────────────────────────────────────────────── @app.errorhandler(404) diff --git a/config.json b/config.json index 2ca4827..1a4f492 100644 --- a/config.json +++ b/config.json @@ -12,8 +12,8 @@ "server": { "host": "0.0.0.0", "port": 5000, - "debug": true, - "secret_key": "change-this-to-a-long-random-secret" + "debug": false, + "secret_key": "ce81867ddcc16729b42d7ae0564140e6bcc83bcf5dbbe77b7e5b6c5aa4199347" }, "database": { diff --git a/gunicorn.pid b/gunicorn.pid new file mode 100644 index 0000000..8ea9527 --- /dev/null +++ b/gunicorn.pid @@ -0,0 +1 @@ +26810 diff --git a/production.py b/production.py index 93bc1ed..9ca815d 100644 --- a/production.py +++ b/production.py @@ -68,30 +68,32 @@ def cmd_start(host: str, port: int, workers: int) -> None: return gunicorn = _gunicorn_bin() - log = open(LOG_FILE, 'a') - proc = subprocess.Popen( - [ - gunicorn, - '--config', CONF_FILE, - '--bind', f'{host}:{port}', - '--workers', str(workers), - '--pid', PID_FILE, - 'wsgi:app', - ], - stdout=log, - stderr=log, - cwd=BASE_DIR, - start_new_session=True, - ) + log = open(LOG_FILE, 'a') + try: + proc = subprocess.Popen( + [ + gunicorn, + '--config', CONF_FILE, + '--bind', f'{host}:{port}', + '--workers', str(workers), + '--pid', PID_FILE, + 'wsgi:app', + ], + stdout=log, + stderr=log, + cwd=BASE_DIR, + start_new_session=True, + ) + except Exception: + log.close() + raise # Wait briefly to confirm the process stayed alive. time.sleep(1.5) + log.close() # Gunicorn has inherited the fd; safe to close our end. if proc.poll() is not None: - log.close() sys.exit(f'Gunicorn exited immediately. Check {LOG_FILE} for details.') - - log.close() # Gunicorn writes its own PID file; confirm it appeared. pid = _read_pid() print(f'Started (PID {pid or proc.pid}) → http://{host}:{port}') diff --git a/requirements.txt b/requirements.txt index fd65f68..7f53415 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,3 @@ -Flask==3.0.0 -Werkzeug==3.0.1 -gunicorn==25.2.0 +Flask==3.1.0 +Werkzeug==3.1.3 +gunicorn==23.0.0 diff --git a/static/css/style.css b/static/css/style.css index 9ab5995..cc20d66 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -139,6 +139,29 @@ body { 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 { background: none; border: none; diff --git a/static/js/app.js b/static/js/app.js index 7601235..6e5e557 100644 --- a/static/js/app.js +++ b/static/js/app.js @@ -22,17 +22,25 @@ const _UI_VAR_MAP = { border_radius: '--radius', }; -// Fetch config then initialise theme immediately (before DOMContentLoaded) -(async function loadConfig() { +// Fetch config and initialise application when ready +document.addEventListener('DOMContentLoaded', async () => { try { const resp = await fetch('/api/config'); if (resp.ok) window.PBCFG = await resp.json(); } catch (e) { console.warn('Could not load /api/config, using CSS fallbacks.', e); } + initialiseTheme(); 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 ──────────────────────────────────────────────────────── @@ -233,6 +241,4 @@ document.addEventListener('keydown', function (e) { } }); -// ── Initialise auto-save once DOM is ready ────────────────────────────────── - -document.addEventListener('DOMContentLoaded', initAutoSave); +// Removed redundant DOMContentLoaded at the bottom — handled by the single listener at the top. diff --git a/static/js/paste_create.js b/static/js/paste_create.js new file mode 100644 index 0000000..bdb3566 --- /dev/null +++ b/static/js/paste_create.js @@ -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'; + } + } +}); diff --git a/static/js/paste_view.js b/static/js/paste_view.js new file mode 100644 index 0000000..9db9709 --- /dev/null +++ b/static/js/paste_view.js @@ -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 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); +} diff --git a/templates/base.html b/templates/base.html index dac9015..d7b29c3 100644 --- a/templates/base.html +++ b/templates/base.html @@ -3,8 +3,9 @@ + {% block title %}{{ cfg.site.name }}{% endblock %} - + @@ -24,7 +26,7 @@ {% block nav_actions %} New {% if cfg.theme.allow_user_toggle %} - + {% endif %} {% endblock %} @@ -64,6 +66,7 @@ + {% block scripts %}{% endblock %} diff --git a/templates/index.html b/templates/index.html index 3013233..b8c77c5 100644 --- a/templates/index.html +++ b/templates/index.html @@ -15,7 +15,7 @@ {% if cfg.theme.allow_user_toggle %} - + {% endif %} {% endblock %} @@ -31,92 +31,5 @@ {% endblock %} {% block scripts %} - + {% endblock %} diff --git a/templates/recent.html b/templates/recent.html index bff4846..f15f302 100644 --- a/templates/recent.html +++ b/templates/recent.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% block title %}Recent Pastes - PasteBin{% endblock %} +{% block title %}Recent Pastes — {{ cfg.site.name }}{% endblock %} {% block content %}
diff --git a/templates/view.html b/templates/view.html index 751bfe2..e98bc03 100644 --- a/templates/view.html +++ b/templates/view.html @@ -5,12 +5,15 @@ {% block nav_actions %} - - - + + + + New {% if cfg.theme.allow_user_toggle %} - + {% endif %} {% endblock %} @@ -25,109 +28,5 @@ {% endblock %} {% block scripts %} - + {% endblock %}