forked from ComputerTech/bastebin
Initial commit — Bastebin
This commit is contained in:
commit
14be14e437
|
|
@ -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/
|
||||
|
|
@ -0,0 +1,229 @@
|
|||
# PasteBin - A Modern Pastebin Clone
|
||||
|
||||
A clean and modern pastebin website built with Python Flask and vanilla JavaScript, inspired by Hastepaste. Share code, text, and snippets with syntax highlighting, expiration options, and a beautiful dark/light theme.
|
||||
|
||||
## Features
|
||||
|
||||
- **Clean, Modern UI** - Responsive design with dark/light theme support
|
||||
- **Syntax Highlighting** - Support for 19+ programming languages using Prism.js
|
||||
- **Expiration Options** - Set pastes to expire after 1 hour, 1 day, 1 week, or 1 month
|
||||
- **Easy Sharing** - Direct links, raw text view, and embed codes
|
||||
- **Download Support** - Download pastes with appropriate file extensions
|
||||
- **Mobile Friendly** - Works great on all devices
|
||||
- **Auto-save Drafts** - Never lose your work with automatic draft saving
|
||||
- **Keyboard Shortcuts** - Ctrl/Cmd + Enter to submit, Ctrl/Cmd + K to focus
|
||||
- **View Counter** - Track how many times a paste has been viewed
|
||||
- **Recent Pastes** - Browse recently created public pastes
|
||||
- **Error Handling** - Proper 404/410 pages for missing/expired pastes
|
||||
|
||||
## Supported Languages
|
||||
|
||||
- Plain Text
|
||||
- JavaScript
|
||||
- Python
|
||||
- Java
|
||||
- C/C++
|
||||
- C#
|
||||
- HTML/CSS
|
||||
- SQL
|
||||
- JSON/XML
|
||||
- Bash/PowerShell
|
||||
- PHP
|
||||
- Ruby
|
||||
- Go
|
||||
- Rust
|
||||
- Markdown
|
||||
|
||||
## Installation
|
||||
|
||||
### Prerequisites
|
||||
- Python 3.7+
|
||||
- pip
|
||||
|
||||
### Setup
|
||||
|
||||
1. **Clone the repository:**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd magic
|
||||
```
|
||||
|
||||
2. **Create a virtual environment:**
|
||||
```bash
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
```
|
||||
|
||||
3. **Install dependencies:**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
4. **Run the application:**
|
||||
```bash
|
||||
python app.py
|
||||
```
|
||||
|
||||
5. **Open your browser:**
|
||||
Navigate to `http://localhost:5000`
|
||||
|
||||
## Configuration
|
||||
|
||||
### Environment Variables
|
||||
You can customize the application using these environment variables:
|
||||
|
||||
- `SECRET_KEY` - Flask secret key (default: 'your-secret-key-change-this')
|
||||
- `DATABASE` - Database file path (default: 'pastebin.db')
|
||||
|
||||
### Security
|
||||
For production deployment:
|
||||
1. Change the `SECRET_KEY` in `app.py` or set the `SECRET_KEY` environment variable
|
||||
2. Use a proper WSGI server like Gunicorn instead of the Flask development server
|
||||
3. Set up proper error logging and monitoring
|
||||
|
||||
## Usage
|
||||
|
||||
### Creating a Paste
|
||||
1. Visit the homepage
|
||||
2. Enter an optional title
|
||||
3. Select the programming language
|
||||
4. Choose expiration time (or never expire)
|
||||
5. Paste your content
|
||||
6. Click "Create Paste"
|
||||
|
||||
### Viewing Pastes
|
||||
- **Regular View**: Formatted with syntax highlighting
|
||||
- **Raw View**: Plain text for copying or embedding
|
||||
- **Download**: Save as a file with proper extension
|
||||
|
||||
### Keyboard Shortcuts
|
||||
- `Ctrl/Cmd + Enter` - Submit the current paste form
|
||||
- `Ctrl/Cmd + K` - Focus on the content textarea
|
||||
|
||||
### Theme
|
||||
- Click the moon/sun icon in the navigation to toggle between light and dark themes
|
||||
- Theme preference is saved in localStorage
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
magic/
|
||||
├── app.py # Main Flask application
|
||||
├── requirements.txt # Python dependencies
|
||||
├── pastebin.db # SQLite database (created automatically)
|
||||
├── templates/ # Jinja2 templates
|
||||
│ ├── base.html # Base template with navigation
|
||||
│ ├── index.html # Create paste page
|
||||
│ ├── view.html # View paste page
|
||||
│ ├── recent.html # Recent pastes page
|
||||
│ ├── 404.html # Not found page
|
||||
│ └── 410.html # Expired page
|
||||
└── static/ # Static assets
|
||||
├── css/
|
||||
│ └── style.css # Main stylesheet
|
||||
└── js/
|
||||
└── app.js # JavaScript functionality
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Public Endpoints
|
||||
- `GET /` - Create paste page
|
||||
- `POST /create` - Create a new paste
|
||||
- `GET /<paste_id>` - View paste
|
||||
- `GET /<paste_id>/raw` - View paste raw content
|
||||
- `GET /recent` - Recent pastes page
|
||||
- `GET /api/languages` - Get supported languages
|
||||
|
||||
## Database Schema
|
||||
|
||||
The application uses SQLite with a single `pastes` table:
|
||||
|
||||
```sql
|
||||
CREATE TABLE pastes (
|
||||
id TEXT PRIMARY KEY, -- Unique paste ID
|
||||
title TEXT, -- Optional title
|
||||
content TEXT NOT NULL, -- Paste content
|
||||
language TEXT DEFAULT 'text', -- Programming language
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP, -- Optional expiration date
|
||||
views INTEGER DEFAULT 0, -- View counter
|
||||
paste_type TEXT DEFAULT 'public'
|
||||
);
|
||||
```
|
||||
|
||||
## Browser Support
|
||||
|
||||
- Chrome 60+
|
||||
- Firefox 60+
|
||||
- Safari 12+
|
||||
- Edge 79+
|
||||
|
||||
## Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Make your changes
|
||||
4. Add tests if applicable
|
||||
5. Submit a pull request
|
||||
|
||||
## License
|
||||
|
||||
This project is open source and available under the MIT License.
|
||||
|
||||
## Deployment
|
||||
|
||||
### Using Gunicorn (Recommended for production)
|
||||
|
||||
1. Install Gunicorn:
|
||||
```bash
|
||||
pip install gunicorn
|
||||
```
|
||||
|
||||
2. Run with Gunicorn:
|
||||
```bash
|
||||
gunicorn -w 4 -b 0.0.0.0:8000 app:app
|
||||
```
|
||||
|
||||
### Using Docker (Optional)
|
||||
|
||||
Create a `Dockerfile`:
|
||||
```dockerfile
|
||||
FROM python:3.9-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY requirements.txt .
|
||||
RUN pip install -r requirements.txt
|
||||
|
||||
COPY . .
|
||||
EXPOSE 5000
|
||||
|
||||
CMD ["python", "app.py"]
|
||||
```
|
||||
|
||||
Build and run:
|
||||
```bash
|
||||
docker build -t pastebin .
|
||||
docker run -p 5000:5000 pastebin
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- Input validation and sanitization
|
||||
- XSS prevention through proper template escaping
|
||||
- CSRF protection via Flask's built-in mechanisms
|
||||
- Rate limiting (consider adding for production)
|
||||
- Content size limits (1MB default)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Database errors**: Delete `pastebin.db` to reset the database
|
||||
**Permission errors**: Ensure the app has write access to the directory
|
||||
**Port conflicts**: Change the port in `app.py` or use environment variables
|
||||
**Theme not saving**: Check if localStorage is enabled in your browser
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
- Inspired by Hastepaste and modern pastebin services
|
||||
- Uses Prism.js for syntax highlighting
|
||||
- Built with Flask web framework
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import uuid
|
||||
import datetime
|
||||
from flask import Flask, render_template, request, jsonify, abort
|
||||
|
||||
# ── Load configuration ────────────────────────────────────────────────────────
|
||||
|
||||
_CONFIG_PATH = os.path.join(os.path.dirname(__file__), 'config.json')
|
||||
|
||||
def _load_config():
|
||||
with open(_CONFIG_PATH, 'r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
|
||||
try:
|
||||
CFG = _load_config()
|
||||
except (FileNotFoundError, json.JSONDecodeError) as e:
|
||||
raise RuntimeError(f"Failed to load config.json: {e}") from e
|
||||
|
||||
_site = CFG['site']
|
||||
_server = CFG['server']
|
||||
_db_cfg = CFG['database']
|
||||
_pastes = CFG['pastes']
|
||||
_theme = CFG['theme']
|
||||
_feat = CFG['features']
|
||||
_ui = CFG['ui']
|
||||
|
||||
# ── Flask app setup ───────────────────────────────────────────────────────────
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config['SECRET_KEY'] = _server.get('secret_key', 'change-me')
|
||||
|
||||
DATABASE = _db_cfg.get('path', 'bastebin.db')
|
||||
MAX_ENCRYPTED_BYTES = _pastes.get('max_size_bytes', 2 * 1024 * 1024)
|
||||
_ENCRYPTED_RE = re.compile(r'^[A-Za-z0-9_-]+:[A-Za-z0-9_-]+$')
|
||||
|
||||
# ── Jinja helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
@app.context_processor
|
||||
def inject_config():
|
||||
return {'cfg': CFG}
|
||||
|
||||
# ── Database ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_db_connection():
|
||||
conn = sqlite3.connect(DATABASE, timeout=10)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute('PRAGMA journal_mode=WAL')
|
||||
return conn
|
||||
|
||||
def init_db():
|
||||
conn = sqlite3.connect(DATABASE)
|
||||
cursor = conn.cursor()
|
||||
cursor.execute("PRAGMA table_info(pastes)")
|
||||
columns = {row[1] for row in cursor.fetchall()}
|
||||
if columns and 'content' in columns and 'encrypted_data' not in columns:
|
||||
cursor.execute("DROP TABLE pastes")
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS pastes (
|
||||
id TEXT PRIMARY KEY,
|
||||
encrypted_data TEXT NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TIMESTAMP,
|
||||
views INTEGER DEFAULT 0
|
||||
)
|
||||
''')
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def generate_paste_id():
|
||||
length = _pastes.get('id_length', 8)
|
||||
return str(uuid.uuid4()).replace('-', '')[:length]
|
||||
|
||||
def validate_encrypted_data(value):
|
||||
if not isinstance(value, str):
|
||||
return False
|
||||
if len(value) > MAX_ENCRYPTED_BYTES:
|
||||
return False
|
||||
return bool(_ENCRYPTED_RE.match(value))
|
||||
|
||||
def _get_paste_or_abort(paste_id):
|
||||
conn = get_db_connection()
|
||||
paste = conn.execute('SELECT * FROM pastes WHERE id = ?', (paste_id,)).fetchone()
|
||||
conn.close()
|
||||
if not paste:
|
||||
abort(404)
|
||||
if paste['expires_at']:
|
||||
expires_at = datetime.datetime.fromisoformat(paste['expires_at'])
|
||||
if expires_at < datetime.datetime.now():
|
||||
abort(410)
|
||||
return paste
|
||||
|
||||
# ── Routes ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@app.route('/')
|
||||
def index():
|
||||
return render_template('index.html')
|
||||
|
||||
@app.route('/create', methods=['POST'])
|
||||
def create_paste():
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return jsonify({'error': 'JSON body required'}), 400
|
||||
|
||||
if 'encrypted_data' in data:
|
||||
# Encrypted path — always accepted regardless of encrypt_pastes setting
|
||||
store_data = data.get('encrypted_data', '')
|
||||
if not validate_encrypted_data(store_data):
|
||||
return jsonify({'error': 'encrypted_data must be a valid iv:ciphertext string'}), 400
|
||||
elif 'content' in data:
|
||||
# Plaintext path — always accepted regardless of encrypt_pastes setting
|
||||
content = data.get('content', '')
|
||||
title = data.get('title', 'Untitled') or 'Untitled'
|
||||
language = data.get('language', _pastes.get('default_language', 'text'))
|
||||
if not content or not isinstance(content, str):
|
||||
return jsonify({'error': 'content must be a non-empty string'}), 400
|
||||
if len(content.encode('utf-8')) > MAX_ENCRYPTED_BYTES:
|
||||
return jsonify({'error': 'Paste too large'}), 413
|
||||
store_data = json.dumps({'title': title, 'content': content, 'language': language})
|
||||
else:
|
||||
return jsonify({'error': 'Provide either encrypted_data or content'}), 400
|
||||
|
||||
allowed_expiry = set(_pastes.get('allow_expiry_options', ['never']))
|
||||
expires_in = data.get('expires_in', 'never')
|
||||
if expires_in not in allowed_expiry:
|
||||
expires_in = 'never'
|
||||
|
||||
expires_at = None
|
||||
if expires_in != 'never':
|
||||
delta_map = {
|
||||
'1hour': datetime.timedelta(hours=1),
|
||||
'1day': datetime.timedelta(days=1),
|
||||
'1week': datetime.timedelta(weeks=1),
|
||||
'1month': datetime.timedelta(days=30),
|
||||
}
|
||||
expires_at = datetime.datetime.now() + delta_map[expires_in]
|
||||
|
||||
paste_id = generate_paste_id()
|
||||
conn = get_db_connection()
|
||||
conn.execute(
|
||||
'INSERT INTO pastes (id, encrypted_data, expires_at) VALUES (?, ?, ?)',
|
||||
(paste_id, store_data, expires_at)
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({'paste_id': paste_id, 'url': f'/{paste_id}'})
|
||||
|
||||
@app.route('/<paste_id>')
|
||||
def view_paste(paste_id):
|
||||
paste = _get_paste_or_abort(paste_id)
|
||||
conn = get_db_connection()
|
||||
conn.execute('UPDATE pastes SET views = views + 1 WHERE id = ?', (paste_id,))
|
||||
conn.commit()
|
||||
paste = conn.execute('SELECT * FROM pastes WHERE id = ?', (paste_id,)).fetchone()
|
||||
conn.close()
|
||||
return render_template('view.html', paste=paste)
|
||||
|
||||
@app.route('/<paste_id>/raw')
|
||||
def view_paste_raw(paste_id):
|
||||
paste = _get_paste_or_abort(paste_id)
|
||||
return jsonify({
|
||||
'id': paste['id'],
|
||||
'encrypted_data': paste['encrypted_data'],
|
||||
'created_at': paste['created_at'],
|
||||
'expires_at': paste['expires_at'],
|
||||
'views': paste['views'],
|
||||
})
|
||||
|
||||
@app.route('/api/languages')
|
||||
def get_languages():
|
||||
return jsonify(CFG.get('languages', []))
|
||||
|
||||
@app.route('/api/config')
|
||||
def get_client_config():
|
||||
"""Expose the browser-safe portion of config to JavaScript."""
|
||||
return jsonify({
|
||||
'site': _site,
|
||||
'theme': _theme,
|
||||
'features': _feat,
|
||||
'ui': _ui,
|
||||
'pastes': {
|
||||
'default_language': _pastes.get('default_language', 'text'),
|
||||
'default_expiry': _pastes.get('default_expiry', 'never'),
|
||||
'allow_expiry_options': _pastes.get('allow_expiry_options', []),
|
||||
'expiry_labels': _pastes.get('expiry_labels', {}),
|
||||
}
|
||||
})
|
||||
|
||||
# ── Error handlers ────────────────────────────────────────────────────────────
|
||||
|
||||
@app.errorhandler(404)
|
||||
def not_found(error):
|
||||
return render_template('404.html'), 404
|
||||
|
||||
@app.errorhandler(410)
|
||||
def gone(error):
|
||||
return render_template('410.html'), 410
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == '__main__':
|
||||
init_db()
|
||||
app.run(
|
||||
debug=_server.get('debug', False),
|
||||
host=_server.get('host', '0.0.0.0'),
|
||||
port=_server.get('port', 5000)
|
||||
)
|
||||
|
|
@ -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"}
|
||||
]
|
||||
}
|
||||
|
|
@ -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'
|
||||
|
|
@ -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()
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
Flask==3.0.0
|
||||
Werkzeug==3.0.1
|
||||
gunicorn==25.2.0
|
||||
|
|
@ -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()
|
||||
|
|
@ -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; }
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* PasteCrypto - Client-side AES-GCM 256-bit encryption/decryption
|
||||
*
|
||||
* The encryption key is stored ONLY in the URL fragment (#key).
|
||||
* The fragment is never sent to the server, so the server stores
|
||||
* only opaque ciphertext and has zero knowledge of paste contents.
|
||||
*
|
||||
* Encrypted format: base64url(12-byte IV) + ":" + base64url(ciphertext)
|
||||
*
|
||||
* API usage (programmatic paste creation):
|
||||
* 1. Generate key: await PasteCrypto.generateKey()
|
||||
* 2. Export key: await PasteCrypto.exportKey(key) → base64url string
|
||||
* 3. Encrypt: await PasteCrypto.encrypt(JSON.stringify({title,content,language}), key)
|
||||
* 4. POST to /create: { encrypted_data: "...", expires_in: "never|1hour|1day|1week|1month" }
|
||||
* 5. Share URL: https://yoursite.com/<paste_id>#<keyBase64url>
|
||||
*/
|
||||
const PasteCrypto = (function () {
|
||||
|
||||
function arrayBufferToBase64url(buffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=/g, '');
|
||||
}
|
||||
|
||||
function base64urlToArrayBuffer(str) {
|
||||
const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4);
|
||||
const binary = atob(padded);
|
||||
const buf = new ArrayBuffer(binary.length);
|
||||
const bytes = new Uint8Array(buf);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
return {
|
||||
/** Generate a new, random AES-GCM 256-bit key. */
|
||||
async generateKey() {
|
||||
return window.crypto.subtle.generateKey(
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
true,
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
},
|
||||
|
||||
/** Export a CryptoKey to a base64url string safe for URL fragments. */
|
||||
async exportKey(key) {
|
||||
const raw = await window.crypto.subtle.exportKey('raw', key);
|
||||
return arrayBufferToBase64url(raw);
|
||||
},
|
||||
|
||||
/** Import a base64url key string into a CryptoKey for decryption. */
|
||||
async importKey(keyBase64url) {
|
||||
const keyBytes = base64urlToArrayBuffer(keyBase64url);
|
||||
return window.crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBytes,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['decrypt']
|
||||
);
|
||||
},
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext string.
|
||||
* Returns a string in the format: base64url(iv):base64url(ciphertext)
|
||||
*/
|
||||
async encrypt(plaintext, key) {
|
||||
const iv = window.crypto.getRandomValues(new Uint8Array(12));
|
||||
const encoded = new TextEncoder().encode(plaintext);
|
||||
const ciphertext = await window.crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
encoded
|
||||
);
|
||||
return arrayBufferToBase64url(iv) + ':' + arrayBufferToBase64url(ciphertext);
|
||||
},
|
||||
|
||||
/**
|
||||
* Decrypt a string produced by encrypt().
|
||||
* Returns the original plaintext string.
|
||||
*/
|
||||
async decrypt(encryptedStr, key) {
|
||||
const colonIdx = encryptedStr.indexOf(':');
|
||||
if (colonIdx === -1) throw new Error('Invalid encrypted data format');
|
||||
const iv = base64urlToArrayBuffer(encryptedStr.slice(0, colonIdx));
|
||||
const ciphertext = base64urlToArrayBuffer(encryptedStr.slice(colonIdx + 1));
|
||||
const decrypted = await window.crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv },
|
||||
key,
|
||||
ciphertext
|
||||
);
|
||||
return new TextDecoder().decode(decrypted);
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
window.PasteCrypto = PasteCrypto;
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Not Found - {{ cfg.site.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="error-page">
|
||||
<div class="error-content">
|
||||
<div class="error-icon">🔍</div>
|
||||
<h1>404 - Paste Not Found</h1>
|
||||
<p>The paste you're looking for doesn't exist or has been removed.</p>
|
||||
|
||||
<div class="error-actions">
|
||||
<a href="{{ url_for('index') }}" class="btn btn-primary">Create New Paste</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Expired - {{ cfg.site.name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="error-page">
|
||||
<div class="error-content">
|
||||
<div class="error-icon">⏰</div>
|
||||
<h1>410 - Paste Expired</h1>
|
||||
<p>This paste has expired and is no longer available.</p>
|
||||
|
||||
<div class="error-actions">
|
||||
<a href="{{ url_for('index') }}" class="btn btn-primary">Create New Paste</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}{{ cfg.site.name }}{% endblock %}</title>
|
||||
<script>
|
||||
(function(){
|
||||
var t = localStorage.getItem('theme');
|
||||
if (t === 'dark' || (!t && window.matchMedia('(prefers-color-scheme: dark)').matches))
|
||||
document.documentElement.setAttribute('data-theme','dark');
|
||||
})();
|
||||
</script>
|
||||
<link id="prism-light" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism.min.css" rel="stylesheet">
|
||||
<link id="prism-dark" href="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/themes/prism-tomorrow.min.css" rel="stylesheet" disabled>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="nav-content">
|
||||
<a href="{{ url_for('index') }}" class="brand">{{ cfg.site.name }}</a>
|
||||
<div class="nav-links">
|
||||
{% block nav_actions %}
|
||||
<a href="{{ url_for('index') }}" class="nav-btn">New</a>
|
||||
{% if cfg.theme.allow_user_toggle %}
|
||||
<button class="theme-toggle" onclick="toggleTheme()">🌙</button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="{% block main_class %}page-container{% endblock %}">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/prism.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-javascript.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-python.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-java.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-c.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-cpp.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-csharp.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markup.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-css.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-sql.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-json.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-bash.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-powershell.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markup-templating.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-php.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-ruby.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-go.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-rust.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-markdown.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-typescript.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-scss.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-yaml.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-swift.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-kotlin.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-diff.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-docker.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-nginx.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-toml.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/prism/1.29.0/components/prism-ini.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='js/crypto.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ cfg.site.name }}{% endblock %}
|
||||
{% block main_class %}full-page{% endblock %}
|
||||
|
||||
{% block nav_actions %}
|
||||
<input type="text" id="title" placeholder="Title (optional)" class="nav-input" maxlength="100" autocomplete="off">
|
||||
<select id="language" class="nav-select"></select>
|
||||
<select id="expires_in" class="nav-select">
|
||||
<option value="never">Never</option>
|
||||
<option value="1hour">1 Hour</option>
|
||||
<option value="1day">1 Day</option>
|
||||
<option value="1week">1 Week</option>
|
||||
<option value="1month">1 Month</option>
|
||||
</select>
|
||||
<button id="submitBtn" class="nav-btn nav-btn-save">Save</button>
|
||||
{% if cfg.theme.allow_user_toggle %}
|
||||
<button class="theme-toggle" onclick="toggleTheme()">🌙</button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<textarea
|
||||
id="content"
|
||||
class="full-editor"
|
||||
placeholder="Paste your code or text here…"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"></textarea>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const textarea = document.getElementById('content');
|
||||
const submitBtn = document.getElementById('submitBtn');
|
||||
const langSelect = document.getElementById('language');
|
||||
|
||||
// Load languages
|
||||
fetch('/api/languages')
|
||||
.then(r => r.json())
|
||||
.then(langs => {
|
||||
langSelect.innerHTML = '';
|
||||
langs.forEach(l => {
|
||||
const o = document.createElement('option');
|
||||
o.value = l.value; o.textContent = l.name;
|
||||
langSelect.appendChild(o);
|
||||
});
|
||||
// Restore saved preference
|
||||
const saved = localStorage.getItem('preferred_language');
|
||||
if (saved) langSelect.value = saved;
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
langSelect.addEventListener('change', () =>
|
||||
localStorage.setItem('preferred_language', langSelect.value));
|
||||
|
||||
const expirySelect = document.getElementById('expires_in');
|
||||
const savedExpiry = localStorage.getItem('preferred_expiry');
|
||||
if (savedExpiry) expirySelect.value = savedExpiry;
|
||||
expirySelect.addEventListener('change', () =>
|
||||
localStorage.setItem('preferred_expiry', expirySelect.value));
|
||||
|
||||
// Ctrl/Cmd+S to save
|
||||
document.addEventListener('keydown', e => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
||||
e.preventDefault(); submitPaste();
|
||||
}
|
||||
});
|
||||
|
||||
submitBtn.addEventListener('click', submitPaste);
|
||||
|
||||
const E2E = {{ cfg.features.encrypt_pastes | tojson }};
|
||||
|
||||
async function submitPaste() {
|
||||
const content = textarea.value;
|
||||
const title = document.getElementById('title').value || 'Untitled';
|
||||
const language = langSelect.value;
|
||||
const expires_in = expirySelect.value;
|
||||
|
||||
if (!content.trim()) { textarea.focus(); return; }
|
||||
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = '…';
|
||||
|
||||
try {
|
||||
let postBody, keyBase64 = null;
|
||||
if (E2E) {
|
||||
const key = await PasteCrypto.generateKey();
|
||||
keyBase64 = await PasteCrypto.exportKey(key);
|
||||
const plain = JSON.stringify({ title, content, language });
|
||||
postBody = { encrypted_data: await PasteCrypto.encrypt(plain, key), expires_in };
|
||||
} else {
|
||||
postBody = { title, content, language, expires_in };
|
||||
}
|
||||
|
||||
const resp = await fetch('/create', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(postBody)
|
||||
});
|
||||
const result = await resp.json();
|
||||
|
||||
if (result.error) {
|
||||
alert('Error: ' + result.error);
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Save';
|
||||
} else {
|
||||
clearDraft();
|
||||
window.location.href = result.url + (keyBase64 ? '#' + keyBase64 : '');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Failed to create paste. Try again.');
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Save';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Recent Pastes - PasteBin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="recent-pastes">
|
||||
<div class="page-header">
|
||||
<h1>Recent Pastes</h1>
|
||||
<p>Browse recently created public pastes</p>
|
||||
</div>
|
||||
|
||||
{% if pastes %}
|
||||
<div class="pastes-grid">
|
||||
{% for paste in pastes %}
|
||||
<div class="paste-card">
|
||||
<div class="paste-card-header">
|
||||
<h3 class="paste-title">
|
||||
<a href="{{ url_for('view_paste', paste_id=paste.id) }}">
|
||||
🔒 Encrypted Paste
|
||||
</a>
|
||||
</h3>
|
||||
<span class="language-badge encrypted-badge" title="Content is end-to-end encrypted">E2E</span>
|
||||
</div>
|
||||
|
||||
<div class="paste-card-meta">
|
||||
<span class="meta-item">
|
||||
<strong>ID:</strong> {{ paste.id }}
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<strong>Created:</strong>
|
||||
<time datetime="{{ paste.created_at }}" class="relative-time">{{ paste.created_at }}</time>
|
||||
</span>
|
||||
<span class="meta-item">
|
||||
<strong>Views:</strong> {{ paste.views }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="paste-card-actions">
|
||||
<a href="{{ url_for('view_paste', paste_id=paste.id) }}" class="btn btn-primary btn-sm">View</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📝</div>
|
||||
<h3>No recent pastes</h3>
|
||||
<p>Be the first to create a paste!</p>
|
||||
<a href="{{ url_for('index') }}" class="btn btn-primary">Create New Paste</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// Format relative timestamps
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
document.querySelectorAll('.relative-time').forEach(timeEl => {
|
||||
const datetime = timeEl.getAttribute('datetime');
|
||||
if (datetime && window.formatDateTime) {
|
||||
timeEl.textContent = window.formatDateTime(datetime);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ cfg.site.name }}{% endblock %}
|
||||
{% block main_class %}full-page{% endblock %}
|
||||
|
||||
{% block nav_actions %}
|
||||
<span id="navPasteTitle" class="nav-paste-title"></span>
|
||||
<button onclick="rawView()" class="nav-btn">Raw</button>
|
||||
<button onclick="copyPaste()" class="nav-btn">Copy</button>
|
||||
<button onclick="downloadPaste()" class="nav-btn">Download</button>
|
||||
<a href="{{ url_for('index') }}" class="nav-btn nav-btn-save">New</a>
|
||||
{% if cfg.theme.allow_user_toggle %}
|
||||
<button class="theme-toggle" onclick="toggleTheme()">🌙</button>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="errorState" class="error-inline" style="display:none">
|
||||
🔐 <strong id="errorTitle">Decryption Failed</strong> — <span id="errorDetail"></span>
|
||||
</div>
|
||||
<div class="view-full" id="viewFull" style="display:none">
|
||||
<pre id="viewPre"><code id="codeBlock"></code></pre>
|
||||
</div>
|
||||
<script type="application/json" id="encryptedPayload">{{ paste.encrypted_data | tojson }}</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
let _decryptedPaste = null;
|
||||
const E2E = {{ cfg.features.encrypt_pastes | tojson }};
|
||||
|
||||
(async function () {
|
||||
let rawPayload;
|
||||
try {
|
||||
rawPayload = JSON.parse(document.getElementById('encryptedPayload').textContent);
|
||||
} catch (e) {
|
||||
showError('Bad Data', 'Could not read the paste payload.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (E2E) {
|
||||
const keyBase64 = window.location.hash.slice(1);
|
||||
if (!keyBase64) {
|
||||
showError('No Key', 'The decryption key is missing from the URL. Use the full link including the # part.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const key = await PasteCrypto.importKey(keyBase64);
|
||||
const plaintext = await PasteCrypto.decrypt(rawPayload, key);
|
||||
_decryptedPaste = JSON.parse(plaintext);
|
||||
} catch (e) {
|
||||
showError('Decryption Failed', 'Wrong key or tampered data.');
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
_decryptedPaste = JSON.parse(rawPayload);
|
||||
} catch (e) {
|
||||
showError('Bad Data', 'Could not parse paste data.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
renderPaste(_decryptedPaste);
|
||||
})();
|
||||
|
||||
function renderPaste(paste) {
|
||||
const title = paste.title || 'Untitled';
|
||||
document.title = title + ' — {{ cfg.site.name }}';
|
||||
document.getElementById('navPasteTitle').textContent = title;
|
||||
|
||||
const lang = paste.language || 'text';
|
||||
const prismLangMap = { text: false, html: 'markup', xml: 'markup', docker: 'docker' };
|
||||
const prismLang = (lang in prismLangMap) ? prismLangMap[lang] : lang;
|
||||
|
||||
const codeBlock = document.getElementById('codeBlock');
|
||||
const viewPre = document.getElementById('viewPre');
|
||||
codeBlock.textContent = paste.content || '';
|
||||
if (prismLang) {
|
||||
codeBlock.className = 'language-' + prismLang;
|
||||
viewPre.className = 'language-' + prismLang;
|
||||
Prism.highlightElement(codeBlock);
|
||||
}
|
||||
|
||||
document.getElementById('viewFull').style.display = 'block';
|
||||
}
|
||||
|
||||
function showError(title, detail) {
|
||||
document.getElementById('errorTitle').textContent = title;
|
||||
document.getElementById('errorDetail').textContent = detail;
|
||||
document.getElementById('errorState').style.display = 'block';
|
||||
}
|
||||
|
||||
function rawView() {
|
||||
if (!_decryptedPaste) return;
|
||||
const blob = new Blob([_decryptedPaste.content], { type: 'text/plain; charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10000);
|
||||
}
|
||||
|
||||
async function copyPaste() {
|
||||
if (!_decryptedPaste) return;
|
||||
const ok = await copyToClipboard(_decryptedPaste.content);
|
||||
const btn = document.querySelector('.nav-btn[onclick="copyPaste()"]');
|
||||
if (btn) { const t = btn.textContent; btn.textContent = ok ? 'Copied!' : 'Failed'; setTimeout(() => btn.textContent = t, 1500); }
|
||||
}
|
||||
|
||||
function downloadPaste() {
|
||||
if (!_decryptedPaste) return;
|
||||
const { title = 'untitled', content = '', language = 'text' } = _decryptedPaste;
|
||||
const extMap = {
|
||||
javascript: '.js', typescript: '.ts', python: '.py', java: '.java',
|
||||
c: '.c', cpp: '.cpp', csharp: '.cs', html: '.html', css: '.css',
|
||||
scss: '.scss', sql: '.sql', json: '.json', yaml: '.yaml', xml: '.xml',
|
||||
bash: '.sh', powershell: '.ps1', php: '.php', ruby: '.rb', go: '.go',
|
||||
rust: '.rs', swift: '.swift', kotlin: '.kt', markdown: '.md',
|
||||
diff: '.diff', docker: '', nginx: '.conf', toml: '.toml', ini: '.ini',
|
||||
};
|
||||
const filename = title.replace(/[^a-z0-9.\-]/gi, '_') + (extMap[language] ?? '.txt');
|
||||
const blob = new Blob([content], { type: 'text/plain' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = Object.assign(document.createElement('a'), { href: url, download: filename });
|
||||
document.body.appendChild(a); a.click(); document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
Loading…
Reference in New Issue