Compare commits
No commits in common. "3c15f1e2c71c7fc34a9675cd3667f84272f350e5" and "24d70aeb08a70a917a39b94fe423e7db9ea54771" have entirely different histories.
3c15f1e2c7
...
24d70aeb08
|
|
@ -60,9 +60,5 @@ Thumbs.db
|
|||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Gunicorn
|
||||
gunicorn.pid
|
||||
|
||||
# Runtime config — contains secrets (SECRET_KEY, admin password hash).
|
||||
# Copy config.example.json → config.json and fill in your values.
|
||||
config.json
|
||||
# User uploads (if you add file upload functionality)
|
||||
uploads/gunicorn.pid
|
||||
|
|
|
|||
104
app.py
104
app.py
|
|
@ -1,4 +1,3 @@
|
|||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
|
@ -134,9 +133,7 @@ def init_db():
|
|||
expires_at TIMESTAMP,
|
||||
views INTEGER DEFAULT 0,
|
||||
deletion_token TEXT,
|
||||
discussions_enabled INTEGER DEFAULT 0,
|
||||
burn_after_read INTEGER DEFAULT 0,
|
||||
burn_token TEXT
|
||||
discussions_enabled INTEGER DEFAULT 0
|
||||
)
|
||||
''')
|
||||
# Migration: add deletion_token column to existing table if missing
|
||||
|
|
@ -145,11 +142,6 @@ def init_db():
|
|||
# Migration: add discussions_enabled column to existing table if missing
|
||||
if columns and 'id' in columns and 'discussions_enabled' not in columns:
|
||||
cursor.execute('ALTER TABLE pastes ADD COLUMN discussions_enabled INTEGER DEFAULT 0')
|
||||
# Migration: add burn_after_read / burn_token columns to existing table if missing
|
||||
if columns and 'id' in columns and 'burn_after_read' not in columns:
|
||||
cursor.execute('ALTER TABLE pastes ADD COLUMN burn_after_read INTEGER DEFAULT 0')
|
||||
if columns and 'id' in columns and 'burn_token' not in columns:
|
||||
cursor.execute('ALTER TABLE pastes ADD COLUMN burn_token TEXT')
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS rate_limits (
|
||||
ip_address TEXT,
|
||||
|
|
@ -292,6 +284,7 @@ def index():
|
|||
def create_paste():
|
||||
# ── Rate limiting ────────────────────────────────────────────────────────
|
||||
# 10 pastes per 10 minutes per IP address (SQLite backed for worker safety)
|
||||
# 10 pastes per 10 minutes per IP address (SQLite backed for worker safety)
|
||||
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
|
||||
|
||||
|
|
@ -300,7 +293,6 @@ def create_paste():
|
|||
return jsonify({'error': 'JSON body required'}), 400
|
||||
|
||||
discussions_enabled = bool(data.get('discussions', False))
|
||||
burn_after_read = bool(data.get('burn_after_read', False))
|
||||
|
||||
if 'encrypted_data' in data:
|
||||
# Encrypted path — always accepted regardless of encrypt_pastes setting
|
||||
|
|
@ -349,7 +341,6 @@ def create_paste():
|
|||
expires_at = datetime.datetime.now(_UTC) + delta
|
||||
|
||||
deletion_token = secrets.token_urlsafe(32)
|
||||
burn_token = secrets.token_urlsafe(32) if burn_after_read else None
|
||||
paste_id = None
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
|
|
@ -357,13 +348,8 @@ def create_paste():
|
|||
paste_id = generate_paste_id()
|
||||
try:
|
||||
conn.execute(
|
||||
'INSERT INTO pastes '
|
||||
'(id, encrypted_data, expires_at, deletion_token, discussions_enabled, burn_after_read, burn_token) '
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
(paste_id, store_data, expires_at, deletion_token,
|
||||
1 if discussions_enabled else 0,
|
||||
1 if burn_after_read else 0,
|
||||
burn_token)
|
||||
'INSERT INTO pastes (id, encrypted_data, expires_at, deletion_token, discussions_enabled) VALUES (?, ?, ?, ?, ?)',
|
||||
(paste_id, store_data, expires_at, deletion_token, 1 if discussions_enabled else 0)
|
||||
)
|
||||
conn.commit()
|
||||
break
|
||||
|
|
@ -396,11 +382,10 @@ def delete_paste(paste_id):
|
|||
if not paste:
|
||||
return jsonify({'error': 'Paste not found'}), 404
|
||||
|
||||
# Verify token — constant-time comparison to prevent timing attacks
|
||||
if not hmac.compare_digest(paste['deletion_token'] or '', token):
|
||||
# Verify token
|
||||
if paste['deletion_token'] != token:
|
||||
return jsonify({'error': 'Invalid deletion token'}), 403
|
||||
|
||||
conn.execute('DELETE FROM comments WHERE paste_id = ?', (paste_id,))
|
||||
conn.execute('DELETE FROM pastes WHERE id = ?', (paste_id,))
|
||||
conn.commit()
|
||||
finally:
|
||||
|
|
@ -420,10 +405,7 @@ def view_paste(paste_id):
|
|||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
burn_after_read = bool(paste['burn_after_read'])
|
||||
burn_token = paste['burn_token'] if burn_after_read else None
|
||||
return render_template('view.html', paste=paste,
|
||||
burn_after_read=burn_after_read, burn_token=burn_token)
|
||||
return render_template('view.html', paste=paste)
|
||||
|
||||
@app.route('/<string:paste_id>/raw')
|
||||
def view_paste_raw(paste_id):
|
||||
|
|
@ -523,43 +505,6 @@ def recent_pastes():
|
|||
conn.close()
|
||||
return render_template('recent.html', pastes=pastes)
|
||||
|
||||
# ── Burn-after-read API ───────────────────────────────────────────────────────
|
||||
|
||||
_BURN_TOKEN_RE = re.compile(r'^[A-Za-z0-9_-]{20,64}$')
|
||||
|
||||
@app.route('/api/burn/<string:paste_id>', methods=['POST'])
|
||||
def burn_paste(paste_id):
|
||||
"""Called by the client after successful decryption to destroy a burn-after-read paste."""
|
||||
if not _PASTE_ID_RE.match(paste_id):
|
||||
abort(404)
|
||||
if not _is_same_origin():
|
||||
return jsonify({'error': 'Cross-Origin request blocked'}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
token = data.get('burn_token', '')
|
||||
if not token or not _BURN_TOKEN_RE.match(str(token)):
|
||||
return jsonify({'error': 'burn_token required'}), 400
|
||||
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
paste = conn.execute(
|
||||
'SELECT burn_after_read, burn_token FROM pastes WHERE id = ?', (paste_id,)
|
||||
).fetchone()
|
||||
if not paste:
|
||||
# Already burned or never existed — treat as success (idempotent)
|
||||
return jsonify({'burned': True})
|
||||
if not paste['burn_after_read']:
|
||||
return jsonify({'error': 'This paste is not flagged for burn-after-read'}), 400
|
||||
# Constant-time comparison prevents timing oracle
|
||||
if not hmac.compare_digest(paste['burn_token'] or '', token):
|
||||
return jsonify({'error': 'Invalid burn token'}), 403
|
||||
conn.execute('DELETE FROM comments WHERE paste_id = ?', (paste_id,))
|
||||
conn.execute('DELETE FROM pastes WHERE id = ?', (paste_id,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify({'burned': True})
|
||||
|
||||
# ── Comments API ──────────────────────────────────────────────────────────────
|
||||
|
||||
_MAX_COMMENT_BYTES = 10240
|
||||
|
|
@ -685,12 +630,9 @@ def admin_dashboard():
|
|||
pastes = conn.execute(
|
||||
'SELECT id, created_at, expires_at, views FROM pastes ORDER BY created_at DESC'
|
||||
).fetchall()
|
||||
active_count = conn.execute(
|
||||
'SELECT COUNT(*) FROM pastes WHERE expires_at IS NULL OR expires_at > ?', (now,)
|
||||
).fetchone()[0]
|
||||
finally:
|
||||
conn.close()
|
||||
return render_template('admin_dashboard.html', pastes=pastes, active_count=active_count)
|
||||
return render_template('admin_dashboard.html', pastes=pastes)
|
||||
|
||||
@app.route('/admin/delete/<string:paste_id>', methods=['POST'])
|
||||
def admin_delete_paste(paste_id):
|
||||
|
|
@ -702,42 +644,12 @@ def admin_delete_paste(paste_id):
|
|||
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
conn.execute('DELETE FROM comments WHERE paste_id = ?', (paste_id,))
|
||||
conn.execute('DELETE FROM pastes WHERE id = ?', (paste_id,))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return redirect(url_for('admin_dashboard'))
|
||||
|
||||
@app.route('/admin/bulk_delete', methods=['POST'])
|
||||
def admin_bulk_delete():
|
||||
if not is_admin():
|
||||
abort(403)
|
||||
if not _is_same_origin():
|
||||
return jsonify({'error': 'Cross-Origin request blocked'}), 403
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
ids = data.get('ids', [])
|
||||
|
||||
if not isinstance(ids, list) or not ids:
|
||||
return jsonify({'error': 'ids must be a non-empty list'}), 400
|
||||
# Cap at 500 to prevent abuse
|
||||
if len(ids) > 500:
|
||||
return jsonify({'error': 'Too many IDs in one request'}), 400
|
||||
# Validate every ID to prevent injection
|
||||
if not all(isinstance(i, str) and _PASTE_ID_RE.match(i) for i in ids):
|
||||
return jsonify({'error': 'Invalid paste ID in list'}), 400
|
||||
|
||||
conn = get_db_connection()
|
||||
try:
|
||||
placeholders = ','.join('?' * len(ids))
|
||||
conn.execute(f'DELETE FROM comments WHERE paste_id IN ({placeholders})', ids)
|
||||
conn.execute(f'DELETE FROM pastes WHERE id IN ({placeholders})', ids)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
return jsonify({'deleted': len(ids)})
|
||||
|
||||
# ── Error handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
@app.errorhandler(404)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"_comment": "Bastebin configuration. Copy this file to config.json, fill in your values, and restart the server.",
|
||||
"_comment": "Bastebin configuration. Restart the server after changing values.",
|
||||
|
||||
"site": {
|
||||
"name": "Bastebin",
|
||||
|
|
@ -13,13 +13,11 @@
|
|||
"host": "0.0.0.0",
|
||||
"port": 5500,
|
||||
"debug": false,
|
||||
"use_https": true,
|
||||
"_secret_key_note": "Prefer the SECRET_KEY environment variable. This fallback is used only if the env var is not set.",
|
||||
"secret_key": "CHANGE-ME-use-a-long-random-string-at-least-32-chars"
|
||||
"secret_key": "ce81867ddcc16729b42d7ae0564140e6bcc83bcf5dbbe77b7e5b6c5aa4199347"
|
||||
},
|
||||
|
||||
"database": {
|
||||
"path": "bastebin.db"
|
||||
"path": "pastebin.db"
|
||||
},
|
||||
|
||||
"pastes": {
|
||||
|
|
@ -135,12 +133,9 @@
|
|||
{"value": "toml", "name": "TOML"},
|
||||
{"value": "ini", "name": "INI / Config"}
|
||||
],
|
||||
|
||||
"admin": {
|
||||
"enabled": true,
|
||||
"_user_note": "Set a strong username — not 'admin'.",
|
||||
"user": "your-admin-username",
|
||||
"_pass_note": "Generate with: python generate_hash.py yourpassword",
|
||||
"pass": "REPLACE-WITH-SCRYPT-HASH-FROM-generate_hash.py"
|
||||
"user": "admin",
|
||||
"pass": "scrypt:32768:8:1$WKz9I6qE4hh0paUQ$6fd34e7f0195280f81301a92f5bac26d247f95d64744cb2c6e44108a3d8420eba5343b7b2ba657f39404f4ef102ce2e62a689e7797a43f3169fd69dca7b5b3c7"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
32002
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
# ── Bastebin — Nginx reverse-proxy configuration ─────────────────────────────
|
||||
#
|
||||
# Copy to /etc/nginx/sites-available/bastebin and symlink:
|
||||
# sudo ln -s /etc/nginx/sites-available/bastebin /etc/nginx/sites-enabled/
|
||||
# sudo nginx -t && sudo systemctl reload nginx
|
||||
#
|
||||
# Assumes:
|
||||
# - Gunicorn is bound to a Unix socket at /run/bastebin/gunicorn.sock
|
||||
# - Your TLS certificates are managed by Certbot / Let's Encrypt
|
||||
# - Static files live at /path/to/bastebin/static/
|
||||
#
|
||||
# Replace every occurrence of:
|
||||
# bastebin.com → your actual domain
|
||||
# /path/to/bastebin → absolute path to your project directory
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name bastebin.com www.bastebin.com;
|
||||
|
||||
# Redirect all HTTP → HTTPS
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
listen [::]:443 ssl http2;
|
||||
server_name bastebin.com www.bastebin.com;
|
||||
|
||||
# ── TLS (managed by Certbot — do not edit manually) ──────────────────────
|
||||
ssl_certificate /etc/letsencrypt/live/bastebin.com/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/bastebin.com/privkey.pem;
|
||||
include /etc/letsencrypt/options-ssl-nginx.conf;
|
||||
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
|
||||
|
||||
# ── Security headers (supplement Flask's own headers) ────────────────────
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
|
||||
add_header X-Robots-Tag "noindex, nofollow" always;
|
||||
|
||||
# ── Block access to sensitive paths ──────────────────────────────────────
|
||||
# These should NEVER be served, even accidentally.
|
||||
location ~* \.(py|json|db|sqlite|sqlite3|log|cfg|ini|toml|env|sh|conf|bak|orig)$ {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
# Block dot-files / hidden files (e.g. .env, .git)
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
# Block __pycache__ and similar Python internals
|
||||
location ~* /__pycache__/ {
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
|
||||
# ── Static files served directly by Nginx (fast path) ───────────────────
|
||||
# Nginx serves these without hitting Gunicorn at all.
|
||||
location /static/ {
|
||||
alias /path/to/bastebin/static/;
|
||||
|
||||
# No directory listings
|
||||
autoindex off;
|
||||
|
||||
# Cache busting — serve with long expiry; filename includes hash in prod
|
||||
expires 7d;
|
||||
add_header Cache-Control "public, max-age=604800, immutable";
|
||||
|
||||
# Only allow known file extensions; block anything else
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
# Passthrough — let the alias serve it
|
||||
}
|
||||
location / {
|
||||
# Anything under /static/ that doesn't match above is denied
|
||||
deny all;
|
||||
return 404;
|
||||
}
|
||||
}
|
||||
|
||||
# ── Gunicorn (Flask application) ─────────────────────────────────────────
|
||||
location / {
|
||||
proxy_pass http://unix:/run/bastebin/gunicorn.sock;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# Timeouts
|
||||
proxy_connect_timeout 10s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
|
||||
# Body size limit — match config.json max_size_bytes (2 MB) + overhead
|
||||
client_max_body_size 3m;
|
||||
}
|
||||
|
||||
# ── Logging ───────────────────────────────────────────────────────────────
|
||||
access_log /var/log/nginx/bastebin.access.log;
|
||||
error_log /var/log/nginx/bastebin.error.log warn;
|
||||
}
|
||||
|
|
@ -411,97 +411,6 @@ pre[class*="language-"], code[class*="language-"] {
|
|||
|
||||
.table-responsive { overflow-x: auto; }
|
||||
|
||||
/* ── Password lock button (active state) ─────────────────────────────── */
|
||||
.nav-btn-locked {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
.nav-btn-locked:hover { background: var(--primary); color: #fff; }
|
||||
|
||||
/* ── Modal overlay ────────────────────────────────────────────────────── */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 200;
|
||||
}
|
||||
.modal-box {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.75rem 2rem;
|
||||
width: 100%;
|
||||
max-width: 380px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.18);
|
||||
}
|
||||
.modal-title {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.modal-desc {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-sub);
|
||||
margin-bottom: 1.25rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.modal-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
}
|
||||
.modal-input:focus { border-color: var(--primary); }
|
||||
.modal-error {
|
||||
font-size: 0.8rem;
|
||||
color: var(--danger);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.25rem;
|
||||
}
|
||||
|
||||
/* ── Admin bulk-delete controls ───────────────────────────────────────── */
|
||||
.admin-bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.admin-bulk-count {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.btn-bulk-delete {
|
||||
background: none;
|
||||
border: 1px solid var(--danger);
|
||||
color: var(--danger);
|
||||
padding: 0.3rem 0.75rem;
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.btn-bulk-delete:hover { background: var(--danger); color: #fff; }
|
||||
.btn-bulk-delete:disabled { opacity: 0.4; cursor: default; }
|
||||
.admin-table td.check-col, .admin-table th.check-col {
|
||||
width: 2rem;
|
||||
text-align: center;
|
||||
padding-left: 0.25rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
/* ── Discussions panel ────────────────────────────────────────────────── */
|
||||
.discussions-panel {
|
||||
flex-direction: column;
|
||||
|
|
@ -605,27 +514,3 @@ pre[class*="language-"], code[class*="language-"] {
|
|||
width: 100%;
|
||||
}
|
||||
.comment-input:focus { border-color: var(--primary); }
|
||||
|
||||
/* ── Burn-after-read banner ───────────────────────────────────────────── */
|
||||
.burn-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.55rem 1.5rem;
|
||||
background: rgba(217, 119, 6, 0.12);
|
||||
border-bottom: 1px solid rgba(217, 119, 6, 0.35);
|
||||
color: #92400e;
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.4;
|
||||
animation: burnSlideIn 0.3s ease;
|
||||
}
|
||||
[data-theme="dark"] .burn-banner {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
border-bottom-color: rgba(245, 158, 11, 0.3);
|
||||
color: #fcd34d;
|
||||
}
|
||||
@keyframes burnSlideIn {
|
||||
from { opacity: 0; transform: translateY(-6px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -115,35 +115,6 @@ const PasteCrypto = (function () {
|
|||
ciphertext
|
||||
);
|
||||
return new TextDecoder().decode(decrypted);
|
||||
},
|
||||
|
||||
/**
|
||||
* Derive an AES-GCM key from a password using PBKDF2.
|
||||
* Returns { key, saltBase64url } — store the salt in the URL fragment.
|
||||
*/
|
||||
async deriveKeyFromPassword(password, saltBytes = null) {
|
||||
const salt = saltBytes || window.crypto.getRandomValues(new Uint8Array(16));
|
||||
const enc = new TextEncoder().encode(password);
|
||||
const baseKey = await window.crypto.subtle.importKey(
|
||||
'raw', enc, 'PBKDF2', false, ['deriveKey']
|
||||
);
|
||||
const key = await window.crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', salt, iterations: 200000, hash: 'SHA-256' },
|
||||
baseKey,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
return { key, saltBase64url: arrayBufferToBase64url(salt) };
|
||||
},
|
||||
|
||||
/**
|
||||
* Re-derive a key from a password and a known salt (base64url string).
|
||||
*/
|
||||
async deriveKeyFromPasswordAndSalt(password, saltBase64url) {
|
||||
const salt = new Uint8Array(base64urlToArrayBuffer(saltBase64url));
|
||||
const { key } = await this.deriveKeyFromPassword(password, salt);
|
||||
return key;
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -9,67 +9,6 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
const submitBtn = document.getElementById('submitBtn');
|
||||
const langSelect = document.getElementById('language');
|
||||
|
||||
// ── Password modal state ─────────────────────────────────────────────────
|
||||
let _pendingPassword = null; // set when user locks paste
|
||||
|
||||
const passwordBtn = document.getElementById('passwordBtn');
|
||||
const passwordModal = document.getElementById('passwordModal');
|
||||
const ppInput = document.getElementById('pastePassword');
|
||||
const ppConfirm = document.getElementById('pastePasswordConfirm');
|
||||
const ppError = document.getElementById('passwordModalError');
|
||||
const ppSave = document.getElementById('passwordModalSave');
|
||||
const ppCancel = document.getElementById('passwordModalCancel');
|
||||
const ppClear = document.getElementById('passwordModalClear');
|
||||
|
||||
function openPasswordModal() {
|
||||
ppInput.value = '';
|
||||
ppConfirm.value = '';
|
||||
ppError.style.display = 'none';
|
||||
passwordModal.style.display = 'flex';
|
||||
ppInput.focus();
|
||||
}
|
||||
function closePasswordModal() {
|
||||
passwordModal.style.display = 'none';
|
||||
}
|
||||
function updatePasswordBtn() {
|
||||
if (_pendingPassword) {
|
||||
passwordBtn.textContent = '\uD83D\uDD13 Locked';
|
||||
passwordBtn.classList.add('nav-btn-locked');
|
||||
} else {
|
||||
passwordBtn.textContent = '\uD83D\uDD12 Lock';
|
||||
passwordBtn.classList.remove('nav-btn-locked');
|
||||
}
|
||||
}
|
||||
|
||||
if (passwordBtn) passwordBtn.addEventListener('click', openPasswordModal);
|
||||
if (ppCancel) ppCancel.addEventListener('click', closePasswordModal);
|
||||
if (ppClear) {
|
||||
ppClear.addEventListener('click', () => {
|
||||
_pendingPassword = null;
|
||||
updatePasswordBtn();
|
||||
closePasswordModal();
|
||||
});
|
||||
}
|
||||
if (ppSave) {
|
||||
ppSave.addEventListener('click', () => {
|
||||
const pw = ppInput.value;
|
||||
const pw2 = ppConfirm.value;
|
||||
if (!pw) { ppError.textContent = 'Password cannot be empty.'; ppError.style.display = 'block'; return; }
|
||||
if (pw !== pw2) { ppError.textContent = 'Passwords do not match.'; ppError.style.display = 'block'; return; }
|
||||
_pendingPassword = pw;
|
||||
updatePasswordBtn();
|
||||
closePasswordModal();
|
||||
});
|
||||
}
|
||||
// Close modal on overlay click
|
||||
if (passwordModal) {
|
||||
passwordModal.addEventListener('click', (e) => { if (e.target === passwordModal) closePasswordModal(); });
|
||||
}
|
||||
// Allow Enter to confirm password modal
|
||||
[ppInput, ppConfirm].forEach(el => {
|
||||
if (el) el.addEventListener('keydown', e => { if (e.key === 'Enter') ppSave?.click(); });
|
||||
});
|
||||
|
||||
// ── Load languages from API ──────────────────────────────────────────────
|
||||
fetch('/api/languages')
|
||||
.then(r => r.json())
|
||||
|
|
@ -105,9 +44,6 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
localStorage.setItem('preferred_discussions', discussCheck.checked));
|
||||
}
|
||||
|
||||
// Burn-after-read — intentionally NOT persisted to localStorage.
|
||||
const burnCheck = document.getElementById('burnAfterRead');
|
||||
|
||||
// ── Ctrl/Cmd+S shortcut ──────────────────────────────────────────────────
|
||||
document.addEventListener('keydown', e => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
|
|
@ -155,7 +91,6 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
// By the time the user clicks Save, window.PBCFG is guaranteed to be populated.
|
||||
const E2E = window.PBCFG?.features?.encrypt_pastes ?? true;
|
||||
const discussions = document.getElementById('allowDiscussions')?.checked ?? false;
|
||||
const burn_after_read = burnCheck?.checked ?? false;
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '…';
|
||||
|
|
@ -163,25 +98,13 @@ document.addEventListener('DOMContentLoaded', function () {
|
|||
try {
|
||||
let postBody, keyBase64 = null;
|
||||
if (E2E) {
|
||||
let key, fragmentValue;
|
||||
if (_pendingPassword) {
|
||||
// Password-based: derive key from password via PBKDF2.
|
||||
// Fragment is "p:<salt>" — no raw key in the URL.
|
||||
const { key: derivedKey, saltBase64url } = await PasteCrypto.deriveKeyFromPassword(_pendingPassword);
|
||||
key = derivedKey;
|
||||
fragmentValue = 'p:' + saltBase64url;
|
||||
} else {
|
||||
// Normal: random key, fragment is the key itself.
|
||||
const keyLen = window.PBCFG?.pastes?.encryption_key_bits ?? 128;
|
||||
key = await PasteCrypto.generateKey(keyLen);
|
||||
const key = await PasteCrypto.generateKey(keyLen);
|
||||
keyBase64 = await PasteCrypto.exportKey(key);
|
||||
fragmentValue = keyBase64;
|
||||
}
|
||||
const plain = JSON.stringify({ title, content, language, discussions, password_protected: !!_pendingPassword });
|
||||
postBody = { encrypted_data: await PasteCrypto.encrypt(plain, key), expires_in, discussions, burn_after_read };
|
||||
keyBase64 = fragmentValue;
|
||||
const plain = JSON.stringify({ title, content, language, discussions });
|
||||
postBody = { encrypted_data: await PasteCrypto.encrypt(plain, key), expires_in, discussions };
|
||||
} else {
|
||||
postBody = { title, content, language, expires_in, discussions, burn_after_read };
|
||||
postBody = { title, content, language, expires_in, discussions };
|
||||
}
|
||||
|
||||
const resp = await fetch('/create', {
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@
|
|||
let _decryptedPaste = null;
|
||||
let _pasteIsEncrypted = false;
|
||||
let _keyBase64 = null;
|
||||
let _burnMeta = { burn_after_read: false, burn_token: null };
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
let rawPayload;
|
||||
|
|
@ -23,12 +22,6 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Read burn-after-read metadata
|
||||
try {
|
||||
const burnIsland = document.getElementById('burnMeta');
|
||||
if (burnIsland) _burnMeta = JSON.parse(burnIsland.textContent);
|
||||
} catch (e) { /* non-fatal */ }
|
||||
|
||||
// Detect format from the data itself.
|
||||
// Encrypted pastes: "base64url:base64url" string.
|
||||
// Plaintext pastes: a JSON object.
|
||||
|
|
@ -36,46 +29,23 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
/^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$/.test(rawPayload);
|
||||
|
||||
if (isEncrypted) {
|
||||
const fragment = window.location.hash.slice(1);
|
||||
if (!fragment) {
|
||||
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;
|
||||
}
|
||||
|
||||
if (fragment.startsWith('p:')) {
|
||||
// Password-protected paste — show password prompt
|
||||
const saltBase64url = fragment.slice(2);
|
||||
showPasswordPrompt(async (password) => {
|
||||
try {
|
||||
const key = await PasteCrypto.deriveKeyFromPasswordAndSalt(password, saltBase64url);
|
||||
const key = await PasteCrypto.importKey(keyBase64);
|
||||
const plaintext = await PasteCrypto.decrypt(rawPayload, key);
|
||||
_decryptedPaste = JSON.parse(plaintext);
|
||||
_pasteIsEncrypted = true;
|
||||
_keyBase64 = null; // no shareable key for password-protected pastes
|
||||
finishRender();
|
||||
scheduleBurn();
|
||||
} catch (e) {
|
||||
return false; // signal wrong password
|
||||
}
|
||||
return true;
|
||||
});
|
||||
} else {
|
||||
// Normal key in fragment
|
||||
try {
|
||||
const key = await PasteCrypto.importKey(fragment);
|
||||
const plaintext = await PasteCrypto.decrypt(rawPayload, key);
|
||||
_decryptedPaste = JSON.parse(plaintext);
|
||||
_pasteIsEncrypted = true;
|
||||
_keyBase64 = fragment;
|
||||
_keyBase64 = keyBase64;
|
||||
} catch (e) {
|
||||
showError('Decryption Failed', 'Wrong key or tampered data.');
|
||||
return;
|
||||
}
|
||||
finishRender();
|
||||
scheduleBurn();
|
||||
}
|
||||
} else {
|
||||
// Plaintext paste — rawPayload is already the parsed JSON object.
|
||||
try {
|
||||
|
|
@ -86,54 +56,16 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|||
showError('Bad Data', 'Could not parse paste data.');
|
||||
return;
|
||||
}
|
||||
finishRender();
|
||||
scheduleBurn();
|
||||
}
|
||||
});
|
||||
|
||||
function showPasswordPrompt(onSubmit) {
|
||||
const overlay = document.getElementById('passwordPrompt');
|
||||
const input = document.getElementById('viewPassword');
|
||||
const submitBtn = document.getElementById('ppSubmit');
|
||||
const errEl = document.getElementById('ppError');
|
||||
if (!overlay) return;
|
||||
|
||||
overlay.style.display = 'flex';
|
||||
input.value = '';
|
||||
errEl.style.display = 'none';
|
||||
setTimeout(() => input.focus(), 50);
|
||||
|
||||
async function attempt() {
|
||||
const password = input.value;
|
||||
if (!password) { input.focus(); return; }
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '…';
|
||||
errEl.style.display = 'none';
|
||||
const ok = await onSubmit(password);
|
||||
if (ok) {
|
||||
overlay.style.display = 'none';
|
||||
} else {
|
||||
errEl.style.display = 'block';
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Decrypt';
|
||||
input.value = '';
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
submitBtn.addEventListener('click', attempt);
|
||||
input.addEventListener('keydown', e => { if (e.key === 'Enter') attempt(); });
|
||||
}
|
||||
|
||||
function finishRender() {
|
||||
renderPaste(_decryptedPaste);
|
||||
initPasteActions();
|
||||
initLineHighlight();
|
||||
initLineHighlight(); // register Prism hook before initLineNumbers triggers Prism
|
||||
initLineNumbers();
|
||||
initWordWrap();
|
||||
initDiscussions();
|
||||
initDeletion();
|
||||
}
|
||||
});
|
||||
|
||||
function initPasteActions() {
|
||||
const rawBtn = document.getElementById('rawBtn');
|
||||
|
|
@ -207,41 +139,6 @@ function showError(title, detail) {
|
|||
document.getElementById('errorState').style.display = 'block';
|
||||
}
|
||||
|
||||
// ── Burn-after-read ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function showBurnBanner() {
|
||||
const existing = document.getElementById('burnBanner');
|
||||
if (existing) return;
|
||||
const banner = document.createElement('div');
|
||||
banner.id = 'burnBanner';
|
||||
banner.className = 'burn-banner';
|
||||
banner.innerHTML = '🔥 <strong>This paste will self-destruct</strong> after you view it. Copy what you need now.';
|
||||
// Insert at the top of main
|
||||
const main = document.querySelector('main');
|
||||
if (main) main.insertBefore(banner, main.firstChild);
|
||||
}
|
||||
|
||||
async function scheduleBurn() {
|
||||
if (!_burnMeta?.burn_after_read || !_burnMeta?.burn_token) return;
|
||||
showBurnBanner();
|
||||
const pasteId = window.location.pathname.split('/').pop();
|
||||
const token = _burnMeta.burn_token;
|
||||
// Give the user a moment to see the content before the request fires.
|
||||
// The paste is already rendered; a brief delay is user-friendly but not a security gate.
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
try {
|
||||
await fetch(`/api/burn/${pasteId}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ burn_token: token }),
|
||||
});
|
||||
} catch (e) {
|
||||
// Fire-and-forget — if it fails (e.g. offline), the server-side
|
||||
// cleanup cron will eventually remove it, or the next viewer burns it.
|
||||
console.warn('[Burn] Request failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function rawView() {
|
||||
const key = window.location.hash;
|
||||
const url = window.location.href.split('?')[0].split('#')[0] + '/raw' + key;
|
||||
|
|
|
|||
|
|
@ -12,19 +12,13 @@
|
|||
<div class="admin-container">
|
||||
<div class="admin-header">
|
||||
<h1>Global Paste Management</h1>
|
||||
<p>Active Pastes: <strong>{{ active_count }}</strong> | Total in DB (inc. expired): {{ pastes|length }}</p>
|
||||
</div>
|
||||
|
||||
<div class="admin-bulk-bar">
|
||||
<button class="btn-bulk-delete" id="bulkDeleteBtn" disabled>Delete Selected</button>
|
||||
<span class="admin-bulk-count" id="bulkCount">0 selected</span>
|
||||
<p>Total Pastes: {{ pastes|length }}</p>
|
||||
</div>
|
||||
|
||||
<div class="table-responsive">
|
||||
<table class="admin-table" id="adminTable">
|
||||
<table class="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="check-col"><input type="checkbox" id="selectAll" title="Select all"></th>
|
||||
<th>ID</th>
|
||||
<th>Created</th>
|
||||
<th>Expires</th>
|
||||
|
|
@ -35,7 +29,6 @@
|
|||
<tbody>
|
||||
{% for paste in pastes %}
|
||||
<tr>
|
||||
<td class="check-col"><input type="checkbox" class="row-check" value="{{ paste.id }}"></td>
|
||||
<td><a href="{{ url_for('view_paste', paste_id=paste.id) }}" target="_blank"><code>{{ paste.id }}</code></a></td>
|
||||
<td>{{ paste.created_at }}</td>
|
||||
<td>{{ paste.expires_at or 'Never' }}</td>
|
||||
|
|
@ -51,64 +44,4 @@
|
|||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script nonce="{{ csp_nonce }}">
|
||||
(function () {
|
||||
const selectAll = document.getElementById('selectAll');
|
||||
const bulkDeleteBtn = document.getElementById('bulkDeleteBtn');
|
||||
const bulkCount = document.getElementById('bulkCount');
|
||||
|
||||
function getChecked() {
|
||||
return [...document.querySelectorAll('.row-check:checked')];
|
||||
}
|
||||
|
||||
function updateBulkBar() {
|
||||
const checked = getChecked();
|
||||
bulkCount.textContent = checked.length + ' selected';
|
||||
bulkDeleteBtn.disabled = checked.length === 0;
|
||||
const all = document.querySelectorAll('.row-check');
|
||||
selectAll.indeterminate = checked.length > 0 && checked.length < all.length;
|
||||
selectAll.checked = all.length > 0 && checked.length === all.length;
|
||||
}
|
||||
|
||||
document.querySelectorAll('.row-check').forEach(cb =>
|
||||
cb.addEventListener('change', updateBulkBar)
|
||||
);
|
||||
|
||||
selectAll.addEventListener('change', function () {
|
||||
document.querySelectorAll('.row-check').forEach(cb => cb.checked = this.checked);
|
||||
updateBulkBar();
|
||||
});
|
||||
|
||||
bulkDeleteBtn.addEventListener('click', async function () {
|
||||
const checked = getChecked();
|
||||
if (!checked.length) return;
|
||||
if (!confirm('Delete ' + checked.length + ' paste(s) permanently?')) return;
|
||||
|
||||
bulkDeleteBtn.disabled = true;
|
||||
bulkDeleteBtn.textContent = 'Deleting…';
|
||||
|
||||
const ids = checked.map(cb => cb.value);
|
||||
try {
|
||||
const resp = await fetch('{{ url_for("admin_bulk_delete") }}', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
if (resp.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
alert('Error: ' + (data.error || resp.status));
|
||||
bulkDeleteBtn.disabled = false;
|
||||
bulkDeleteBtn.textContent = 'Delete Selected';
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Request failed. Please try again.');
|
||||
bulkDeleteBtn.disabled = false;
|
||||
bulkDeleteBtn.textContent = 'Delete Selected';
|
||||
}
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
|
|||
|
|
@ -17,10 +17,6 @@
|
|||
<label class="nav-label" title="Allow discussions on this paste">
|
||||
<input type="checkbox" id="allowDiscussions"> Discuss
|
||||
</label>
|
||||
<label class="nav-label" title="Delete this paste the moment it is first opened">
|
||||
<input type="checkbox" id="burnAfterRead"> 🔥 Burn
|
||||
</label>
|
||||
<button id="passwordBtn" class="nav-btn" title="Set a password for this paste">🔒 Lock</button>
|
||||
<button id="submitBtn" class="nav-btn nav-btn-save">Save</button>
|
||||
{% if cfg.theme.allow_user_toggle %}
|
||||
<button id="themeToggle" class="theme-toggle">🌙</button>
|
||||
|
|
@ -28,26 +24,6 @@
|
|||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="passwordModal" class="modal-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="passwordModalTitle">
|
||||
<div class="modal-box">
|
||||
<h2 id="passwordModalTitle" class="modal-title">🔒 Password Protect</h2>
|
||||
<p class="modal-desc">The password is never sent to the server. Anyone viewing this paste will need it to decrypt.</p>
|
||||
<div class="form-group">
|
||||
<label for="pastePassword">Password</label>
|
||||
<input type="password" id="pastePassword" class="modal-input" placeholder="Enter password…" autocomplete="new-password">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="pastePasswordConfirm">Confirm Password</label>
|
||||
<input type="password" id="pastePasswordConfirm" class="modal-input" placeholder="Confirm password…" autocomplete="new-password">
|
||||
</div>
|
||||
<div id="passwordModalError" class="modal-error" style="display:none"></div>
|
||||
<div class="modal-actions">
|
||||
<button id="passwordModalClear" class="btn">Remove Lock</button>
|
||||
<button id="passwordModalCancel" class="btn">Cancel</button>
|
||||
<button id="passwordModalSave" class="btn btn-primary">Set Password</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
id="content"
|
||||
class="full-editor"
|
||||
|
|
|
|||
|
|
@ -21,21 +21,8 @@
|
|||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}<div id="passwordPrompt" class="modal-overlay" style="display:none" role="dialog" aria-modal="true" aria-labelledby="ppTitle">
|
||||
<div class="modal-box">
|
||||
<h2 id="ppTitle" class="modal-title">🔒 Password Required</h2>
|
||||
<p class="modal-desc">This paste is password protected. Enter the password to view it.</p>
|
||||
<div class="form-group">
|
||||
<label for="viewPassword">Password</label>
|
||||
<input type="password" id="viewPassword" class="modal-input" placeholder="Enter password…" autocomplete="current-password">
|
||||
</div>
|
||||
<div id="ppError" class="modal-error" style="display:none">Incorrect password or corrupted data.</div>
|
||||
<div class="modal-actions">
|
||||
<a href="/" class="btn">Cancel</a>
|
||||
<button id="ppSubmit" class="btn btn-primary">Decrypt</button>
|
||||
</div>
|
||||
</div>
|
||||
</div><div id="errorState" class="error-inline" style="display:none">
|
||||
{% block content %}
|
||||
<div id="errorState" class="error-inline" style="display:none">
|
||||
🔐 <strong id="errorTitle">Decryption Failed</strong> — <span id="errorDetail"></span>
|
||||
</div>
|
||||
<div class="view-full" id="viewFull" style="display:none">
|
||||
|
|
@ -58,7 +45,6 @@
|
|||
</div>
|
||||
</div>
|
||||
<script type="application/json" id="encryptedPayload">{{ paste.encrypted_data | tojson }}</script>
|
||||
<script type="application/json" id="burnMeta">{{ {'burn_after_read': burn_after_read, 'burn_token': burn_token} | tojson }}</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
|
|
|
|||
Loading…
Reference in New Issue