diff --git a/.gitignore b/.gitignore index 535c6c2..4a4b29e 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,4 @@ Thumbs.db *.temp # User uploads (if you add file upload functionality) -uploads/ \ No newline at end of file +uploads/gunicorn.pid diff --git a/app.py b/app.py index 3a7c677..93e6d33 100644 --- a/app.py +++ b/app.py @@ -8,7 +8,8 @@ import threading import time import uuid import datetime -from flask import Flask, render_template, request, jsonify, abort, Response, g +from flask import Flask, render_template, request, jsonify, abort, Response, g, session, redirect, url_for +from werkzeug.security import check_password_hash _UTC = datetime.timezone.utc @@ -49,6 +50,10 @@ if _secret_key in _DEFAULT_KEYS: 'or in config.json before starting the server.' ) app.config['SECRET_KEY'] = _secret_key +# ── Session hardening ───────────────────────────────────────────────────────── +app.config['SESSION_COOKIE_HTTPONLY'] = True +app.config['SESSION_COOKIE_SECURE'] = _server.get('use_https', False) +app.config['SESSION_COOKIE_SAMESITE'] = 'Lax' if _server.get('debug', False): print('WARNING: debug=true is set in config.json — never use debug mode in production!', @@ -126,9 +131,13 @@ def init_db(): encrypted_data TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, expires_at TIMESTAMP, - views INTEGER DEFAULT 0 + views INTEGER DEFAULT 0, + deletion_token TEXT ) ''') + # Migration: add deletion_token column to existing table if missing + if columns and 'id' in columns and 'deletion_token' not in columns: + cursor.execute('ALTER TABLE pastes ADD COLUMN deletion_token TEXT') cursor.execute(''' CREATE TABLE IF NOT EXISTS rate_limits ( ip_address TEXT, @@ -188,6 +197,37 @@ def validate_encrypted_data(value): return False return bool(_ENCRYPTED_RE.match(value)) +def _is_same_origin(): + """Verify that the request is from the same origin to prevent CSRF.""" + origin = request.headers.get('Origin') + referer = request.headers.get('Referer') + base_url = request.host_url.rstrip('/') # e.g. http://localhost:5500 + + if origin: + return origin.rstrip('/') == base_url + if referer: + return referer.startswith(base_url) + return False + +def _check_rate_limit(remote_ip, key_prefix='rl', window=600, limit=10): + """Generic rate limiting via SQLite. Window is in seconds.""" + now_ts = time.time() + conn = get_db_connection() + try: + count = conn.execute( + 'SELECT COUNT(*) FROM rate_limits WHERE ip_address = ? AND timestamp > ?', + (f"{key_prefix}:{remote_ip}", now_ts - window) + ).fetchone()[0] + + if count >= limit: + return False + + conn.execute('INSERT INTO rate_limits (ip_address, timestamp) VALUES (?, ?)', (f"{key_prefix}:{remote_ip}", now_ts)) + conn.commit() + return True + finally: + conn.close() + def _get_paste_or_abort(paste_id): conn = get_db_connection() try: @@ -214,24 +254,9 @@ def index(): def create_paste(): # ── Rate limiting ──────────────────────────────────────────────────────── # 10 pastes per 10 minutes per IP address (SQLite backed for worker safety) - remote_ip = request.remote_addr - now_ts = time.time() - window = 600 # 10 minutes - conn = get_db_connection() - try: - # Purge old for this IP specifically or just check current - count = conn.execute( - 'SELECT COUNT(*) FROM rate_limits WHERE ip_address = ? AND timestamp > ?', - (remote_ip, now_ts - window) - ).fetchone()[0] - - if count >= 10: - return jsonify({'error': 'Rate limit exceeded. Please wait a few minutes.'}), 429 - - conn.execute('INSERT INTO rate_limits (ip_address, timestamp) VALUES (?, ?)', (remote_ip, now_ts)) - conn.commit() - finally: - conn.close() + # 10 pastes per 10 minutes per IP address (SQLite backed for worker safety) + if not _check_rate_limit(request.remote_addr, key_prefix='create', limit=10): + return jsonify({'error': 'Rate limit exceeded. Please wait a few minutes.'}), 429 data = request.get_json(silent=True) if not data: @@ -283,6 +308,7 @@ def create_paste(): else: expires_at = datetime.datetime.now(_UTC) + delta + deletion_token = secrets.token_urlsafe(32) paste_id = None conn = get_db_connection() try: @@ -290,8 +316,8 @@ def create_paste(): paste_id = generate_paste_id() try: conn.execute( - 'INSERT INTO pastes (id, encrypted_data, expires_at) VALUES (?, ?, ?)', - (paste_id, store_data, expires_at) + 'INSERT INTO pastes (id, encrypted_data, expires_at, deletion_token) VALUES (?, ?, ?, ?)', + (paste_id, store_data, expires_at, deletion_token) ) conn.commit() break @@ -301,7 +327,39 @@ def create_paste(): return jsonify({'error': 'Service temporarily unavailable'}), 503 finally: conn.close() - return jsonify({'paste_id': paste_id, 'url': f'/{paste_id}'}) + return jsonify({'paste_id': paste_id, 'url': f'/{paste_id}', 'deletion_token': deletion_token}) + +@app.route('/api/delete/', methods=['POST']) +def delete_paste(paste_id): + if not _PASTE_ID_RE.match(paste_id): + abort(404) + + # CSRF check + if not _is_same_origin(): + return jsonify({'error': 'Cross-Origin request blocked'}), 403 + + data = request.get_json(silent=True) or {} + token = data.get('token') + + if not token: + return jsonify({'error': 'Deletion token required'}), 400 + + conn = get_db_connection() + try: + paste = conn.execute('SELECT deletion_token FROM pastes WHERE id = ?', (paste_id,)).fetchone() + if not paste: + return jsonify({'error': 'Paste not found'}), 404 + + # Verify token + if paste['deletion_token'] != token: + return jsonify({'error': 'Invalid deletion token'}), 403 + + conn.execute('DELETE FROM pastes WHERE id = ?', (paste_id,)) + conn.commit() + finally: + conn.close() + + return jsonify({'success': True, 'message': 'Paste deleted successfully'}) @app.route('/') def view_paste(paste_id): @@ -415,6 +473,71 @@ def recent_pastes(): conn.close() return render_template('recent.html', pastes=pastes) +# ── Admin Panel ─────────────────────────────────────────────────────────────── + +def is_admin(): + return session.get('admin_logged_in') == True + +@app.route('/admin/login', methods=['GET', 'POST']) +def admin_login(): + error = None + admin_cfg = CFG.get('admin', {}) + if request.method == 'POST': + # 1. Rate limit + if not _check_rate_limit(request.remote_addr, key_prefix='admin_login', window=3600, limit=5): + return render_template('admin_login.html', error='Too many login attempts. Try again later.'), 429 + + # 2. CSRF + if not _is_same_origin(): + return "CSRF Blocked", 403 + + user = request.form.get('username') + pw = request.form.get('password') + stored_hash = admin_cfg.get('pass') + if user == admin_cfg.get('user') and check_password_hash(stored_hash, pw): + session['admin_logged_in'] = True + return redirect(url_for('admin_dashboard')) + else: + time.sleep(1) # Slow down brute force + error = 'Invalid credentials' + return render_template('admin_login.html', error=error) + +@app.route('/admin/logout') +def admin_logout(): + session.pop('admin_logged_in', None) + return redirect(url_for('index')) + +@app.route('/admin') +def admin_dashboard(): + if not is_admin(): + return redirect(url_for('admin_login')) + + now = datetime.datetime.now(_UTC).isoformat() + conn = get_db_connection() + try: + pastes = conn.execute( + 'SELECT id, created_at, expires_at, views FROM pastes ORDER BY created_at DESC' + ).fetchall() + finally: + conn.close() + return render_template('admin_dashboard.html', pastes=pastes) + +@app.route('/admin/delete/', methods=['POST']) +def admin_delete_paste(paste_id): + if not is_admin(): + abort(403) + + if not _is_same_origin(): + abort(403) + + conn = get_db_connection() + try: + conn.execute('DELETE FROM pastes WHERE id = ?', (paste_id,)) + conn.commit() + finally: + conn.close() + return redirect(url_for('admin_dashboard')) + # ── Error handlers ──────────────────────────────────────────────────────────── @app.errorhandler(404) diff --git a/bastebin.nginx.conf b/bastebin.nginx.conf deleted file mode 100644 index 2b13efb..0000000 --- a/bastebin.nginx.conf +++ /dev/null @@ -1,40 +0,0 @@ -server { - listen 80; - server_name bastebin.com www.bastebin.com; - - # Static files serving - offload from Flask/Gunicorn - location /static/ { - alias /home/computertech/bastebin/static/; - expires 30d; - add_header Cache-Control "public, no-transform"; - } - - # Proxy all other requests to Gunicorn - location / { - proxy_pass http://127.0.0.1:5500; - - # Standard proxy headers - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - - # Timeouts and keeping connections alive - proxy_http_version 1.1; - proxy_set_header Connection ""; - proxy_connect_timeout 90; - proxy_send_timeout 90; - proxy_read_timeout 90; - - # Max payload size (matching 2MB config or slightly above) - client_max_body_size 5M; - - # Buffer settings - proxy_buffers 16 16k; - proxy_buffer_size 32k; - } - - # Log files - ensure these directories exist or use /var/log/nginx - access_log /var/log/nginx/bastebin.access.log; - error_log /var/log/nginx/bastebin.error.log; -} diff --git a/config.json b/config.json index e5dba86..325e29e 100644 --- a/config.json +++ b/config.json @@ -132,5 +132,10 @@ {"value": "nginx", "name": "Nginx Config"}, {"value": "toml", "name": "TOML"}, {"value": "ini", "name": "INI / Config"} - ] + ], + "admin": { + "enabled": true, + "user": "admin", + "pass": "scrypt:32768:8:1$WKz9I6qE4hh0paUQ$6fd34e7f0195280f81301a92f5bac26d247f95d64744cb2c6e44108a3d8420eba5343b7b2ba657f39404f4ef102ce2e62a689e7797a43f3169fd69dca7b5b3c7" + } } diff --git a/generate_hash.py b/generate_hash.py new file mode 100644 index 0000000..291d859 --- /dev/null +++ b/generate_hash.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +import getpass +from werkzeug.security import generate_password_hash + +def main(): + print("--- Bastebin Admin Password Hasher ---") + password = getpass.getpass("Enter the new admin password: ") + confirm = getpass.getpass("Confirm password: ") + + if password != confirm: + print("Error: Passwords do not match.") + return + + hashed = generate_password_hash(password) + print("\nSuccess! Copy the following hash into your config.json:") + print("-" * 20) + print(hashed) + print("-" * 20) + +if __name__ == "__main__": + main() diff --git a/gunicorn.pid b/gunicorn.pid index 8ea9527..30fabb3 100644 --- a/gunicorn.pid +++ b/gunicorn.pid @@ -1 +1 @@ -26810 +32002 diff --git a/static/css/style.css b/static/css/style.css index cc20d66..09fb248 100644 --- a/static/css/style.css +++ b/static/css/style.css @@ -290,3 +290,76 @@ pre[class*="language-"], code[class*="language-"] { .nav-input { width: 90px; } .nav-paste-title { display: none; } } + +/* ── Utility & New Actions ─────────────────────────────────────────────── */ +.nav-btn-danger { + color: var(--danger); + border-color: var(--danger); +} +.nav-btn-danger:hover { + background: var(--danger); + color: #fff; + border-color: var(--danger); +} + +.nav-user { + font-size: 0.8rem; + color: var(--text-muted); + margin-right: 0.5rem; +} + +/* ── Auth Box (Login) ──────────────────────────────────────────────────── */ +.auth-box { + max-width: 360px; + margin: 4rem auto; + padding: 2rem; + background: var(--surface); + border: 1px solid var(--nav-border); + border-radius: var(--radius); + text-align: center; +} +.auth-box h1 { font-size: 1.25rem; margin-bottom: 1.5rem; } +.form-group { text-align: left; margin-bottom: 1rem; } +.form-group label { display: block; font-size: 0.8rem; color: var(--text-sub); margin-bottom: 0.3rem; } +.form-group input { + width: 100%; padding: 0.5rem; border: 1px solid var(--border); + border-radius: var(--radius); background: var(--bg); color: var(--text); outline: none; +} +.form-group input:focus { border-color: var(--primary); } +.submit-btn { + width: 100%; padding: 0.6rem; background: var(--primary); color: #fff; + border: none; border-radius: var(--radius); cursor: pointer; font-weight: 600; margin-top: 0.5rem; +} +.submit-btn:hover { background: var(--primary-h); } +.error-msg { color: var(--danger); font-size: 0.8rem; margin-bottom: 1rem; } + +/* ── Admin Dashboard ───────────────────────────────────────────────────── */ +.admin-header { margin-bottom: 2rem; border-bottom: 1px solid var(--border); padding-bottom: 1rem; } +.admin-header h1 { font-size: 1.5rem; } +.admin-header p { color: var(--text-sub); } + +.admin-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} +.admin-table th, .admin-table td { + padding: 0.75rem; + border-bottom: 1px solid var(--border); + text-align: left; +} +.admin-table th { color: var(--text-sub); font-weight: 600; } +.admin-table tr:hover { background: var(--surface); } + +.btn-delete-small { + background: none; + border: 1px solid var(--danger); + color: var(--danger); + padding: 0.2rem 0.5rem; + border-radius: var(--radius); + cursor: pointer; + font-size: 0.75rem; +} +.btn-delete-small:hover { background: var(--danger); color: #fff; } + +.table-responsive { overflow-x: auto; } diff --git a/static/js/paste_create.js b/static/js/paste_create.js index 8031414..b1dbb98 100644 --- a/static/js/paste_create.js +++ b/static/js/paste_create.js @@ -113,6 +113,9 @@ document.addEventListener('DOMContentLoaded', function () { if (typeof window.clearDraft === 'function') { window.clearDraft(); } + if (result.deletion_token) { + localStorage.setItem('del_' + result.paste_id, result.deletion_token); + } window.location.href = result.url + (keyBase64 ? '#' + keyBase64 : ''); } } catch (err) { diff --git a/static/js/paste_view.js b/static/js/paste_view.js index eeec6a9..ee3acb5 100644 --- a/static/js/paste_view.js +++ b/static/js/paste_view.js @@ -57,6 +57,7 @@ document.addEventListener('DOMContentLoaded', async () => { renderPaste(_decryptedPaste); initPasteActions(); initLineNumbers(); + initDeletion(); }); function initPasteActions() { @@ -174,3 +175,41 @@ function downloadPaste() { document.body.removeChild(a); URL.revokeObjectURL(url); } + +function initDeletion() { + const pasteId = window.location.pathname.split('/').pop(); + const token = localStorage.getItem('del_' + pasteId); + const deleteBtn = document.getElementById('deleteBtn'); + + if (token && deleteBtn) { + deleteBtn.style.display = 'inline-block'; + deleteBtn.addEventListener('click', async () => { + if (!confirm('Are you sure you want to delete this paste? This action cannot be undone.')) return; + + deleteBtn.disabled = true; + deleteBtn.textContent = '...'; + + try { + const resp = await fetch('/api/delete/' + pasteId, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }) + }); + const result = await resp.json(); + if (result.success) { + localStorage.removeItem('del_' + pasteId); + window.location.href = '/'; + } else { + alert('Error: ' + (result.error || 'Unknown error')); + deleteBtn.disabled = false; + deleteBtn.textContent = 'Delete'; + } + } catch (err) { + console.error(err); + alert('Failed to delete paste. Connection error.'); + deleteBtn.disabled = false; + deleteBtn.textContent = 'Delete'; + } + }); + } +} diff --git a/templates/admin_dashboard.html b/templates/admin_dashboard.html new file mode 100644 index 0000000..bc610cc --- /dev/null +++ b/templates/admin_dashboard.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} + +{% block title %}Admin Dashboard - {{ cfg.site.name }}{% endblock %} + +{% block nav_actions %} +Logged in as Admin +Logout +New Paste +{% endblock %} + +{% block content %} +
+
+

Global Paste Management

+

Total Pastes: {{ pastes|length }}

+
+ +
+ + + + + + + + + + + + {% for paste in pastes %} + + + + + + + + {% endfor %} + +
IDCreatedExpiresViewsActions
{{ paste.id }}{{ paste.created_at }}{{ paste.expires_at or 'Never' }}{{ paste.views }} +
+ +
+
+
+
+{% endblock %} diff --git a/templates/admin_login.html b/templates/admin_login.html new file mode 100644 index 0000000..2a0780b --- /dev/null +++ b/templates/admin_login.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block title %}Admin Login - {{ cfg.site.name }}{% endblock %} + +{% block nav_actions %} +Back +{% endblock %} + +{% block content %} +
+

Admin Access

+ {% if error %} +
{{ error }}
+ {% endif %} +
+
+ + +
+
+ + +
+ +
+
+{% endblock %} diff --git a/templates/view.html b/templates/view.html index e98bc03..07c07ea 100644 --- a/templates/view.html +++ b/templates/view.html @@ -11,6 +11,7 @@ + New {% if cfg.theme.allow_user_toggle %}