Initial commit: PasteBin server (Flask, Docker, gunicorn)

This commit is contained in:
Jarian Cottingham 2026-05-06 04:49:23 +00:00
parent 63ee936f3d
commit 016914c6ca
12 changed files with 965 additions and 0 deletions

13
.dockerignore Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,2 @@
flask>=3.0.0
gunicorn>=21.2.0

313
static/style.css Normal file
View 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
View 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">&#128247;</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">&#128100;</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 = '&#128247;';
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 = '&#128100;';
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
View 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
View 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
View 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>