diff --git a/.env b/.env deleted file mode 100644 index 6017bdc..0000000 --- a/.env +++ /dev/null @@ -1,5 +0,0 @@ - -I2PCAKE_SECRET_KEY='3de1d3c8c7c7d41f77e68e4c77609d57' -I2PCAKE_ADMIN_PASSWORD='z^K4@qR9#pGv$L!w' -I2PCAKE_ENCRYPTION_KEY='bZgG05n1hVDFs3qc0N2n-HqH8J1nZ_0mY7yK6xX5_wI=' -RATELIMIT_STORAGE_URI=redis://localhost:6379 diff --git a/.gitignore b/.gitignore index f0ee8c0..2c518e9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,14 @@ uploads/ database.db .DS_Store +upload* +*.db +.env +wsgi.py + +# Ignore Python cache files +__pycache__/ +*.py[cod] +# Ignore virtual environment directories +venv/ + diff --git a/README.md b/README.md index 9f11b73..5ab8340 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,2 @@ -# i2pcake - +# i2pcake + diff --git a/app.py b/app.py index 8b44f83..00b5e7e 100644 --- a/app.py +++ b/app.py @@ -1,521 +1,603 @@ -import os -import uuid -import sqlite3 -import secrets -from datetime import datetime, timedelta, timezone -from flask import Flask, render_template, request, redirect, url_for, flash, send_from_directory, abort, send_file, jsonify, session, g -from werkzeug.utils import secure_filename -from werkzeug.middleware.proxy_fix import ProxyFix -from io import BytesIO - -# --- Library Imports --- -from PIL import Image -from pygments import highlight -from pygments.lexers import get_lexer_by_name -from pygments.formatters import HtmlFormatter -from apscheduler.schedulers.background import BackgroundScheduler -from cryptography.fernet import Fernet -from flask_limiter import Limiter -from flask_limiter.util import get_remote_address -from dotenv import load_dotenv # Import python-dotenv - -# Load environment variables from .env file -load_dotenv() - -# --- Configuration --- -app = Flask(__name__) -# This is the definitive fix for URL generation behind a proxy. -app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) - -# Load secrets from environment variables -app.config['SECRET_KEY'] = os.getenv('I2PCAKE_SECRET_KEY', 'default-dev-key-if-not-set') -app.config['ADMIN_PASSWORD'] = os.getenv('I2PCAKE_ADMIN_PASSWORD') -encryption_key = os.getenv('I2PCAKE_ENCRYPTION_KEY') -app.config['ENCRYPTION_KEY'] = encryption_key.encode('utf-8') if encryption_key else None - -app.config['SERVER_NAME'] = 'drop.i2p' -app.config['UPLOAD_FOLDER'] = 'uploads' -app.config['DATABASE'] = 'database.db' -app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024 -app.config['ADMIN_URL'] = '/s3cr3t-4dm1n-p4n3l-d3adbeef' -ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'tiff'} - -# --- I2P-Aware Rate Limiting --- -def i2p_key_func(): - i2p_b32_address = request.headers.get('X-I2P-DestB32') - if i2p_b32_address: - return i2p_b32_address - forwarded_for = request.headers.get('X-Forwarded-For') - if forwarded_for: - return forwarded_for.split(',')[0].strip() - return get_remote_address() - -limiter = Limiter( - app=app, - key_func=i2p_key_func, - default_limits=["200 per day", "50 per hour"], - storage_uri="memory://" -) - -# --- Cryptography Setup --- -fernet = Fernet(app.config['ENCRYPTION_KEY']) if app.config['ENCRYPTION_KEY'] else None - -# --- Expiry Time Mapping --- -EXPIRY_MAP = { - "15m": timedelta(minutes=15), - "1h": timedelta(hours=1), - "2h": timedelta(hours=2), - "4h": timedelta(hours=4), - "8h": timedelta(hours=8), - "12h": timedelta(hours=12), - "24h": timedelta(hours=24), - "48h": timedelta(hours=48) -} - -# --- Curated Language List --- -POPULAR_LANGUAGES = [ - 'bash', 'c', 'cpp', 'csharp', 'css', 'go', 'html', 'java', - 'javascript', 'json', 'kotlin', 'lua', 'markdown', 'php', - 'python', 'ruby', 'rust', 'sql', 'swift', 'typescript', - 'xml', 'yaml' -] - -# --- New Database Connection Management --- -def get_db(): - """Opens a new database connection if there is none yet for the current application context.""" - if 'db' not in g: - # Open connection - db = sqlite3.connect( - app.config['DATABASE'], - detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES, - check_same_thread=False - ) - # Access columns by name - db.row_factory = sqlite3.Row - - # Tune SQLite for higher throughput / lower tail latency - db.execute("PRAGMA journal_mode = WAL;") - db.execute("PRAGMA synchronous = NORMAL;") - db.execute("PRAGMA busy_timeout = 5000;") # wait up to 5s if database is locked - - g.db = db - return g.db - -def close_db(e=None): - """Closes the database again at the end of the request.""" - db = g.pop('db', None) - if db is not None: - db.close() - -# Register the close_db function to be called when the app context is torn down -app.teardown_appcontext(close_db) - -# --- Database Setup --- -def init_db(): - with app.app_context(): - db = get_db() - cursor = db.cursor() - cursor.execute( - 'CREATE TABLE IF NOT EXISTS pastes (' - 'id TEXT PRIMARY KEY, ' - 'content BLOB NOT NULL, ' - 'language TEXT NOT NULL, ' - 'expiry_date DATETIME NOT NULL' - ')' - ) - cursor.execute( - 'CREATE TABLE IF NOT EXISTS images (' - 'id TEXT PRIMARY KEY, ' - 'upload_date DATETIME NOT NULL, ' - 'expiry_date DATETIME NOT NULL' - ')' - ) - cursor.execute( - 'CREATE TABLE IF NOT EXISTS stats (' - 'stat_key TEXT PRIMARY KEY, ' - 'stat_value INTEGER NOT NULL' - ')' - ) - stats_to_initialize = ['total_images', 'total_pastes', 'total_api_uploads'] - for stat in stats_to_initialize: - cursor.execute( - "INSERT OR IGNORE INTO stats (stat_key, stat_value) VALUES (?, 0)", - (stat,) - ) - db.commit() - -# --- Statistics Helper --- -def update_stat(stat_key, increment=1): - db = get_db() - db.execute( - "UPDATE stats SET stat_value = stat_value + ? WHERE stat_key = ?", - (increment, stat_key) - ) - db.commit() - -# --- Deletion Scheduler Setup --- -scheduler = BackgroundScheduler(daemon=True) -def cleanup_expired_content(): - # This function runs in a background thread, so it needs its own app context - with app.app_context(): - now = datetime.now() - print(f"[{now}] Running cleanup job...") - db = sqlite3.connect(app.config['DATABASE']) - cursor = db.cursor() - cursor.execute("DELETE FROM pastes WHERE expiry_date < ?", (now,)) - pastes_deleted = cursor.rowcount - cursor.execute("SELECT id FROM images WHERE expiry_date < ?", (now,)) - expired_images = cursor.fetchall() - images_deleted = 0 - for image_record in expired_images: - filename = image_record[0] - filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename) - try: - os.remove(filepath) - cursor.execute("DELETE FROM images WHERE id = ?", (filename,)) - images_deleted += 1 - except OSError: - pass - db.commit() - db.close() - if pastes_deleted > 0: - print(f"Cleaned up {pastes_deleted} expired paste(s).") - if images_deleted > 0: - print(f"Cleaned up {images_deleted} expired image(s).") - -# --- Helper Functions --- -def allowed_file(filename): - return ( - '.' in filename and - filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS - ) - -def process_and_encrypt_image(file_stream, original_filename): - try: - img = Image.open(file_stream) - if img.mode in ('RGBA', 'P'): - img = img.convert('RGB') - output_buffer = BytesIO() - img.save(output_buffer, 'webp', quality=80) - output_buffer.seek(0) - image_data = output_buffer.read() - encrypted_data = fernet.encrypt(image_data) - new_filename = f"{uuid.uuid4().hex}.webp" - filepath = os.path.join(app.config['UPLOAD_FOLDER'], new_filename) - with open(filepath, 'wb') as f: - f.write(encrypted_data) - return new_filename - except Exception as e: - print(f"Could not process image {original_filename}: {e}") - return None - -def get_time_left(expiry_date_str): - if not expiry_date_str: - return "N/A" - try: - expiry_date = datetime.fromisoformat(expiry_date_str) - now = datetime.now() - remaining = expiry_date - now - if remaining.total_seconds() <= 0: - return "Expired" - days, seconds = remaining.days, remaining.seconds - hours = days * 24 + seconds // 3600 - minutes = (seconds % 3600) // 60 - if hours > 0: - return f"~{hours}h {minutes}m" - else: - return f"~{minutes}m" - except Exception: - return "Invalid date" - -# Context processor to inject banner variables by reading from a file -@app.context_processor -def inject_announcement(): - try: - with open('announcement.txt', 'r') as f: - message = f.read().strip() - if message: - return dict( - announcement_enabled=True, - announcement_message=message - ) - except FileNotFoundError: - pass - return dict(announcement_enabled=False, announcement_message='') - -# --- Custom Error Handlers --- -@app.errorhandler(413) -def request_entity_too_large(error): - if request.path.startswith('/api/'): - return jsonify(error="File is too large (max 10MB)."), 413 - flash('File is too large (max 10MB). Please upload a smaller file.', 'error') - return redirect(url_for('index')) - -@app.errorhandler(429) -def ratelimit_handler(e): - if request.path.startswith('/api/'): - return jsonify(error=f"ratelimit exceeded: {e.description}"), 429 - flash('You have made too many requests. Please wait a while before trying again.', 'error') - return redirect(url_for('index')) - -# --- Web UI Routes --- -@app.route('/') -def index(): - db = get_db() - stats_list = db.execute("SELECT stat_key, stat_value FROM stats").fetchall() - stats = {row['stat_key']: row['stat_value'] for row in stats_list} - return render_template( - 'index.html', - languages=POPULAR_LANGUAGES, - stats=stats, - allowed_extensions=list(ALLOWED_EXTENSIONS) - ) - -@app.route('/donate') -def donate_page(): - return render_template('donate.html') - -@app.route(app.config['ADMIN_URL'], methods=['GET', 'POST']) -def admin_dashboard(): - if request.method == 'POST': - password_attempt = request.form.get('password') - if password_attempt == app.config['ADMIN_PASSWORD']: - session['admin_logged_in'] = True - return redirect(url_for('admin_dashboard')) - else: - flash('Incorrect password.', 'error') - - if not session.get('admin_logged_in'): - return render_template('admin.html', auth_success=False) - - db = get_db() - now = datetime.now() - images_raw = db.execute( - "SELECT id, expiry_date FROM images WHERE expiry_date >= ? ORDER BY expiry_date ASC", - (now,) - ).fetchall() - pastes_raw = db.execute( - "SELECT id, language, expiry_date FROM pastes WHERE expiry_date >= ? ORDER BY expiry_date ASC", - (now,) - ).fetchall() - - images = [ - (i['id'], i['expiry_date'], get_time_left(i['expiry_date'])) - for i in images_raw - ] - pastes = [ - (p['id'], p['language'], p['expiry_date'], get_time_left(p['expiry_date'])) - for p in pastes_raw - ] - return render_template( - 'admin.html', - images=images, - pastes=pastes, - auth_success=True - ) - -@app.route('/upload/image', methods=['POST']) -@limiter.limit("10 per hour") -def upload_image(): - if 'file' not in request.files: - flash('No file part in request.', 'error') - return redirect(url_for('index', _anchor='image')) - file = request.files['file'] - if file.filename == '': - flash('No file selected.', 'error') - return redirect(url_for('index', _anchor='image')) - if file and allowed_file(file.filename): - new_filename = process_and_encrypt_image(file.stream, file.filename) - if not new_filename: - flash('There was an error processing the image.', 'error') - return redirect(url_for('index', _anchor='image')) - now = datetime.now() - expiry_key = request.form.get('expiry', '1h') - expiry_delta = EXPIRY_MAP.get(expiry_key, timedelta(hours=1)) - expiry_date = now + expiry_delta - db = get_db() - db.execute( - "INSERT INTO images (id, upload_date, expiry_date) VALUES (?, ?, ?)", - (new_filename, now, expiry_date) - ) - db.commit() - update_stat('total_images') - return redirect(url_for('view_image', filename=new_filename)) - flash('Invalid file type. Please check the allowed formats.', 'error') - return redirect(url_for('index', _anchor='image')) - -@app.route('/upload/paste', methods=['POST']) -@limiter.limit("20 per hour") -def upload_paste(): - content = request.form.get('content') - language = request.form.get('language', 'text') - expiry_key = request.form.get('expiry', '1h') - if not content or not content.strip(): - flash('Paste content cannot be empty.', 'error') - return redirect(url_for('index', _anchor='paste')) - paste_id = uuid.uuid4().hex - expiry_delta = EXPIRY_MAP.get(expiry_key, timedelta(hours=1)) - expiry_date = datetime.now() + expiry_delta - encrypted_content = fernet.encrypt(content.encode('utf-8')) - db = get_db() - db.execute( - "INSERT INTO pastes (id, content, language, expiry_date) VALUES (?, ?, ?, ?)", - (paste_id, encrypted_content, language, expiry_date) - ) - db.commit() - update_stat('total_pastes') - return redirect(url_for('view_paste', paste_id=paste_id)) - -@app.route('/image/') -def view_image(filename): - safe_filename = secure_filename(filename) - filepath = os.path.join(app.config['UPLOAD_FOLDER'], safe_filename) - if not os.path.exists(filepath): - flash('The image you requested has expired or does not exist.', 'error') - return redirect(url_for('index')) - return render_template('view_image.html', filename=safe_filename) - -@app.route('/paste/') -def view_paste(paste_id): - db = get_db() - result = db.execute( - "SELECT content, language FROM pastes WHERE id = ?", - (paste_id,) - ).fetchone() - if result is None: - flash('The paste you requested has expired or does not exist.', 'error') - return redirect(url_for('index')) - encrypted_content = result['content'] - language = result['language'] - decrypted_content = fernet.decrypt(encrypted_content).decode('utf-8') - try: - lexer = get_lexer_by_name(language) - except: - lexer = get_lexer_by_name('text') - formatter = HtmlFormatter( - style='monokai', - cssclass="syntax", - noclasses=False - ) - highlighted_content = highlight(decrypted_content, lexer, formatter) - css_styles = formatter.get_style_defs('.syntax') - return render_template( - 'view_paste.html', - paste_id=paste_id, - highlighted_content=highlighted_content, - css_styles=css_styles - ) - -@app.route('/uploads/') -def get_upload(filename): - safe_filename = secure_filename(filename) - filepath = os.path.join(app.config['UPLOAD_FOLDER'], safe_filename) - try: - with open(filepath, 'rb') as f: - encrypted_data = f.read() - decrypted_data = fernet.decrypt(encrypted_data) - return send_file(BytesIO(decrypted_data), mimetype='image/webp') - except (FileNotFoundError, IOError): - abort(404) - except Exception as e: - print(f"Could not decrypt or serve file {filename}: {e}") - abort(500) - -@app.route('/admin/delete/image/', methods=['POST']) -def delete_image(filename): - if not session.get('admin_logged_in'): - abort(401) - safe_filename = secure_filename(filename) - filepath = os.path.join(app.config['UPLOAD_FOLDER'], safe_filename) - try: - os.remove(filepath) - db = get_db() - db.execute("DELETE FROM images WHERE id = ?", (safe_filename,)) - db.commit() - flash(f'Image "{safe_filename}" has been deleted.', 'success') - except OSError as e: - flash(f'Error deleting image file: {e}', 'error') - return redirect(url_for('admin_dashboard')) - -@app.route('/admin/delete/paste/', methods=['POST']) -def delete_paste(paste_id): - if not session.get('admin_logged_in'): - abort(401) - db = get_db() - db.execute("DELETE FROM pastes WHERE id = ?", (paste_id,)) - db.commit() - flash(f'Paste "{paste_id}" has been deleted.', 'success') - return redirect(url_for('admin_dashboard')) - -# --- API Routes --- -@app.route('/api/upload/image', methods=['POST']) -@limiter.limit("50 per hour") -def api_upload_image(): - if 'file' not in request.files: - return jsonify({"error": "No file part in the request"}), 400 - file = request.files['file'] - if file.filename == '': - return jsonify({"error": "No file selected"}), 400 - if file and allowed_file(file.filename): - new_filename = process_and_encrypt_image(file.stream, file.filename) - if not new_filename: - return jsonify({"error": "Failed to process image"}), 500 - now = datetime.now() - expiry = request.form.get('expiry', '1h') - expiry_delta = EXPIRY_MAP.get(expiry, timedelta(hours=1)) - expiry_date = now + expiry_delta - db = get_db() - db.execute( - "INSERT INTO images (id, upload_date, expiry_date) VALUES (?, ?, ?)", - (new_filename, now, expiry_date) - ) - db.commit() - update_stat('total_api_uploads') - image_url = url_for('get_upload', filename=new_filename, _external=True) - return jsonify({"success": True, "url": image_url, "expires_in": expiry}), 200 - return jsonify({"error": "Invalid file type. Please check the allowed formats."}), 400 - -@app.route('/api/upload/paste', methods=['POST']) -@limiter.limit("100 per hour") -def api_upload_paste(): - if not request.is_json: - return jsonify({"error": "Request must be JSON"}), 400 - data = request.get_json() - content = data.get('content') - language = data.get('language', 'text') - expiry = data.get('expiry', '1h') - if not content: - return jsonify({"error": "Paste content is missing"}), 400 - paste_id = uuid.uuid4().hex - expiry_delta = EXPIRY_MAP.get(expiry, timedelta(hours=1)) - expiry_date = datetime.now() + expiry_delta - encrypted_content = fernet.encrypt(content.encode('utf-8')) - db = get_db() - db.execute( - "INSERT INTO pastes (id, content, language, expiry_date) VALUES (?, ?, ?, ?)", - (paste_id, encrypted_content, language, expiry_date) - ) - db.commit() - update_stat('total_api_uploads') - paste_url = url_for('view_paste', paste_id=paste_id, _external=True) - return jsonify({"success": True, "url": paste_url, "expires_in": expiry}), 200 - -# --- Main Execution --- -if __name__ == '__main__': - # Check if essential environment variables are set - if not all([ - app.config['SECRET_KEY'], - app.config['ADMIN_PASSWORD'], - app.config['ENCRYPTION_KEY'] - ]): - print("FATAL ERROR: One or more required environment variables are not set.") - print("Please create a .env file or set them on your system.") - exit(1) - - if not os.path.exists(app.config['UPLOAD_FOLDER']): - os.makedirs(app.config['UPLOAD_FOLDER']) - init_db() - scheduler.add_job(cleanup_expired_content, 'interval', minutes=30) - scheduler.start() - print("--- Deletion Scheduler and Cleanup Job are Running ---") - app.run(debug=True, use_reloader=False) \ No newline at end of file +# app.py + +import os +import uuid +import sqlite3 +from datetime import datetime, timedelta +from io import BytesIO + +from flask import ( + Flask, render_template, request, redirect, url_for, flash, + session, g, abort, send_file, jsonify, Response +) +from werkzeug.utils import secure_filename +from werkzeug.middleware.proxy_fix import ProxyFix +from werkzeug.security import generate_password_hash, check_password_hash + +from PIL import Image +from pygments import highlight +from pygments.lexers import get_lexer_by_name +from pygments.formatters import HtmlFormatter +from apscheduler.schedulers.background import BackgroundScheduler +from cryptography.fernet import Fernet +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from dotenv import load_dotenv + +# Load environment variables from a .env file +load_dotenv() + +app = Flask(__name__) +app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) + + +app.config['SECRET_KEY'] = os.getenv('SSP_SECRET_KEY') +app.config['ADMIN_PASSWORD_HASH'] = os.getenv('SSP_ADMIN_PASSWORD_HASH') +app.config['ADMIN_URL'] = os.getenv('SSP_ADMIN_URL') + +enc_key = os.getenv('SSP_ENCRYPTION_KEY') +if not enc_key: + raise ValueError("FATAL: SSP_ENCRYPTION_KEY is not set in the environment.") +app.config['ENCRYPTION_KEY'] = enc_key.encode('utf-8') + +app.config['UPLOAD_FOLDER'] = os.getenv('SSP_UPLOAD_FOLDER', 'uploads') +app.config['DATABASE_PATH'] = os.getenv('SSP_DATABASE_PATH', 'database.db') +app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024 # 10MB + +app.config['FLASK_DEBUG'] = os.getenv('SSP_FLASK_DEBUG', 'False').lower() in ('true', '1', 't') + +ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'tiff'} + +# --- Rate Limiting (I2P-aware) --- +def i2p_key_func(): + # Prioritize the I2P destination header for rate limiting + b32 = request.headers.get('X-I2P-DestB32') + if b32: + return b32 + # Fallback to X-Forwarded-For if behind a standard proxy + fwd = request.headers.get('X-Forwarded-For') + if fwd: + return fwd.split(',')[0].strip() + # Final fallback to the direct remote address + return get_remote_address() + +limiter = Limiter( + app=app, + key_func=i2p_key_func, + default_limits=["200 per day", "50 per hour"], + storage_uri="memory://" +) + +fernet = Fernet(app.config['ENCRYPTION_KEY']) + +# --- Expiry Map & Languages --- +EXPIRY_MAP = { + "15m": timedelta(minutes=15), "1h": timedelta(hours=1), "2h": timedelta(hours=2), + "4h": timedelta(hours=4), "8h": timedelta(hours=8), "12h": timedelta(hours=12), + "24h": timedelta(hours=24), "48h": timedelta(hours=48) +} +POPULAR_LANGUAGES = [ + 'text', 'bash', 'c', 'cpp', 'csharp', 'css', 'go', 'html', 'java', 'javascript', 'json', + 'kotlin', 'lua', 'markdown', 'php', 'python', 'ruby', 'rust', 'sql', 'swift', + 'typescript', 'xml', 'yaml' +] + +# --- Database Helpers --- +def get_db(): + if 'db' not in g: + g.db = sqlite3.connect(app.config['DATABASE_PATH']) + g.db.row_factory = sqlite3.Row + return g.db + +def close_db(e=None): + db = g.pop('db', None) + if db: + db.close() + +app.teardown_appcontext(close_db) + +def init_db(): + with app.app_context(): + db = get_db() + c = db.cursor() + c.execute(''' + CREATE TABLE IF NOT EXISTS pastes ( + id TEXT PRIMARY KEY, + content BLOB NOT NULL, + language TEXT NOT NULL, + expiry_date DATETIME NOT NULL, + password_hash TEXT, + view_count INTEGER DEFAULT 0, + max_views INTEGER + ) + ''') + c.execute(''' + CREATE TABLE IF NOT EXISTS images ( + id TEXT PRIMARY KEY, + upload_date DATETIME NOT NULL, + expiry_date DATETIME NOT NULL, + password_hash TEXT, + view_count INTEGER DEFAULT 0, + max_views INTEGER + ) + ''') + c.execute('CREATE TABLE IF NOT EXISTS stats (stat_key TEXT PRIMARY KEY, stat_value INTEGER NOT NULL)') + for stat in ['total_images', 'total_pastes', 'total_api_uploads']: + c.execute("INSERT OR IGNORE INTO stats(stat_key,stat_value) VALUES(?,0)", (stat,)) + db.commit() + +def update_stat(key, inc=1): + db = get_db() + db.execute("UPDATE stats SET stat_value = stat_value + ? WHERE stat_key = ?", (inc, key)) + db.commit() + +# --- Cleanup Scheduler --- +scheduler = BackgroundScheduler(daemon=True) + +def cleanup_expired_content(): + with app.app_context(): + now = datetime.now() + conn = sqlite3.connect(app.config['DATABASE_PATH']) + cur = conn.cursor() + cur.execute("DELETE FROM pastes WHERE expiry_date < ?", (now,)) + cur.execute("SELECT id FROM images WHERE expiry_date < ?", (now,)) + for (img_id,) in cur.fetchall(): + path = os.path.join(app.config['UPLOAD_FOLDER'], img_id) + try: + os.remove(path) + except OSError as e: + app.logger.error(f"Error removing expired image file {path}: {e}") + cur.execute("DELETE FROM images WHERE id = ?", (img_id,)) + conn.commit() + conn.close() + +# --- Utility Functions --- +def allowed_file(fn): + return '.' in fn and fn.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + +def get_time_left(expiry_str): + try: + expiry = datetime.fromisoformat(expiry_str) + rem = expiry - datetime.now() + if rem.total_seconds() <= 0: + return "Expired" + days = rem.days + hrs = rem.seconds // 3600 + mins = (rem.seconds % 3600) // 60 + if days > 0: + return f"~{days} days {hrs} hours" + if hrs > 0: + return f"~{hrs} hours {mins} minutes" + return f"~{mins} minutes" + except (ValueError, TypeError): + return "N/A" + +def process_and_encrypt_image(stream, orig_fn, keep_exif=False): + try: + img = Image.open(stream) + if img.mode in ('RGBA', 'P'): + img = img.convert('RGB') + buf = BytesIO() + exif = img.info.get('exif') if keep_exif and 'exif' in img.info else None + + save_params = {'quality': 80} + if exif: + save_params['exif'] = exif + + img.save(buf, 'WEBP', **save_params) + buf.seek(0) + encrypted = fernet.encrypt(buf.read()) + + new_fn = f"{uuid.uuid4().hex}.webp" + path = os.path.join(app.config['UPLOAD_FOLDER'], new_fn) + with open(path, 'wb') as f: + f.write(encrypted) + return new_fn + except Exception as e: + app.logger.error(f"Image processing failed ({orig_fn}): {e}") + return None + +@app.context_processor +def inject_announcement(): + try: + with open('announcement.txt', 'r') as f: + msg = f.read().strip() + if msg: + return dict(announcement_enabled=True, announcement_message=msg) + except FileNotFoundError: + pass + return dict(announcement_enabled=False, announcement_message='') + +# --- Error Handlers --- +@app.errorhandler(404) +def not_found(e): + flash('Content not found or has expired.', 'error') + return redirect(url_for('index')) + +@app.errorhandler(410) +def gone(e): + flash('Content has expired due to exceeding its view limit.', 'error') + return redirect(url_for('index')) + +@app.errorhandler(413) +def too_large(e): + if request.path.startswith('/api/'): + return jsonify(error="File is too large (max 10MB)."), 413 + flash('File is too large (max 10MB).', 'error') + return redirect(url_for('index')) + +@app.errorhandler(429) +def rate_limited(e): + if request.path.startswith('/api/'): + return jsonify(error=f"Rate limit exceeded: {e.description}"), 429 + flash('Too many requests. Please wait a while.', 'error') + return redirect(url_for('index')) + +# --- Health Check --- +@app.route('/healthz') +def healthz(): + try: + conn = sqlite3.connect(app.config['DATABASE_PATH']) + conn.execute("SELECT 1").fetchone() + conn.close() + db_status = "ok" + except Exception as e: + app.logger.error(f"Health check DB error: {e}") + db_status = "error" + sched_status = "running" if scheduler.running and scheduler.state == 1 else "stopped" + return jsonify(database=db_status, scheduler=sched_status) + +# --- Web UI Routes --- +@app.route('/') +def index(): + db = get_db() + rows = db.execute("SELECT stat_key, stat_value FROM stats").fetchall() + stats = {r['stat_key']: r['stat_value'] for r in rows} + # We want 'text' to be at the top of the list in the index page dropdown + index_languages = [lang for lang in POPULAR_LANGUAGES if lang != 'text'] + return render_template( + 'index.html', + languages=index_languages, + stats=stats, + allowed_extensions=list(ALLOWED_EXTENSIONS) + ) + +@app.route('/donate') +def donate_page(): + return render_template('donate.html') + +if not app.config.get('ADMIN_URL'): + raise ValueError("Configuration Error: SSP_ADMIN_URL is not set.") + +@app.route(app.config['ADMIN_URL'], methods=['GET', 'POST']) +def admin_dashboard(): + if request.method == 'POST': + pw = request.form.get('password', '') + if app.config['ADMIN_PASSWORD_HASH'] and check_password_hash(app.config['ADMIN_PASSWORD_HASH'], pw): + session['admin_logged_in'] = True + return redirect(url_for('admin_dashboard')) + flash('Incorrect password.', 'error') + + if not session.get('admin_logged_in'): + return render_template('admin.html', auth_success=False) + + db = get_db() + now = datetime.now() + imgs = db.execute("SELECT id, expiry_date, view_count, max_views FROM images ORDER BY expiry_date ASC").fetchall() + past = db.execute("SELECT id, language, expiry_date, view_count, max_views FROM pastes ORDER BY expiry_date ASC").fetchall() + + images = [(i['id'], i['expiry_date'], get_time_left(i['expiry_date']), i['view_count'], i['max_views']) for i in imgs] + pastes = [(p['id'], p['language'], p['expiry_date'], get_time_left(p['expiry_date']), p['view_count'], p['max_views']) for p in past] + + return render_template('admin.html', auth_success=True, images=images, pastes=pastes) + +@app.route('/upload/image', methods=['POST']) +@limiter.limit("10 per hour") +def upload_image(): + if 'file' not in request.files or request.files['file'].filename == '': + flash('No file selected.', 'error') + return redirect(url_for('index', _anchor='image')) + + file = request.files['file'] + if file and allowed_file(file.filename): + keep_exif = bool(request.form.get('keep_exif')) + new_fn = process_and_encrypt_image(file.stream, file.filename, keep_exif) + if not new_fn: + flash('Error processing image.', 'error') + return redirect(url_for('index', _anchor='image')) + + now = datetime.now() + expiry = now + EXPIRY_MAP.get(request.form.get('expiry', '1h'), timedelta(hours=1)) + pw = request.form.get('password') or None + pw_hash = generate_password_hash(pw, method='pbkdf2:sha256') if pw else None + mv = request.form.get('max_views') + mv = int(mv) if mv and mv.isdigit() else None + + db = get_db() + db.execute( + 'INSERT INTO images (id, upload_date, expiry_date, password_hash, max_views, view_count) VALUES (?, ?, ?, ?, ?, ?)', + (new_fn, now, expiry, pw_hash, mv, -1) + ) + db.commit() + update_stat('total_images') + + flash('Image uploaded successfully! This is your shareable link.', 'success') + return redirect(url_for('view_image', filename=new_fn)) + + flash('Invalid file type.', 'error') + return redirect(url_for('index', _anchor='image')) + + +@app.route('/upload/paste', methods=['POST']) +@limiter.limit("20 per hour") +def upload_paste(): + content = request.form.get('content', '').strip() + if not content: + flash('Paste content cannot be empty.', 'error') + return redirect(url_for('index', _anchor='paste')) + + now = datetime.now() + expiry = now + EXPIRY_MAP.get(request.form.get('expiry', '1h'), timedelta(hours=1)) + pw = request.form.get('password') or None + pw_hash = generate_password_hash(pw, method='pbkdf2:sha256') if pw else None + mv = request.form.get('max_views') + mv = int(mv) if mv and mv.isdigit() else None + + paste_id = uuid.uuid4().hex + encrypted = fernet.encrypt(content.encode('utf-8')) + db = get_db() + db.execute( + 'INSERT INTO pastes (id, content, language, expiry_date, password_hash, max_views, view_count) VALUES (?, ?, ?, ?, ?, ?, ?)', + (paste_id, encrypted, request.form.get('language', 'text'), expiry, pw_hash, mv, -1) + ) + db.commit() + update_stat('total_pastes') + + flash('Paste created successfully! This is your shareable link.', 'success') + return redirect(url_for('view_paste', paste_id=paste_id)) + + +@app.route('/image/', methods=['GET', 'POST']) +def view_image(filename): + db = get_db() + row = db.execute("SELECT * FROM images WHERE id = ?", (filename,)).fetchone() + + if not row or datetime.now() > datetime.fromisoformat(row['expiry_date']): + if row: # If row exists but is expired, delete it. + db.execute("DELETE FROM images WHERE id = ?", (filename,)) + db.commit() + abort(404) + + pw_hash = row['password_hash'] + if pw_hash and not session.get(f'unlocked_image_{filename}'): + if request.method == 'POST': + if check_password_hash(pw_hash, request.form.get('password', '')): + session[f'unlocked_image_{filename}'] = True + return redirect(url_for('view_image', filename=filename)) + flash('Incorrect password.', 'error') + return render_template('view_image.html', password_required=True, filename=filename) + + return render_template('view_image.html', + password_required=False, + filename=filename, + time_left=get_time_left(row['expiry_date']) + ) + + +@app.route('/paste/', methods=['GET', 'POST']) +def view_paste(paste_id): + db = get_db() + row = db.execute("SELECT * FROM pastes WHERE id = ?", (paste_id,)).fetchone() + + if not row or datetime.now() > datetime.fromisoformat(row['expiry_date']): + if row: + db.execute("DELETE FROM pastes WHERE id = ?", (paste_id,)) + db.commit() + abort(404) + + pw_hash = row['password_hash'] + if pw_hash and not session.get(f'unlocked_paste_{paste_id}'): + if request.method == 'POST': + if check_password_hash(pw_hash, request.form.get('password', '')): + session[f'unlocked_paste_{paste_id}'] = True + return redirect(url_for('view_paste', paste_id=paste_id)) + flash('Incorrect password.', 'error') + return render_template('view_paste.html', password_required=True, paste_id=paste_id) + + if row['max_views'] is not None and row['view_count'] >= row['max_views']: + db.execute("DELETE FROM pastes WHERE id = ?", (paste_id,)) + db.commit() + abort(410) + + # Only increment view count on the initial, non-overridden view + if 'lang' not in request.args: + db.execute("UPDATE pastes SET view_count = view_count + 1 WHERE id = ?", (paste_id,)) + db.commit() + + content = fernet.decrypt(row['content']).decode('utf-8') + + # Get the language, allowing for a user override via URL parameter + default_language = row['language'] + selected_language = request.args.get('lang', default_language) + + try: + lexer = get_lexer_by_name(selected_language) + except: + lexer = get_lexer_by_name('text') + + fmt = HtmlFormatter(style='monokai', cssclass='syntax', linenos='table') + highlighted = highlight(content, lexer, fmt) + + return render_template('view_paste.html', + password_required=False, + paste_id=paste_id, + highlighted_content=highlighted, + time_left=get_time_left(row['expiry_date']), + languages=POPULAR_LANGUAGES, + selected_language=selected_language + ) + + +@app.route('/paste//raw') +def paste_raw(paste_id): + db = get_db() + row = db.execute("SELECT * FROM pastes WHERE id = ?", (paste_id,)).fetchone() + + if not row or datetime.now() > datetime.fromisoformat(row['expiry_date']): + abort(404) + + if row['password_hash'] and not session.get(f'unlocked_paste_{paste_id}'): + abort(403) + + if row['max_views'] is not None and row['view_count'] >= row['max_views']: + db.execute("DELETE FROM pastes WHERE id = ?", (paste_id,)) + db.commit() + abort(410) + + db.execute("UPDATE pastes SET view_count = view_count + 1 WHERE id = ?", (paste_id,)) + db.commit() + + text = fernet.decrypt(row['content']).decode('utf-8') + return Response(text, mimetype='text/plain') + + +@app.route('/uploads/') +def get_upload(filename): + safe_fn = secure_filename(filename) + path = os.path.join(app.config['UPLOAD_FOLDER'], safe_fn) + db = get_db() + + row = db.execute("SELECT * FROM images WHERE id = ?", (safe_fn,)).fetchone() + + if not row or not os.path.exists(path) or datetime.now() > datetime.fromisoformat(row['expiry_date']): + if row: + db.execute("DELETE FROM images WHERE id = ?", (safe_fn,)) + db.commit() + if os.path.exists(path): os.remove(path) + abort(404) + + if row['password_hash'] and not session.get(f'unlocked_image_{safe_fn}'): + abort(403) + + if row['max_views'] is not None and row['view_count'] >= row['max_views']: + db.execute("DELETE FROM images WHERE id = ?", (safe_fn,)) + db.commit() + os.remove(path) + abort(410) + + db.execute("UPDATE images SET view_count = view_count + 1 WHERE id = ?", (safe_fn,)) + db.commit() + + try: + with open(path, 'rb') as f: + encrypted = f.read() + data = fernet.decrypt(encrypted) + return send_file(BytesIO(data), mimetype='image/webp') + except Exception as e: + app.logger.error(f"Error serving image {safe_fn}: {e}") + abort(500) + +@app.route('/admin/delete/image/', methods=['POST']) +def delete_image(filename): + if not session.get('admin_logged_in'): abort(401) + safe = secure_filename(filename) + path = os.path.join(app.config['UPLOAD_FOLDER'], safe) + try: + if os.path.exists(path): os.remove(path) + db = get_db() + db.execute("DELETE FROM images WHERE id = ?", (safe,)) + db.commit() + flash(f'Image "{safe}" has been deleted.', 'success') + except Exception as e: + flash(f'Error deleting image file: {e}', 'error') + return redirect(url_for('admin_dashboard')) + +@app.route('/admin/delete/paste/', methods=['POST']) +def delete_paste(paste_id): + if not session.get('admin_logged_in'): abort(401) + try: + db = get_db() + db.execute("DELETE FROM pastes WHERE id = ?", (paste_id,)) + db.commit() + flash(f'Paste "{paste_id}" has been deleted.', 'success') + except Exception as e: + flash(f'Error deleting paste: {e}', 'error') + return redirect(url_for('admin_dashboard')) + +# --- API Routes --- +@app.route('/api/upload/image', methods=['POST']) +@limiter.limit("50 per hour") +def api_upload_image(): + if 'file' not in request.files or request.files['file'].filename == '': + return jsonify(error="No file selected"), 400 + + file = request.files['file'] + if file and allowed_file(file.filename): + new_fn = process_and_encrypt_image(file.stream, file.filename, bool(request.form.get('keep_exif'))) + if not new_fn: return jsonify(error="Failed to process image"), 500 + + now = datetime.now() + expiry = now + EXPIRY_MAP.get(request.form.get('expiry', '1h'), timedelta(hours=1)) + pw = request.form.get('password') + pw_hash = generate_password_hash(pw, method='pbkdf2:sha256') if pw else None + mv = request.form.get('max_views') + mv = int(mv) if mv and mv.isdigit() else None + + db = get_db() + db.execute( + 'INSERT INTO images (id, upload_date, expiry_date, password_hash, max_views, view_count) VALUES (?, ?, ?, ?, ?, ?)', + (new_fn, now, expiry, pw_hash, mv, -1) + ) + db.commit() + update_stat('total_api_uploads') + return jsonify(success=True, url=url_for('get_upload', filename=new_fn, _external=True)), 200 + + return jsonify(error="Invalid file type"), 400 + + +@app.route('/api/upload/paste', methods=['POST']) +@limiter.limit("100 per hour") +def api_upload_paste(): + if not request.is_json: return jsonify(error="Request must be JSON"), 400 + + data = request.get_json() + content = data.get('content', '').strip() + if not content: return jsonify(error="Paste content is missing"), 400 + + now = datetime.now() + expiry = now + EXPIRY_MAP.get(data.get('expiry', '1h'), timedelta(hours=1)) + pw = data.get('password') + pw_hash = generate_password_hash(pw, method='pbkdf2:sha256') if pw else None + mv = data.get('max_views') + mv = int(mv) if mv and str(mv).isdigit() else None + + paste_id = uuid.uuid4().hex + encrypted = fernet.encrypt(content.encode('utf-8')) + db = get_db() + db.execute( + 'INSERT INTO pastes (id, content, language, expiry_date, password_hash, max_views, view_count) VALUES (?, ?, ?, ?, ?, ?, ?)', + (paste_id, encrypted, data.get('language', 'text'), expiry, pw_hash, mv, -1) + ) + db.commit() + update_stat('total_api_uploads') + return jsonify(success=True, url=url_for('view_paste', paste_id=paste_id, _external=True)), 200 + + +if __name__ == '__main__': + required_vars = ['SSP_SECRET_KEY', 'SSP_ADMIN_PASSWORD_HASH', 'SSP_ADMIN_URL', 'SSP_ENCRYPTION_KEY'] + missing_vars = [var for var in required_vars if not os.getenv(var)] + if missing_vars: + print(f"FATAL ERROR: Required environment variables are not set: {', '.join(missing_vars)}") + exit(1) + + os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + with app.app_context(): + init_db() + scheduler.add_job(cleanup_expired_content, 'interval', minutes=15) + scheduler.start() + + print(f"Starting Flask app with debug mode: {app.config['FLASK_DEBUG']}") + + # Run the app. Debug mode is controlled by the SSP_FLASK_DEBUG environment variable. + # For production, it's recommended to use a proper WSGI server like Gunicorn or uWSGI. + app.run(debug=app.config['FLASK_DEBUG'], use_reloader=False) \ No newline at end of file diff --git a/static/css/tailwind.css b/static/css/tailwind.css index 11a34c1..36af27f 100644 --- a/static/css/tailwind.css +++ b/static/css/tailwind.css @@ -1,10 +1,10 @@ -/* -! tailwindcss v3.4.1 | MIT License | https://tailwindcss.com -*/ - -/* 1. Preflight (Tailwind's base styles) */ -*,::before,::after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}::before,::after{--tw-content:''}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;font-family:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none} - -/* 2. Utility Classes */ -*,::before,::after{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / .5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / .5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: } -.h-6{height:1.5rem}.h-12{height:3rem}.h-20{height:5rem}.h-24{height:6rem}.w-6{width:1.5rem}.w-12{width:3rem}.w-20{width:5rem}.w-24{width:6rem}.w-full{width:100%}.max-w-2xl{max-width:42rem}.max-w-4xl{max-w:56rem}.flex-shrink-0{flex-shrink:0}.mx-auto{margin-left:auto;margin-right:auto}.my-4{margin-top:1rem;margin-bottom:1rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-8{margin-top:2rem}.mt-16{margin-top:4rem}.inline-block{display:inline-block}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.list-disc{list-style-type:disc}.list-inside{list-style-position:inside}.min-h-screen{min-height:100vh}.max-h-60vh{max-height:60vh}.object-contain{object-fit:contain}.justify-center{justify-content:center}.items-center{align-items:center}.gap-8{gap:2rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.overflow-x-auto{overflow-x:auto}.overflow-hidden{overflow:hidden}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-gray-600{--tw-border-opacity:1;border-color:rgb(75 85 99 / var(--tw-border-opacity))}.border-gray-700{--tw-border-opacity:1;border-color:rgb(55 65 81 / var(--tw-border-opacity))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81 / var(--tw-bg-opacity))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246 / var(--tw-bg-opacity))}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.pt-6{padding-top:1.5rem}.text-center{text-align:center}.font-sans{font-family:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"}.text-xs{font-size:.75rem;line-height:1rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.font-mono{font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace}.text-white{--tw-text-opacity:1;color:rgb(255 255 255 / var(--tw-text-opacity))}.text-blue-300{--tw-text-opacity:1;color:rgb(147 197 253 / var(--tw-text-opacity))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250 / var(--tw-text-opacity))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219 / var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175 / var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128 / var(--tw-text-opacity))}.shadow-lg{--tw-shadow:0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)}.ring-blue-500{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246 / var(--tw-ring-opacity))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255 / var(--tw-text-opacity))}.hover\:text-blue-300:hover{--tw-text-opacity:1;color:rgb(147 197 253 / var(--tw-text-opacity))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:shadow-outline:focus{box-shadow:0 0 0 3px rgb(96 165 250 / .5);outline:none}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246 / var(--tw-ring-opacity))}@media (min-width: 768px){.md\:grid-cols-3{grid-template-columns:repeat(3, minmax(0, 1fr))}} +/* +! tailwindcss v3.4.1 | MIT License | https://tailwindcss.com +*/ + +/* 1. Preflight (Tailwind's base styles) */ +*,::before,::after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}::before,::after{--tw-content:''}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;font-family:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none} + +/* 2. Utility Classes */ +*,::before,::after{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / .5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }::backdrop{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:rgb(59 130 246 / .5);--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: } +.h-6{height:1.5rem}.h-12{height:3rem}.h-20{height:5rem}.h-24{height:6rem}.w-6{width:1.5rem}.w-12{width:3rem}.w-20{width:5rem}.w-24{width:6rem}.w-full{width:100%}.max-w-2xl{max-width:42rem}.max-w-4xl{max-w:56rem}.flex-shrink-0{flex-shrink:0}.mx-auto{margin-left:auto;margin-right:auto}.my-4{margin-top:1rem;margin-bottom:1rem}.mb-2{margin-bottom:.5rem}.mb-4{margin-bottom:1rem}.mb-6{margin-bottom:1.5rem}.mb-8{margin-bottom:2rem}.mt-1{margin-top:.25rem}.mt-2{margin-top:.5rem}.mt-4{margin-top:1rem}.mt-8{margin-top:2rem}.mt-16{margin-top:4rem}.inline-block{display:inline-block}.block{display:block}.flex{display:flex}.grid{display:grid}.hidden{display:none}.list-disc{list-style-type:disc}.list-inside{list-style-position:inside}.min-h-screen{min-height:100vh}.max-h-60vh{max-height:60vh}.object-contain{object-fit:contain}.justify-center{justify-content:center}.items-center{align-items:center}.gap-8{gap:2rem}.space-y-2>:not([hidden])~:not([hidden]){--tw-space-y-reverse:0;margin-top:calc(.5rem * calc(1 - var(--tw-space-y-reverse)));margin-bottom:calc(.5rem * var(--tw-space-y-reverse))}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem * var(--tw-space-x-reverse));margin-left:calc(.5rem * calc(1 - var(--tw-space-x-reverse)))}.overflow-x-auto{overflow-x:auto}.overflow-hidden{overflow:hidden}.rounded-lg{border-radius:.5rem}.rounded-md{border-radius:.25rem}.border{border-width:1px}.border-b{border-bottom-width:1px}.border-t{border-top-width:1px}.border-dashed{border-style:dashed}.border-gray-600{--tw-border-opacity:1;border-color:rgb(75 85 99 / var(--tw-border-opacity))}.border-gray-700{--tw-border-opacity:1;border-color:rgb(55 65 81 / var(--tw-border-opacity))}.bg-gray-700{--tw-bg-opacity:1;background-color:rgb(55 65 81 / var(--tw-bg-opacity))}.bg-blue-500{--tw-bg-opacity:1;background-color:rgb(59 130 246 / var(--tw-bg-opacity))}.p-2{padding:.5rem}.p-3{padding:.75rem}.p-4{padding:1rem}.p-6{padding:1.5rem}.p-8{padding:2rem}.py-3{padding-top:.75rem;padding-bottom:.75rem}.py-4{padding-top:1rem;padding-bottom:1rem}.py-8{padding-top:2rem;padding-bottom:2rem}.px-4{padding-left:1rem;padding-right:1rem}.px-5{padding-left:1.25rem;padding-right:1.25rem}.px-6{padding-left:1.5rem;padding-right:1.5rem}.pt-6{padding-top:1.5rem}.text-center{text-align:center}.font-sans{font-family:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"}.text-xs{font-size:.75rem;line-height:1rem}.text-xl{font-size:1.25rem;line-height:1.75rem}.text-2xl{font-size:1.5rem;line-height:2rem}.text-3xl{font-size:1.875rem;line-height:2.25rem}.text-4xl{font-size:2.25rem;line-height:2.5rem}.font-bold{font-weight:700}.font-semibold{font-weight:600}.font-mono{font-family:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace}.text-white{--tw-text-opacity:1;color:rgb(255 255 255 / var(--tw-text-opacity))}.text-blue-300{--tw-text-opacity:1;color:rgb(147 197 253 / var(--tw-text-opacity))}.text-blue-400{--tw-text-opacity:1;color:rgb(96 165 250 / var(--tw-text-opacity))}.text-gray-300{--tw-text-opacity:1;color:rgb(209 213 219 / var(--tw-text-opacity))}.text-gray-400{--tw-text-opacity:1;color:rgb(156 163 175 / var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128 / var(--tw-text-opacity))}.shadow-lg{--tw-shadow:0 10px 15px -3px rgb(0 0 0 / .1), 0 4px 6px -4px rgb(0 0 0 / .1);--tw-shadow-colored:0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow)}.outline-none{outline:2px solid transparent;outline-offset:2px}.ring-2{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)}.ring-blue-500{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246 / var(--tw-ring-opacity))}.hover\:text-white:hover{--tw-text-opacity:1;color:rgb(255 255 255 / var(--tw-text-opacity))}.hover\:text-blue-300:hover{--tw-text-opacity:1;color:rgb(147 197 253 / var(--tw-text-opacity))}.focus\:outline-none:focus{outline:2px solid transparent;outline-offset:2px}.focus\:shadow-outline:focus{box-shadow:0 0 0 3px rgb(96 165 250 / .5);outline:none}.focus\:ring-2:focus{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000)}.focus\:ring-blue-500:focus{--tw-ring-opacity:1;--tw-ring-color:rgb(59 130 246 / var(--tw-ring-opacity))}@media (min-width: 768px){.md\:grid-cols-3{grid-template-columns:repeat(3, minmax(0, 1fr))}} diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..7982750 Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/images/stormycloud.svg b/static/images/stormycloud.svg index 0c90962..f28ebae 100644 --- a/static/images/stormycloud.svg +++ b/static/images/stormycloud.svg @@ -1,73 +1,73 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/templates/admin.html b/templates/admin.html index 830c500..a9fca68 100644 --- a/templates/admin.html +++ b/templates/admin.html @@ -1,150 +1,149 @@ - - - - - - Admin Dashboard - I2P Secure Share - - - - - - {% if announcement_enabled and announcement_message %} -
- {{ announcement_message }} - -
- {% endif %} - -
-
-
- - StormyCloud Logo - -

