1 Commits

Author SHA1 Message Date
StormyCloud
69af50424c testing boxes 2025-06-30 21:42:14 -05:00
5 changed files with 127 additions and 190 deletions

143
app.py
View File

@@ -3,9 +3,6 @@
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
@@ -16,8 +13,6 @@ 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
@@ -35,9 +30,6 @@ 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')
@@ -52,19 +44,9 @@ 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
# Ensure debug mode is never enabled in production app.config['FLASK_DEBUG'] = os.getenv('SSP_FLASK_DEBUG', 'False').lower() in ('true', '1', 't')
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():
@@ -164,70 +146,13 @@ 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: {sanitize_error_message(e)}") app.logger.error(f"Error removing expired image file {path}: {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):
@@ -269,7 +194,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: {sanitize_error_message(e)}") app.logger.error(f"Image processing failed ({orig_fn}): {e}")
return None return None
@app.context_processor @app.context_processor
@@ -317,7 +242,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: {sanitize_error_message(e)}") app.logger.error(f"Health check DB error: {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)
@@ -358,8 +283,10 @@ def admin_dashboard():
db = get_db() db = get_db()
now = datetime.now() 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() # Fetch only active images and pastes
imgs = db.execute("SELECT id, expiry_date, view_count, max_views FROM images WHERE expiry_date > ? ORDER BY expiry_date ASC", (now,)).fetchall()
past = db.execute("SELECT id, language, expiry_date, view_count, max_views FROM pastes WHERE expiry_date > ? ORDER BY expiry_date ASC", (now,)).fetchall()
images = [(i['id'], i['expiry_date'], get_time_left(i['expiry_date']), i['view_count'], i['max_views']) for i in imgs] 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] pastes = [(p['id'], p['language'], p['expiry_date'], get_time_left(p['expiry_date']), p['view_count'], p['max_views']) for p in past]
@@ -374,7 +301,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) and validate_file_content(file.stream, file.filename): if file and allowed_file(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:
@@ -410,11 +337,6 @@ 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))
@@ -449,11 +371,10 @@ def view_image(filename):
abort(404) abort(404)
pw_hash = row['password_hash'] pw_hash = row['password_hash']
session_key = f'unlocked_image_{filename}' if pw_hash and not session.get(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[session_key] = secrets.token_hex(16) session[f'unlocked_image_{filename}'] = True
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)
@@ -477,11 +398,10 @@ def view_paste(paste_id):
abort(404) abort(404)
pw_hash = row['password_hash'] pw_hash = row['password_hash']
session_key = f'unlocked_paste_{paste_id}' if pw_hash and not session.get(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[session_key] = secrets.token_hex(16) session[f'unlocked_paste_{paste_id}'] = True
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)
@@ -545,24 +465,8 @@ 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()
@@ -592,7 +496,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: {sanitize_error_message(e)}") app.logger.error(f"Error serving image {safe_fn}: {e}")
abort(500) abort(500)
@app.route('/admin/delete/image/<filename>', methods=['POST']) @app.route('/admin/delete/image/<filename>', methods=['POST'])
@@ -607,8 +511,7 @@ 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('Error deleting image file.', 'error') flash(f'Error deleting image file: {e}', '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'])
@@ -620,20 +523,18 @@ 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('Error deleting paste.', 'error') flash(f'Error deleting paste: {e}', '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) and validate_file_content(file.stream, file.filename): if file and allowed_file(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
@@ -658,20 +559,12 @@ 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

@@ -32,9 +32,11 @@
{% 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">
<a href="/" class="inline-block mb-4"> <div class="w-full max-w-7xl mx-auto p-4">
<img src="/static/images/stormycloud.svg" alt="StormyCloud Logo" style="width:350px; max-width:100%;" class="mx-auto"/> <header class="flex flex-col items-center mb-8">
</a> <a href="/" class="inline-block mb-4">
<img src="{{ url_for('static', filename='images/stormycloud.svg') }}" alt="StormyCloud Logo" style="width: 350px; max-width: 100%;" class="mx-auto">
</a>
<h1 class="text-4xl font-bold text-white">Admin Dashboard</h1> <h1 class="text-4xl font-bold text-white">Admin Dashboard</h1>
</header> </header>
@@ -60,7 +62,6 @@
<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>
@@ -95,7 +96,6 @@
<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>
@@ -122,7 +122,6 @@
{% 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

@@ -17,6 +17,7 @@
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;
} }
input:disabled { background-color: #2d3748; color: #718096; cursor: not-allowed; }
.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; }
@@ -37,6 +38,7 @@
.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;
cursor: pointer;
} }
.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;
@@ -46,6 +48,13 @@
border: 1px solid #4a5568; border: 1px solid #4a5568;
border-radius: 0.5rem; border-radius: 0.5rem;
} }
input[type="checkbox"] {
width: 20px;
height: 20px;
accent-color: #4299e1;
vertical-align: middle;
cursor: pointer;
}
/* API Docs Styling */ /* API Docs Styling */
.docs-container h3 { .docs-container h3 {
font-size: 1.5rem; font-size: 1.5rem;
@@ -100,9 +109,12 @@
<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"
</a> alt="StormyCloud Logo"
style="width:350px;max-width:100%;"
class="mx-auto"/>
</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>
@@ -140,13 +152,19 @@
<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">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<h2 class="text-2xl font-semibold mb-6 text-white">Upload an Image</h2> <h2 class="text-2xl font-semibold mb-6 text-white">Upload an Image</h2>
<div class="mb-6"> <div class="mb-6">
<label for="image-file" class="block text-gray-300 text-sm font-bold mb-2">Image File:</label> <label for="image-file" class="block text-gray-300 text-sm font-bold mb-2">Image File:</label>
<input type="file" name="file" id="image-file" required> <input type="file" name="file" id="image-file" required>
<p class="text-xs text-gray-500 mt-1">Max 10MB; WebP conversion.</p> <div class="flex items-center space-x-2 text-xs text-gray-500 mt-1">
<label class="inline-flex items-center space-x-1 cursor-pointer" title="EXIF data includes details like camera model, date, and location. It is stripped by default for your privacy.">
<input type="checkbox" name="keep_exif" class="w-3 h-3 accent-blue-500 focus:ring-blue-500">
<span>Keep EXIF</span>
</label>
<span>·</span>
<span>Max 10MB; WebP conversion.</span>
</div>
</div> </div>
<div class="mb-6"> <div class="mb-6">
@@ -162,32 +180,36 @@
<option value="48h">48 hours</option> <option value="48h">48 hours</option>
</select> </select>
</div> </div>
<div class="flex mb-6 items-center">
<div style="width: 50%;">
<div class="flex items-center">
<label class="label-with-icon">
<input type="checkbox" id="image-pw-protect" name="password_protect">
<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>
<span>Password</span>
</label>
<div id="image-pw-options" class="reveal ml-2">
<input type="password" name="password" id="image-password" class="p-2 w-48 rounded-md border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500" disabled>
</div>
</div>
</div>
<div class="mb-6 text-gray-300" style="display:grid;grid-template-columns:repeat(3,1fr);gap:1.5rem;align-items:start;"> <div style="width: 50%; text-align: right;">
<label class="label-with-icon"> <div class="inline-flex items-center">
<input type="checkbox" name="keep_exif"> <label for="image-max-views-select" class="label-with-icon mr-2">
<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> <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>
<span>Keep EXIF Data</span> <span>Max Views:</span>
</label> </label>
<label class="label-with-icon"> <select name="max_views" id="image-max-views-select" class="p-2 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<input type="checkbox" id="image-pw-protect" name="password_protect"> <option value="" selected>Unlimited</option>
<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> <option value="1">1</option>
<span>Password</span> <option value="10">10</option>
</label> <option value="20">20</option>
<label class="label-with-icon" title="Removed after this many successful views"> <option value="50">50</option>
<input type="checkbox" id="image-views-protect" name="views_protect"> </select>
<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>
</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>
<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>
<div id="image-views-options" class="reveal mb-6">
<label for="image-max-views" class="block text-gray-300 text-sm font-bold mb-1">Max views:</label>
<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> </div>
<button type="submit" class="btn w-full text-white font-bold py-3 px-5 rounded-md focus:shadow-outline">Upload Image</button> <button type="submit" class="btn w-full text-white font-bold py-3 px-5 rounded-md focus:shadow-outline">Upload Image</button>
@@ -196,7 +218,6 @@
<div id="paste-form" class="content-container rounded-lg p-8 shadow-lg hidden tab-content"> <div id="paste-form" class="content-container rounded-lg p-8 shadow-lg hidden tab-content">
<form action="/upload/paste" method="POST"> <form action="/upload/paste" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
<h2 class="text-2xl font-semibold mb-6 text-white">Create a Paste</h2> <h2 class="text-2xl font-semibold mb-6 text-white">Create a Paste</h2>
<div class="mb-6"> <div class="mb-6">
<label for="paste-content" class="block text-gray-300 text-sm font-bold mb-2">Paste Content:</label> <label for="paste-content" class="block text-gray-300 text-sm font-bold mb-2">Paste Content:</label>
@@ -224,26 +245,38 @@
<option value="48h">48 hours</option> <option value="48h">48 hours</option>
</select> </select>
</div> </div>
<div class="mb-6 text-gray-300" style="display:grid;grid-template-columns:repeat(2,1fr);gap:2rem;align-items:start;">
<label class="label-with-icon"> <div class="flex mb-6 items-center">
<input type="checkbox" id="paste-pw-protect" name="password_protect"> <div style="width: 50%;">
<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> <div class="flex items-center">
<span>Password</span> <label class="label-with-icon">
</label> <input type="checkbox" id="paste-pw-protect" name="password_protect" class="mr-2">
<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="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="paste-views-protect" name="views_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="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 id="paste-pw-options" class="reveal ml-2">
</label> <input type="password" name="password" id="paste-password" placeholder="Enter password..." class="p-2 w-48 rounded-md border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500" disabled>
</div> </div>
<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>
<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> <div style="width: 50%; text-align: right;">
<div id="paste-views-options" class="reveal mb-6"> <div class="inline-flex items-center">
<label for="paste-max-views" class="block text-gray-300 text-sm font-bold mb-1">Max views:</label> <label for="paste-max-views-select" class="label-with-icon mr-2">
<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"> <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>
<span>Max Views:</span>
</label>
<select name="max_views" id="paste-max-views-select" class="p-2 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="" selected>Unlimited</option>
<option value="1">1</option>
<option value="10">10</option>
<option value="20">20</option>
<option value="50">50</option>
</select>
</div>
</div>
</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> <button type="submit" class="btn w-full text-white font-bold py-3 px-5 rounded-md focus:shadow-outline">Create Paste</button>
</form> </form>
</div> </div>
@@ -315,7 +348,7 @@ http://{{ request.host }}/api/upload/paste</pre>
</div> </div>
<div class="text-center text-xs text-gray-500 mt-6 mb-8"> <div class="text-center text-xs text-gray-500 mt-6 mb-8">
<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"> <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 Version 1.1
</a> </a>
</div> </div>
@@ -391,15 +424,29 @@ http://{{ request.host }}/api/upload/paste</pre>
showTab(window.location.hash); showTab(window.location.hash);
function toggle(cbId, tgtId) { function toggle(cbId, tgtId) {
const cb = document.getElementById(cbId), tgt = document.getElementById(tgtId); const cb = document.getElementById(cbId);
if (!cb||!tgt) return; const tgt = document.getElementById(tgtId);
cb.addEventListener('change', () => tgt.style.display = cb.checked ? 'block' : 'none'); if (!cb || !tgt) return;
tgt.style.display = cb.checked ? 'block' : 'none';
const input = tgt.querySelector('input[type="password"]');
const toggleState = () => {
// Use inline-block for side-by-side layout
tgt.style.display = cb.checked ? 'inline-block' : 'none';
if (input) {
input.disabled = !cb.checked;
if (!cb.checked) {
input.value = ''; // Clear input if checkbox is unchecked
}
}
};
cb.addEventListener('change', toggleState);
toggleState(); // Set initial state on page load
} }
toggle('image-pw-protect','image-pw-options'); toggle('image-pw-protect','image-pw-options');
toggle('image-views-protect','image-views-options');
toggle('paste-pw-protect','paste-pw-options'); toggle('paste-pw-protect','paste-pw-options');
toggle('paste-views-protect','paste-views-options');
const closeBtn = document.getElementById('close-announcement'); const closeBtn = document.getElementById('close-announcement');
if (closeBtn) closeBtn.addEventListener('click', ()=> if (closeBtn) closeBtn.addEventListener('click', ()=>

View File

@@ -35,7 +35,6 @@
<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,7 +111,6 @@
<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"