Initial commit — Bastebin

This commit is contained in:
ComputerTech 2026-03-26 14:44:36 +00:00
commit 14be14e437
18 changed files with 2054 additions and 0 deletions

64
.gitignore vendored Normal file
View File

@ -0,0 +1,64 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# Flask
instance/
.webassets-cache
# Environment
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Database
*.db
*.sqlite3
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Logs
*.log
# Temporary files
*.tmp
*.temp
# User uploads (if you add file upload functionality)
uploads/

229
README.md Normal file
View File

@ -0,0 +1,229 @@
# PasteBin - A Modern Pastebin Clone
A clean and modern pastebin website built with Python Flask and vanilla JavaScript, inspired by Hastepaste. Share code, text, and snippets with syntax highlighting, expiration options, and a beautiful dark/light theme.
## Features
- **Clean, Modern UI** - Responsive design with dark/light theme support
- **Syntax Highlighting** - Support for 19+ programming languages using Prism.js
- **Expiration Options** - Set pastes to expire after 1 hour, 1 day, 1 week, or 1 month
- **Easy Sharing** - Direct links, raw text view, and embed codes
- **Download Support** - Download pastes with appropriate file extensions
- **Mobile Friendly** - Works great on all devices
- **Auto-save Drafts** - Never lose your work with automatic draft saving
- **Keyboard Shortcuts** - Ctrl/Cmd + Enter to submit, Ctrl/Cmd + K to focus
- **View Counter** - Track how many times a paste has been viewed
- **Recent Pastes** - Browse recently created public pastes
- **Error Handling** - Proper 404/410 pages for missing/expired pastes
## Supported Languages
- Plain Text
- JavaScript
- Python
- Java
- C/C++
- C#
- HTML/CSS
- SQL
- JSON/XML
- Bash/PowerShell
- PHP
- Ruby
- Go
- Rust
- Markdown
## Installation
### Prerequisites
- Python 3.7+
- pip
### Setup
1. **Clone the repository:**
```bash
git clone <repository-url>
cd magic
```
2. **Create a virtual environment:**
```bash
python -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
3. **Install dependencies:**
```bash
pip install -r requirements.txt
```
4. **Run the application:**
```bash
python app.py
```
5. **Open your browser:**
Navigate to `http://localhost:5000`
## Configuration
### Environment Variables
You can customize the application using these environment variables:
- `SECRET_KEY` - Flask secret key (default: 'your-secret-key-change-this')
- `DATABASE` - Database file path (default: 'pastebin.db')
### Security
For production deployment:
1. Change the `SECRET_KEY` in `app.py` or set the `SECRET_KEY` environment variable
2. Use a proper WSGI server like Gunicorn instead of the Flask development server
3. Set up proper error logging and monitoring
## Usage
### Creating a Paste
1. Visit the homepage
2. Enter an optional title
3. Select the programming language
4. Choose expiration time (or never expire)
5. Paste your content
6. Click "Create Paste"
### Viewing Pastes
- **Regular View**: Formatted with syntax highlighting
- **Raw View**: Plain text for copying or embedding
- **Download**: Save as a file with proper extension
### Keyboard Shortcuts
- `Ctrl/Cmd + Enter` - Submit the current paste form
- `Ctrl/Cmd + K` - Focus on the content textarea
### Theme
- Click the moon/sun icon in the navigation to toggle between light and dark themes
- Theme preference is saved in localStorage
## Project Structure
```
magic/
├── app.py # Main Flask application
├── requirements.txt # Python dependencies
├── pastebin.db # SQLite database (created automatically)
├── templates/ # Jinja2 templates
│ ├── base.html # Base template with navigation
│ ├── index.html # Create paste page
│ ├── view.html # View paste page
│ ├── recent.html # Recent pastes page
│ ├── 404.html # Not found page
│ └── 410.html # Expired page
└── static/ # Static assets
├── css/
│ └── style.css # Main stylesheet
└── js/
└── app.js # JavaScript functionality
```
## API Endpoints
### Public Endpoints
- `GET /` - Create paste page
- `POST /create` - Create a new paste
- `GET /<paste_id>` - View paste
- `GET /<paste_id>/raw` - View paste raw content
- `GET /recent` - Recent pastes page
- `GET /api/languages` - Get supported languages
## Database Schema
The application uses SQLite with a single `pastes` table:
```sql
CREATE TABLE pastes (
id TEXT PRIMARY KEY, -- Unique paste ID
title TEXT, -- Optional title
content TEXT NOT NULL, -- Paste content
language TEXT DEFAULT 'text', -- Programming language
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP, -- Optional expiration date
views INTEGER DEFAULT 0, -- View counter
paste_type TEXT DEFAULT 'public'
);
```
## Browser Support
- Chrome 60+
- Firefox 60+
- Safari 12+
- Edge 79+
## Contributing
1. Fork the repository
2. Create a feature branch
3. Make your changes
4. Add tests if applicable
5. Submit a pull request
## License
This project is open source and available under the MIT License.
## Deployment
### Using Gunicorn (Recommended for production)
1. Install Gunicorn:
```bash
pip install gunicorn
```
2. Run with Gunicorn:
```bash
gunicorn -w 4 -b 0.0.0.0:8000 app:app
```
### Using Docker (Optional)
Create a `Dockerfile`:
```dockerfile
FROM python:3.9-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
EXPOSE 5000
CMD ["python", "app.py"]
```
Build and run:
```bash
docker build -t pastebin .
docker run -p 5000:5000 pastebin
```
## Security Considerations
- Input validation and sanitization
- XSS prevention through proper template escaping
- CSRF protection via Flask's built-in mechanisms
- Rate limiting (consider adding for production)
- Content size limits (1MB default)
## Troubleshooting
**Database errors**: Delete `pastebin.db` to reset the database
**Permission errors**: Ensure the app has write access to the directory
**Port conflicts**: Change the port in `app.py` or use environment variables
**Theme not saving**: Check if localStorage is enabled in your browser
## Acknowledgments
- Inspired by Hastepaste and modern pastebin services
- Uses Prism.js for syntax highlighting
- Built with Flask web framework

211
app.py Normal file
View File