Admin Dashboard

-
- -
- {% if auth_success %} -

Active Images

- {% if images %} -
- - - - - - - - - - - {% for image in images %} - - - - - - - {% endfor %} - -
FilenameExpires On (UTC)Time LeftAction
{{ image[0] }}{{ image[1] }}{{ image[2] }} -
- -
-
-
- {% else %} -

No active images.

- {% endif %} - -

Active Pastes

- {% if pastes %} -
- - - - - - - - - - - - {% for paste in pastes %} - - - - - - - - {% endfor %} - -
IDLanguageExpires On (UTC)Time LeftAction
{{ paste[0] }}{{ paste[1] }}{{ paste[2] }}{{ paste[3] }} -
- -
-
-
- {% else %} -

No active pastes.

- {% endif %} - - {% else %} -

Admin Login

- {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} - - {% endfor %} -
- {% endif %} - {% endwith %} -
-
- - -
-
-
- {% endif %} - -
- -
-
- - + + + + + + Admin Dashboard - I2P Secure Share + + + + + + + {% if announcement_enabled and announcement_message %} +
+ {{ announcement_message }} + +
+ {% endif %} + +
+ + StormyCloud Logo + +

Admin Dashboard

+ + +
+ {% if auth_success %} +

Active Images

+ {% if images %} +
+ + + + + + + + + + + {% for image in images %} + + + + + + + {% endfor %} + +
FilenameExpires On (UTC)Time LeftAction
{{ image[0] }}{{ image[1] }}{{ image[2] }} +
+ +
+
+
+ {% else %} +

