From 016914c6ca7f93a137e94d42116c06499b1d0955 Mon Sep 17 00:00:00 2001 From: Jarian Cottingham Date: Wed, 6 May 2026 04:49:23 +0000 Subject: [PATCH] Initial commit: PasteBin server (Flask, Docker, gunicorn) --- .dockerignore | 13 ++ .env.example | 5 + .gitignore | 29 ++++ Dockerfile | 16 ++ app.py | 295 +++++++++++++++++++++++++++++++++++ docker-compose.yml | 18 +++ requirements.txt | 2 + static/style.css | 313 ++++++++++++++++++++++++++++++++++++++ templates/index.html | 141 +++++++++++++++++ templates/view_file.html | 41 +++++ templates/view_image.html | 40 +++++ templates/view_text.html | 52 +++++++ 12 files changed, 965 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app.py create mode 100644 docker-compose.yml create mode 100644 requirements.txt create mode 100644 static/style.css create mode 100644 templates/index.html create mode 100644 templates/view_file.html create mode 100644 templates/view_image.html create mode 100644 templates/view_text.html diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..18be61d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +venv/ +__pycache__/ +*.pyc +.git/ +.gitignore +*.md +Dockerfile +docker-compose.yml +docker-compose.override.yml +.env +.dockerignore +store/ +uploads/ \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..7f4c3aa --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d7a420 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..37bf694 --- /dev/null +++ b/Dockerfile @@ -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 \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..182776c --- /dev/null +++ b/app.py @@ -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('/', 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('//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('//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))) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..29b6462 --- /dev/null +++ b/docker-compose.yml @@ -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: \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..9ccb73f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +flask>=3.0.0 +gunicorn>=21.2.0 \ No newline at end of file diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..1493fc4 --- /dev/null +++ b/static/style.css @@ -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; + } +} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..7d81ac1 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,141 @@ + + + + + + PasteBin + + + +
+
+

PasteBin

+

Share text, images, and files temporarily

+
+ + {% if error %} +
{{ error }}
+ {% endif %} + +
+ + + +
+ +
+ + +
+ + +
+ +
+
+ + +
+
+ +
+
+ +
+ + + +
+
+
+ +
+
+ +
+ + + +
+
+
+ +
+ + +
+ + +
+
+ + + + \ No newline at end of file diff --git a/templates/view_file.html b/templates/view_file.html new file mode 100644 index 0000000..daf1475 --- /dev/null +++ b/templates/view_file.html @@ -0,0 +1,41 @@ + + + + + + {{ paste.title or 'PasteBin' }} - PasteBin + + + +
+
+

PasteBin

+
+ +
+ {% if paste.title %} +

{{ paste.title }}

+ {% endif %} +
+ ID: {{ paste_id }} + File: {{ paste.filename }} + Created: {{ paste.created_at[:19] | replace('T', ' ') }} + {% if paste.expires_at %} + Expires: {{ paste.expires_at[:19] | replace('T', ' ') }} + {% else %} + Never expires + {% endif %} +
+ +
+

This is a binary file. Download it to view.

+

Type: {{ paste.mimetype }}

+
+ +
+ Download +
+
+
+ + \ No newline at end of file diff --git a/templates/view_image.html b/templates/view_image.html new file mode 100644 index 0000000..41b8045 --- /dev/null +++ b/templates/view_image.html @@ -0,0 +1,40 @@ + + + + + + {{ paste.title or 'PasteBin' }} - PasteBin + + + +
+
+

PasteBin

+
+ +
+ {% if paste.title %} +

{{ paste.title }}

+ {% endif %} +
+ ID: {{ paste_id }} + Created: {{ paste.created_at[:19] | replace('T', ' ') }} + {% if paste.expires_at %} + Expires: {{ paste.expires_at[:19] | replace('T', ' ') }} + {% else %} + Never expires + {% endif %} +
+ +
+ {{ paste.filename }} +
+ + +
+
+ + \ No newline at end of file diff --git a/templates/view_text.html b/templates/view_text.html new file mode 100644 index 0000000..a14f2d4 --- /dev/null +++ b/templates/view_text.html @@ -0,0 +1,52 @@ + + + + + + {{ paste.title or 'PasteBin' }} - PasteBin + + + +
+
+

PasteBin

+
+ +
+ {% if paste.title %} +

{{ paste.title }}

+ {% endif %} +
+ ID: {{ paste_id }} + Created: {{ paste.created_at[:19] | replace('T', ' ') }} + {% if paste.expires_at %} + Expires: {{ paste.expires_at[:19] | replace('T', ' ') }} + {% else %} + Never expires + {% endif %} +
+ +
+
{{ paste.data }}
+
+ +
+ Raw + Download + +
+
+
+ + + + \ No newline at end of file