@ -0,0 +1,211 @@
import json
import os
import re
import sqlite3
import uuid
import datetime
from flask import Flask, render_template, request, jsonify, abort
# ── Load configuration ────────────────────────────────────────────────────────
_CONFIG_PATH = os.path.join(os.path.dirname(__file__), 'config.json')
def _load_config():
with open(_CONFIG_PATH, 'r', encoding='utf-8') as f:
return json.load(f)
try:
CFG = _load_config()
except (FileNotFoundError, json.JSONDecodeError) as e:
raise RuntimeError(f"Failed to load config.json: {e}") from e
_site = CFG['site']
_server = CFG['server']
_db_cfg = CFG['database']
_pastes = CFG['pastes']
_theme = CFG['theme']
_feat = CFG['features']
_ui = CFG['ui']
# ── Flask app setup ───────────────────────────────────────────────────────────
app = Flask(__name__)
app.config['SECRET_KEY'] = _server.get('secret_key', 'change-me')
DATABASE = _db_cfg.get('path', 'bastebin.db')
MAX_ENCRYPTED_BYTES = _pastes.get('max_size_bytes', 2 * 1024 * 1024)
_ENCRYPTED_RE = re.compile(r'^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$')
# ── Jinja helpers ─────────────────────────────────────────────────────────────
@app.context_processor
def inject_config():
return {'cfg': CFG}
# ── Database ──────────────────────────────────────────────────────────────────
def get_db_connection():
conn = sqlite3.connect(DATABASE, timeout=10)
conn.row_factory = sqlite3.Row
conn.execute('PRAGMA journal_mode=WAL')
return conn
def init_db():
conn = sqlite3.connect(DATABASE)
cursor = conn.cursor()
cursor.execute("PRAGMA table_info(pastes)")
columns = {row[1] for row in cursor.fetchall()}
if columns and 'content' in columns and 'encrypted_data' not in columns:
cursor.execute("DROP TABLE pastes")
cursor.execute('''
CREATE TABLE IF NOT EXISTS pastes (
id TEXT PRIMARY KEY,
encrypted_data TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
expires_at TIMESTAMP,
views INTEGER DEFAULT 0
)
''')
conn.commit()
conn.close()
# ── Helpers ───────────────────────────────────────────────────────────────────
def generate_paste_id():
length = _pastes.get('id_length', 8)
return str(uuid.uuid4()).replace('-', '')[:length]
def validate_encrypted_data(value):
if not isinstance(value, str):
return False
if len(value) > MAX_ENCRYPTED_BYTES:
return False
return bool(_ENCRYPTED_RE.match(value))
def _get_paste_or_abort(paste_id):
conn = get_db_connection()
paste = conn.execute('SELECT * FROM pastes WHERE id = ?', (paste_id,)).fetchone()
conn.close()
if not paste:
abort(404)
if paste['expires_at']:
expires_at = datetime.datetime.fromisoformat(paste['expires_at'])
if expires_at < datetime.datetime.now():
abort(410)
return paste
# ── Routes ────────────────────────────────────────────────────────────────────
@app.route('/')
def index():
return render_template('index.html')
@app.route('/create', methods=['POST'])
def create_paste():
data = request.get_json(silent=True)
if not data:
return jsonify({'error': 'JSON body required'}), 400
if 'encrypted_data' in data:
# Encrypted path — always accepted regardless of encrypt_pastes setting
store_data = data.get('encrypted_data', '')
if not validate_encrypted_data(store_data):
return jsonify({'error': 'encrypted_data must be a valid iv:ciphertext string'}), 400
elif 'content' in data:
# Plaintext path — always accepted regardless of encrypt_pastes setting
content = data.get('content', '')
title = data.get('title', 'Untitled') or 'Untitled'
language = data.get('language', _pastes.get('default_language', 'text'))
if not content or not isinstance(content, str):
return jsonify({'error': 'content must be a non-empty string'}), 400
if len(content.encode('utf-8')) > MAX_ENCRYPTED_BYTES:
return jsonify({'error': 'Paste too large'}), 413
store_data = json.dumps({'title': title, 'content': content, 'language': language})
else:
return jsonify({'error': 'Provide either encrypted_data or content'}), 400
allowed_expiry = set(_pastes.get('allow_expiry_options', ['never']))
expires_in = data.get('expires_in', 'never')
if expires_in not in allowed_expiry:
expires_in = 'never'
expires_at = None
if expires_in != 'never':
delta_map = {
'1hour': datetime.timedelta(hours=1),
'1day': datetime.timedelta(days=1),
'1week': datetime.timedelta(weeks=1),
'1month': datetime.timedelta(days=30),
}
expires_at = datetime.datetime.now() + delta_map[expires_in]
paste_id = generate_paste_id()
conn = get_db_connection()
conn.execute(
'INSERT INTO pastes (id, encrypted_data, expires_at) VALUES (?, ?, ?)',
(paste_id, store_data, expires_at)
)
conn.commit()
conn.close()
return jsonify({'paste_id': paste_id, 'url': f'/{paste_id}'})
@app.route('/<paste_id>')
def view_paste(paste_id):
paste = _get_paste_or_abort(paste_id)
conn = get_db_connection()
conn.execute('UPDATE pastes SET views = views + 1 WHERE id = ?', (paste_id,))
conn.commit()
paste = conn.execute('SELECT * FROM pastes WHERE id = ?', (paste_id,)).fetchone()
conn.close()
return render_template('view.html', paste=paste)
@app.route('/<paste_id>/raw')
def view_paste_raw(paste_id):
paste = _get_paste_or_abort(paste_id)
return jsonify({
'id': paste['id'],
'encrypted_data': paste['encrypted_data'],
'created_at': paste['created_at'],
'expires_at': paste['expires_at'],
'views': paste['views'],
})
@app.route('/api/languages')
def get_languages():
return jsonify(CFG.get('languages', []))
@app.route('/api/config')
def get_client_config():
"""Expose the browser-safe portion of config to JavaScript."""
return jsonify({
'site': _site,
'theme': _theme,
'features': _feat,
'ui': _ui,
'pastes': {
'default_language': _pastes.get('default_language', 'text'),
'default_expiry': _pastes.get('default_expiry', 'never'),
'allow_expiry_options': _pastes.get('allow_expiry_options', []),
'expiry_labels': _pastes.get('expiry_labels', {}),
}
})
# ── Error handlers ────────────────────────────────────────────────────────────
@app.errorhandler(404)
def not_found(error):
return render_template('404.html'), 404
@app.errorhandler(410)
def gone(error):
return render_template('410.html'), 410
# ── Entry point ───────────────────────────────────────────────────────────────
if __name__ == '__main__':
init_db()
app.run(
debug=_server.get('debug', False),
host=_server.get('host', '0.0.0.0'),
port=_server.get('port', 5000)
)