No active images.

+ {% endif %} + +

Active Pastes

+ {% if pastes %} +
+ + + + + + + + + + + + {% for paste in pastes %} + + + + + + + + {% endfor %} + +
IDLanguageExpires On (UTC)Time LeftAction
{{ paste[0] }}{{ paste[1] }}{{ paste[2] }}{{ paste[3] }} +
+ +
+
+
+ {% else %} +

No active pastes.

+ {% endif %} + + {% else %} +

Admin Login

+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + + {% endfor %} +
+ {% endif %} + {% endwith %} +
+
+ + +
+
+
+ {% endif %} + +
+ +
+ + + \ No newline at end of file diff --git a/templates/donate.html b/templates/donate.html index bca1306..3ab1aa3 100644 --- a/templates/donate.html +++ b/templates/donate.html @@ -1,64 +1,66 @@ - - - - - - Support the Project - I2P Secure Share - - - - - -{% if announcement_enabled and announcement_message %} -
- {{ announcement_message }} - -
-{% endif %} - -
-
-
- - StormyCloud Logo - -

Support the Service

-
- -
-

This service is developed and maintained for the I2P community free of charge. If you find it valuable, please consider a small donation to help cover server costs and support future development.

- -
-

Monero (XMR)

-
- 45Gtj5tkhs4EsbnV7kkhMCRpbZUdqCQqR5qmLFVLAvbFCYaPL4pFbBkEBLJ7beHqkiJxdTBkPwFsT5EMu5jDrYBHPjQzPuv -
-
- - -
- -
-
- - - + + + + + + Support the Project - I2P Secure Share + + + + + + + +{% if announcement_enabled and announcement_message %} +
+ {{ announcement_message }} + +
+{% endif %} + +
+
+
+ + StormyCloud Logo + +

