commit 14be14e4375a0a4604f8cb68309b099bda187dcd Author: ComputerTech Date: Thu Mar 26 14:44:36 2026 +0000 Initial commit — Bastebin diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..535c6c2 --- /dev/null +++ b/.gitignore @@ -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/ \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..96dcf9b --- /dev/null +++ b/README.md @@ -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 + 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 /` - View paste +- `GET //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 \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..e4b6533 --- /dev/null +++ b/app.py @@ -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('/') +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('//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) + ) diff --git a/config.json b/config.json new file mode 100644 index 0000000..2ca4827 --- /dev/null +++ b/config.json @@ -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"} + ] +} diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..d79010c --- /dev/null +++ b/gunicorn.conf.py @@ -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' diff --git a/production.py b/production.py new file mode 100644 index 0000000..93bc1ed --- /dev/null +++ b/production.py @@ -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() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..fd65f68 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask==3.0.0 +Werkzeug==3.0.1 +gunicorn==25.2.0 diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..c870cde --- /dev/null +++ b/setup.py @@ -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() diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..9ab5995 --- /dev/null +++ b/static/css/style.css @@ -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; } +} diff --git a/static/js/app.js b/static/js/app.js new file mode 100644 index 0000000..7601235 --- /dev/null +++ b/static/js/app.js @@ -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); diff --git a/static/js/crypto.js b/static/js/crypto.js new file mode 100644 index 0000000..57d83f1 --- /dev/null +++ b/static/js/crypto.js @@ -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/# + */ +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; diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000..3cbe041 --- /dev/null +++ b/templates/404.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block title %}Not Found - {{ cfg.site.name }}{% endblock %} + +{% block content %} +
+
+
🔍
+

404 - Paste Not Found

+

The paste you're looking for doesn't exist or has been removed.

+ + +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/410.html b/templates/410.html new file mode 100644 index 0000000..bc18740 --- /dev/null +++ b/templates/410.html @@ -0,0 +1,17 @@ +{% extends "base.html" %} + +{% block title %}Expired - {{ cfg.site.name }}{% endblock %} + +{% block content %} +
+
+
+

410 - Paste Expired

+

This paste has expired and is no longer available.

+ + +
+
+{% endblock %} \ No newline at end of file diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..3cb9910 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,71 @@ + + + + + + {% block title %}{{ cfg.site.name }}{% endblock %} + + + + + + + + + +
+ {% block content %}{% endblock %} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% block scripts %}{% endblock %} + + diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..3013233 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,122 @@ +{% extends "base.html" %} + +{% block title %}{{ cfg.site.name }}{% endblock %} +{% block main_class %}full-page{% endblock %} + +{% block nav_actions %} + + + + +{% if cfg.theme.allow_user_toggle %} + +{% endif %} +{% endblock %} + +{% block content %} + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/recent.html b/templates/recent.html new file mode 100644 index 0000000..bff4846 --- /dev/null +++ b/templates/recent.html @@ -0,0 +1,68 @@ +{% extends "base.html" %} + +{% block title %}Recent Pastes - PasteBin{% endblock %} + +{% block content %} +
+ + + {% if pastes %} +
+ {% for paste in pastes %} +
+ + +
+ + ID: {{ paste.id }} + + + Created: + + + + Views: {{ paste.views }} + +
+ +
+ View +
+
+ {% endfor %} +
+ {% else %} +
+
📝
+

No recent pastes

+

Be the first to create a paste!

+ Create New Paste +
+ {% endif %} +
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/view.html b/templates/view.html new file mode 100644 index 0000000..3624171 --- /dev/null +++ b/templates/view.html @@ -0,0 +1,128 @@ +{% extends "base.html" %} + +{% block title %}{{ cfg.site.name }}{% endblock %} +{% block main_class %}full-page{% endblock %} + +{% block nav_actions %} + + + + +New +{% if cfg.theme.allow_user_toggle %} + +{% endif %} +{% endblock %} + +{% block content %} + + + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..d1a6ecb --- /dev/null +++ b/wsgi.py @@ -0,0 +1,6 @@ +from app import app, init_db + +init_db() + +if __name__ == '__main__': + app.run()