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