Support the Service

+
+ +
+

This service is developed and maintained for the I2P community free of charge. If you find it valuable, please consider a small donation to help cover server costs and support future development.

+ +
+

Monero (XMR)

+
+ 45Gtj5tkhs4EsbnV7kkhMCRpbZUdqCQqR5qmLFVLAvbFCYaPL4pFbBkEBLJ7beHqkiJxdTBkPwFsT5EMu5jDrYBHPjQzPuv +
+
+ + +
+ +
+
+ + + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 280d35d..acf9928 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,331 +1,409 @@ - - - - - - I2P Secure Share - - - - - - - {% if announcement_enabled and announcement_message %} -
- {{ announcement_message }} - -
- {% endif %} - -
-
-
- - StormyCloud Logo - -

I2P Secure Share

-

Anonymously share images and text pastes.

-
- -
- - - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} - - {% endfor %} -
- {% endif %} - {% endwith %} - - - -
-
-
-

Upload an Image

-
- - -

Max file size: 10MB. Images are converted to WebP.

-
- -
- - -
-
-
-
- - -
- - - - - - - -
- -
-

Features

-
-

Encrypted at Rest

All uploaded files and pastes are fully encrypted on the server, ensuring your data is protected.

-

Anonymous by Design

Image metadata (EXIF) is stripped and no unnecessary logs are kept. Built for the I2P network.

