Compare commits
1 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
69af50424c |
143
app.py
143
app.py
@@ -3,9 +3,6 @@
|
||||
import os
|
||||
import uuid
|
||||
import sqlite3
|
||||
import mimetypes
|
||||
import secrets
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from io import BytesIO
|
||||
|
||||
@@ -16,8 +13,6 @@ from flask import (
|
||||
from werkzeug.utils import secure_filename
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
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 pygments import highlight
|
||||
@@ -35,9 +30,6 @@ load_dotenv()
|
||||
app = Flask(__name__)
|
||||
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['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['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024 # 10MB
|
||||
|
||||
# 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'
|
||||
app.config['FLASK_DEBUG'] = os.getenv('SSP_FLASK_DEBUG', 'False').lower() in ('true', '1', 't')
|
||||
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'tiff'}
|
||||
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) ---
|
||||
def i2p_key_func():
|
||||
@@ -164,70 +146,13 @@ def cleanup_expired_content():
|
||||
try:
|
||||
os.remove(path)
|
||||
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,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# --- 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):
|
||||
"""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
|
||||
|
||||
def get_time_left(expiry_str):
|
||||
@@ -269,7 +194,7 @@ def process_and_encrypt_image(stream, orig_fn, keep_exif=False):
|
||||
f.write(encrypted)
|
||||
return new_fn
|
||||
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
|
||||
|
||||
@app.context_processor
|
||||
@@ -317,7 +242,7 @@ def healthz():
|
||||
conn.close()
|
||||
db_status = "ok"
|
||||
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"
|
||||
sched_status = "running" if scheduler.running and scheduler.state == 1 else "stopped"
|
||||
return jsonify(database=db_status, scheduler=sched_status)
|
||||
@@ -358,8 +283,10 @@ def admin_dashboard():
|
||||
|
||||
db = get_db()
|
||||
now = datetime.now()
|
||||
imgs = db.execute("SELECT id, expiry_date, view_count, max_views FROM images ORDER BY expiry_date ASC").fetchall()
|
||||
past = db.execute("SELECT id, language, expiry_date, view_count, max_views FROM pastes ORDER BY expiry_date ASC").fetchall()
|
||||
|
||||
# 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]
|
||||
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'))
|
||||
|
||||
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'))
|
||||
new_fn = process_and_encrypt_image(file.stream, file.filename, keep_exif)
|
||||
if not new_fn:
|
||||
@@ -410,11 +337,6 @@ def upload_paste():
|
||||
if not content:
|
||||
flash('Paste content cannot be empty.', 'error')
|
||||
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()
|
||||
expiry = now + EXPIRY_MAP.get(request.form.get('expiry', '1h'), timedelta(hours=1))
|
||||
@@ -449,11 +371,10 @@ def view_image(filename):
|
||||
abort(404)
|
||||
|
||||
pw_hash = row['password_hash']
|
||||
session_key = f'unlocked_image_{filename}'
|
||||
if pw_hash and not session.get(session_key):
|
||||
if pw_hash and not session.get(f'unlocked_image_{filename}'):
|
||||
if request.method == 'POST':
|
||||
if check_password_hash(pw_hash, request.form.get('password', '')):
|
||||
session[session_key] = secrets.token_hex(16)
|
||||
session[f'unlocked_image_{filename}'] = True
|
||||
return redirect(url_for('view_image', filename=filename))
|
||||
flash('Incorrect password.', 'error')
|
||||
return render_template('view_image.html', password_required=True, filename=filename)
|
||||
@@ -477,11 +398,10 @@ def view_paste(paste_id):
|
||||
abort(404)
|
||||
|
||||
pw_hash = row['password_hash']
|
||||
session_key = f'unlocked_paste_{paste_id}'
|
||||
if pw_hash and not session.get(session_key):
|
||||
if pw_hash and not session.get(f'unlocked_paste_{paste_id}'):
|
||||
if request.method == 'POST':
|
||||
if check_password_hash(pw_hash, request.form.get('password', '')):
|
||||
session[session_key] = secrets.token_hex(16)
|
||||
session[f'unlocked_paste_{paste_id}'] = True
|
||||
return redirect(url_for('view_paste', paste_id=paste_id))
|
||||
flash('Incorrect password.', 'error')
|
||||
return render_template('view_paste.html', password_required=True, paste_id=paste_id)
|
||||
@@ -545,24 +465,8 @@ def paste_raw(paste_id):
|
||||
|
||||
@app.route('/uploads/<filename>')
|
||||
def get_upload(filename):
|
||||
# Enhanced security validation
|
||||
if not validate_filename_security(filename):
|
||||
abort(404)
|
||||
|
||||
safe_fn = secure_filename(filename)
|
||||
|
||||
# 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
|
||||
path = os.path.join(app.config['UPLOAD_FOLDER'], safe_fn)
|
||||
db = get_db()
|
||||
|
||||
row = db.execute("SELECT * FROM images WHERE id = ?", (safe_fn,)).fetchone()
|
||||
@@ -592,7 +496,7 @@ def get_upload(filename):
|
||||
data = fernet.decrypt(encrypted)
|
||||
return send_file(BytesIO(data), mimetype='image/webp')
|
||||
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)
|
||||
|
||||
@app.route('/admin/delete/image/<filename>', methods=['POST'])
|
||||
@@ -607,8 +511,7 @@ def delete_image(filename):
|
||||
db.commit()
|
||||
flash(f'Image "{safe}" has been deleted.', 'success')
|
||||
except Exception as e:
|
||||
flash('Error deleting image file.', 'error')
|
||||
app.logger.error(f'Error deleting image file: {sanitize_error_message(e)}')
|
||||
flash(f'Error deleting image file: {e}', 'error')
|
||||
return redirect(url_for('admin_dashboard'))
|
||||
|
||||
@app.route('/admin/delete/paste/<paste_id>', methods=['POST'])
|
||||
@@ -620,20 +523,18 @@ def delete_paste(paste_id):
|
||||
db.commit()
|
||||
flash(f'Paste "{paste_id}" has been deleted.', 'success')
|
||||
except Exception as e:
|
||||
flash('Error deleting paste.', 'error')
|
||||
app.logger.error(f'Error deleting paste: {sanitize_error_message(e)}')
|
||||
flash(f'Error deleting paste: {e}', 'error')
|
||||
return redirect(url_for('admin_dashboard'))
|
||||
|
||||
# --- API Routes ---
|
||||
@app.route('/api/upload/image', methods=['POST'])
|
||||
@limiter.limit("50 per hour")
|
||||
@csrf.exempt
|
||||
def api_upload_image():
|
||||
if 'file' not in request.files or request.files['file'].filename == '':
|
||||
return jsonify(error="No file selected"), 400
|
||||
|
||||
file = request.files['file']
|
||||
if file and allowed_file(file.filename) 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')))
|
||||
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'])
|
||||
@limiter.limit("100 per hour")
|
||||
@csrf.exempt
|
||||
def api_upload_paste():
|
||||
if not request.is_json: return jsonify(error="Request must be JSON"), 400
|
||||
|
||||
data = request.get_json()
|
||||
if not isinstance(data, dict):
|
||||
return jsonify(error="Invalid JSON data"), 400
|
||||
|
||||
content = data.get('content', '').strip()
|
||||
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()
|
||||
expiry = now + EXPIRY_MAP.get(data.get('expiry', '1h'), timedelta(hours=1))
|
||||
|
@@ -32,9 +32,11 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center justify-center min-h-screen py-8">
|
||||
<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"/>
|
||||
</a>
|
||||
<div class="w-full max-w-7xl mx-auto p-4">
|
||||
<header class="flex flex-col items-center mb-8">
|
||||
<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>
|
||||
</header>
|
||||
|
||||
@@ -60,7 +62,6 @@
|
||||
<td class="font-mono text-sm">{{ image[2] }}</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?');">
|
||||
<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>
|
||||
</form>
|
||||
</td>
|
||||
@@ -95,7 +96,6 @@
|
||||
<td class="font-mono text-sm">{{ paste[3] }}</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?');">
|
||||
<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>
|
||||
</form>
|
||||
</td>
|
||||
@@ -122,7 +122,6 @@
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
<form method="POST" action="{{ url_for('admin_dashboard') }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="mb-6">
|
||||
<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>
|
||||
|
@@ -17,6 +17,7 @@
|
||||
select,textarea,input[type="text"],input[type="password"],input[type="number"] {
|
||||
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-error { background-color:#e53e3e; }
|
||||
.announcement-bar { background-color:#2563eb; border-bottom:1px solid #1e3a8a; }
|
||||
@@ -37,6 +38,7 @@
|
||||
.stat-value { color:#63b3ed; }
|
||||
.label-with-icon {
|
||||
display:inline-flex; align-items:center; gap:0.5rem; font-size:0.875rem; color:#cbd5e0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.label-with-icon svg {
|
||||
width:1rem; height:1rem; color:#4299e1; flex-shrink:0;
|
||||
@@ -46,6 +48,13 @@
|
||||
border: 1px solid #4a5568;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
input[type="checkbox"] {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
accent-color: #4299e1;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
}
|
||||
/* API Docs Styling */
|
||||
.docs-container h3 {
|
||||
font-size: 1.5rem;
|
||||
@@ -100,9 +109,12 @@
|
||||
<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">
|
||||
<header class="text-center mb-8">
|
||||
<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"/>
|
||||
</a>
|
||||
<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"/>
|
||||
</a>
|
||||
<h1 class="text-4xl font-bold text-white">I2P Secure Share</h1>
|
||||
<p class="text-gray-400">Anonymously share images and text pastes.</p>
|
||||
</header>
|
||||
@@ -140,13 +152,19 @@
|
||||
<div class="noscript-forms-container">
|
||||
<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">
|
||||
<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>
|
||||
<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 class="mb-6">
|
||||
@@ -162,32 +180,36 @@
|
||||
<option value="48h">48 hours</option>
|
||||
</select>
|
||||
</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;">
|
||||
<label class="label-with-icon">
|
||||
<input type="checkbox" name="keep_exif">
|
||||
<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>
|
||||
<span>Keep EXIF Data</span>
|
||||
</label>
|
||||
<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>
|
||||
<label class="label-with-icon" title="Removed after this many successful views">
|
||||
<input type="checkbox" id="image-views-protect" name="views_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="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>
|
||||
</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 style="width: 50%; text-align: right;">
|
||||
<div class="inline-flex items-center">
|
||||
<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="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="image-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>
|
||||
|
||||
<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">
|
||||
<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>
|
||||
<div class="mb-6">
|
||||
<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>
|
||||
</select>
|
||||
</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">
|
||||
<input type="checkbox" id="paste-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>
|
||||
<label class="label-with-icon" title="Removed after this many successful views">
|
||||
<input type="checkbox" id="paste-views-protect" name="views_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="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>
|
||||
</div>
|
||||
<div id="paste-pw-options" class="reveal mb-6">
|
||||
<label for="paste-password" class="block text-gray-300 text-sm font-bold mb-1">Password:</label>
|
||||
<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 id="paste-views-options" class="reveal mb-6">
|
||||
<label for="paste-max-views" class="block text-gray-300 text-sm font-bold mb-1">Max views:</label>
|
||||
<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 class="flex mb-6 items-center">
|
||||
<div style="width: 50%;">
|
||||
<div class="flex items-center">
|
||||
<label class="label-with-icon">
|
||||
<input type="checkbox" id="paste-pw-protect" name="password_protect" class="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="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="paste-pw-options" class="reveal ml-2">
|
||||
<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>
|
||||
|
||||
<div style="width: 50%; text-align: right;">
|
||||
<div class="inline-flex items-center">
|
||||
<label for="paste-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="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>
|
||||
|
||||
<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>
|
||||
@@ -315,7 +348,7 @@ http://{{ request.host }}/api/upload/paste</pre>
|
||||
</div>
|
||||
|
||||
<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
|
||||
</a>
|
||||
</div>
|
||||
@@ -391,15 +424,29 @@ http://{{ request.host }}/api/upload/paste</pre>
|
||||
showTab(window.location.hash);
|
||||
|
||||
function toggle(cbId, tgtId) {
|
||||
const cb = document.getElementById(cbId), tgt = document.getElementById(tgtId);
|
||||
if (!cb||!tgt) return;
|
||||
cb.addEventListener('change', () => tgt.style.display = cb.checked ? 'block' : 'none');
|
||||
tgt.style.display = cb.checked ? 'block' : 'none';
|
||||
const cb = document.getElementById(cbId);
|
||||
const tgt = document.getElementById(tgtId);
|
||||
if (!cb || !tgt) return;
|
||||
|
||||
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-views-protect','image-views-options');
|
||||
toggle('paste-pw-protect','paste-pw-options');
|
||||
toggle('paste-views-protect','paste-views-options');
|
||||
|
||||
const closeBtn = document.getElementById('close-announcement');
|
||||
if (closeBtn) closeBtn.addEventListener('click', ()=>
|
||||
|
@@ -35,7 +35,6 @@
|
||||
<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>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="mb-6">
|
||||
<label for="password" class="block text-gray-300 text-sm font-bold mb-2">Password:</label>
|
||||
<input type="password" name="password" id="password"
|
||||
|
@@ -111,7 +111,6 @@
|
||||
<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>
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
<div class="mb-6">
|
||||
<label for="password" class="block text-gray-300 text-sm font-bold mb-2">Password:</label>
|
||||
<input type="password" name="password" id="password"
|
||||
|
Reference in New Issue
Block a user