135
config.json Normal file
View File

@ -0,0 +1,135 @@
{
"_comment": "Bastebin configuration. Restart the server after changing values.",
"site": {
"name": "Bastebin",
"tagline": "Simple, fast, and end-to-end encrypted.",
"brand_icon": "📋",
"footer_text": "Simple, fast, and reliable.",
"base_url": ""
},
"server": {
"host": "0.0.0.0",
"port": 5000,
"debug": true,
"secret_key": "change-this-to-a-long-random-secret"
},
"database": {
"path": "pastebin.db"
},
"pastes": {
"max_size_bytes": 2097152,
"id_length": 8,
"recent_limit": 50,
"default_language": "text",
"default_expiry": "never",
"allow_expiry_options": ["never", "1hour", "1day", "1week", "1month"],
"expiry_labels": {
"never": "Never",
"1hour": "1 Hour",
"1day": "1 Day",
"1week": "1 Week",
"1month": "1 Month"
}
},
"theme": {
"default": "auto",
"allow_user_toggle": true,
"light": {
"primary": "#2563eb",
"primary_hover": "#1d4ed8",
"success": "#059669",
"danger": "#dc2626",
"warning": "#d97706",
"background": "#ffffff",
"surface": "#f8fafc",
"card": "#ffffff",
"border": "#e2e8f0",
"text_primary": "#1e293b",
"text_secondary": "#64748b",
"text_muted": "#94a3b8",
"navbar_bg": "#ffffff",
"navbar_border": "#e2e8f0",
"code_bg": "#f8fafc",
"code_border": "#e2e8f0",
"prism_theme": "prism"
},
"dark": {
"primary": "#3b82f6",
"primary_hover": "#2563eb",
"success": "#10b981",
"danger": "#ef4444",
"warning": "#f59e0b",
"background": "#0f172a",
"surface": "#1e293b",
"card": "#1e293b",
"border": "#334155",
"text_primary": "#f1f5f9",
"text_secondary": "#cbd5e1",
"text_muted": "#64748b",
"navbar_bg": "#1e293b",
"navbar_border": "#334155",
"code_bg": "#0f172a",
"code_border": "#334155",
"prism_theme": "prism-tomorrow"
}
},
"features": {
"encrypt_pastes": true,
"show_recent": false,
"show_view_count": true,
"show_e2e_banner": true,
"allow_raw_api": true,
"auto_save_draft": true,
"draft_max_age_days": 7,
"keyboard_shortcuts": true
},
"ui": {
"code_font_family": "'JetBrains Mono', 'Fira Code', 'Consolas', monospace",
"code_font_size": "0.875rem",
"code_line_height": "1.6",
"textarea_rows": 20,
"border_radius": "8px",
"border_radius_sm": "4px",
"border_radius_lg": "12px",
"animation_speed": "0.2s"
},
"languages": [
{"value": "text", "name": "Plain Text"},
{"value": "javascript", "name": "JavaScript"},
{"value": "typescript", "name": "TypeScript"},
{"value": "python", "name": "Python"},
{"value": "java", "name": "Java"},
{"value": "c", "name": "C"},
{"value": "cpp", "name": "C++"},
{"value": "csharp", "name": "C#"},
{"value": "html", "name": "HTML"},
{"value": "css", "name": "CSS"},
{"value": "scss", "name": "SCSS"},
{"value": "sql", "name": "SQL"},
{"value": "json", "name": "JSON"},
{"value": "yaml", "name": "YAML"},
{"value": "xml", "name": "XML"},
{"value": "bash", "name": "Bash"},
{"value": "powershell", "name": "PowerShell"},
{"value": "php", "name": "PHP"},
{"value": "ruby", "name": "Ruby"},
{"value": "go", "name": "Go"},
{"value": "rust", "name": "Rust"},
{"value": "swift", "name": "Swift"},
{"value": "kotlin", "name": "Kotlin"},
{"value": "markdown", "name": "Markdown"},
{"value": "diff", "name": "Diff / Patch"},
{"value": "docker", "name": "Dockerfile"},
{"value": "nginx", "name": "Nginx Config"},
{"value": "toml", "name": "TOML"},
{"value": "ini", "name": "INI / Config"}
]
}

28
gunicorn.conf.py Normal file
View File

@ -0,0 +1,28 @@
import multiprocessing
# Bind address — change to a Unix socket for nginx proxy:
# bind = 'unix:/tmp/magic.sock'
bind = '0.0.0.0:5000'
# One worker per CPU core, +1. Keep at 2 minimum for SQLite (WAL mode handles
# concurrent reads; writes are serialised at the SQLite layer).
workers = max(2, multiprocessing.cpu_count() + 1)
# Use threads inside each worker for extra concurrency without extra processes.
worker_class = 'gthread'
threads = 4
# Kill and restart a worker after this many requests to prevent memory leaks.
max_requests = 1000
max_requests_jitter = 100
timeout = 60
keepalive = 5
# Pre-fork the app once so all workers share the loaded config.
preload_app = True
# Log to stdout so systemd / docker can capture it.
accesslog = '-'
errorlog = '-'
loglevel = 'info'

188
production.py Normal file
View File