-

StormyCloud Infrastructure

A fast, reliable, and secure platform dedicated to the privacy of the I2P community.

-
-
- - - -
-
- - - + + + + + + I2P Secure Share + + + + + + + + +{% if announcement_enabled and announcement_message %} +
+ {{ announcement_message }} + +
+{% endif %} + +
+
+
+ + StormyCloud Logo + +

I2P Secure Share

+

Anonymously share images and text pastes.

+
+ +
+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category,message in messages %} + + {% endfor %} +
+ {% endif %} + {% endwith %} + + + +
+
+
+

Upload an Image

+ +
+ + +

Max 10MB; WebP conversion.

+
+ +
+ + +
+ +
+ + + +
+ +
+ + +
+
+ + +
+ + +
+
+ + + + + + + + +
+ + + +
+

Features

+
+
+
+ +
+

Encrypted at Rest

+

All uploaded files and pastes are fully encrypted on the server, ensuring your data is protected.

+
+
+
+ +
+

Anonymous by Design

+

Image metadata (EXIF) is stripped and no unnecessary logs are kept. Built for the I2P network.

+
+
+
+ +
+

StormyCloud Infrastructure

+

A fast, reliable, and secure platform dedicated to the privacy of the I2P community.

+
+
+
+ + +
+
+
+ + + \ No newline at end of file diff --git a/templates/view_image.html b/templates/view_image.html index 3876eb1..4252706 100644 --- a/templates/view_image.html +++ b/templates/view_image.html @@ -1,98 +1,120 @@ - - - - - - Image Uploaded - I2P Secure Share - - - - - - -{% if announcement_enabled and announcement_message %} -
- {{ announcement_message }} - -
-{% endif %} - -
-
-
- - StormyCloud Logo - -

