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