@ -0,0 +1,188 @@
#!/usr/bin/env python3
"""
production.py Manage the Bastebin Gunicorn process.
Usage:
python production.py start [--host HOST] [--port PORT] [--workers N]
python production.py stop
python production.py restart [--host HOST] [--port PORT] [--workers N]
python production.py status
"""
import argparse
import multiprocessing
import os
import signal
import subprocess
import sys
import time
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
PID_FILE = os.path.join(BASE_DIR, 'gunicorn.pid')
LOG_FILE = os.path.join(BASE_DIR, 'gunicorn.log')
CONF_FILE = os.path.join(BASE_DIR, 'gunicorn.conf.py')
VENV_BIN = os.path.join(BASE_DIR, '.venv', 'bin')
def _gunicorn_bin() -> str:
candidate = os.path.join(VENV_BIN, 'gunicorn')
if os.path.isfile(candidate):
return candidate
import shutil
found = shutil.which('gunicorn')
if not found:
sys.exit('gunicorn not found — run python setup.py first.')
return found
def _read_pid() -> int | None:
try:
with open(PID_FILE) as f:
return int(f.read().strip())
except (FileNotFoundError, ValueError):
return None
def _process_alive(pid: int) -> bool:
try:
os.kill(pid, 0)
return True
except (ProcessLookupError, PermissionError):
return False
def cmd_status() -> None:
pid = _read_pid()
if pid and _process_alive(pid):
print(f'Running (PID {pid})')
else:
if pid:
os.remove(PID_FILE)
print('Stopped')
def cmd_start(host: str, port: int, workers: int) -> None:
pid = _read_pid()
if pid and _process_alive(pid):
print(f'Already running (PID {pid}). Use "restart" to reload.')
return
gunicorn = _gunicorn_bin()
log = open(LOG_FILE, 'a')
proc = subprocess.Popen(
[
gunicorn,
'--config', CONF_FILE,
'--bind', f'{host}:{port}',
'--workers', str(workers),
'--pid', PID_FILE,
'wsgi:app',
],
stdout=log,
stderr=log,
cwd=BASE_DIR,
start_new_session=True,
)
# Wait briefly to confirm the process stayed alive.
time.sleep(1.5)
if proc.poll() is not None:
log.close()
sys.exit(f'Gunicorn exited immediately. Check {LOG_FILE} for details.')
log.close()
# Gunicorn writes its own PID file; confirm it appeared.
pid = _read_pid()
print(f'Started (PID {pid or proc.pid}) → http://{host}:{port}')
print(f'Logs: {LOG_FILE}')
def cmd_stop(graceful: bool = True) -> bool:
pid = _read_pid()
if not pid or not _process_alive(pid):
print('Not running.')
if pid:
os.remove(PID_FILE)
return False
sig = signal.SIGTERM if graceful else signal.SIGKILL
os.kill(pid, sig)
# Wait up to 10 s for a clean shutdown.
for _ in range(20):
time.sleep(0.5)
if not _process_alive(pid):
break
if _process_alive(pid):
# Force-kill if still alive after graceful period.
os.kill(pid, signal.SIGKILL)
time.sleep(0.5)
if os.path.exists(PID_FILE):
os.remove(PID_FILE)
print(f'Stopped (was PID {pid})')
return True
def cmd_restart(host: str, port: int, workers: int) -> None:
pid = _read_pid()
if pid and _process_alive(pid):
# Send SIGHUP — Gunicorn performs a graceful reload without downtime.
os.kill(pid, signal.SIGHUP)
print(f'Reloaded (PID {pid})')
else:
print('Not running — starting fresh.')
cmd_start(host, port, workers)
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog='production.py',
description='Manage the Bastebin Gunicorn process.',
)
sub = parser.add_subparsers(dest='command', metavar='COMMAND')
sub.required = True
def _add_server_args(p: argparse.ArgumentParser) -> None:
p.add_argument('--host', default='0.0.0.0', metavar='HOST',
help='Bind address (default: 0.0.0.0)')
p.add_argument('--port', default=5000, type=int, metavar='PORT',
help='Bind port (default: 5000)')
p.add_argument('--workers', default=None, type=int, metavar='N',
help='Worker processes (default: cpu_count + 1, min 2)')
p_start = sub.add_parser('start', help='Start Gunicorn in the background')
p_restart = sub.add_parser('restart', help='Gracefully reload (SIGHUP) or start if stopped')
sub.add_parser('stop', help='Stop Gunicorn gracefully')
sub.add_parser('status', help='Show whether Gunicorn is running')
_add_server_args(p_start)
_add_server_args(p_restart)
return parser
def main() -> None:
parser = _build_parser()
args = parser.parse_args()
default_workers = max(2, multiprocessing.cpu_count() + 1)
workers = getattr(args, 'workers', None) or default_workers
host = getattr(args, 'host', '0.0.0.0')
port = getattr(args, 'port', 5000)
if args.command == 'start':
cmd_start(host, port, workers)
elif args.command == 'stop':
cmd_stop()
elif args.command == 'restart':
cmd_restart(host, port, workers)
elif args.command == 'status':
cmd_status()
if __name__ == '__main__':
main()

3
requirements.txt Normal file
View File

@ -0,0 +1,3 @@
Flask==3.0.0
Werkzeug==3.0.1
gunicorn==25.2.0

153
setup.py Normal file
View File