Image Uploaded Successfully

-

Share the link below. The file will be deleted based on the expiration you selected.

-
- -
- -
- Uploaded Image -
- - - - - - - -
-

Find this service useful? Consider supporting its future.

-
-
- - - -
-
- - - - + + + + + + View Image - I2P Secure Share + + + + + + + + {% if announcement_enabled and announcement_message %} +
+ {{ announcement_message }} + +
+ {% endif %} + +
+
+ {% if password_required %} +
+

Enter Password to View Image

+
+
+ + +
+ +
+
+ {% else %} +
+ + StormyCloud Logo + +

View Image

+

Expires in: {{ time_left }}

+
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + + {% endfor %} +
+ {% endif %} + {% endwith %} + +
+ Uploaded Image +
+ + + + +
+

Find this service useful? Consider supporting its future.

+
+ +
+ {% endif %} + + +
+
+ + + + \ No newline at end of file diff --git a/templates/view_paste.html b/templates/view_paste.html index abc2929..dfc8a2a 100644 --- a/templates/view_paste.html +++ b/templates/view_paste.html @@ -1,111 +1,214 @@ - - - - - - Paste Created - I2P Secure Share - - - - - - -{% if announcement_enabled and announcement_message %} -
- {{ announcement_message }} - -
-{% endif %} - -
-
-
- - StormyCloud Logo - -

