paste-bin/app.py

295 lines
10 KiB
Python

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)))