@ -0,0 +1,153 @@
#!/usr/bin/env python3
"""
setup.py First-time setup for Bastebin.
What this does:
1. Checks that a supported Python version is available.
2. Creates a virtual environment at .venv/ (if absent).
3. Installs all dependencies from requirements.txt into the venv.
4. Initialises the SQLite database (creates the pastes table).
5. Warns if config.json contains the default secret key.
Usage:
python setup.py
"""
import os
import subprocess
import sys
MIN_PYTHON = (3, 11)
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
VENV_DIR = os.path.join(BASE_DIR, '.venv')
REQS_FILE = os.path.join(BASE_DIR, 'requirements.txt')
CONFIG = os.path.join(BASE_DIR, 'config.json')
# ── Helpers ───────────────────────────────────────────────────────────────────
def _step(msg: str) -> None:
print(f'\n {msg}')
def _ok(msg: str = 'done') -> None:
print(f'{msg}')
def _warn(msg: str) -> None:
print(f'{msg}')
def _fail(msg: str) -> None:
print(f'\n{msg}')
sys.exit(1)
def _venv_python() -> str:
return os.path.join(VENV_DIR, 'bin', 'python')
def _venv_pip() -> str:
return os.path.join(VENV_DIR, 'bin', 'pip')
def _run(*args: str, capture: bool = False) -> subprocess.CompletedProcess:
return subprocess.run(
args,
check=True,
capture_output=capture,
text=True,
)
# ── Steps ─────────────────────────────────────────────────────────────────────
def check_python() -> None:
_step('Checking Python version...')
if sys.version_info < MIN_PYTHON:
_fail(
f'Python {MIN_PYTHON[0]}.{MIN_PYTHON[1]}+ is required '
f'(found {sys.version.split()[0]}).'
)
_ok(f'Python {sys.version.split()[0]}')
def create_venv() -> None:
_step('Setting up virtual environment...')
if os.path.isfile(_venv_python()):
_ok('.venv already exists — skipping creation')
return
_run(sys.executable, '-m', 'venv', VENV_DIR)
_ok(f'Created {VENV_DIR}')
def install_dependencies() -> None:
_step('Installing dependencies...')
if not os.path.isfile(REQS_FILE):
_fail(f'requirements.txt not found at {REQS_FILE}')
_run(_venv_pip(), 'install', '--quiet', '--upgrade', 'pip')
_run(_venv_pip(), 'install', '--quiet', '-r', REQS_FILE)
_ok('All packages installed')
def initialise_database() -> None:
_step('Initialising database...')
# Import app inside the venv's Python so the correct packages are used.
result = _run(
_venv_python(),
'-c',
'import sys; sys.path.insert(0, "."); from app import init_db; init_db(); print("ok")',
capture=True,
)
if 'ok' in result.stdout:
_ok('Database ready')
else:
_warn('Database initialisation produced unexpected output — check manually.')
def check_secret_key() -> None:
_step('Checking configuration...')
try:
import json
with open(CONFIG, encoding='utf-8') as f:
cfg = json.load(f)
key = cfg.get('server', {}).get('secret_key', '')
if key in ('', 'change-this-to-a-long-random-secret', 'change-me'):
_warn(
'config.json still has the default secret_key. '
'Set a long, random value before deploying to production.'
)
else:
_ok('Secret key looks good')
except (FileNotFoundError, Exception) as e:
_warn(f'Could not read config.json: {e}')
# ── Entry point ───────────────────────────────────────────────────────────────
def main() -> None:
print('' * 50)
print(' Bastebin setup')
print('' * 50)
check_python()
create_venv()
install_dependencies()
initialise_database()
check_secret_key()
print()
print('' * 50)
print(' Setup complete.')
print()
print(' Start the server:')
print(' python production.py start')
print()
print(' Stop the server:')
print(' python production.py stop')
print('' * 50)
print()
if __name__ == '__main__':
main()

269
static/css/style.css Normal file
View File