Paste Created Successfully

-

Share the link below. The paste will be deleted based on the expiration you selected.

-
- -
- -
- {{ highlighted_content|safe }} -
- - - - - - - -
-

Find this service useful? Consider supporting its future.

-
-
- - -
-
- - - - + + + + + + View Paste - I2P Secure Share + + + + + + + + {% if announcement_enabled and announcement_message %} +
+ {{ announcement_message }} + +
+ {% endif %} + +
+
+ {% if password_required %} +
+

Enter Password to View Paste

+
+
+ + +
+ +
+
+ {% else %} +
+ + StormyCloud Logo + +

View Paste

+

Expires in: {{ time_left }}

+
+ +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} +
+ {% for category, message in messages %} + + {% endfor %} +
+ {% endif %} + {% endwith %} + +
+
+ + + +
+
+ +
+ {{ highlighted_content|safe }} +
+ + + + +
+

Find this service useful? Consider supporting its future.

+
+ +
+ {% endif %} + + +
+ + + + + \ No newline at end of file diff --git a/wsgi.py b/wsgi.py deleted file mode 100644 index e497378..0000000 --- a/wsgi.py +++ /dev/null @@ -1,8 +0,0 @@ -# wsgi.py -# This file is the entry point for the Gunicorn WSGI server. -# It imports the 'app' object from your main application file. - -from app import app - -if __name__ == "__main__": - app.run()