Comprehensive security & reliability audit: hardened CSP, fixed vulnerabilities, improved theme management, and added line numbers toggle.
This commit is contained in:
parent
6d05b40021
commit
0bb85da6bb
189
app.py
189
app.py
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
26810
|
||||||
|
|
@ -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}')
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
|
|
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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 %}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue