4 Commits

Author SHA1 Message Date
StormyCloud
1a42d0b988 added CSRF 2025-08-17 20:45:38 -05:00
Stormycloud
599e331754 added security updates 2025-08-17 20:06:17 -05:00
5abc6c80ec Update templates/index.html 2025-06-25 09:47:15 -04:00
9ff1e0b0b9 Update templates/index.html 2025-06-25 09:35:06 -04:00
5 changed files with 538 additions and 422 deletions

137
app.py
View File

@@ -3,6 +3,9 @@
import os import os
import uuid import uuid
import sqlite3 import sqlite3
import mimetypes
import secrets
import re
from datetime import datetime, timedelta from datetime import datetime, timedelta
from io import BytesIO from io import BytesIO
@@ -13,6 +16,8 @@ from flask import (
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
from werkzeug.middleware.proxy_fix import ProxyFix from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
# Note: For production, consider adding Flask-WTF for CSRF protection
from flask_wtf.csrf import CSRFProtect
from PIL import Image from PIL import Image
from pygments import highlight from pygments import highlight
@@ -30,6 +35,9 @@ load_dotenv()
app = Flask(__name__) app = Flask(__name__)
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1) app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
# Note: CSRF protection would be initialized here if Flask-WTF is available
csrf = CSRFProtect(app)
app.config['SECRET_KEY'] = os.getenv('SSP_SECRET_KEY') app.config['SECRET_KEY'] = os.getenv('SSP_SECRET_KEY')
app.config['ADMIN_PASSWORD_HASH'] = os.getenv('SSP_ADMIN_PASSWORD_HASH') app.config['ADMIN_PASSWORD_HASH'] = os.getenv('SSP_ADMIN_PASSWORD_HASH')
@@ -44,9 +52,19 @@ app.config['UPLOAD_FOLDER'] = os.getenv('SSP_UPLOAD_FOLDER', 'uploads')
app.config['DATABASE_PATH'] = os.getenv('SSP_DATABASE_PATH', 'database.db') app.config['DATABASE_PATH'] = os.getenv('SSP_DATABASE_PATH', 'database.db')
app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024 # 10MB app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024 # 10MB
app.config['FLASK_DEBUG'] = os.getenv('SSP_FLASK_DEBUG', 'False').lower() in ('true', '1', 't') # Ensure debug mode is never enabled in production
debug_env = os.getenv('SSP_FLASK_DEBUG', 'False').lower()
app.config['FLASK_DEBUG'] = debug_env in ('true', '1', 't') and os.getenv('FLASK_ENV') != 'production'
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'tiff'} ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'tiff'}
ALLOWED_MIME_TYPES = {
'image/png', 'image/jpeg', 'image/gif', 'image/webp',
'image/bmp', 'image/x-icon', 'image/tiff'
}
# Maximum filename length and allowed characters
MAX_FILENAME_LENGTH = 255
SAFE_FILENAME_REGEX = re.compile(r'^[a-zA-Z0-9._-]+$')
# --- Rate Limiting (I2P-aware) --- # --- Rate Limiting (I2P-aware) ---
def i2p_key_func(): def i2p_key_func():
@@ -146,13 +164,70 @@ def cleanup_expired_content():
try: try:
os.remove(path) os.remove(path)
except OSError as e: except OSError as e:
app.logger.error(f"Error removing expired image file {path}: {e}") app.logger.error(f"Error removing expired image file: {sanitize_error_message(e)}")
cur.execute("DELETE FROM images WHERE id = ?", (img_id,)) cur.execute("DELETE FROM images WHERE id = ?", (img_id,))
conn.commit() conn.commit()
conn.close() conn.close()
# --- Utility Functions --- # --- Utility Functions ---
def sanitize_error_message(error_msg):
"""Sanitize error messages to prevent information disclosure"""
# Remove file paths and sensitive information
sanitized = re.sub(r'/[\w/.-]+', '[path]', str(error_msg))
sanitized = re.sub(r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b', '[ip]', sanitized)
return sanitized
def secure_session_key(prefix, identifier):
"""Generate cryptographically secure session keys"""
random_token = secrets.token_hex(16)
return f"{prefix}_{identifier}_{random_token}"
def validate_filename_security(filename):
"""Enhanced filename validation for security"""
if not filename or len(filename) > MAX_FILENAME_LENGTH:
return False
# Check for path traversal attempts
if '..' in filename or '/' in filename or '\\' in filename:
return False
# Check for null bytes and control characters
if '\x00' in filename or any(ord(c) < 32 for c in filename if c != '\t'):
return False
# Ensure filename matches safe pattern
if not SAFE_FILENAME_REGEX.match(filename):
return False
return True
def validate_file_content(file_stream, filename):
"""Validate file content matches expected image format"""
try:
# Reset stream position
file_stream.seek(0)
# Check MIME type
mime_type, _ = mimetypes.guess_type(filename)
if mime_type not in ALLOWED_MIME_TYPES:
return False
# Try to open as image to verify it's actually an image
file_stream.seek(0)
img = Image.open(file_stream)
img.verify() # Verify it's a valid image
# Reset stream for later use
file_stream.seek(0)
return True
except Exception:
return False
def allowed_file(fn): def allowed_file(fn):
"""Enhanced file validation with security checks"""
if not fn or not validate_filename_security(fn):
return False
return '.' in fn and fn.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS return '.' in fn and fn.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def get_time_left(expiry_str): def get_time_left(expiry_str):
@@ -194,7 +269,7 @@ def process_and_encrypt_image(stream, orig_fn, keep_exif=False):
f.write(encrypted) f.write(encrypted)
return new_fn return new_fn
except Exception as e: except Exception as e:
app.logger.error(f"Image processing failed ({orig_fn}): {e}") app.logger.error(f"Image processing failed: {sanitize_error_message(e)}")
return None return None
@app.context_processor @app.context_processor
@@ -242,7 +317,7 @@ def healthz():
conn.close() conn.close()
db_status = "ok" db_status = "ok"
except Exception as e: except Exception as e:
app.logger.error(f"Health check DB error: {e}") app.logger.error(f"Health check DB error: {sanitize_error_message(e)}")
db_status = "error" db_status = "error"
sched_status = "running" if scheduler.running and scheduler.state == 1 else "stopped" sched_status = "running" if scheduler.running and scheduler.state == 1 else "stopped"
return jsonify(database=db_status, scheduler=sched_status) return jsonify(database=db_status, scheduler=sched_status)
@@ -299,7 +374,7 @@ def upload_image():
return redirect(url_for('index', _anchor='image')) return redirect(url_for('index', _anchor='image'))
file = request.files['file'] file = request.files['file']
if file and allowed_file(file.filename): if file and allowed_file(file.filename) and validate_file_content(file.stream, file.filename):
keep_exif = bool(request.form.get('keep_exif')) keep_exif = bool(request.form.get('keep_exif'))
new_fn = process_and_encrypt_image(file.stream, file.filename, keep_exif) new_fn = process_and_encrypt_image(file.stream, file.filename, keep_exif)
if not new_fn: if not new_fn:
@@ -335,6 +410,11 @@ def upload_paste():
if not content: if not content:
flash('Paste content cannot be empty.', 'error') flash('Paste content cannot be empty.', 'error')
return redirect(url_for('index', _anchor='paste')) return redirect(url_for('index', _anchor='paste'))
# Input validation and size limits
if len(content) > 1024 * 1024: # 1MB limit for pastes
flash('Paste content is too large (max 1MB).', 'error')
return redirect(url_for('index', _anchor='paste'))
now = datetime.now() now = datetime.now()
expiry = now + EXPIRY_MAP.get(request.form.get('expiry', '1h'), timedelta(hours=1)) expiry = now + EXPIRY_MAP.get(request.form.get('expiry', '1h'), timedelta(hours=1))
@@ -369,10 +449,11 @@ def view_image(filename):
abort(404) abort(404)
pw_hash = row['password_hash'] pw_hash = row['password_hash']
if pw_hash and not session.get(f'unlocked_image_{filename}'): session_key = f'unlocked_image_{filename}'
if pw_hash and not session.get(session_key):
if request.method == 'POST': if request.method == 'POST':
if check_password_hash(pw_hash, request.form.get('password', '')): if check_password_hash(pw_hash, request.form.get('password', '')):
session[f'unlocked_image_{filename}'] = True session[session_key] = secrets.token_hex(16)
return redirect(url_for('view_image', filename=filename)) return redirect(url_for('view_image', filename=filename))
flash('Incorrect password.', 'error') flash('Incorrect password.', 'error')
return render_template('view_image.html', password_required=True, filename=filename) return render_template('view_image.html', password_required=True, filename=filename)
@@ -396,10 +477,11 @@ def view_paste(paste_id):
abort(404) abort(404)
pw_hash = row['password_hash'] pw_hash = row['password_hash']
if pw_hash and not session.get(f'unlocked_paste_{paste_id}'): session_key = f'unlocked_paste_{paste_id}'
if pw_hash and not session.get(session_key):
if request.method == 'POST': if request.method == 'POST':
if check_password_hash(pw_hash, request.form.get('password', '')): if check_password_hash(pw_hash, request.form.get('password', '')):
session[f'unlocked_paste_{paste_id}'] = True session[session_key] = secrets.token_hex(16)
return redirect(url_for('view_paste', paste_id=paste_id)) return redirect(url_for('view_paste', paste_id=paste_id))
flash('Incorrect password.', 'error') flash('Incorrect password.', 'error')
return render_template('view_paste.html', password_required=True, paste_id=paste_id) return render_template('view_paste.html', password_required=True, paste_id=paste_id)
@@ -463,8 +545,24 @@ def paste_raw(paste_id):
@app.route('/uploads/<filename>') @app.route('/uploads/<filename>')
def get_upload(filename): def get_upload(filename):
# Enhanced security validation
if not validate_filename_security(filename):
abort(404)
safe_fn = secure_filename(filename) safe_fn = secure_filename(filename)
path = os.path.join(app.config['UPLOAD_FOLDER'], safe_fn)
# Additional path traversal protection
if safe_fn != filename or not safe_fn:
abort(404)
# Ensure the file path is within the upload directory
upload_dir = os.path.abspath(app.config['UPLOAD_FOLDER'])
file_path = os.path.abspath(os.path.join(upload_dir, safe_fn))
if not file_path.startswith(upload_dir + os.sep):
abort(404)
path = file_path
db = get_db() db = get_db()
row = db.execute("SELECT * FROM images WHERE id = ?", (safe_fn,)).fetchone() row = db.execute("SELECT * FROM images WHERE id = ?", (safe_fn,)).fetchone()
@@ -494,7 +592,7 @@ def get_upload(filename):
data = fernet.decrypt(encrypted) data = fernet.decrypt(encrypted)
return send_file(BytesIO(data), mimetype='image/webp') return send_file(BytesIO(data), mimetype='image/webp')
except Exception as e: except Exception as e:
app.logger.error(f"Error serving image {safe_fn}: {e}") app.logger.error(f"Error serving image: {sanitize_error_message(e)}")
abort(500) abort(500)
@app.route('/admin/delete/image/<filename>', methods=['POST']) @app.route('/admin/delete/image/<filename>', methods=['POST'])
@@ -509,7 +607,8 @@ def delete_image(filename):
db.commit() db.commit()
flash(f'Image "{safe}" has been deleted.', 'success') flash(f'Image "{safe}" has been deleted.', 'success')
except Exception as e: except Exception as e:
flash(f'Error deleting image file: {e}', 'error') flash('Error deleting image file.', 'error')
app.logger.error(f'Error deleting image file: {sanitize_error_message(e)}')
return redirect(url_for('admin_dashboard')) return redirect(url_for('admin_dashboard'))
@app.route('/admin/delete/paste/<paste_id>', methods=['POST']) @app.route('/admin/delete/paste/<paste_id>', methods=['POST'])
@@ -521,18 +620,20 @@ def delete_paste(paste_id):
db.commit() db.commit()
flash(f'Paste "{paste_id}" has been deleted.', 'success') flash(f'Paste "{paste_id}" has been deleted.', 'success')
except Exception as e: except Exception as e:
flash(f'Error deleting paste: {e}', 'error') flash('Error deleting paste.', 'error')
app.logger.error(f'Error deleting paste: {sanitize_error_message(e)}')
return redirect(url_for('admin_dashboard')) return redirect(url_for('admin_dashboard'))
# --- API Routes --- # --- API Routes ---
@app.route('/api/upload/image', methods=['POST']) @app.route('/api/upload/image', methods=['POST'])
@limiter.limit("50 per hour") @limiter.limit("50 per hour")
@csrf.exempt
def api_upload_image(): def api_upload_image():
if 'file' not in request.files or request.files['file'].filename == '': if 'file' not in request.files or request.files['file'].filename == '':
return jsonify(error="No file selected"), 400 return jsonify(error="No file selected"), 400
file = request.files['file'] file = request.files['file']
if file and allowed_file(file.filename): if file and allowed_file(file.filename) and validate_file_content(file.stream, file.filename):
new_fn = process_and_encrypt_image(file.stream, file.filename, bool(request.form.get('keep_exif'))) 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 if not new_fn: return jsonify(error="Failed to process image"), 500
@@ -557,12 +658,20 @@ def api_upload_image():
@app.route('/api/upload/paste', methods=['POST']) @app.route('/api/upload/paste', methods=['POST'])
@limiter.limit("100 per hour") @limiter.limit("100 per hour")
@csrf.exempt
def api_upload_paste(): def api_upload_paste():
if not request.is_json: return jsonify(error="Request must be JSON"), 400 if not request.is_json: return jsonify(error="Request must be JSON"), 400
data = request.get_json() data = request.get_json()
if not isinstance(data, dict):
return jsonify(error="Invalid JSON data"), 400
content = data.get('content', '').strip() content = data.get('content', '').strip()
if not content: return jsonify(error="Paste content is missing"), 400 if not content: return jsonify(error="Paste content is missing"), 400
# Input validation and size limits
if len(content) > 1024 * 1024: # 1MB limit for pastes
return jsonify(error="Paste content is too large (max 1MB)"), 400
now = datetime.now() now = datetime.now()
expiry = now + EXPIRY_MAP.get(data.get('expiry', '1h'), timedelta(hours=1)) expiry = now + EXPIRY_MAP.get(data.get('expiry', '1h'), timedelta(hours=1))

View File

@@ -60,6 +60,7 @@
<td class="font-mono text-sm">{{ image[2] }}</td> <td class="font-mono text-sm">{{ image[2] }}</td>
<td> <td>
<form action="{{ url_for('delete_image', filename=image[0]) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this image?');"> <form action="{{ url_for('delete_image', filename=image[0]) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this image?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn-danger text-white font-bold py-1 px-3 rounded text-sm">Delete</button> <button type="submit" class="btn-danger text-white font-bold py-1 px-3 rounded text-sm">Delete</button>
</form> </form>
</td> </td>
@@ -94,6 +95,7 @@
<td class="font-mono text-sm">{{ paste[3] }}</td> <td class="font-mono text-sm">{{ paste[3] }}</td>
<td> <td>
<form action="{{ url_for('delete_paste', paste_id=paste[0]) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this paste?');"> <form action="{{ url_for('delete_paste', paste_id=paste[0]) }}" method="POST" onsubmit="return confirm('Are you sure you want to delete this paste?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<button type="submit" class="btn-danger text-white font-bold py-1 px-3 rounded text-sm">Delete</button> <button type="submit" class="btn-danger text-white font-bold py-1 px-3 rounded text-sm">Delete</button>
</form> </form>
</td> </td>
@@ -120,6 +122,7 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<form method="POST" action="{{ url_for('admin_dashboard') }}"> <form method="POST" action="{{ url_for('admin_dashboard') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-6"> <div class="mb-6">
<label for="password" class="block text-gray-300 text-sm font-bold mb-2">Password:</label> <label for="password" class="block text-gray-300 text-sm font-bold mb-2">Password:</label>
<input type="password" name="password" id="password" class="w-full p-2 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500" required> <input type="password" name="password" id="password" class="w-full p-2 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500" required>

View File

@@ -1,409 +1,411 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"/> <meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>I2P Secure Share</title> <title>I2P Secure Share</title>
<link rel="stylesheet" href="/static/css/tailwind.css"/> <link rel="stylesheet" href="/static/css/tailwind.css"/>
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon"> <link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
<style> <style>
body { background-color: #1a202c; color: #cbd5e0; } body { background-color: #1a202c; color: #cbd5e0; }
.content-container { background-color: #2d3748; border:1px solid #4a5568; border-radius:0.5rem; } .content-container { background-color: #2d3748; border:1px solid #4a5568; border-radius:0.5rem; }
.tab { border-bottom:2px solid transparent; cursor:pointer; } .tab { border-bottom:2px solid transparent; cursor:pointer; }
.tab.active { border-bottom-color:#63b3ed; color:#ffffff; } .tab.active { border-bottom-color:#63b3ed; color:#ffffff; }
.btn { background-color:#4299e1; transition:background-color .3s ease; } .btn { background-color:#4299e1; transition:background-color .3s ease; }
.btn:hover { background-color:#3182ce; } .btn:hover { background-color:#3182ce; }
select,textarea,input[type="text"],input[type="password"],input[type="number"] { select,textarea,input[type="text"],input[type="password"],input[type="number"] {
background-color:#4a5568; border:1px solid #718096; color:#cbd5e0; background-color:#4a5568; border:1px solid #718096; color:#cbd5e0;
} }
.alert-success { background-color:#38a169; } .alert-success { background-color:#38a169; }
.alert-error { background-color:#e53e3e; } .alert-error { background-color:#e53e3e; }
.announcement-bar { background-color:#2563eb; border-bottom:1px solid #1e3a8a; } .announcement-bar { background-color:#2563eb; border-bottom:1px solid #1e3a8a; }
.reveal { display:none; } .reveal { display:none; }
input[type="file"] { input[type="file"] {
width:100%; padding:0.5rem 1rem; border-radius:0.375rem; width:100%; padding:0.5rem 1rem; border-radius:0.375rem;
background-color:#4a5568; border:1px solid #718096; color:#cbd5e0; cursor:pointer; background-color:#4a5568; border:1px solid #718096; color:#cbd5e0; cursor:pointer;
} }
input[type="file"]::file-selector-button { input[type="file"]::file-selector-button {
background-color:#2d3748; color:#cbd5e0; border:none; background-color:#2d3748; color:#cbd5e0; border:none;
padding:0.5rem 1rem; margin-right:1rem; border-radius:0.375rem; cursor:pointer; padding:0.5rem 1rem; margin-right:1rem; border-radius:0.375rem; cursor:pointer;
transition:background-color .3s ease; transition:background-color .3s ease;
} }
input[type="file"]::file-selector-button:hover { background-color:#3a4a5a; } input[type="file"]::file-selector-button:hover { background-color:#3a4a5a; }
.stat-card { .stat-card {
background-color:#2d3748; border:1px solid #4a5568; border-radius:0.5rem; background-color:#2d3748; border:1px solid #4a5568; border-radius:0.5rem;
} }
.stat-value { color:#63b3ed; } .stat-value { color:#63b3ed; }
.label-with-icon { .label-with-icon {
display:inline-flex; align-items:center; gap:0.5rem; font-size:0.875rem; color:#cbd5e0; display:inline-flex; align-items:center; gap:0.5rem; font-size:0.875rem; color:#cbd5e0;
} }
.label-with-icon svg { .label-with-icon svg {
width:1rem; height:1rem; color:#4299e1; flex-shrink:0; width:1rem; height:1rem; color:#4299e1; flex-shrink:0;
} }
.feature-card { .feature-card {
background-color: #2d3748; background-color: #2d3748;
border: 1px solid #4a5568; border: 1px solid #4a5568;
border-radius: 0.5rem; border-radius: 0.5rem;
} }
/* API Docs Styling */ /* API Docs Styling */
.docs-container h3 { .docs-container h3 {
font-size: 1.5rem; font-size: 1.5rem;
font-weight: 600; font-weight: 600;
color: #ffffff; color: #ffffff;
margin-top: 2rem; margin-top: 2rem;
margin-bottom: 1rem; margin-bottom: 1rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
border-bottom: 1px solid #4a5568; border-bottom: 1px solid #4a5568;
} }
.docs-container p { margin-bottom: 1rem; color: #a0aec0; } .docs-container p { margin-bottom: 1rem; color: #a0aec0; }
.docs-container ul { list-style-position: inside; margin-bottom: 1rem; } .docs-container ul { list-style-position: inside; margin-bottom: 1rem; }
.docs-container li { margin-bottom: 0.5rem; color: #cbd5e0;} .docs-container li { margin-bottom: 0.5rem; color: #cbd5e0;}
.docs-container code { .docs-container code {
background-color:#1a202c; background-color:#1a202c;
color:#f7fafc; color:#f7fafc;
padding:0.2rem 0.4rem; padding:0.2rem 0.4rem;
border-radius:0.25rem; border-radius:0.25rem;
font-family:monospace; font-family:monospace;
font-size: 0.875rem; font-size: 0.875rem;
} }
.docs-container pre { .docs-container pre {
background-color:#1a202c; background-color:#1a202c;
padding:1rem; padding:1rem;
border-radius:0.5rem; border-radius:0.5rem;
overflow-x:auto; overflow-x:auto;
color:#f7fafc; color:#f7fafc;
font-family:monospace; font-family:monospace;
} }
</style> </style>
<noscript> <noscript>
<style> <style>
.tab-nav-container{display:none;} .tab-nav-container{display:none;}
.tab-content{display:none!important;} .tab-content{display:none!important;}
#image-form,#paste-form{display:block!important;margin-bottom:2rem;} #image-form,#paste-form{display:block!important;margin-bottom:2rem;}
#api-docs,#stats-content,#tos-content,.features-section{display:none!important;} #api-docs,#stats-content,#tos-content,.features-section{display:none!important;}
@media(min-width:1024px){.noscript-forms-container{display:flex;gap:1.5rem;} .noscript-forms-container>div{flex:1;}} @media(min-width:1024px){.noscript-forms-container{display:flex;gap:1.5rem;} .noscript-forms-container>div{flex:1;}}
.reveal{display:block!important;} .reveal{display:block!important;}
</style> </style>
</noscript> </noscript>
</head> </head>
<body class="font-sans"> <body class="font-sans">
{% if announcement_enabled and announcement_message %} {% if announcement_enabled and announcement_message %}
<div id="announcement-bar" class="announcement-bar text-white text-center p-2 relative shadow-lg"> <div id="announcement-bar" class="announcement-bar text-white text-center p-2 relative shadow-lg">
<span>{{ announcement_message }}</span> <span>{{ announcement_message }}</span>
<button id="close-announcement" <button id="close-announcement"
class="absolute top-0 right-0 mt-2 mr-4 text-white hover:text-gray-200 text-2xl leading-none">&times;</button> class="absolute top-0 right-0 mt-2 mr-4 text-white hover:text-gray-200 text-2xl leading-none">&times;</button>
</div> </div>
{% endif %} {% endif %}
<div class="flex items-center justify-center min-h-screen py-8"> <div class="flex items-center justify-center min-h-screen py-8">
<div id="main-container" class="w-full max-w-2xl mx-auto p-4"> <div id="main-container" class="w-full max-w-2xl mx-auto p-4">
<header class="text-center mb-8"> <header class="text-center mb-8">
<a href="/" class="inline-block mb-4"> <a href="/" class="inline-block mb-4">
<img src="/static/images/stormycloud.svg" alt="StormyCloud Logo" style="width:350px; max-width:100%;" class="mx-auto"/> <img src="/static/images/stormycloud.svg" alt="StormyCloud Logo" style="width:350px; max-width:100%;" class="mx-auto"/>
</a> </a>
<h1 class="text-4xl font-bold text-white">I2P Secure Share</h1> <h1 class="text-4xl font-bold text-white">I2P Secure Share</h1>
<p class="text-gray-400">Anonymously share images and text pastes.</p> <p class="text-gray-400">Anonymously share images and text pastes.</p>
</header> </header>
<main> <main>
<div id="js-error-container" <div id="js-error-container"
class="hidden alert-error text-white p-3 rounded-md shadow-lg mb-4" class="hidden alert-error text-white p-3 rounded-md shadow-lg mb-4"
role="alert"></div> role="alert"></div>
{% with messages = get_flashed_messages(with_categories=true) %} {% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %} {% if messages %}
<div class="mb-4"> <div class="mb-4">
{% for category,message in messages %} {% for category,message in messages %}
<div class="alert-{{category}} text-white p-3 rounded-md shadow-lg" <div class="alert-{{category}} text-white p-3 rounded-md shadow-lg"
role="alert">{{ message }}</div> role="alert">{{ message }}</div>
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% endif %}
{% endwith %} {% endwith %}
<div class="mb-4 border-b border-gray-700 tab-nav-container"> <div class="mb-4 border-b border-gray-700 tab-nav-container">
<nav class="flex -mb-px" id="tab-nav"> <nav class="flex -mb-px" id="tab-nav">
<a href="#image" <a href="#image"
class="tab active text-gray-300 py-4 px-6 block hover:text-white">Image Uploader</a> class="tab active text-gray-300 py-4 px-6 block hover:text-white">Image Uploader</a>
<a href="#paste" <a href="#paste"
class="tab text-gray-300 py-4 px-6 block hover:text-white">Pastebin</a> class="tab text-gray-300 py-4 px-6 block hover:text-white">Pastebin</a>
<a href="#api" <a href="#api"
class="tab text-gray-300 py-4 px-6 block hover:text-white">API</a> class="tab text-gray-300 py-4 px-6 block hover:text-white">API</a>
<a href="#stats" <a href="#stats"
class="tab text-gray-300 py-4 px-6 block hover:text-white">Stats</a> class="tab text-gray-300 py-4 px-6 block hover:text-white">Stats</a>
<a href="#tos" <a href="#tos"
class="tab text-gray-300 py-4 px-6 block hover:text-white">Terms</a> class="tab text-gray-300 py-4 px-6 block hover:text-white">Terms</a>
</nav> </nav>
</div> </div>
<div class="noscript-forms-container"> <div class="noscript-forms-container">
<div id="image-form" class="content-container rounded-lg p-8 shadow-lg tab-content"> <div id="image-form" class="content-container rounded-lg p-8 shadow-lg tab-content">
<form action="/upload/image" method="POST" enctype="multipart/form-data"> <form action="/upload/image" method="POST" enctype="multipart/form-data">
<h2 class="text-2xl font-semibold mb-6 text-white">Upload an Image</h2> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<h2 class="text-2xl font-semibold mb-6 text-white">Upload an Image</h2>
<div class="mb-6">
<label for="image-file" class="block text-gray-300 text-sm font-bold mb-2">Image File:</label> <div class="mb-6">
<input type="file" name="file" id="image-file" required> <label for="image-file" class="block text-gray-300 text-sm font-bold mb-2">Image File:</label>
<p class="text-xs text-gray-500 mt-1">Max 10MB; WebP conversion.</p> <input type="file" name="file" id="image-file" required>
</div> <p class="text-xs text-gray-500 mt-1">Max 10MB; WebP conversion.</p>
</div>
<div class="mb-6">
<label for="image-expiry" class="block text-gray-300 text-sm font-bold mb-2">Delete after:</label> <div class="mb-6">
<select name="expiry" id="image-expiry" class="w-full p-2 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> <label for="image-expiry" class="block text-gray-300 text-sm font-bold mb-2">Delete after:</label>
<option value="15m">15 minutes</option> <select name="expiry" id="image-expiry" class="w-full p-2 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="1h" selected>1 hour</option> <option value="15m">15 minutes</option>
<option value="2h">2 hours</option> <option value="1h" selected>1 hour</option>
<option value="4h">4 hours</option> <option value="2h">2 hours</option>
<option value="8h">8 hours</option> <option value="4h">4 hours</option>
<option value="12h">12 hours</option> <option value="8h">8 hours</option>
<option value="24h">24 hours</option> <option value="12h">12 hours</option>
<option value="48h">48 hours</option> <option value="24h">24 hours</option>
</select> <option value="48h">48 hours</option>
</div> </select>
</div>
<div class="mb-6 text-gray-300" style="display:grid;grid-template-columns:repeat(3,1fr);gap:1.5rem;align-items:start;">
<label class="label-with-icon"> <div class="mb-6 text-gray-300" style="display:grid;grid-template-columns:repeat(3,1fr);gap:1.5rem;align-items:start;">
<input type="checkbox" name="keep_exif"> <label class="label-with-icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6M7 7h10M4 6h16M4 6a2 2 0 012-2h8l2 2h6a2 2 0 012 2v12a2 2 0 01-2 2H6a2 2 0 01-2-2V6z"/></svg> <input type="checkbox" name="keep_exif">
<span>Keep EXIF Data</span> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6M7 7h10M4 6h16M4 6a2 2 0 012-2h8l2 2h6a2 2 0 012 2v12a2 2 0 01-2 2H6a2 2 0 01-2-2V6z"/></svg>
</label> <span>Keep EXIF Data</span>
<label class="label-with-icon"> </label>
<input type="checkbox" id="image-pw-protect" name="password_protect"> <label class="label-with-icon">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 11c1.657 0 3-1.343 3-3V6a3 3 0 10-6 0v2c0 1.657 1.343 3 3 3z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 11h14a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2v-8a2 2 0 012-2z"/></svg> <input type="checkbox" id="image-pw-protect" name="password_protect">
<span>Password</span> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 11c1.657 0 3-1.343 3-3V6a3 3 0 10-6 0v2c0 1.657 1.343 3 3 3z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 11h14a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2v-8a2 2 0 012-2z"/></svg>
</label> <span>Password</span>
<label class="label-with-icon" title="Removed after this many successful views"> </label>
<input type="checkbox" id="image-views-protect" name="views_protect"> <label class="label-with-icon" title="Removed after this many successful views">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg> <input type="checkbox" id="image-views-protect" name="views_protect">
<span>Max Views</span> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</label> <span>Max Views</span>
</div> </label>
</div>
<div id="image-pw-options" class="reveal mb-6">
<label for="image-password" class="block text-gray-300 text-sm font-bold mb-1">Password:</label> <div id="image-pw-options" class="reveal mb-6">
<input type="password" name="password" id="image-password" class="w-full p-2 rounded-md border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"> <label for="image-password" class="block text-gray-300 text-sm font-bold mb-1">Password:</label>
</div> <input type="password" name="password" id="image-password" class="w-full p-2 rounded-md border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
<div id="image-views-options" class="reveal mb-6"> </div>
<label for="image-max-views" class="block text-gray-300 text-sm font-bold mb-1">Max views:</label> <div id="image-views-options" class="reveal mb-6">
<input type="number" name="max_views" id="image-max-views" min="1" class="w-full p-2 rounded-md border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"> <label for="image-max-views" class="block text-gray-300 text-sm font-bold mb-1">Max views:</label>
</div> <input type="number" name="max_views" id="image-max-views" min="1" class="w-full p-2 rounded-md border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<button type="submit" class="btn w-full text-white font-bold py-3 px-5 rounded-md focus:shadow-outline">Upload Image</button>
</form> <button type="submit" class="btn w-full text-white font-bold py-3 px-5 rounded-md focus:shadow-outline">Upload Image</button>
</div> </form>
</div>
<div id="paste-form" class="content-container rounded-lg p-8 shadow-lg hidden tab-content">
<form action="/upload/paste" method="POST"> <div id="paste-form" class="content-container rounded-lg p-8 shadow-lg hidden tab-content">
<h2 class="text-2xl font-semibold mb-6 text-white">Create a Paste</h2> <form action="/upload/paste" method="POST">
<div class="mb-6"> <input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<label for="paste-content" class="block text-gray-300 text-sm font-bold mb-2">Paste Content:</label> <h2 class="text-2xl font-semibold mb-6 text-white">Create a Paste</h2>
<textarea name="content" id="paste-content" rows="10" class="w-full p-2 rounded-md font-mono focus:outline-none focus:ring-2 focus:ring-blue-500" required></textarea> <div class="mb-6">
</div> <label for="paste-content" class="block text-gray-300 text-sm font-bold mb-2">Paste Content:</label>
<div class="mb-6"> <textarea name="content" id="paste-content" rows="10" class="w-full p-2 rounded-md font-mono focus:outline-none focus:ring-2 focus:ring-blue-500" required></textarea>
<label for="paste-language" class="block text-gray-300 text-sm font-bold mb-2">Language:</label> </div>
<select name="language" id="paste-language" class="w-full p-2 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> <div class="mb-6">
<option value="text">Plain Text</option> <label for="paste-language" class="block text-gray-300 text-sm font-bold mb-2">Language:</label>
{% for lang in languages %} <select name="language" id="paste-language" class="w-full p-2 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="{{ lang }}">{{ lang|capitalize }}</option> <option value="text">Plain Text</option>
{% endfor %} {% for lang in languages %}
</select> <option value="{{ lang }}">{{ lang|capitalize }}</option>
</div> {% endfor %}
<div class="mb-6"> </select>
<label for="paste-expiry" class="block text-gray-300 text-sm font-bold mb-2">Delete after:</label> </div>
<select name="expiry" id="paste-expiry" class="w-full p-2 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"> <div class="mb-6">
<option value="15m">15 minutes</option> <label for="paste-expiry" class="block text-gray-300 text-sm font-bold mb-2">Delete after:</label>
<option value="1h" selected>1 hour</option> <select name="expiry" id="paste-expiry" class="w-full p-2 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="2h">2 hours</option> <option value="15m">15 minutes</option>
<option value="4h">4 hours</option> <option value="1h" selected>1 hour</option>
<option value="8h">8 hours</option> <option value="2h">2 hours</option>
<option value="12h">12 hours</option> <option value="4h">4 hours</option>
<option value="24h">24 hours</option> <option value="8h">8 hours</option>
<option value="48h">48 hours</option> <option value="12h">12 hours</option>
</select> <option value="24h">24 hours</option>
</div> <option value="48h">48 hours</option>
<div class="mb-6 text-gray-300" style="display:grid;grid-template-columns:repeat(2,1fr);gap:2rem;align-items:start;"> </select>
<label class="label-with-icon"> </div>
<input type="checkbox" id="paste-pw-protect" name="password_protect"> <div class="mb-6 text-gray-300" style="display:grid;grid-template-columns:repeat(2,1fr);gap:2rem;align-items:start;">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 11c1.657 0 3-1.343 3-3V6a3 3 0 10-6 0v2c0 1.657 1.343 3 3 3z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 11h14a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2v-8a2 2 0 012-2z"/></svg> <label class="label-with-icon">
<span>Password</span> <input type="checkbox" id="paste-pw-protect" name="password_protect">
</label> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 11c1.657 0 3-1.343 3-3V6a3 3 0 10-6 0v2c0 1.657 1.343 3 3 3z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 11h14a2 2 0 012 2v8a2 2 0 01-2 2H5a2 2 0 01-2-2v-8a2 2 0 012-2z"/></svg>
<label class="label-with-icon" title="Removed after this many successful views"> <span>Password</span>
<input type="checkbox" id="paste-views-protect" name="views_protect"> </label>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg> <label class="label-with-icon" title="Removed after this many successful views">
<span>Max Views</span> <input type="checkbox" id="paste-views-protect" name="views_protect">
</label> <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.477 0 8.268 2.943 9.542 7-1.274 4.057-5.065 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/></svg>
</div> <span>Max Views</span>
<div id="paste-pw-options" class="reveal mb-6"> </label>
<label for="paste-password" class="block text-gray-300 text-sm font-bold mb-1">Password:</label> </div>
<input type="password" name="password" id="paste-password" class="w-full p-2 rounded-md border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"> <div id="paste-pw-options" class="reveal mb-6">
</div> <label for="paste-password" class="block text-gray-300 text-sm font-bold mb-1">Password:</label>
<div id="paste-views-options" class="reveal mb-6"> <input type="password" name="password" id="paste-password" class="w-full p-2 rounded-md border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
<label for="paste-max-views" class="block text-gray-300 text-sm font-bold mb-1">Max views:</label> </div>
<input type="number" name="max_views" id="paste-max-views" min="1" class="w-full p-2 rounded-md border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"> <div id="paste-views-options" class="reveal mb-6">
</div> <label for="paste-max-views" class="block text-gray-300 text-sm font-bold mb-1">Max views:</label>
<button type="submit" class="btn w-full text-white font-bold py-3 px-5 rounded-md focus:shadow-outline">Create Paste</button> <input type="number" name="max_views" id="paste-max-views" min="1" class="w-full p-2 rounded-md border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500">
</form> </div>
</div> <button type="submit" class="btn w-full text-white font-bold py-3 px-5 rounded-md focus:shadow-outline">Create Paste</button>
</form>
<div id="api-docs" class="content-container docs-container rounded-lg p-8 shadow-lg hidden tab-content"> </div>
<h3>Introduction</h3>
<p>The API allows programmatic uploads. All endpoints are rate-limited. No API key is required.</p> <div id="api-docs" class="content-container docs-container rounded-lg p-8 shadow-lg hidden tab-content">
<h3>Introduction</h3>
<h3>Uploading an Image</h3> <p>The API allows programmatic uploads. All endpoints are rate-limited. No API key is required.</p>
<p>Send a <code>POST</code> request with <code>multipart/form-data</code>.</p>
<ul> <h3>Uploading an Image</h3>
<li><strong>Endpoint:</strong> <code>POST /api/upload/image</code></li> <p>Send a <code>POST</code> request with <code>multipart/form-data</code>.</p>
<li><strong>Parameter <code>file</code>:</strong> (Required) The image file.</li> <ul>
<li><strong>Parameter <code>expiry</code>:</strong> (Optional) Values: <code>15m</code>, <code>1h</code>, <code>2h</code>, <code>4h</code>, <code>8h</code>, <code>12h</code>, <code>24h</code>, <code>48h</code>.</li> <li><strong>Endpoint:</strong> <code>POST /api/upload/image</code></li>
<li><strong>Parameter <code>password</code>:</strong> (Optional) A password to protect the content.</li> <li><strong>Parameter <code>file</code>:</strong> (Required) The image file.</li>
<li><strong>Parameter <code>max_views</code>:</strong> (Optional) An integer for auto-deletion after N views.</li> <li><strong>Parameter <code>expiry</code>:</strong> (Optional) Values: <code>15m</code>, <code>1h</code>, <code>2h</code>, <code>4h</code>, <code>8h</code>, <code>12h</code>, <code>24h</code>, <code>48h</code>.</li>
</ul> <li><strong>Parameter <code>password</code>:</strong> (Optional) A password to protect the content.</li>
<pre>curl -X POST -F "file=@/path/to/image.jpg" http://{{ request.host }}/api/upload/image</pre> <li><strong>Parameter <code>max_views</code>:</strong> (Optional) An integer for auto-deletion after N views.</li>
</ul>
<h3>Creating a Paste</h3> <pre>curl -X POST -F "file=@/path/to/image.jpg" http://{{ request.host }}/api/upload/image</pre>
<p>Send a <code>POST</code> request with a JSON payload.</p>
<ul> <h3>Creating a Paste</h3>
<li><strong>Endpoint:</strong> <code>POST /api/upload/paste</code></li> <p>Send a <code>POST</code> request with a JSON payload.</p>
<li><strong>JSON Field <code>content</code>:</strong> (Required) The paste text.</li> <ul>
<li><strong>JSON Field <code>language</code>:</strong> (Optional) A valid language for syntax highlighting. Defaults to 'text'.</li> <li><strong>Endpoint:</strong> <code>POST /api/upload/paste</code></li>
<li><strong>JSON Field <code>expiry</code>:</strong> (Optional) Same values as image expiry.</li> <li><strong>JSON Field <code>content</code>:</strong> (Required) The paste text.</li>
<li><strong>JSON Field <code>password</code>:</strong> (Optional) A password to protect the content.</li> <li><strong>JSON Field <code>language</code>:</strong> (Optional) A valid language for syntax highlighting. Defaults to 'text'.</li>
<li><strong>JSON Field <code>max_views</code>:</strong> (Optional) An integer for auto-deletion after N views.</li> <li><strong>JSON Field <code>expiry</code>:</strong> (Optional) Same values as image expiry.</li>
</ul> <li><strong>JSON Field <code>password</code>:</strong> (Optional) A password to protect the content.</li>
<pre>curl -X POST -H "Content-Type: application/json" \ <li><strong>JSON Field <code>max_views</code>:</strong> (Optional) An integer for auto-deletion after N views.</li>
-d '{"content":"Hello World", "expiry":"1h"}' \ </ul>
http://{{ request.host }}/api/upload/paste</pre> <pre>curl -X POST -H "Content-Type: application/json" \
</div> -d '{"content":"Hello World", "expiry":"1h"}' \
http://{{ request.host }}/api/upload/paste</pre>
<div id="stats-content" class="content-container rounded-lg p-8 shadow-lg hidden tab-content"> </div>
<h2 class="text-3xl font-bold text-white text-center mb-8">Service Statistics</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div id="stats-content" class="content-container rounded-lg p-8 shadow-lg hidden tab-content">
<div class="stat-card p-6 text-center"> <h2 class="text-3xl font-bold text-white text-center mb-8">Service Statistics</h2>
<h3 class="text-xl font-semibold text-white mb-2">Total Image Uploads</h3> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<p class="text-5xl font-bold stat-value">{{ stats.total_images }}</p> <div class="stat-card p-6 text-center">
</div> <h3 class="text-xl font-semibold text-white mb-2">Total Image Uploads</h3>
<div class="stat-card p-6 text-center"> <p class="text-5xl font-bold stat-value">{{ stats.total_images }}</p>
<h3 class="text-xl font-semibold text-white mb-2">Total Paste Uploads</h3> </div>
<p class="text-5xl font-bold stat-value">{{ stats.total_pastes }}</p> <div class="stat-card p-6 text-center">
</div> <h3 class="text-xl font-semibold text-white mb-2">Total Paste Uploads</h3>
<div class="stat-card p-6 text-center"> <p class="text-5xl font-bold stat-value">{{ stats.total_pastes }}</p>
<h3 class="text-xl font-semibold text-white mb-2">Total API Uploads</h3> </div>
<p class="text-5xl font-bold stat-value">{{ stats.total_api_uploads }}</p> <div class="stat-card p-6 text-center">
</div> <h3 class="text-xl font-semibold text-white mb-2">Total API Uploads</h3>
</div> <p class="text-5xl font-bold stat-value">{{ stats.total_api_uploads }}</p>
</div> </div>
</div>
<div id="tos-content" class="content-container tos-container rounded-lg p-8 shadow-lg hidden tab-content"> </div>
<h2 class="text-3xl font-bold text-white mb-4">Terms of Service</h2>
<p><strong>Last Updated: June 20, 2025</strong></p> <div id="tos-content" class="content-container tos-container rounded-lg p-8 shadow-lg hidden tab-content">
<p>By using this service you agree to these terms. The service is provided “as-is” with a focus on privacy.</p> <h2 class="text-3xl font-bold text-white mb-4">Terms of Service</h2>
<h3 class="mt-6 text-white text-xl font-semibold mb-2">1. Privacy & Data</h3> <p><strong>Last Updated: June 20, 2025</strong></p>
<ul class="list-disc list-inside text-gray-300 mb-4"> <p>By using this service you agree to these terms. The service is provided “as-is” with a focus on privacy.</p>
<li>No logs of your identity or IP.</li> <h3 class="mt-6 text-white text-xl font-semibold mb-2">1. Privacy & Data</h3>
<li>All uploads are encrypted at rest.</li> <ul class="list-disc list-inside text-gray-300 mb-4">
<li>Data auto-deletes after expiry or view limit.</li> <li>No logs of your identity or IP.</li>
<li>EXIF metadata only kept if opted-in.</li> <li>All uploads are encrypted at rest.</li>
</ul> <li>Data auto-deletes after expiry or view limit.</li>
<h3 class="mt-6 text-white text-xl font-semibold mb-2">2. Acceptable Use</h3> <li>EXIF metadata only kept if opted-in.</li>
<p class="text-gray-300 mb-4">Do not upload illegal or harmful content. We reserve the right to remove content that violates these terms.</p> </ul>
<h3 class="mt-6 text-white text-xl font-semibold mb-2">3. Liability</h3> <h3 class="mt-6 text-white text-xl font-semibold mb-2">2. Acceptable Use</h3>
<p class="text-gray-300">This free service comes with no warranties. We are not responsible for data loss.</p> <p class="text-gray-300 mb-4">Do not upload illegal or harmful content. We reserve the right to remove content that violates these terms.</p>
</div> <h3 class="mt-6 text-white text-xl font-semibold mb-2">3. Liability</h3>
</div> <p class="text-gray-300">This free service comes with no warranties. We are not responsible for data loss.</p>
</div>
<div class="text-center text-xs text-gray-500 mt-6 mb-8"> </div>
<a href="https://github.com/your-username/your-repo-name" target="_blank" rel="noopener noreferrer" class="hover:text-gray-400 transition-colors">
Version 1.1 <div class="text-center text-xs text-gray-500 mt-6 mb-8">
</a> <a href="http://git.idk.i2p/stormycloud/drop.i2p/releases/tag/v1.1" target="_blank" rel="noopener noreferrer" class="hover:text-gray-400 transition-colors">
</div> Version 1.1
</a>
<section class="text-center features-section"> </div>
<h2 class="text-3xl font-bold text-white mb-8">Features</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-8"> <section class="text-center features-section">
<div class="feature-card p-6 rounded-lg"> <h2 class="text-3xl font-bold text-white mb-8">Features</h2>
<div class="flex items-center justify-center h-12 w-12 rounded-md bg-blue-500 text-white mx-auto mb-4"> <div class="grid grid-cols-1 md:grid-cols-3 gap-8">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg> <div class="feature-card p-6 rounded-lg">
</div> <div class="flex items-center justify-center h-12 w-12 rounded-md bg-blue-500 text-white mx-auto mb-4">
<h3 class="text-xl font-semibold text-white mb-2">Encrypted at Rest</h3> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" /></svg>
<p class="text-gray-400">All uploaded files and pastes are fully encrypted on the server, ensuring your data is protected.</p> </div>
</div> <h3 class="text-xl font-semibold text-white mb-2">Encrypted at Rest</h3>
<div class="feature-card p-6 rounded-lg"> <p class="text-gray-400">All uploaded files and pastes are fully encrypted on the server, ensuring your data is protected.</p>
<div class="flex items-center justify-center h-12 w-12 rounded-md bg-blue-500 text-white mx-auto mb-4"> </div>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 11c0 3.517-1.009 6.786-2.673 9.356 m-2.673-9.356C6.673 8.214 9.327 5.5 12 5.5 c2.673 0 5.327 2.714 5.327 5.5s-1.009 6.786-2.673 9.356m-2.673-9.356h0z" /></svg> <div class="feature-card p-6 rounded-lg">
</div> <div class="flex items-center justify-center h-12 w-12 rounded-md bg-blue-500 text-white mx-auto mb-4">
<h3 class="text-xl font-semibold text-white mb-2">Anonymous by Design</h3> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 11c0 3.517-1.009 6.786-2.673 9.356 m-2.673-9.356C6.673 8.214 9.327 5.5 12 5.5 c2.673 0 5.327 2.714 5.327 5.5s-1.009 6.786-2.673 9.356m-2.673-9.356h0z" /></svg>
<p class="text-gray-400">Image metadata (EXIF) is stripped and no unnecessary logs are kept. Built for the I2P network.</p> </div>
</div> <h3 class="text-xl font-semibold text-white mb-2">Anonymous by Design</h3>
<div class="feature-card p-6 rounded-lg"> <p class="text-gray-400">Image metadata (EXIF) is stripped and no unnecessary logs are kept. Built for the I2P network.</p>
<div class="flex items-center justify-center h-12 w-12 rounded-md bg-blue-500 text-white mx-auto mb-4"> </div>
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" /></svg> <div class="feature-card p-6 rounded-lg">
</div> <div class="flex items-center justify-center h-12 w-12 rounded-md bg-blue-500 text-white mx-auto mb-4">
<h3 class="text-xl font-semibold text-white mb-2">StormyCloud Infrastructure</h3> <svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" /></svg>
<p class="text-gray-400">A fast, reliable, and secure platform dedicated to the privacy of the I2P community.</p> </div>
</div> <h3 class="text-xl font-semibold text-white mb-2">StormyCloud Infrastructure</h3>
</div> <p class="text-gray-400">A fast, reliable, and secure platform dedicated to the privacy of the I2P community.</p>
</section> </div>
</div>
<footer class="text-center text-gray-500 mt-16 border-t border-gray-700 pt-8 pb-8"> </section>
<a href="http://stormycloud.i2p" class="hover:text-gray-400">StormyCloud</a>
<span class="mx-2">|</span> <footer class="text-center text-gray-500 mt-16 border-t border-gray-700 pt-8 pb-8">
<a href="/donate" class="hover:text-gray-400">Donate</a> <a href="http://stormycloud.i2p" class="hover:text-gray-400">StormyCloud</a>
</footer> <span class="mx-2">|</span>
</main> <a href="/donate" class="hover:text-gray-400">Donate</a>
</div> </footer>
</div> </main>
</div>
<script> </div>
document.addEventListener('DOMContentLoaded', () => {
const contentDivs = { <script>
'#image': document.querySelector('#image-form'), document.addEventListener('DOMContentLoaded', () => {
'#paste': document.querySelector('#paste-form'), const contentDivs = {
'#api': document.querySelector('#api-docs'), '#image': document.querySelector('#image-form'),
'#stats': document.querySelector('#stats-content'), '#paste': document.querySelector('#paste-form'),
'#tos': document.querySelector('#tos-content') '#api': document.querySelector('#api-docs'),
}; '#stats': document.querySelector('#stats-content'),
const tabs = document.querySelectorAll('#tab-nav a'); '#tos': document.querySelector('#tos-content')
function showTab(hash) { };
if (!hash || !contentDivs[hash]) hash = '#image'; const tabs = document.querySelectorAll('#tab-nav a');
function showTab(hash) {
Object.values(contentDivs).forEach(d => { if (!hash || !contentDivs[hash]) hash = '#image';
if(d) d.classList.add('hidden');
}); Object.values(contentDivs).forEach(d => {
tabs.forEach(t => t.classList.remove('active')); if(d) d.classList.add('hidden');
});
if(contentDivs[hash]) { tabs.forEach(t => t.classList.remove('active'));
contentDivs[hash].classList.remove('hidden');
} if(contentDivs[hash]) {
const activeTab = document.querySelector(`#tab-nav a[href="${hash}"]`); contentDivs[hash].classList.remove('hidden');
if(activeTab) { }
activeTab.classList.add('active'); const activeTab = document.querySelector(`#tab-nav a[href="${hash}"]`);
} if(activeTab) {
} activeTab.classList.add('active');
tabs.forEach(tab => tab.addEventListener('click', e => { }
e.preventDefault(); }
const h = e.target.hash; tabs.forEach(tab => tab.addEventListener('click', e => {
window.history.replaceState(null, null, ' ' + h); e.preventDefault();
showTab(h); const h = e.target.hash;
})); window.history.replaceState(null, null, ' ' + h);
showTab(window.location.hash); showTab(h);
}));
function toggle(cbId, tgtId) { showTab(window.location.hash);
const cb = document.getElementById(cbId), tgt = document.getElementById(tgtId);
if (!cb||!tgt) return; function toggle(cbId, tgtId) {
cb.addEventListener('change', () => tgt.style.display = cb.checked ? 'block' : 'none'); const cb = document.getElementById(cbId), tgt = document.getElementById(tgtId);
tgt.style.display = cb.checked ? 'block' : 'none'; if (!cb||!tgt) return;
} cb.addEventListener('change', () => tgt.style.display = cb.checked ? 'block' : 'none');
toggle('image-pw-protect','image-pw-options'); tgt.style.display = cb.checked ? 'block' : 'none';
toggle('image-views-protect','image-views-options'); }
toggle('paste-pw-protect','paste-pw-options'); toggle('image-pw-protect','image-pw-options');
toggle('paste-views-protect','paste-views-options'); toggle('image-views-protect','image-views-options');
toggle('paste-pw-protect','paste-pw-options');
const closeBtn = document.getElementById('close-announcement'); toggle('paste-views-protect','paste-views-options');
if (closeBtn) closeBtn.addEventListener('click', ()=>
document.getElementById('announcement-bar').style.display = 'none' const closeBtn = document.getElementById('close-announcement');
); if (closeBtn) closeBtn.addEventListener('click', ()=>
}); document.getElementById('announcement-bar').style.display = 'none'
</script> );
</body> });
</script>
</body>
</html> </html>

View File

@@ -35,6 +35,7 @@
<div class="content-container rounded-lg p-8 shadow-lg"> <div class="content-container rounded-lg p-8 shadow-lg">
<h2 class="text-2xl font-semibold text-white mb-6">Enter Password to View Image</h2> <h2 class="text-2xl font-semibold text-white mb-6">Enter Password to View Image</h2>
<form method="POST"> <form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-6"> <div class="mb-6">
<label for="password" class="block text-gray-300 text-sm font-bold mb-2">Password:</label> <label for="password" class="block text-gray-300 text-sm font-bold mb-2">Password:</label>
<input type="password" name="password" id="password" <input type="password" name="password" id="password"

View File

@@ -111,6 +111,7 @@
<div class="content-container rounded-lg p-10 shadow-lg"></div> <div class="content-container rounded-lg p-10 shadow-lg"></div>
<h2 class="text-2xl font-semibold text-white mb-6">Enter Password to View Paste</h2> <h2 class="text-2xl font-semibold text-white mb-6">Enter Password to View Paste</h2>
<form method="POST"> <form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<div class="mb-6"> <div class="mb-6">
<label for="password" class="block text-gray-300 text-sm font-bold mb-2">Password:</label> <label for="password" class="block text-gray-300 text-sm font-bold mb-2">Password:</label>
<input type="password" name="password" id="password" <input type="password" name="password" id="password"