@ -0,0 +1,269 @@
/* ── Reset ─────────────────────────────────────────────────────────────── */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
/* ── Variables ─────────────────────────────────────────────────────────── */
:root {
--primary: #2563eb;
--primary-h: #1d4ed8;
--bg: #ffffff;
--surface: #f8fafc;
--border: #e2e8f0;
--text: #1e293b;
--text-sub: #64748b;
--text-muted: #94a3b8;
--code-bg: #f8fafc;
--nav-bg: #ffffff;
--nav-border: #e2e8f0;
--danger: #dc2626;
--radius: 4px;
--mono: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
}
[data-theme="dark"] {
--primary: #3b82f6;
--primary-h: #2563eb;
--bg: #0f172a;
--surface: #1e293b;
--border: #334155;
--text: #f1f5f9;
--text-sub: #cbd5e1;
--text-muted: #64748b;
--code-bg: #0f172a;
--nav-bg: #1e293b;
--nav-border: #334155;
--danger: #ef4444;
}
/* ── Layout ────────────────────────────────────────────────────────────── */
html, body {
height: 100%;
overflow: hidden;
}
body {
display: flex;
flex-direction: column;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: var(--bg);
color: var(--text);
font-size: 14px;
line-height: 1.5;
}
/* ── Navbar ────────────────────────────────────────────────────────────── */
.navbar {
flex-shrink: 0;
background: var(--nav-bg);
border-bottom: 1px solid var(--nav-border);
height: 42px;
display: flex;
align-items: center;
padding: 0 1rem;
gap: 0.5rem;
z-index: 10;
}
.nav-content {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
}
.brand {
font-weight: 700;
color: var(--primary);
text-decoration: none;
font-size: 1rem;
white-space: nowrap;
margin-right: 0.5rem;
}
.brand:hover { color: var(--primary-h); }
.nav-links {
display: flex;
align-items: center;
gap: 0.4rem;
flex: 1;
}
/* Nav controls */
.nav-input {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 0.8rem;
padding: 0.25rem 0.5rem;
width: 160px;
outline: none;
}
.nav-input:focus { border-color: var(--primary); }
.nav-select {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text);
font-size: 0.8rem;
padding: 0.25rem 0.4rem;
cursor: pointer;
outline: none;
}
.nav-select:focus { border-color: var(--primary); }
.nav-btn {
background: none;
border: 1px solid var(--border);
border-radius: var(--radius);
color: var(--text-sub);
font-size: 0.8rem;
padding: 0.25rem 0.6rem;
cursor: pointer;
text-decoration: none;
white-space: nowrap;
line-height: 1.4;
}
.nav-btn:hover { border-color: var(--primary); color: var(--primary); }
.nav-btn-save {
border-color: var(--primary);
color: var(--primary);
font-weight: 600;
}
.nav-btn-save:hover { background: var(--primary); color: #fff; }
.nav-paste-title {
font-size: 0.85rem;
color: var(--text-sub);
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex-shrink: 1;
}
.theme-toggle {
background: none;
border: none;
font-size: 1rem;
cursor: pointer;
padding: 0.2rem 0.3rem;
border-radius: var(--radius);
line-height: 1;
color: var(--text-sub);
}
.theme-toggle:hover { background: var(--surface); }
/* ── Full-page main ────────────────────────────────────────────────────── */
.full-page {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
/* ── Editor textarea ───────────────────────────────────────────────────── */
.full-editor {
flex: 1;
width: 100%;
height: 100%;
border: none;
outline: none;
resize: none;
padding: 1rem 1.5rem;
font-family: var(--mono);
font-size: 0.875rem;
line-height: 1.6;
background: var(--bg);
color: var(--text);
tab-size: 4;
}
/* ── View: full-page code ──────────────────────────────────────────────── */
.view-full {
flex: 1;
overflow: auto;
background: var(--code-bg);
min-height: 0;
}
.view-full pre {
margin: 0;
padding: 1rem 1.5rem;
min-height: 100%;
background: transparent;
}
.view-full code {
font-family: var(--mono);
font-size: 0.875rem;
line-height: 1.6;
}
/* ── Inline error (view page) ──────────────────────────────────────────── */
.error-inline {
padding: 0.75rem 1.5rem;
color: var(--danger);
font-size: 0.875rem;
border-bottom: 1px solid var(--border);
background: var(--surface);
}
/* ── Page-container (error pages, etc.) ───────────────────────────────── */
.page-container {
flex: 1;
max-width: 900px;
width: 100%;
margin: 0 auto;
padding: 2rem 1rem;
overflow-y: auto;
}
/* ── Error pages ───────────────────────────────────────────────────────── */
.error-page {
display: flex;
justify-content: center;
align-items: center;
min-height: 60%;
}
.error-content { text-align: center; max-width: 380px; }
.error-content h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
.error-content p { color: var(--text-sub); margin-bottom: 1.5rem; }
.error-actions { display: flex; justify-content: center; gap: 0.75rem; }
/* Generic button (used on error pages) */
.btn {
display: inline-block;
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.4rem 0.9rem;
font-size: 0.85rem;
text-decoration: none;
cursor: pointer;
background: none;
color: var(--text-sub);
}
.btn:hover { border-color: var(--primary); color: var(--primary); }
.btn-primary { border-color: var(--primary); color: var(--primary); font-weight: 600; }
.btn-primary:hover { background: var(--primary); color: #fff; }
/* ── Notification toast ────────────────────────────────────────────────── */
.notification {
position: fixed;
bottom: 1rem;
right: 1rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.5rem 1rem;
font-size: 0.8rem;
color: var(--text-sub);
z-index: 100;
}
/* ── Prism overrides: transparent background ───────────────────────────── */
pre[class*="language-"], code[class*="language-"] {
background: transparent !important;
}
/* ── Responsive ────────────────────────────────────────────────────────── */
@media (max-width: 600px) {
.nav-input { width: 90px; }
.nav-paste-title { display: none; }
}

238
static/js/app.js Normal file
View File

@ -0,0 +1,238 @@
// ── Config ─────────────────────────────────────────────────────────────────
window.PBCFG = null;
// Map config theme keys → CSS custom property names used in style.css
const _CSS_VAR_MAP = {
primary: '--primary',
primary_hover: '--primary-h',
danger: '--danger',
background: '--bg',
surface: '--surface',
border: '--border',
text_primary: '--text',
text_secondary: '--text-sub',
text_muted: '--text-muted',
code_bg: '--code-bg',
navbar_bg: '--nav-bg',
navbar_border: '--nav-border',
};
// Map config ui keys → CSS custom property names
const _UI_VAR_MAP = {
border_radius: '--radius',
};
// Fetch config then initialise theme immediately (before DOMContentLoaded)
(async function loadConfig() {
try {
const resp = await fetch('/api/config');
if (resp.ok) window.PBCFG = await resp.json();
} catch (e) {
console.warn('Could not load /api/config, using CSS fallbacks.', e);
}
initialiseTheme();
applyUiVars();
})();
// ── Theme Management ────────────────────────────────────────────────────────
function initialiseTheme() {
const saved = localStorage.getItem('theme');
const configDefault = window.PBCFG?.theme?.default || 'auto';
let theme;
if (saved === 'light' || saved === 'dark') {
theme = saved;
} else if (configDefault === 'dark') {
theme = 'dark';
} else if (configDefault === 'light') {
theme = 'light';
} else {
// 'auto' or unset → follow OS preference
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
applyTheme(theme);
// Live OS theme changes (only when user hasn't pinned a preference)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function (e) {
if (!localStorage.getItem('theme')) {
applyTheme(e.matches ? 'dark' : 'light');
}
});
}
function applyTheme(theme) {
document.documentElement.setAttribute('data-theme', theme);
applyConfigCssVars(theme);
swapPrismTheme(theme);
updateThemeToggle(theme);
}
function applyConfigCssVars(theme) {
const cfg = window.PBCFG?.theme;
if (!cfg) return;
const palette = cfg[theme] || {};
const root = document.documentElement;
for (const [key, cssVar] of Object.entries(_CSS_VAR_MAP)) {
if (palette[key] !== undefined) {
root.style.setProperty(cssVar, palette[key]);
}
}
}
function applyUiVars() {
const ui = window.PBCFG?.ui;
if (!ui) return;
const root = document.documentElement;
for (const [key, cssVar] of Object.entries(_UI_VAR_MAP)) {
if (ui[key] !== undefined) {
root.style.setProperty(cssVar, ui[key]);
}
}
}
function swapPrismTheme(theme) {
const light = document.getElementById('prism-light');
const dark = document.getElementById('prism-dark');
if (!light || !dark) return;
if (theme === 'dark') {
light.disabled = true;
dark.disabled = false;
} else {
light.disabled = false;
dark.disabled = true;
}
}
function toggleTheme() {
const current = document.documentElement.getAttribute('data-theme') || 'light';
const newTheme = current === 'light' ? 'dark' : 'light';
applyTheme(newTheme);
if (window.PBCFG?.theme?.allow_user_toggle !== false) {
localStorage.setItem('theme', newTheme);
}
}
function updateThemeToggle(theme) {
const btn = document.querySelector('.theme-toggle');
if (!btn) return;
btn.textContent = theme === 'light' ? '🌙' : '☀️';
btn.title = `Switch to ${theme === 'light' ? 'dark' : 'light'} mode`;
}
// ── Clipboard ───────────────────────────────────────────────────────────────
async function copyToClipboard(text) {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch (e) { /* fall through */ }
// Fallback: temporary textarea
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;top:0;left:0;opacity:0';
document.body.appendChild(ta);
ta.focus();
ta.select();
let ok = false;
try { ok = document.execCommand('copy'); } catch (_) {}
document.body.removeChild(ta);
return ok;
}
// ── Time formatting ─────────────────────────────────────────────────────────
window.formatDateTime = function (dateString) {
const date = new Date(dateString);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
if (diffDays < 7) return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
if (diffDays < 30) { const w = Math.floor(diffDays / 7); return `${w} week${w > 1 ? 's' : ''} ago`; }
if (diffDays < 365) { const m = Math.floor(diffDays / 30); return `${m} month${m > 1 ? 's' : ''} ago`; }
const y = Math.floor(diffDays / 365);
return `${y} year${y > 1 ? 's' : ''} ago`;
};
// ── Draft auto-save ─────────────────────────────────────────────────────────
function initAutoSave() {
const contentTextarea = document.getElementById('content');
const titleInput = document.getElementById('title');
if (!contentTextarea) return;
if (window.PBCFG?.features?.auto_save_draft !== false) {
loadDraft();
}
let saveTimeout;
function saveDraft() {
clearTimeout(saveTimeout);
saveTimeout = setTimeout(() => {
const draft = {
title: titleInput ? titleInput.value : '',
content: contentTextarea.value,
timestamp: new Date().toISOString()
};
if (draft.content.trim()) {
localStorage.setItem('paste_draft', JSON.stringify(draft));
} else {
localStorage.removeItem('paste_draft');
}
}, 1000);
}
contentTextarea.addEventListener('input', saveDraft);
if (titleInput) titleInput.addEventListener('input', saveDraft);
}
function loadDraft() {
const raw = localStorage.getItem('paste_draft');
if (!raw) return;
try {
const draft = JSON.parse(raw);
const maxDays = window.PBCFG?.features?.draft_max_age_days ?? 7;
const maxAge = maxDays * 86400000;
const draftAge = Date.now() - new Date(draft.timestamp).getTime();
if (draftAge > maxAge) { localStorage.removeItem('paste_draft'); return; }
if (draft.content && draft.content.trim() &&
confirm('A draft was found. Would you like to restore it?')) {
const title = document.getElementById('title');
const content = document.getElementById('content');
if (title) title.value = draft.title || '';
if (content) content.value = draft.content || '';
}
} catch (e) {
localStorage.removeItem('paste_draft');
}
}
function clearDraft() {
localStorage.removeItem('paste_draft');
}
// ── Keyboard shortcuts ──────────────────────────────────────────────────────
document.addEventListener('keydown', function (e) {
if (window.PBCFG?.features?.keyboard_shortcuts === false) return;
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
const textarea = document.getElementById('content');
if (textarea) { e.preventDefault(); textarea.focus(); }
}
});
// ── Initialise auto-save once DOM is ready ──────────────────────────────────
document.addEventListener('DOMContentLoaded', initAutoSave);

107
static/js/crypto.js Normal file
View File

@ -0,0 +1,107 @@
'use strict';
/**
* PasteCrypto - Client-side AES-GCM 256-bit encryption/decryption
*
* The encryption key is stored ONLY in the URL fragment (#key).
* The fragment is never sent to the server, so the server stores
* only opaque ciphertext and has zero knowledge of paste contents.
*
* Encrypted format: base64url(12-byte IV) + ":" + base64url(ciphertext)
*
* API usage (programmatic paste creation):
* 1. Generate key: await PasteCrypto.generateKey()
* 2. Export key: await PasteCrypto.exportKey(key) base64url string
* 3. Encrypt: await PasteCrypto.encrypt(JSON.stringify({title,content,language}), key)
* 4. POST to /create: { encrypted_data: "...", expires_in: "never|1hour|1day|1week|1month" }
* 5. Share URL: https://yoursite.com/<paste_id>#<keyBase64url>
*/
const PasteCrypto = (function () {
function arrayBufferToBase64url(buffer) {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=/g, '');
}
function base64urlToArrayBuffer(str) {
const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
const binary = atob(padded);
const buf = new ArrayBuffer(binary.length);
const bytes = new Uint8Array(buf);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return buf;
}
return {
/** Generate a new, random AES-GCM 256-bit key. */
async generateKey() {
return window.crypto.subtle.generateKey(
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
},
/** Export a CryptoKey to a base64url string safe for URL fragments. */
async exportKey(key) {
const raw = await window.crypto.subtle.exportKey('raw', key);
return arrayBufferToBase64url(raw);
},
/** Import a base64url key string into a CryptoKey for decryption. */
async importKey(keyBase64url) {
const keyBytes = base64urlToArrayBuffer(keyBase64url);
return window.crypto.subtle.importKey(
'raw',
keyBytes,
{ name: 'AES-GCM', length: 256 },
false,
['decrypt']
);
},
/**
* Encrypt a plaintext string.
* Returns a string in the format: base64url(iv):base64url(ciphertext)
*/
async encrypt(plaintext, key) {
const iv = window.crypto.getRandomValues(new Uint8Array(12));
const encoded = new TextEncoder().encode(plaintext);
const ciphertext = await window.crypto.subtle.encrypt(
{ name: 'AES-GCM', iv },
key,
encoded
);
return arrayBufferToBase64url(iv) + ':' + arrayBufferToBase64url(ciphertext);
},
/**
* Decrypt a string produced by encrypt().
* Returns the original plaintext string.
*/
async decrypt(encryptedStr, key) {
const colonIdx = encryptedStr.indexOf(':');
if (colonIdx === -1) throw new Error('Invalid encrypted data format');
const iv = base64urlToArrayBuffer(encryptedStr.slice(0, colonIdx));
const ciphertext = base64urlToArrayBuffer(encryptedStr.slice(colonIdx + 1));
const decrypted = await window.crypto.subtle.decrypt(
{ name: 'AES-GCM', iv },
key,
ciphertext
);
return new TextDecoder().decode(decrypted);
}
};
})();
window.PasteCrypto = PasteCrypto;

17
templates/404.html Normal file
View File

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Not Found - {{ cfg.site.name }}{% endblock %}
{% block content %}
<div class="error-page">
<div class="error-content">
<div class="error-icon">🔍</div>
<h1>404 - Paste Not Found</h1>
<p>The paste you're looking for doesn't exist or has been removed.</p>
<div class="error-actions">
<a href="{{ url_for('index') }}" class="btn btn-primary">Create New Paste</a>
</div>
</div>
</div>
{% endblock %}

17
templates/410.html Normal file
View File

@ -0,0 +1,17 @@
{% extends "base.html" %}
{% block title %}Expired - {{ cfg.site.name }}{% endblock %}
{% block content %}
<div class="error-page">
<div class="error-content">
<div class="error-icon"></div>
<h1>410 - Paste Expired</h1>
<p>This paste has expired and is no longer available.</p>
<div class="error-actions">
<a href="{{ url_for('index') }}" class="btn btn-primary">Create New Paste</a>
</div>
</div>
</div>
{% endblock %}

71
templates/base.html Normal file
View File

@ -0,0 +1,71 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}{{ cfg.site.name }}{% endblock %}</title>
<script>
(function(){
var t = localStorage.getItem('theme');
if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches))
document.documentElement.setAttribute('data-theme','dark');
})();
</script>
<link id="prism-light" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" rel="stylesheet">
<link id="prism-dark" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" rel="stylesheet" disabled>
<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') }}">
</head>
<body>
<nav class="navbar">
<div class="nav-content">
<a href="{{ url_for('index') }}" class="brand">{{ cfg.site.name }}</a>
<div class="nav-links">
{% block nav_actions %}
<a href="{{ url_for('index') }}" class="nav-btn">New</a>
{% if cfg.theme.allow_user_toggle %}
<button class="theme-toggle" onclick="toggleTheme()">🌙</button>
{% endif %}
{% endblock %}
</div>
</div>
</nav>
<main class="{% block main_class %}page-container{% endblock %}">
{% block content %}{% endblock %}
</main>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-java.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-c.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-cpp.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-csharp.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markup.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-css.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-sql.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-powershell.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markup-templating.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-php.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-ruby.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-go.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-rust.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markdown.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-scss.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-yaml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-swift.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-kotlin.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-diff.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-docker.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-nginx.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-toml.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-ini.min.js"></script>
<script src="{{ url_for('static', filename='js/crypto.js') }}"></script>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>

