From 14be14e4375a0a4604f8cb68309b099bda187dcd Mon Sep 17 00:00:00 2001 From: ComputerTech Date: Thu, 26 Mar 2026 14:44:36 +0000 Subject: [PATCH] =?UTF-8?q?Initial=20commit=20=E2=80=94=20Bastebin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 64 ++++++++++ README.md | 229 +++++++++++++++++++++++++++++++++++ app.py | 211 +++++++++++++++++++++++++++++++++ config.json | 135 +++++++++++++++++++++ gunicorn.conf.py | 28 +++++ production.py | 188 +++++++++++++++++++++++++++++ requirements.txt | 3 + setup.py | 153 ++++++++++++++++++++++++ static/css/style.css | 269 ++++++++++++++++++++++++++++++++++++++++++ static/js/app.js | 238 +++++++++++++++++++++++++++++++++++++ static/js/crypto.js | 107 +++++++++++++++++ templates/404.html | 17 +++ templates/410.html | 17 +++ templates/base.html | 71 +++++++++++ templates/index.html | 122 +++++++++++++++++++ templates/recent.html | 68 +++++++++++ templates/view.html | 128 ++++++++++++++++++++ wsgi.py | 6 + 18 files changed, 2054 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.py create mode 100644 config.json create mode 100644 gunicorn.conf.py create mode 100644 production.py create mode 100644 requirements.txt create mode 100644 setup.py create mode 100644 static/css/style.css create mode 100644 static/js/app.js create mode 100644 static/js/crypto.js create mode 100644 templates/404.html create mode 100644 templates/410.html create mode 100644 templates/base.html create mode 100644 templates/index.html create mode 100644 templates/recent.html create mode 100644 templates/view.html create mode 100644 wsgi.py 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()