Initial commit: PasteBin server (Flask, Docker, gunicorn)
This commit is contained in:
parent
63ee936f3d
commit
016914c6ca
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
*.md
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
docker-compose.override.yml
|
||||||
|
.env
|
||||||
|
.dockerignore
|
||||||
|
store/
|
||||||
|
uploads/
|
||||||
5
.env.example
Normal file
5
.env.example
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# Server port exposed on host (maps to container port 8080)
|
||||||
|
PORT=9780
|
||||||
|
|
||||||
|
# Secret key for Flask sessions - generate with: python3 -c "import secrets; print(secrets.token_hex(32))"
|
||||||
|
SECRET_KEY=change-me-to-a-random-secret
|
||||||
29
.gitignore
vendored
Normal file
29
.gitignore
vendored
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.egg-info/
|
||||||
|
.eggs/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# Virtual environment
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Local data (stored in Docker volumes)
|
||||||
|
store/
|
||||||
|
uploads/
|
||||||
|
|
||||||
|
# Environment (contains secrets)
|
||||||
|
.env
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN mkdir -p /app/uploads /app/store
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENV PORT=8080
|
||||||
|
|
||||||
|
CMD gunicorn --bind 0.0.0.0:${PORT} --workers 4 --timeout 120 app:app
|
||||||
295
app.py
Normal file
295
app.py
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
import json
|
||||||
|
import fcntl
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from flask import Flask, request, redirect, url_for, render_template, send_file, abort
|
||||||
|
from werkzeug.utils import secure_filename
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.config['MAX_CONTENT_LENGTH'] = 20 * 1024 * 1024
|
||||||
|
app.config['UPLOAD_FOLDER'] = os.environ.get('UPLOAD_FOLDER', os.path.join(os.path.dirname(os.path.abspath(__file__)), 'uploads'))
|
||||||
|
app.config['STORE_FOLDER'] = os.environ.get('STORE_FOLDER', os.path.join(os.path.dirname(os.path.abspath(__file__)), 'store'))
|
||||||
|
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'paste-bin-secret')
|
||||||
|
|
||||||
|
EXPIRY_OPTIONS = [
|
||||||
|
('1h', '1 hour'),
|
||||||
|
('1d', '1 day'),
|
||||||
|
('1w', '1 week'),
|
||||||
|
('1m', '1 month'),
|
||||||
|
('forever', 'Never'),
|
||||||
|
]
|
||||||
|
|
||||||
|
ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'bmp', 'webp', 'svg', 'tiff'}
|
||||||
|
ALLOWED_TEXT_EXTENSIONS = {'txt', 'py', 'js', 'ts', 'c', 'cpp', 'h', 'java', 'rb', 'go', 'rs', 'md', 'json', 'xml', 'yaml', 'yml', 'html', 'css', 'sh', 'log', 'csv', 'sql', 'ini', 'cfg', 'toml', 'lua', 'php', 'swift', 'kt', 'scala', 'r', 'pl', 'hs', 'zig', 'nix'}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dirs():
|
||||||
|
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||||
|
os.makedirs(app.config['STORE_FOLDER'], exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_id():
|
||||||
|
while True:
|
||||||
|
pid = uuid.uuid4().hex[:8]
|
||||||
|
if not os.path.exists(os.path.join(app.config['STORE_FOLDER'], pid)):
|
||||||
|
return pid
|
||||||
|
|
||||||
|
|
||||||
|
def parse_expiry(expiry_key):
|
||||||
|
if expiry_key == 'forever':
|
||||||
|
return None
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
if expiry_key == '1h':
|
||||||
|
return (now + timedelta(hours=1)).isoformat()
|
||||||
|
elif expiry_key == '1d':
|
||||||
|
return (now + timedelta(days=1)).isoformat()
|
||||||
|
elif expiry_key == '1w':
|
||||||
|
return (now + timedelta(weeks=1)).isoformat()
|
||||||
|
elif expiry_key == '1m':
|
||||||
|
return (now + timedelta(days=30)).isoformat()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def store_paste(paste_id, paste_data):
|
||||||
|
store_path = os.path.join(app.config['STORE_FOLDER'], paste_id)
|
||||||
|
with open(store_path, 'w') as f:
|
||||||
|
fcntl.flock(f, fcntl.LOCK_EX)
|
||||||
|
json.dump(paste_data, f)
|
||||||
|
fcntl.flock(f, fcntl.LOCK_UN)
|
||||||
|
|
||||||
|
|
||||||
|
def load_paste(paste_id):
|
||||||
|
store_path = os.path.join(app.config['STORE_FOLDER'], paste_id)
|
||||||
|
if not os.path.exists(store_path):
|
||||||
|
return None
|
||||||
|
with open(store_path, 'r') as f:
|
||||||
|
fcntl.flock(f, fcntl.LOCK_SH)
|
||||||
|
data = json.load(f)
|
||||||
|
fcntl.flock(f, fcntl.LOCK_UN)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def delete_paste(paste_id):
|
||||||
|
store_path = os.path.join(app.config['STORE_FOLDER'], paste_id)
|
||||||
|
paste = load_paste(paste_id)
|
||||||
|
if paste and paste['type'] in ('image', 'file') and paste.get('filepath'):
|
||||||
|
filepath = paste['filepath']
|
||||||
|
if os.path.exists(filepath):
|
||||||
|
os.remove(filepath)
|
||||||
|
if os.path.exists(store_path):
|
||||||
|
os.remove(store_path)
|
||||||
|
|
||||||
|
|
||||||
|
def is_expired(paste):
|
||||||
|
if not paste.get('expires_at'):
|
||||||
|
return False
|
||||||
|
expires_at = datetime.fromisoformat(paste['expires_at'])
|
||||||
|
return expires_at < datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_expired():
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
for filename in os.listdir(app.config['STORE_FOLDER']):
|
||||||
|
filepath = os.path.join(app.config['STORE_FOLDER'], filename)
|
||||||
|
try:
|
||||||
|
with open(filepath, 'r') as f:
|
||||||
|
fcntl.flock(f, fcntl.LOCK_SH)
|
||||||
|
paste = json.load(f)
|
||||||
|
fcntl.flock(f, fcntl.LOCK_UN)
|
||||||
|
except (json.JSONDecodeError, IOError):
|
||||||
|
continue
|
||||||
|
if is_expired(paste):
|
||||||
|
delete_paste(filename)
|
||||||
|
|
||||||
|
|
||||||
|
def is_image(filename):
|
||||||
|
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
|
||||||
|
return ext in ALLOWED_IMAGE_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def is_text_file(filename):
|
||||||
|
ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else ''
|
||||||
|
return ext in ALLOWED_TEXT_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
def get_text_content(paste_id):
|
||||||
|
content_path = os.path.join(app.config['STORE_FOLDER'], paste_id + '.txt')
|
||||||
|
if os.path.exists(content_path):
|
||||||
|
with open(content_path, 'r', encoding='utf-8') as f:
|
||||||
|
return f.read()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def save_text_content(paste_id, content):
|
||||||
|
content_path = os.path.join(app.config['STORE_FOLDER'], paste_id + '.txt')
|
||||||
|
with open(content_path, 'w', encoding='utf-8') as f:
|
||||||
|
f.write(content)
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def before_request():
|
||||||
|
ensure_dirs()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/', methods=['GET'])
|
||||||
|
def index():
|
||||||
|
return render_template('index.html', expiry_options=EXPIRY_OPTIONS)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/paste', methods=['POST'])
|
||||||
|
def create_paste():
|
||||||
|
paste_type = request.form.get('paste_type', 'text')
|
||||||
|
title = request.form.get('title', '').strip()
|
||||||
|
expiry_key = request.form.get('expiry', '1d')
|
||||||
|
expires_at = parse_expiry(expiry_key)
|
||||||
|
created_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
if paste_type == 'text':
|
||||||
|
content = request.form.get('content', '').strip()
|
||||||
|
if not content:
|
||||||
|
return render_template('index.html', expiry_options=EXPIRY_OPTIONS, error='Content cannot be empty'), 400
|
||||||
|
|
||||||
|
paste_id = generate_id()
|
||||||
|
save_text_content(paste_id, content)
|
||||||
|
store_paste(paste_id, {
|
||||||
|
'type': 'text',
|
||||||
|
'title': title,
|
||||||
|
'expires_at': expires_at,
|
||||||
|
'created_at': created_at,
|
||||||
|
})
|
||||||
|
return redirect(url_for('view_paste', paste_id=paste_id))
|
||||||
|
|
||||||
|
elif paste_type == 'image':
|
||||||
|
file = request.files.get('file')
|
||||||
|
if not file or file.filename == '':
|
||||||
|
return render_template('index.html', expiry_options=EXPIRY_OPTIONS, error='No file selected'), 400
|
||||||
|
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
if not is_image(filename):
|
||||||
|
return render_template('index.html', expiry_options=EXPIRY_OPTIONS, error='Invalid image format. Allowed: ' + ', '.join(sorted(ALLOWED_IMAGE_EXTENSIONS))), 400
|
||||||
|
|
||||||
|
paste_id = generate_id()
|
||||||
|
ext = filename.rsplit('.', 1)[-1]
|
||||||
|
saved_name = f"{paste_id}.{ext}"
|
||||||
|
filepath = os.path.join(app.config['UPLOAD_FOLDER'], saved_name)
|
||||||
|
file.save(filepath)
|
||||||
|
|
||||||
|
store_paste(paste_id, {
|
||||||
|
'type': 'image',
|
||||||
|
'title': title or filename,
|
||||||
|
'expires_at': expires_at,
|
||||||
|
'created_at': created_at,
|
||||||
|
'filepath': filepath,
|
||||||
|
'filename': filename,
|
||||||
|
'mimetype': file.mimetype or f'image/{ext}',
|
||||||
|
})
|
||||||
|
return redirect(url_for('view_paste', paste_id=paste_id))
|
||||||
|
|
||||||
|
elif paste_type == 'file':
|
||||||
|
file = request.files.get('file')
|
||||||
|
if not file or file.filename == '':
|
||||||
|
return render_template('index.html', expiry_options=EXPIRY_OPTIONS, error='No file selected'), 400
|
||||||
|
|
||||||
|
filename = secure_filename(file.filename)
|
||||||
|
paste_id = generate_id()
|
||||||
|
|
||||||
|
if is_text_file(filename):
|
||||||
|
content = file.read().decode('utf-8', errors='replace')
|
||||||
|
save_text_content(paste_id, content)
|
||||||
|
store_paste(paste_id, {
|
||||||
|
'type': 'text',
|
||||||
|
'title': title or filename,
|
||||||
|
'expires_at': expires_at,
|
||||||
|
'created_at': created_at,
|
||||||
|
'filename': filename,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
ext = filename.rsplit('.', 1)[-1] if '.' in filename else 'bin'
|
||||||
|
saved_name = f"{paste_id}.{ext}"
|
||||||
|
filepath = os.path.join(app.config['UPLOAD_FOLDER'], saved_name)
|
||||||
|
file.save(filepath)
|
||||||
|
|
||||||
|
store_paste(paste_id, {
|
||||||
|
'type': 'file',
|
||||||
|
'title': title or filename,
|
||||||
|
'expires_at': expires_at,
|
||||||
|
'created_at': created_at,
|
||||||
|
'filepath': filepath,
|
||||||
|
'filename': filename,
|
||||||
|
'mimetype': file.mimetype or 'application/octet-stream',
|
||||||
|
})
|
||||||
|
return redirect(url_for('view_paste', paste_id=paste_id))
|
||||||
|
|
||||||
|
else:
|
||||||
|
return render_template('index.html', expiry_options=EXPIRY_OPTIONS, error='Invalid paste type'), 400
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/<paste_id>', methods=['GET'])
|
||||||
|
def view_paste(paste_id):
|
||||||
|
paste = load_paste(paste_id)
|
||||||
|
if not paste:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if is_expired(paste):
|
||||||
|
delete_paste(paste_id)
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if paste['type'] == 'text':
|
||||||
|
paste['data'] = get_text_content(paste_id) or ''
|
||||||
|
return render_template('view_text.html', paste=paste, paste_id=paste_id)
|
||||||
|
elif paste['type'] == 'image':
|
||||||
|
return render_template('view_image.html', paste=paste, paste_id=paste_id)
|
||||||
|
elif paste['type'] == 'file':
|
||||||
|
return render_template('view_file.html', paste=paste, paste_id=paste_id)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/<paste_id>/download', methods=['GET'])
|
||||||
|
def download_file(paste_id):
|
||||||
|
paste = load_paste(paste_id)
|
||||||
|
if not paste:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if is_expired(paste):
|
||||||
|
delete_paste(paste_id)
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if paste['type'] == 'text':
|
||||||
|
from io import BytesIO
|
||||||
|
content = (get_text_content(paste_id) or '').encode('utf-8')
|
||||||
|
return send_file(BytesIO(content), download_name=paste.get('filename', 'paste.txt'), as_attachment=True)
|
||||||
|
elif paste['type'] in ('image', 'file'):
|
||||||
|
filepath = paste.get('filepath')
|
||||||
|
if not filepath or not os.path.exists(filepath):
|
||||||
|
abort(404)
|
||||||
|
return send_file(filepath, download_name=paste['filename'], as_attachment=True, mimetype=paste.get('mimetype', 'application/octet-stream'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/<paste_id>/raw', methods=['GET'])
|
||||||
|
def raw_paste(paste_id):
|
||||||
|
paste = load_paste(paste_id)
|
||||||
|
if not paste:
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if is_expired(paste):
|
||||||
|
delete_paste(paste_id)
|
||||||
|
abort(404)
|
||||||
|
|
||||||
|
if paste['type'] == 'text':
|
||||||
|
content = get_text_content(paste_id) or ''
|
||||||
|
return content, 200, {'Content-Type': 'text/plain; charset=utf-8'}
|
||||||
|
elif paste['type'] == 'image':
|
||||||
|
filepath = paste.get('filepath')
|
||||||
|
if not filepath or not os.path.exists(filepath):
|
||||||
|
abort(404)
|
||||||
|
return send_file(filepath, mimetype=paste.get('mimetype', 'image/png'))
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(413)
|
||||||
|
def request_entity_too_large(e):
|
||||||
|
return render_template('index.html', expiry_options=EXPIRY_OPTIONS, error='File too large. Maximum size is 20 MB.'), 413
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
ensure_dirs()
|
||||||
|
app.run(host='0.0.0.0', port=int(os.environ.get('PORT', 8080)))
|
||||||
18
docker-compose.yml
Normal file
18
docker-compose.yml
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
pastebin:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "${PORT:-9780}:8080"
|
||||||
|
volumes:
|
||||||
|
- uploads-data:/app/uploads
|
||||||
|
- store-data:/app/store
|
||||||
|
environment:
|
||||||
|
- PORT=8080
|
||||||
|
- SECRET_KEY=${SECRET_KEY:-change-me-to-a-random-secret}
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
uploads-data:
|
||||||
|
store-data:
|
||||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
flask>=3.0.0
|
||||||
|
gunicorn>=21.2.0
|
||||||
313
static/style.css
Normal file
313
static/style.css
Normal file
@ -0,0 +1,313 @@
|
|||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
min-height: 100vh;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 2rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
color: #e94560;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 a {
|
||||||
|
color: #e94560;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
header p {
|
||||||
|
color: #a0a0a0;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
background: #e94560;
|
||||||
|
color: white;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
border-bottom: 2px solid #16213e;
|
||||||
|
padding-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #a0a0a0;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
margin-bottom: -2px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
color: #e94560;
|
||||||
|
border-bottom-color: #e94560;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: #a0a0a0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"],
|
||||||
|
textarea,
|
||||||
|
select {
|
||||||
|
width: 100%;
|
||||||
|
background: #16213e;
|
||||||
|
border: 1px solid #0f3460;
|
||||||
|
color: #e0e0e0;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"]:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #e94560;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 200px;
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-input {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-label {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
background: #16213e;
|
||||||
|
border: 2px dashed #0f3460;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-label:hover {
|
||||||
|
border-color: #e94560;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-text {
|
||||||
|
color: #a0a0a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chosen-file {
|
||||||
|
display: block;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
color: #e94560;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn {
|
||||||
|
width: 100%;
|
||||||
|
background: #e94560;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 1rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.submit-btn:hover {
|
||||||
|
background: #c83a54;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paste-view {
|
||||||
|
background: #16213e;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paste-view h2 {
|
||||||
|
color: #e94560;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid #0f3460;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #a0a0a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta code {
|
||||||
|
background: #0f3460;
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #e94560;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper {
|
||||||
|
background: #0f3460;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper pre {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-wrapper code {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #e0e0e0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-wrapper {
|
||||||
|
text-align: center;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paste-image {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 60vh;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.file-info p {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.75rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.6rem 1.2rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #e94560;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #c83a54;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #0f3460;
|
||||||
|
color: #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #1a4a8a;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
.container {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
gap: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
141
templates/index.html
Normal file
141
templates/index.html
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>PasteBin</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1><a href="/">PasteBin</a></h1>
|
||||||
|
<p>Share text, images, and files temporarily</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="error">{{ error }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab active" data-tab="text">Text</button>
|
||||||
|
<button class="tab" data-tab="image">Image</button>
|
||||||
|
<button class="tab" data-tab="file">File</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="POST" action="/paste" enctype="multipart/form-data" id="uploadForm">
|
||||||
|
<input type="hidden" name="paste_type" id="pasteType" value="text">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="title">Title (optional)</label>
|
||||||
|
<input type="text" id="title" name="title" placeholder="Give your paste a name">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="text-panel" class="panel active">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="content">Content</label>
|
||||||
|
<textarea id="content" name="content" placeholder="Paste your text here..." rows="12"></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="image-panel" class="panel">
|
||||||
|
<div class="form-group">
|
||||||
|
<label id="file-label">Image (max 20 MB)</label>
|
||||||
|
<div class="file-input">
|
||||||
|
<input type="file" id="upload-file" name="file" accept="image/png,image/jpeg,image/gif,image/bmp,image/webp,image/svg+xml,image/tiff" style="display:none">
|
||||||
|
<label for="upload-file" class="file-label">
|
||||||
|
<span class="file-icon" id="file-icon">📷</span>
|
||||||
|
<span class="file-text" id="file-text">Choose image or drag here</span>
|
||||||
|
</label>
|
||||||
|
<span id="chosen-filename" class="chosen-file"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="file-panel" class="panel">
|
||||||
|
<div class="form-group">
|
||||||
|
<label id="file-label2">File (max 20 MB)</label>
|
||||||
|
<div class="file-input">
|
||||||
|
<input type="file" id="upload-file2" style="display:none">
|
||||||
|
<label for="upload-file2" class="file-label">
|
||||||
|
<span class="file-icon">👤</span>
|
||||||
|
<span class="file-text">Choose file or drag here</span>
|
||||||
|
</label>
|
||||||
|
<span id="chosen-filename2" class="chosen-file"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="expiry">Expires</label>
|
||||||
|
<select id="expiry" name="expiry">
|
||||||
|
{% for key, label in expiry_options %}
|
||||||
|
<option value="{{ key }}" {% if key == '1d' %}selected{% endif %}>{{ label }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="submit-btn">Create Paste</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
const tabs = document.querySelectorAll('.tab');
|
||||||
|
const panels = document.querySelectorAll('.panel');
|
||||||
|
const pasteTypeInput = document.getElementById('pasteType');
|
||||||
|
const uploadFile = document.getElementById('upload-file');
|
||||||
|
const uploadFile2 = document.getElementById('upload-file2');
|
||||||
|
const chosenFilename = document.getElementById('chosen-filename');
|
||||||
|
const chosenFilename2 = document.getElementById('chosen-filename2');
|
||||||
|
const fileIcon = document.getElementById('file-icon');
|
||||||
|
const fileText = document.getElementById('file-text');
|
||||||
|
const fileLabel = document.getElementById('file-label');
|
||||||
|
const contentTextarea = document.getElementById('content');
|
||||||
|
|
||||||
|
function switchTab(tabName) {
|
||||||
|
tabs.forEach(t => t.classList.remove('active'));
|
||||||
|
panels.forEach(p => p.classList.remove('active'));
|
||||||
|
document.querySelector('[data-tab="' + tabName + '"]').classList.add('active');
|
||||||
|
document.getElementById(tabName + '-panel').classList.add('active');
|
||||||
|
pasteTypeInput.value = tabName;
|
||||||
|
|
||||||
|
uploadFile.setAttribute('name', '__unused__');
|
||||||
|
contentTextarea.setAttribute('name', '__unused__');
|
||||||
|
|
||||||
|
if (tabName === 'text') {
|
||||||
|
contentTextarea.setAttribute('name', 'content');
|
||||||
|
} else if (tabName === 'image') {
|
||||||
|
uploadFile.setAttribute('name', 'file');
|
||||||
|
uploadFile.setAttribute('accept', 'image/png,image/jpeg,image/gif,image/bmp,image/webp,image/svg+xml,image/tiff');
|
||||||
|
fileIcon.innerHTML = '📷';
|
||||||
|
fileText.textContent = 'Choose image or drag here';
|
||||||
|
fileLabel.textContent = 'Image (max 20 MB)';
|
||||||
|
} else if (tabName === 'file') {
|
||||||
|
uploadFile.setAttribute('name', 'file');
|
||||||
|
uploadFile.removeAttribute('accept');
|
||||||
|
fileIcon.innerHTML = '👤';
|
||||||
|
fileText.textContent = 'Choose file or drag here';
|
||||||
|
fileLabel.textContent = 'File (max 20 MB)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tabs.forEach(tab => {
|
||||||
|
tab.addEventListener('click', function() {
|
||||||
|
switchTab(this.dataset.tab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadFile.addEventListener('change', function() {
|
||||||
|
chosenFilename.textContent = this.files[0] ? this.files[0].name : '';
|
||||||
|
chosenFilename2.textContent = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadFile2.addEventListener('change', function() {
|
||||||
|
chosenFilename2.textContent = this.files[0] ? this.files[0].name : '';
|
||||||
|
chosenFilename.textContent = '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
41
templates/view_file.html
Normal file
41
templates/view_file.html
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ paste.title or 'PasteBin' }} - PasteBin</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1><a href="/">PasteBin</a></h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="paste-view">
|
||||||
|
{% if paste.title %}
|
||||||
|
<h2>{{ paste.title }}</h2>
|
||||||
|
{% endif %}
|
||||||
|
<div class="meta">
|
||||||
|
<span>ID: <code>{{ paste_id }}</code></span>
|
||||||
|
<span>File: {{ paste.filename }}</span>
|
||||||
|
<span>Created: {{ paste.created_at[:19] | replace('T', ' ') }}</span>
|
||||||
|
{% if paste.expires_at %}
|
||||||
|
<span>Expires: {{ paste.expires_at[:19] | replace('T', ' ') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span>Never expires</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-wrapper file-info">
|
||||||
|
<p>This is a binary file. Download it to view.</p>
|
||||||
|
<p>Type: {{ paste.mimetype }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<a href="/{{ paste_id }}/download" class="btn btn-primary">Download</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
40
templates/view_image.html
Normal file
40
templates/view_image.html
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ paste.title or 'PasteBin' }} - PasteBin</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1><a href="/">PasteBin</a></h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="paste-view">
|
||||||
|
{% if paste.title %}
|
||||||
|
<h2>{{ paste.title }}</h2>
|
||||||
|
{% endif %}
|
||||||
|
<div class="meta">
|
||||||
|
<span>ID: <code>{{ paste_id }}</code></span>
|
||||||
|
<span>Created: {{ paste.created_at[:19] | replace('T', ' ') }}</span>
|
||||||
|
{% if paste.expires_at %}
|
||||||
|
<span>Expires: {{ paste.expires_at[:19] | replace('T', ' ') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span>Never expires</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-wrapper image-wrapper">
|
||||||
|
<img src="/{{ paste_id }}/raw" alt="{{ paste.filename }}" class="paste-image">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<a href="/{{ paste_id }}/raw" class="btn btn-secondary">View Full</a>
|
||||||
|
<a href="/{{ paste_id }}/download" class="btn btn-primary">Download</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
52
templates/view_text.html
Normal file
52
templates/view_text.html
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ paste.title or 'PasteBin' }} - PasteBin</title>
|
||||||
|
<link rel="stylesheet" href="/static/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<header>
|
||||||
|
<h1><a href="/">PasteBin</a></h1>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="paste-view">
|
||||||
|
{% if paste.title %}
|
||||||
|
<h2>{{ paste.title }}</h2>
|
||||||
|
{% endif %}
|
||||||
|
<div class="meta">
|
||||||
|
<span>ID: <code>{{ paste_id }}</code></span>
|
||||||
|
<span>Created: {{ paste.created_at[:19] | replace('T', ' ') }}</span>
|
||||||
|
{% if paste.expires_at %}
|
||||||
|
<span>Expires: {{ paste.expires_at[:19] | replace('T', ' ') }}</span>
|
||||||
|
{% else %}
|
||||||
|
<span>Never expires</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="content-wrapper">
|
||||||
|
<pre><code>{{ paste.data }}</code></pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<a href="/{{ paste_id }}/raw" class="btn btn-secondary">Raw</a>
|
||||||
|
<a href="/{{ paste_id }}/download" class="btn btn-primary">Download</a>
|
||||||
|
<button class="btn btn-secondary copy-btn" onclick="copyToClipboard()">Copy</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copyToClipboard() {
|
||||||
|
const text = document.querySelector('pre code').textContent;
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
const btn = document.querySelector('.copy-btn');
|
||||||
|
btn.textContent = 'Copied!';
|
||||||
|
setTimeout(() => btn.textContent = 'Copy', 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
x
Reference in New Issue
Block a user