122
templates/index.html Normal file
View File

@ -0,0 +1,122 @@
{% extends "base.html" %}
{% block title %}{{ cfg.site.name }}{% endblock %}
{% block main_class %}full-page{% endblock %}
{% block nav_actions %}
<input type="text" id="title" placeholder="Title (optional)" class="nav-input" maxlength="100" autocomplete="off">
<select id="language" class="nav-select"></select>
<select id="expires_in" class="nav-select">
<option value="never">Never</option>
<option value="1hour">1 Hour</option>
<option value="1day">1 Day</option>
<option value="1week">1 Week</option>
<option value="1month">1 Month</option>
</select>
<button id="submitBtn" class="nav-btn nav-btn-save">Save</button>
{% if cfg.theme.allow_user_toggle %}
<button class="theme-toggle" onclick="toggleTheme()">🌙</button>
{% endif %}
{% endblock %}
{% block content %}
<textarea
id="content"
class="full-editor"
placeholder="Paste your code or text here…"
spellcheck="false"
autocomplete="off"
autocorrect="off"
autocapitalize="off"></textarea>
{% endblock %}
{% block scripts %}
<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 %}

68
templates/recent.html Normal file
View File

@ -0,0 +1,68 @@
{% extends "base.html" %}
{% block title %}Recent Pastes - PasteBin{% endblock %}
{% block content %}
<div class="recent-pastes">
<div class="page-header">
<h1>Recent Pastes</h1>
<p>Browse recently created public pastes</p>
</div>
{% if pastes %}
<div class="pastes-grid">
{% for paste in pastes %}
<div class="paste-card">
<div class="paste-card-header">
<h3 class="paste-title">
<a href="{{ url_for('view_paste', paste_id=paste.id) }}">
🔒 Encrypted Paste
</a>
</h3>
<span class="language-badge encrypted-badge" title="Content is end-to-end encrypted">E2E</span>
</div>
<div class="paste-card-meta">
<span class="meta-item">
<strong>ID:</strong> {{ paste.id }}
</span>
<span class="meta-item">
<strong>Created:</strong>
<time datetime="{{ paste.created_at }}" class="relative-time">{{ paste.created_at }}</time>
</span>
<span class="meta-item">
<strong>Views:</strong> {{ paste.views }}
</span>
</div>
<div class="paste-card-actions">
<a href="{{ url_for('view_paste', paste_id=paste.id) }}" class="btn btn-primary btn-sm">View</a>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">📝</div>
<h3>No recent pastes</h3>
<p>Be the first to create a paste!</p>
<a href="{{ url_for('index') }}" class="btn btn-primary">Create New Paste</a>
</div>
{% endif %}
</div>
{% endblock %}
{% block scripts %}
<script>
// Format relative timestamps
document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('.relative-time').forEach(timeEl => {
const datetime = timeEl.getAttribute('datetime');
if (datetime && window.formatDateTime) {
timeEl.textContent = window.formatDateTime(datetime);
}
});
});
</script>
{% endblock %}

128
templates/view.html Normal file
View File

@ -0,0 +1,128 @@
{% extends "base.html" %}
{% block title %}{{ cfg.site.name }}{% endblock %}
{% block main_class %}full-page{% endblock %}
{% block nav_actions %}
<span id="navPasteTitle" class="nav-paste-title"></span>
<button onclick="rawView()" class="nav-btn">Raw</button>
<button onclick="copyPaste()" class="nav-btn">Copy</button>
<button onclick="downloadPaste()" class="nav-btn">Download</button>
<a href="{{ url_for('index') }}" class="nav-btn nav-btn-save">New</a>
{% if cfg.theme.allow_user_toggle %}
<button class="theme-toggle" onclick="toggleTheme()">🌙</button>
{% endif %}
{% endblock %}
{% 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">
<pre id="viewPre"><code id="codeBlock"></code></pre>
</div>
<script type="application/json" id="encryptedPayload">{{ paste.encrypted_data | tojson }}</script>
{% endblock %}
{% block scripts %}
<script>
let _decryptedPaste = null;
const E2E = {{ cfg.features.encrypt_pastes | tojson }};
(async function () {
let rawPayload;
try {
rawPayload = JSON.parse(document.getElementById('encryptedPayload').textContent);
} catch (e) {
showError('Bad Data', 'Could not read the paste payload.');
return;
}
if (E2E) {
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 {
try {
_decryptedPaste = 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 %}

6
wsgi.py Normal file
View File

@ -0,0 +1,6 @@
from app import app, init_db
init_db()
if __name__ == '__main__':
app.run()