Merge branch 'v1.1'
This commit is contained in:
5
.env
5
.env
@ -1,5 +0,0 @@
|
||||
|
||||
I2PCAKE_SECRET_KEY='3de1d3c8c7c7d41f77e68e4c77609d57'
|
||||
I2PCAKE_ADMIN_PASSWORD='z^K4@qR9#pGv$L!w'
|
||||
I2PCAKE_ENCRYPTION_KEY='bZgG05n1hVDFs3qc0N2n-HqH8J1nZ_0mY7yK6xX5_wI='
|
||||
RATELIMIT_STORAGE_URI=redis://localhost:6379
|
11
.gitignore
vendored
11
.gitignore
vendored
@ -1,3 +1,14 @@
|
||||
uploads/
|
||||
database.db
|
||||
.DS_Store
|
||||
upload*
|
||||
*.db
|
||||
.env
|
||||
wsgi.py
|
||||
|
||||
# Ignore Python cache files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
# Ignore virtual environment directories
|
||||
venv/
|
||||
|
||||
|
716
app.py
716
app.py
@ -1,14 +1,19 @@
|
||||
# app.py
|
||||
|
||||
import os
|
||||
import uuid
|
||||
import sqlite3
|
||||
import secrets
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from flask import Flask, render_template, request, redirect, url_for, flash, send_from_directory, abort, send_file, jsonify, session, g
|
||||
from werkzeug.utils import secure_filename
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from datetime import datetime, timedelta
|
||||
from io import BytesIO
|
||||
|
||||
# --- Library Imports ---
|
||||
from flask import (
|
||||
Flask, render_template, request, redirect, url_for, flash,
|
||||
session, g, abort, send_file, jsonify, Response
|
||||
)
|
||||
from werkzeug.utils import secure_filename
|
||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
from PIL import Image
|
||||
from pygments import highlight
|
||||
from pygments.lexers import get_lexer_by_name
|
||||
@ -17,37 +22,43 @@ from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from cryptography.fernet import Fernet
|
||||
from flask_limiter import Limiter
|
||||
from flask_limiter.util import get_remote_address
|
||||
from dotenv import load_dotenv # Import python-dotenv
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Load environment variables from .env file
|
||||
# Load environment variables from a .env file
|
||||
load_dotenv()
|
||||
|
||||
# --- Configuration ---
|
||||
app = Flask(__name__)
|
||||
# This is the definitive fix for URL generation behind a proxy.
|
||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_prefix=1)
|
||||
|
||||
# Load secrets from environment variables
|
||||
app.config['SECRET_KEY'] = os.getenv('I2PCAKE_SECRET_KEY', 'default-dev-key-if-not-set')
|
||||
app.config['ADMIN_PASSWORD'] = os.getenv('I2PCAKE_ADMIN_PASSWORD')
|
||||
encryption_key = os.getenv('I2PCAKE_ENCRYPTION_KEY')
|
||||
app.config['ENCRYPTION_KEY'] = encryption_key.encode('utf-8') if encryption_key else None
|
||||
|
||||
app.config['SERVER_NAME'] = 'drop.i2p'
|
||||
app.config['UPLOAD_FOLDER'] = 'uploads'
|
||||
app.config['DATABASE'] = 'database.db'
|
||||
app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024
|
||||
app.config['ADMIN_URL'] = '/s3cr3t-4dm1n-p4n3l-d3adbeef'
|
||||
app.config['SECRET_KEY'] = os.getenv('SSP_SECRET_KEY')
|
||||
app.config['ADMIN_PASSWORD_HASH'] = os.getenv('SSP_ADMIN_PASSWORD_HASH')
|
||||
app.config['ADMIN_URL'] = os.getenv('SSP_ADMIN_URL')
|
||||
|
||||
enc_key = os.getenv('SSP_ENCRYPTION_KEY')
|
||||
if not enc_key:
|
||||
raise ValueError("FATAL: SSP_ENCRYPTION_KEY is not set in the environment.")
|
||||
app.config['ENCRYPTION_KEY'] = enc_key.encode('utf-8')
|
||||
|
||||
app.config['UPLOAD_FOLDER'] = os.getenv('SSP_UPLOAD_FOLDER', 'uploads')
|
||||
app.config['DATABASE_PATH'] = os.getenv('SSP_DATABASE_PATH', 'database.db')
|
||||
app.config['MAX_CONTENT_LENGTH'] = 10 * 1024 * 1024 # 10MB
|
||||
|
||||
app.config['FLASK_DEBUG'] = os.getenv('SSP_FLASK_DEBUG', 'False').lower() in ('true', '1', 't')
|
||||
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'bmp', 'ico', 'tiff'}
|
||||
|
||||
# --- I2P-Aware Rate Limiting ---
|
||||
# --- Rate Limiting (I2P-aware) ---
|
||||
def i2p_key_func():
|
||||
i2p_b32_address = request.headers.get('X-I2P-DestB32')
|
||||
if i2p_b32_address:
|
||||
return i2p_b32_address
|
||||
forwarded_for = request.headers.get('X-Forwarded-For')
|
||||
if forwarded_for:
|
||||
return forwarded_for.split(',')[0].strip()
|
||||
# Prioritize the I2P destination header for rate limiting
|
||||
b32 = request.headers.get('X-I2P-DestB32')
|
||||
if b32:
|
||||
return b32
|
||||
# Fallback to X-Forwarded-For if behind a standard proxy
|
||||
fwd = request.headers.get('X-Forwarded-For')
|
||||
if fwd:
|
||||
return fwd.split(',')[0].strip()
|
||||
# Final fallback to the direct remote address
|
||||
return get_remote_address()
|
||||
|
||||
limiter = Limiter(
|
||||
@ -57,216 +68,196 @@ limiter = Limiter(
|
||||
storage_uri="memory://"
|
||||
)
|
||||
|
||||
# --- Cryptography Setup ---
|
||||
fernet = Fernet(app.config['ENCRYPTION_KEY']) if app.config['ENCRYPTION_KEY'] else None
|
||||
fernet = Fernet(app.config['ENCRYPTION_KEY'])
|
||||
|
||||
# --- Expiry Time Mapping ---
|
||||
# --- Expiry Map & Languages ---
|
||||
EXPIRY_MAP = {
|
||||
"15m": timedelta(minutes=15),
|
||||
"1h": timedelta(hours=1),
|
||||
"2h": timedelta(hours=2),
|
||||
"4h": timedelta(hours=4),
|
||||
"8h": timedelta(hours=8),
|
||||
"12h": timedelta(hours=12),
|
||||
"24h": timedelta(hours=24),
|
||||
"48h": timedelta(hours=48)
|
||||
"15m": timedelta(minutes=15), "1h": timedelta(hours=1), "2h": timedelta(hours=2),
|
||||
"4h": timedelta(hours=4), "8h": timedelta(hours=8), "12h": timedelta(hours=12),
|
||||
"24h": timedelta(hours=24), "48h": timedelta(hours=48)
|
||||
}
|
||||
|
||||
# --- Curated Language List ---
|
||||
POPULAR_LANGUAGES = [
|
||||
'bash', 'c', 'cpp', 'csharp', 'css', 'go', 'html', 'java',
|
||||
'javascript', 'json', 'kotlin', 'lua', 'markdown', 'php',
|
||||
'python', 'ruby', 'rust', 'sql', 'swift', 'typescript',
|
||||
'xml', 'yaml'
|
||||
'text', 'bash', 'c', 'cpp', 'csharp', 'css', 'go', 'html', 'java', 'javascript', 'json',
|
||||
'kotlin', 'lua', 'markdown', 'php', 'python', 'ruby', 'rust', 'sql', 'swift',
|
||||
'typescript', 'xml', 'yaml'
|
||||
]
|
||||
|
||||
# --- New Database Connection Management ---
|
||||
# --- Database Helpers ---
|
||||
def get_db():
|
||||
"""Opens a new database connection if there is none yet for the current application context."""
|
||||
if 'db' not in g:
|
||||
# Open connection
|
||||
db = sqlite3.connect(
|
||||
app.config['DATABASE'],
|
||||
detect_types=sqlite3.PARSE_DECLTYPES | sqlite3.PARSE_COLNAMES,
|
||||
check_same_thread=False
|
||||
)
|
||||
# Access columns by name
|
||||
db.row_factory = sqlite3.Row
|
||||
|
||||
# Tune SQLite for higher throughput / lower tail latency
|
||||
db.execute("PRAGMA journal_mode = WAL;")
|
||||
db.execute("PRAGMA synchronous = NORMAL;")
|
||||
db.execute("PRAGMA busy_timeout = 5000;") # wait up to 5s if database is locked
|
||||
|
||||
g.db = db
|
||||
g.db = sqlite3.connect(app.config['DATABASE_PATH'])
|
||||
g.db.row_factory = sqlite3.Row
|
||||
return g.db
|
||||
|
||||
def close_db(e=None):
|
||||
"""Closes the database again at the end of the request."""
|
||||
db = g.pop('db', None)
|
||||
if db is not None:
|
||||
if db:
|
||||
db.close()
|
||||
|
||||
# Register the close_db function to be called when the app context is torn down
|
||||
app.teardown_appcontext(close_db)
|
||||
|
||||
# --- Database Setup ---
|
||||
def init_db():
|
||||
with app.app_context():
|
||||
db = get_db()
|
||||
cursor = db.cursor()
|
||||
cursor.execute(
|
||||
'CREATE TABLE IF NOT EXISTS pastes ('
|
||||
'id TEXT PRIMARY KEY, '
|
||||
'content BLOB NOT NULL, '
|
||||
'language TEXT NOT NULL, '
|
||||
'expiry_date DATETIME NOT NULL'
|
||||
')'
|
||||
c = db.cursor()
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS pastes (
|
||||
id TEXT PRIMARY KEY,
|
||||
content BLOB NOT NULL,
|
||||
language TEXT NOT NULL,
|
||||
expiry_date DATETIME NOT NULL,
|
||||
password_hash TEXT,
|
||||
view_count INTEGER DEFAULT 0,
|
||||
max_views INTEGER
|
||||
)
|
||||
cursor.execute(
|
||||
'CREATE TABLE IF NOT EXISTS images ('
|
||||
'id TEXT PRIMARY KEY, '
|
||||
'upload_date DATETIME NOT NULL, '
|
||||
'expiry_date DATETIME NOT NULL'
|
||||
')'
|
||||
)
|
||||
cursor.execute(
|
||||
'CREATE TABLE IF NOT EXISTS stats ('
|
||||
'stat_key TEXT PRIMARY KEY, '
|
||||
'stat_value INTEGER NOT NULL'
|
||||
')'
|
||||
)
|
||||
stats_to_initialize = ['total_images', 'total_pastes', 'total_api_uploads']
|
||||
for stat in stats_to_initialize:
|
||||
cursor.execute(
|
||||
"INSERT OR IGNORE INTO stats (stat_key, stat_value) VALUES (?, 0)",
|
||||
(stat,)
|
||||
''')
|
||||
c.execute('''
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
id TEXT PRIMARY KEY,
|
||||
upload_date DATETIME NOT NULL,
|
||||
expiry_date DATETIME NOT NULL,
|
||||
password_hash TEXT,
|
||||
view_count INTEGER DEFAULT 0,
|
||||
max_views INTEGER
|
||||
)
|
||||
''')
|
||||
c.execute('CREATE TABLE IF NOT EXISTS stats (stat_key TEXT PRIMARY KEY, stat_value INTEGER NOT NULL)')
|
||||
for stat in ['total_images', 'total_pastes', 'total_api_uploads']:
|
||||
c.execute("INSERT OR IGNORE INTO stats(stat_key,stat_value) VALUES(?,0)", (stat,))
|
||||
db.commit()
|
||||
|
||||
# --- Statistics Helper ---
|
||||
def update_stat(stat_key, increment=1):
|
||||
def update_stat(key, inc=1):
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE stats SET stat_value = stat_value + ? WHERE stat_key = ?",
|
||||
(increment, stat_key)
|
||||
)
|
||||
db.execute("UPDATE stats SET stat_value = stat_value + ? WHERE stat_key = ?", (inc, key))
|
||||
db.commit()
|
||||
|
||||
# --- Deletion Scheduler Setup ---
|
||||
# --- Cleanup Scheduler ---
|
||||
scheduler = BackgroundScheduler(daemon=True)
|
||||
|
||||
def cleanup_expired_content():
|
||||
# This function runs in a background thread, so it needs its own app context
|
||||
with app.app_context():
|
||||
now = datetime.now()
|
||||
print(f"[{now}] Running cleanup job...")
|
||||
db = sqlite3.connect(app.config['DATABASE'])
|
||||
cursor = db.cursor()
|
||||
cursor.execute("DELETE FROM pastes WHERE expiry_date < ?", (now,))
|
||||
pastes_deleted = cursor.rowcount
|
||||
cursor.execute("SELECT id FROM images WHERE expiry_date < ?", (now,))
|
||||
expired_images = cursor.fetchall()
|
||||
images_deleted = 0
|
||||
for image_record in expired_images:
|
||||
filename = image_record[0]
|
||||
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
|
||||
conn = sqlite3.connect(app.config['DATABASE_PATH'])
|
||||
cur = conn.cursor()
|
||||
cur.execute("DELETE FROM pastes WHERE expiry_date < ?", (now,))
|
||||
cur.execute("SELECT id FROM images WHERE expiry_date < ?", (now,))
|
||||
for (img_id,) in cur.fetchall():
|
||||
path = os.path.join(app.config['UPLOAD_FOLDER'], img_id)
|
||||
try:
|
||||
os.remove(filepath)
|
||||
cursor.execute("DELETE FROM images WHERE id = ?", (filename,))
|
||||
images_deleted += 1
|
||||
except OSError:
|
||||
pass
|
||||
db.commit()
|
||||
db.close()
|
||||
if pastes_deleted > 0:
|
||||
print(f"Cleaned up {pastes_deleted} expired paste(s).")
|
||||
if images_deleted > 0:
|
||||
print(f"Cleaned up {images_deleted} expired image(s).")
|
||||
os.remove(path)
|
||||
except OSError as e:
|
||||
app.logger.error(f"Error removing expired image file {path}: {e}")
|
||||
cur.execute("DELETE FROM images WHERE id = ?", (img_id,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# --- Helper Functions ---
|
||||
def allowed_file(filename):
|
||||
return (
|
||||
'.' in filename and
|
||||
filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
)
|
||||
# --- Utility Functions ---
|
||||
def allowed_file(fn):
|
||||
return '.' in fn and fn.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
|
||||
|
||||
def process_and_encrypt_image(file_stream, original_filename):
|
||||
def get_time_left(expiry_str):
|
||||
try:
|
||||
img = Image.open(file_stream)
|
||||
expiry = datetime.fromisoformat(expiry_str)
|
||||
rem = expiry - datetime.now()
|
||||
if rem.total_seconds() <= 0:
|
||||
return "Expired"
|
||||
days = rem.days
|
||||
hrs = rem.seconds // 3600
|
||||
mins = (rem.seconds % 3600) // 60
|
||||
if days > 0:
|
||||
return f"~{days} days {hrs} hours"
|
||||
if hrs > 0:
|
||||
return f"~{hrs} hours {mins} minutes"
|
||||
return f"~{mins} minutes"
|
||||
except (ValueError, TypeError):
|
||||
return "N/A"
|
||||
|
||||
def process_and_encrypt_image(stream, orig_fn, keep_exif=False):
|
||||
try:
|
||||
img = Image.open(stream)
|
||||
if img.mode in ('RGBA', 'P'):
|
||||
img = img.convert('RGB')
|
||||
output_buffer = BytesIO()
|
||||
img.save(output_buffer, 'webp', quality=80)
|
||||
output_buffer.seek(0)
|
||||
image_data = output_buffer.read()
|
||||
encrypted_data = fernet.encrypt(image_data)
|
||||
new_filename = f"{uuid.uuid4().hex}.webp"
|
||||
filepath = os.path.join(app.config['UPLOAD_FOLDER'], new_filename)
|
||||
with open(filepath, 'wb') as f:
|
||||
f.write(encrypted_data)
|
||||
return new_filename
|
||||
buf = BytesIO()
|
||||
exif = img.info.get('exif') if keep_exif and 'exif' in img.info else None
|
||||
|
||||
save_params = {'quality': 80}
|
||||
if exif:
|
||||
save_params['exif'] = exif
|
||||
|
||||
img.save(buf, 'WEBP', **save_params)
|
||||
buf.seek(0)
|
||||
encrypted = fernet.encrypt(buf.read())
|
||||
|
||||
new_fn = f"{uuid.uuid4().hex}.webp"
|
||||
path = os.path.join(app.config['UPLOAD_FOLDER'], new_fn)
|
||||
with open(path, 'wb') as f:
|
||||
f.write(encrypted)
|
||||
return new_fn
|
||||
except Exception as e:
|
||||
print(f"Could not process image {original_filename}: {e}")
|
||||
app.logger.error(f"Image processing failed ({orig_fn}): {e}")
|
||||
return None
|
||||
|
||||
def get_time_left(expiry_date_str):
|
||||
if not expiry_date_str:
|
||||
return "N/A"
|
||||
try:
|
||||
expiry_date = datetime.fromisoformat(expiry_date_str)
|
||||
now = datetime.now()
|
||||
remaining = expiry_date - now
|
||||
if remaining.total_seconds() <= 0:
|
||||
return "Expired"
|
||||
days, seconds = remaining.days, remaining.seconds
|
||||
hours = days * 24 + seconds // 3600
|
||||
minutes = (seconds % 3600) // 60
|
||||
if hours > 0:
|
||||
return f"~{hours}h {minutes}m"
|
||||
else:
|
||||
return f"~{minutes}m"
|
||||
except Exception:
|
||||
return "Invalid date"
|
||||
|
||||
# Context processor to inject banner variables by reading from a file
|
||||
@app.context_processor
|
||||
def inject_announcement():
|
||||
try:
|
||||
with open('announcement.txt', 'r') as f:
|
||||
message = f.read().strip()
|
||||
if message:
|
||||
return dict(
|
||||
announcement_enabled=True,
|
||||
announcement_message=message
|
||||
)
|
||||
msg = f.read().strip()
|
||||
if msg:
|
||||
return dict(announcement_enabled=True, announcement_message=msg)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return dict(announcement_enabled=False, announcement_message='')
|
||||
|
||||
# --- Custom Error Handlers ---
|
||||
# --- Error Handlers ---
|
||||
@app.errorhandler(404)
|
||||
def not_found(e):
|
||||
flash('Content not found or has expired.', 'error')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
@app.errorhandler(410)
|
||||
def gone(e):
|
||||
flash('Content has expired due to exceeding its view limit.', 'error')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
@app.errorhandler(413)
|
||||
def request_entity_too_large(error):
|
||||
def too_large(e):
|
||||
if request.path.startswith('/api/'):
|
||||
return jsonify(error="File is too large (max 10MB)."), 413
|
||||
flash('File is too large (max 10MB). Please upload a smaller file.', 'error')
|
||||
flash('File is too large (max 10MB).', 'error')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
@app.errorhandler(429)
|
||||
def ratelimit_handler(e):
|
||||
def rate_limited(e):
|
||||
if request.path.startswith('/api/'):
|
||||
return jsonify(error=f"ratelimit exceeded: {e.description}"), 429
|
||||
flash('You have made too many requests. Please wait a while before trying again.', 'error')
|
||||
return jsonify(error=f"Rate limit exceeded: {e.description}"), 429
|
||||
flash('Too many requests. Please wait a while.', 'error')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
# --- Health Check ---
|
||||
@app.route('/healthz')
|
||||
def healthz():
|
||||
try:
|
||||
conn = sqlite3.connect(app.config['DATABASE_PATH'])
|
||||
conn.execute("SELECT 1").fetchone()
|
||||
conn.close()
|
||||
db_status = "ok"
|
||||
except Exception as e:
|
||||
app.logger.error(f"Health check DB error: {e}")
|
||||
db_status = "error"
|
||||
sched_status = "running" if scheduler.running and scheduler.state == 1 else "stopped"
|
||||
return jsonify(database=db_status, scheduler=sched_status)
|
||||
|
||||
# --- Web UI Routes ---
|
||||
@app.route('/')
|
||||
def index():
|
||||
db = get_db()
|
||||
stats_list = db.execute("SELECT stat_key, stat_value FROM stats").fetchall()
|
||||
stats = {row['stat_key']: row['stat_value'] for row in stats_list}
|
||||
rows = db.execute("SELECT stat_key, stat_value FROM stats").fetchall()
|
||||
stats = {r['stat_key']: r['stat_value'] for r in rows}
|
||||
# We want 'text' to be at the top of the list in the index page dropdown
|
||||
index_languages = [lang for lang in POPULAR_LANGUAGES if lang != 'text']
|
||||
return render_template(
|
||||
'index.html',
|
||||
languages=POPULAR_LANGUAGES,
|
||||
languages=index_languages,
|
||||
stats=stats,
|
||||
allowed_extensions=list(ALLOWED_EXTENSIONS)
|
||||
)
|
||||
@ -275,14 +266,16 @@ def index():
|
||||
def donate_page():
|
||||
return render_template('donate.html')
|
||||
|
||||
if not app.config.get('ADMIN_URL'):
|
||||
raise ValueError("Configuration Error: SSP_ADMIN_URL is not set.")
|
||||
|
||||
@app.route(app.config['ADMIN_URL'], methods=['GET', 'POST'])
|
||||
def admin_dashboard():
|
||||
if request.method == 'POST':
|
||||
password_attempt = request.form.get('password')
|
||||
if password_attempt == app.config['ADMIN_PASSWORD']:
|
||||
pw = request.form.get('password', '')
|
||||
if app.config['ADMIN_PASSWORD_HASH'] and check_password_hash(app.config['ADMIN_PASSWORD_HASH'], pw):
|
||||
session['admin_logged_in'] = True
|
||||
return redirect(url_for('admin_dashboard'))
|
||||
else:
|
||||
flash('Incorrect password.', 'error')
|
||||
|
||||
if not session.get('admin_logged_in'):
|
||||
@ -290,232 +283,321 @@ def admin_dashboard():
|
||||
|
||||
db = get_db()
|
||||
now = datetime.now()
|
||||
images_raw = db.execute(
|
||||
"SELECT id, expiry_date FROM images WHERE expiry_date >= ? ORDER BY expiry_date ASC",
|
||||
(now,)
|
||||
).fetchall()
|
||||
pastes_raw = db.execute(
|
||||
"SELECT id, language, expiry_date FROM pastes WHERE expiry_date >= ? ORDER BY expiry_date ASC",
|
||||
(now,)
|
||||
).fetchall()
|
||||
imgs = db.execute("SELECT id, expiry_date, view_count, max_views FROM images ORDER BY expiry_date ASC").fetchall()
|
||||
past = db.execute("SELECT id, language, expiry_date, view_count, max_views FROM pastes ORDER BY expiry_date ASC").fetchall()
|
||||
|
||||
images = [
|
||||
(i['id'], i['expiry_date'], get_time_left(i['expiry_date']))
|
||||
for i in images_raw
|
||||
]
|
||||
pastes = [
|
||||
(p['id'], p['language'], p['expiry_date'], get_time_left(p['expiry_date']))
|
||||
for p in pastes_raw
|
||||
]
|
||||
return render_template(
|
||||
'admin.html',
|
||||
images=images,
|
||||
pastes=pastes,
|
||||
auth_success=True
|
||||
)
|
||||
images = [(i['id'], i['expiry_date'], get_time_left(i['expiry_date']), i['view_count'], i['max_views']) for i in imgs]
|
||||
pastes = [(p['id'], p['language'], p['expiry_date'], get_time_left(p['expiry_date']), p['view_count'], p['max_views']) for p in past]
|
||||
|
||||
return render_template('admin.html', auth_success=True, images=images, pastes=pastes)
|
||||
|
||||
@app.route('/upload/image', methods=['POST'])
|
||||
@limiter.limit("10 per hour")
|
||||
def upload_image():
|
||||
if 'file' not in request.files:
|
||||
flash('No file part in request.', 'error')
|
||||
return redirect(url_for('index', _anchor='image'))
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
if 'file' not in request.files or request.files['file'].filename == '':
|
||||
flash('No file selected.', 'error')
|
||||
return redirect(url_for('index', _anchor='image'))
|
||||
|
||||
file = request.files['file']
|
||||
if file and allowed_file(file.filename):
|
||||
new_filename = process_and_encrypt_image(file.stream, file.filename)
|
||||
if not new_filename:
|
||||
flash('There was an error processing the image.', 'error')
|
||||
keep_exif = bool(request.form.get('keep_exif'))
|
||||
new_fn = process_and_encrypt_image(file.stream, file.filename, keep_exif)
|
||||
if not new_fn:
|
||||
flash('Error processing image.', 'error')
|
||||
return redirect(url_for('index', _anchor='image'))
|
||||
|
||||
now = datetime.now()
|
||||
expiry_key = request.form.get('expiry', '1h')
|
||||
expiry_delta = EXPIRY_MAP.get(expiry_key, timedelta(hours=1))
|
||||
expiry_date = now + expiry_delta
|
||||
expiry = now + EXPIRY_MAP.get(request.form.get('expiry', '1h'), timedelta(hours=1))
|
||||
pw = request.form.get('password') or None
|
||||
pw_hash = generate_password_hash(pw, method='pbkdf2:sha256') if pw else None
|
||||
mv = request.form.get('max_views')
|
||||
mv = int(mv) if mv and mv.isdigit() else None
|
||||
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO images (id, upload_date, expiry_date) VALUES (?, ?, ?)",
|
||||
(new_filename, now, expiry_date)
|
||||
'INSERT INTO images (id, upload_date, expiry_date, password_hash, max_views, view_count) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
(new_fn, now, expiry, pw_hash, mv, -1)
|
||||
)
|
||||
db.commit()
|
||||
update_stat('total_images')
|
||||
return redirect(url_for('view_image', filename=new_filename))
|
||||
flash('Invalid file type. Please check the allowed formats.', 'error')
|
||||
|
||||
flash('Image uploaded successfully! This is your shareable link.', 'success')
|
||||
return redirect(url_for('view_image', filename=new_fn))
|
||||
|
||||
flash('Invalid file type.', 'error')
|
||||
return redirect(url_for('index', _anchor='image'))
|
||||
|
||||
|
||||
@app.route('/upload/paste', methods=['POST'])
|
||||
@limiter.limit("20 per hour")
|
||||
def upload_paste():
|
||||
content = request.form.get('content')
|
||||
language = request.form.get('language', 'text')
|
||||
expiry_key = request.form.get('expiry', '1h')
|
||||
if not content or not content.strip():
|
||||
content = request.form.get('content', '').strip()
|
||||
if not content:
|
||||
flash('Paste content cannot be empty.', 'error')
|
||||
return redirect(url_for('index', _anchor='paste'))
|
||||
|
||||
now = datetime.now()
|
||||
expiry = now + EXPIRY_MAP.get(request.form.get('expiry', '1h'), timedelta(hours=1))
|
||||
pw = request.form.get('password') or None
|
||||
pw_hash = generate_password_hash(pw, method='pbkdf2:sha256') if pw else None
|
||||
mv = request.form.get('max_views')
|
||||
mv = int(mv) if mv and mv.isdigit() else None
|
||||
|
||||
paste_id = uuid.uuid4().hex
|
||||
expiry_delta = EXPIRY_MAP.get(expiry_key, timedelta(hours=1))
|
||||
expiry_date = datetime.now() + expiry_delta
|
||||
encrypted_content = fernet.encrypt(content.encode('utf-8'))
|
||||
encrypted = fernet.encrypt(content.encode('utf-8'))
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO pastes (id, content, language, expiry_date) VALUES (?, ?, ?, ?)",
|
||||
(paste_id, encrypted_content, language, expiry_date)
|
||||
'INSERT INTO pastes (id, content, language, expiry_date, password_hash, max_views, view_count) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
(paste_id, encrypted, request.form.get('language', 'text'), expiry, pw_hash, mv, -1)
|
||||
)
|
||||
db.commit()
|
||||
update_stat('total_pastes')
|
||||
|
||||
flash('Paste created successfully! This is your shareable link.', 'success')
|
||||
return redirect(url_for('view_paste', paste_id=paste_id))
|
||||
|
||||
@app.route('/image/<filename>')
|
||||
def view_image(filename):
|
||||
safe_filename = secure_filename(filename)
|
||||
filepath = os.path.join(app.config['UPLOAD_FOLDER'], safe_filename)
|
||||
if not os.path.exists(filepath):
|
||||
flash('The image you requested has expired or does not exist.', 'error')
|
||||
return redirect(url_for('index'))
|
||||
return render_template('view_image.html', filename=safe_filename)
|
||||
|
||||
@app.route('/paste/<paste_id>')
|
||||
@app.route('/image/<filename>', methods=['GET', 'POST'])
|
||||
def view_image(filename):
|
||||
db = get_db()
|
||||
row = db.execute("SELECT * FROM images WHERE id = ?", (filename,)).fetchone()
|
||||
|
||||
if not row or datetime.now() > datetime.fromisoformat(row['expiry_date']):
|
||||
if row: # If row exists but is expired, delete it.
|
||||
db.execute("DELETE FROM images WHERE id = ?", (filename,))
|
||||
db.commit()
|
||||
abort(404)
|
||||
|
||||
pw_hash = row['password_hash']
|
||||
if pw_hash and not session.get(f'unlocked_image_{filename}'):
|
||||
if request.method == 'POST':
|
||||
if check_password_hash(pw_hash, request.form.get('password', '')):
|
||||
session[f'unlocked_image_{filename}'] = True
|
||||
return redirect(url_for('view_image', filename=filename))
|
||||
flash('Incorrect password.', 'error')
|
||||
return render_template('view_image.html', password_required=True, filename=filename)
|
||||
|
||||
return render_template('view_image.html',
|
||||
password_required=False,
|
||||
filename=filename,
|
||||
time_left=get_time_left(row['expiry_date'])
|
||||
)
|
||||
|
||||
|
||||
@app.route('/paste/<paste_id>', methods=['GET', 'POST'])
|
||||
def view_paste(paste_id):
|
||||
db = get_db()
|
||||
result = db.execute(
|
||||
"SELECT content, language FROM pastes WHERE id = ?",
|
||||
(paste_id,)
|
||||
).fetchone()
|
||||
if result is None:
|
||||
flash('The paste you requested has expired or does not exist.', 'error')
|
||||
return redirect(url_for('index'))
|
||||
encrypted_content = result['content']
|
||||
language = result['language']
|
||||
decrypted_content = fernet.decrypt(encrypted_content).decode('utf-8')
|
||||
row = db.execute("SELECT * FROM pastes WHERE id = ?", (paste_id,)).fetchone()
|
||||
|
||||
if not row or datetime.now() > datetime.fromisoformat(row['expiry_date']):
|
||||
if row:
|
||||
db.execute("DELETE FROM pastes WHERE id = ?", (paste_id,))
|
||||
db.commit()
|
||||
abort(404)
|
||||
|
||||
pw_hash = row['password_hash']
|
||||
if pw_hash and not session.get(f'unlocked_paste_{paste_id}'):
|
||||
if request.method == 'POST':
|
||||
if check_password_hash(pw_hash, request.form.get('password', '')):
|
||||
session[f'unlocked_paste_{paste_id}'] = True
|
||||
return redirect(url_for('view_paste', paste_id=paste_id))
|
||||
flash('Incorrect password.', 'error')
|
||||
return render_template('view_paste.html', password_required=True, paste_id=paste_id)
|
||||
|
||||
if row['max_views'] is not None and row['view_count'] >= row['max_views']:
|
||||
db.execute("DELETE FROM pastes WHERE id = ?", (paste_id,))
|
||||
db.commit()
|
||||
abort(410)
|
||||
|
||||
# Only increment view count on the initial, non-overridden view
|
||||
if 'lang' not in request.args:
|
||||
db.execute("UPDATE pastes SET view_count = view_count + 1 WHERE id = ?", (paste_id,))
|
||||
db.commit()
|
||||
|
||||
content = fernet.decrypt(row['content']).decode('utf-8')
|
||||
|
||||
# Get the language, allowing for a user override via URL parameter
|
||||
default_language = row['language']
|
||||
selected_language = request.args.get('lang', default_language)
|
||||
|
||||
try:
|
||||
lexer = get_lexer_by_name(language)
|
||||
lexer = get_lexer_by_name(selected_language)
|
||||
except:
|
||||
lexer = get_lexer_by_name('text')
|
||||
formatter = HtmlFormatter(
|
||||
style='monokai',
|
||||
cssclass="syntax",
|
||||
noclasses=False
|
||||
)
|
||||
highlighted_content = highlight(decrypted_content, lexer, formatter)
|
||||
css_styles = formatter.get_style_defs('.syntax')
|
||||
return render_template(
|
||||
'view_paste.html',
|
||||
|
||||
fmt = HtmlFormatter(style='monokai', cssclass='syntax', linenos='table')
|
||||
highlighted = highlight(content, lexer, fmt)
|
||||
|
||||
return render_template('view_paste.html',
|
||||
password_required=False,
|
||||
paste_id=paste_id,
|
||||
highlighted_content=highlighted_content,
|
||||
css_styles=css_styles
|
||||
highlighted_content=highlighted,
|
||||
time_left=get_time_left(row['expiry_date']),
|
||||
languages=POPULAR_LANGUAGES,
|
||||
selected_language=selected_language
|
||||
)
|
||||
|
||||
|
||||
@app.route('/paste/<paste_id>/raw')
|
||||
def paste_raw(paste_id):
|
||||
db = get_db()
|
||||
row = db.execute("SELECT * FROM pastes WHERE id = ?", (paste_id,)).fetchone()
|
||||
|
||||
if not row or datetime.now() > datetime.fromisoformat(row['expiry_date']):
|
||||
abort(404)
|
||||
|
||||
if row['password_hash'] and not session.get(f'unlocked_paste_{paste_id}'):
|
||||
abort(403)
|
||||
|
||||
if row['max_views'] is not None and row['view_count'] >= row['max_views']:
|
||||
db.execute("DELETE FROM pastes WHERE id = ?", (paste_id,))
|
||||
db.commit()
|
||||
abort(410)
|
||||
|
||||
db.execute("UPDATE pastes SET view_count = view_count + 1 WHERE id = ?", (paste_id,))
|
||||
db.commit()
|
||||
|
||||
text = fernet.decrypt(row['content']).decode('utf-8')
|
||||
return Response(text, mimetype='text/plain')
|
||||
|
||||
|
||||
@app.route('/uploads/<filename>')
|
||||
def get_upload(filename):
|
||||
safe_filename = secure_filename(filename)
|
||||
filepath = os.path.join(app.config['UPLOAD_FOLDER'], safe_filename)
|
||||
try:
|
||||
with open(filepath, 'rb') as f:
|
||||
encrypted_data = f.read()
|
||||
decrypted_data = fernet.decrypt(encrypted_data)
|
||||
return send_file(BytesIO(decrypted_data), mimetype='image/webp')
|
||||
except (FileNotFoundError, IOError):
|
||||
safe_fn = secure_filename(filename)
|
||||
path = os.path.join(app.config['UPLOAD_FOLDER'], safe_fn)
|
||||
db = get_db()
|
||||
|
||||
row = db.execute("SELECT * FROM images WHERE id = ?", (safe_fn,)).fetchone()
|
||||
|
||||
if not row or not os.path.exists(path) or datetime.now() > datetime.fromisoformat(row['expiry_date']):
|
||||
if row:
|
||||
db.execute("DELETE FROM images WHERE id = ?", (safe_fn,))
|
||||
db.commit()
|
||||
if os.path.exists(path): os.remove(path)
|
||||
abort(404)
|
||||
|
||||
if row['password_hash'] and not session.get(f'unlocked_image_{safe_fn}'):
|
||||
abort(403)
|
||||
|
||||
if row['max_views'] is not None and row['view_count'] >= row['max_views']:
|
||||
db.execute("DELETE FROM images WHERE id = ?", (safe_fn,))
|
||||
db.commit()
|
||||
os.remove(path)
|
||||
abort(410)
|
||||
|
||||
db.execute("UPDATE images SET view_count = view_count + 1 WHERE id = ?", (safe_fn,))
|
||||
db.commit()
|
||||
|
||||
try:
|
||||
with open(path, 'rb') as f:
|
||||
encrypted = f.read()
|
||||
data = fernet.decrypt(encrypted)
|
||||
return send_file(BytesIO(data), mimetype='image/webp')
|
||||
except Exception as e:
|
||||
print(f"Could not decrypt or serve file {filename}: {e}")
|
||||
app.logger.error(f"Error serving image {safe_fn}: {e}")
|
||||
abort(500)
|
||||
|
||||
@app.route('/admin/delete/image/<filename>', methods=['POST'])
|
||||
def delete_image(filename):
|
||||
if not session.get('admin_logged_in'):
|
||||
abort(401)
|
||||
safe_filename = secure_filename(filename)
|
||||
filepath = os.path.join(app.config['UPLOAD_FOLDER'], safe_filename)
|
||||
if not session.get('admin_logged_in'): abort(401)
|
||||
safe = secure_filename(filename)
|
||||
path = os.path.join(app.config['UPLOAD_FOLDER'], safe)
|
||||
try:
|
||||
os.remove(filepath)
|
||||
if os.path.exists(path): os.remove(path)
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM images WHERE id = ?", (safe_filename,))
|
||||
db.execute("DELETE FROM images WHERE id = ?", (safe,))
|
||||
db.commit()
|
||||
flash(f'Image "{safe_filename}" has been deleted.', 'success')
|
||||
except OSError as e:
|
||||
flash(f'Image "{safe}" has been deleted.', 'success')
|
||||
except Exception as e:
|
||||
flash(f'Error deleting image file: {e}', 'error')
|
||||
return redirect(url_for('admin_dashboard'))
|
||||
|
||||
@app.route('/admin/delete/paste/<paste_id>', methods=['POST'])
|
||||
def delete_paste(paste_id):
|
||||
if not session.get('admin_logged_in'):
|
||||
abort(401)
|
||||
if not session.get('admin_logged_in'): abort(401)
|
||||
try:
|
||||
db = get_db()
|
||||
db.execute("DELETE FROM pastes WHERE id = ?", (paste_id,))
|
||||
db.commit()
|
||||
flash(f'Paste "{paste_id}" has been deleted.', 'success')
|
||||
except Exception as e:
|
||||
flash(f'Error deleting paste: {e}', 'error')
|
||||
return redirect(url_for('admin_dashboard'))
|
||||
|
||||
# --- API Routes ---
|
||||
@app.route('/api/upload/image', methods=['POST'])
|
||||
@limiter.limit("50 per hour")
|
||||
def api_upload_image():
|
||||
if 'file' not in request.files:
|
||||
return jsonify({"error": "No file part in the request"}), 400
|
||||
if 'file' not in request.files or request.files['file'].filename == '':
|
||||
return jsonify(error="No file selected"), 400
|
||||
|
||||
file = request.files['file']
|
||||
if file.filename == '':
|
||||
return jsonify({"error": "No file selected"}), 400
|
||||
if file and allowed_file(file.filename):
|
||||
new_filename = process_and_encrypt_image(file.stream, file.filename)
|
||||
if not new_filename:
|
||||
return jsonify({"error": "Failed to process image"}), 500
|
||||
new_fn = process_and_encrypt_image(file.stream, file.filename, bool(request.form.get('keep_exif')))
|
||||
if not new_fn: return jsonify(error="Failed to process image"), 500
|
||||
|
||||
now = datetime.now()
|
||||
expiry = request.form.get('expiry', '1h')
|
||||
expiry_delta = EXPIRY_MAP.get(expiry, timedelta(hours=1))
|
||||
expiry_date = now + expiry_delta
|
||||
expiry = now + EXPIRY_MAP.get(request.form.get('expiry', '1h'), timedelta(hours=1))
|
||||
pw = request.form.get('password')
|
||||
pw_hash = generate_password_hash(pw, method='pbkdf2:sha256') if pw else None
|
||||
mv = request.form.get('max_views')
|
||||
mv = int(mv) if mv and mv.isdigit() else None
|
||||
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO images (id, upload_date, expiry_date) VALUES (?, ?, ?)",
|
||||
(new_filename, now, expiry_date)
|
||||
'INSERT INTO images (id, upload_date, expiry_date, password_hash, max_views, view_count) VALUES (?, ?, ?, ?, ?, ?)',
|
||||
(new_fn, now, expiry, pw_hash, mv, -1)
|
||||
)
|
||||
db.commit()
|
||||
update_stat('total_api_uploads')
|
||||
image_url = url_for('get_upload', filename=new_filename, _external=True)
|
||||
return jsonify({"success": True, "url": image_url, "expires_in": expiry}), 200
|
||||
return jsonify({"error": "Invalid file type. Please check the allowed formats."}), 400
|
||||
return jsonify(success=True, url=url_for('get_upload', filename=new_fn, _external=True)), 200
|
||||
|
||||
return jsonify(error="Invalid file type"), 400
|
||||
|
||||
|
||||
@app.route('/api/upload/paste', methods=['POST'])
|
||||
@limiter.limit("100 per hour")
|
||||
def api_upload_paste():
|
||||
if not request.is_json:
|
||||
return jsonify({"error": "Request must be JSON"}), 400
|
||||
if not request.is_json: return jsonify(error="Request must be JSON"), 400
|
||||
|
||||
data = request.get_json()
|
||||
content = data.get('content')
|
||||
language = data.get('language', 'text')
|
||||
expiry = data.get('expiry', '1h')
|
||||
if not content:
|
||||
return jsonify({"error": "Paste content is missing"}), 400
|
||||
content = data.get('content', '').strip()
|
||||
if not content: return jsonify(error="Paste content is missing"), 400
|
||||
|
||||
now = datetime.now()
|
||||
expiry = now + EXPIRY_MAP.get(data.get('expiry', '1h'), timedelta(hours=1))
|
||||
pw = data.get('password')
|
||||
pw_hash = generate_password_hash(pw, method='pbkdf2:sha256') if pw else None
|
||||
mv = data.get('max_views')
|
||||
mv = int(mv) if mv and str(mv).isdigit() else None
|
||||
|
||||
paste_id = uuid.uuid4().hex
|
||||
expiry_delta = EXPIRY_MAP.get(expiry, timedelta(hours=1))
|
||||
expiry_date = datetime.now() + expiry_delta
|
||||
encrypted_content = fernet.encrypt(content.encode('utf-8'))
|
||||
encrypted = fernet.encrypt(content.encode('utf-8'))
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO pastes (id, content, language, expiry_date) VALUES (?, ?, ?, ?)",
|
||||
(paste_id, encrypted_content, language, expiry_date)
|
||||
'INSERT INTO pastes (id, content, language, expiry_date, password_hash, max_views, view_count) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
(paste_id, encrypted, data.get('language', 'text'), expiry, pw_hash, mv, -1)
|
||||
)
|
||||
db.commit()
|
||||
update_stat('total_api_uploads')
|
||||
paste_url = url_for('view_paste', paste_id=paste_id, _external=True)
|
||||
return jsonify({"success": True, "url": paste_url, "expires_in": expiry}), 200
|
||||
return jsonify(success=True, url=url_for('view_paste', paste_id=paste_id, _external=True)), 200
|
||||
|
||||
|
||||
# --- Main Execution ---
|
||||
if __name__ == '__main__':
|
||||
# Check if essential environment variables are set
|
||||
if not all([
|
||||
app.config['SECRET_KEY'],
|
||||
app.config['ADMIN_PASSWORD'],
|
||||
app.config['ENCRYPTION_KEY']
|
||||
]):
|
||||
print("FATAL ERROR: One or more required environment variables are not set.")
|
||||
print("Please create a .env file or set them on your system.")
|
||||
required_vars = ['SSP_SECRET_KEY', 'SSP_ADMIN_PASSWORD_HASH', 'SSP_ADMIN_URL', 'SSP_ENCRYPTION_KEY']
|
||||
missing_vars = [var for var in required_vars if not os.getenv(var)]
|
||||
if missing_vars:
|
||||
print(f"FATAL ERROR: Required environment variables are not set: {', '.join(missing_vars)}")
|
||||
exit(1)
|
||||
|
||||
if not os.path.exists(app.config['UPLOAD_FOLDER']):
|
||||
os.makedirs(app.config['UPLOAD_FOLDER'])
|
||||
os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True)
|
||||
with app.app_context():
|
||||
init_db()
|
||||
scheduler.add_job(cleanup_expired_content, 'interval', minutes=30)
|
||||
scheduler.add_job(cleanup_expired_content, 'interval', minutes=15)
|
||||
scheduler.start()
|
||||
print("--- Deletion Scheduler and Cleanup Job are Running ---")
|
||||
app.run(debug=True, use_reloader=False)
|
||||
|
||||
print(f"Starting Flask app with debug mode: {app.config['FLASK_DEBUG']}")
|
||||
|
||||
# Run the app. Debug mode is controlled by the SSP_FLASK_DEBUG environment variable.
|
||||
# For production, it's recommended to use a proper WSGI server like Gunicorn or uWSGI.
|
||||
app.run(debug=app.config['FLASK_DEBUG'], use_reloader=False)
|
BIN
static/favicon.ico
Normal file
BIN
static/favicon.ico
Normal file
Binary file not shown.
After Width: | Height: | Size: 15 KiB |
@ -5,6 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Admin Dashboard - I2P Secure Share</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/tailwind.css') }}">
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
|
||||
<style>
|
||||
body { background-color: #1a202c; color: #cbd5e0; }
|
||||
.content-container { background-color: #2d3748; border: 1px solid #4a5568; }
|
||||
@ -31,10 +32,8 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center justify-center min-h-screen py-8">
|
||||
<div class="w-full max-w-4xl mx-auto p-4">
|
||||
<header class="text-center mb-8">
|
||||
<a href="/" class="inline-block mb-4">
|
||||
<img src="{{ url_for('static', filename='images/stormycloud.svg') }}" alt="StormyCloud Logo" style="width: 550px; 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>
|
||||
<h1 class="text-4xl font-bold text-white">Admin Dashboard</h1>
|
||||
</header>
|
||||
|
@ -4,7 +4,9 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Support the Project - I2P Secure Share</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='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">
|
||||
|
||||
<style>
|
||||
body { background-color: #1a202c; color: #cbd5e0; }
|
||||
.content-container { background-color: #2d3748; border: 1px solid #4a5568; }
|
||||
@ -25,7 +27,7 @@
|
||||
<div 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="{{ url_for('static', filename='images/stormycloud.svg') }}" alt="StormyCloud Logo" style="width: 550px; 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>
|
||||
<h1 class="text-3xl font-bold text-white">Support the Service</h1>
|
||||
</header>
|
||||
@ -41,13 +43,13 @@
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-12 border-t border-gray-700 pt-6">
|
||||
<a href="{{ url_for('index') }}" class="text-blue-400 hover:text-blue-300">Back to Uploader</a>
|
||||
<a href="/" class="text-blue-400 hover:text-blue-300">Back to Uploader</a>
|
||||
</div>
|
||||
</main>
|
||||
<footer class="text-center text-gray-500 mt-16 border-t border-gray-700 pt-8 pb-8">
|
||||
<a href="http://stormycloud.i2p" class="hover:text-gray-400">StormyCloud</a>
|
||||
<span class="mx-2">|</span>
|
||||
<a href="{{ url_for('donate_page') }}" class="hover:text-gray-400">Donate</a>
|
||||
<a href="/donate" class="hover:text-gray-400">Donate</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,34 +1,80 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>I2P Secure Share</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='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">
|
||||
|
||||
<style>
|
||||
body { background-color: #1a202c; color: #cbd5e0; }
|
||||
.content-container { background-color: #2d3748; border: 1px solid #4a5568; }
|
||||
.content-container { background-color: #2d3748; border:1px solid #4a5568; border-radius:0.5rem; }
|
||||
.tab { border-bottom:2px solid transparent; cursor:pointer; }
|
||||
.tab.active { border-bottom-color:#63b3ed; color:#ffffff; }
|
||||
.btn { background-color: #4299e1; transition: background-color 0.3s ease; }
|
||||
.btn { background-color:#4299e1; transition:background-color .3s ease; }
|
||||
.btn:hover { background-color:#3182ce; }
|
||||
.btn:disabled { background-color: #2b6cb0; cursor: not-allowed; }
|
||||
input[type="file"]::file-selector-button { background-color: #4a5568; color: #cbd5e0; border: none; padding: 0.5rem 1rem; border-radius: 0.25rem; cursor: pointer; transition: background-color 0.3s ease; }
|
||||
input[type="file"]::file-selector-button:hover { background-color: #2d3748; }
|
||||
select, textarea, input { background-color: #4a5568; border: 1px solid #718096; }
|
||||
select,textarea,input[type="text"],input[type="password"],input[type="number"] {
|
||||
background-color:#4a5568; border:1px solid #718096; color:#cbd5e0;
|
||||
}
|
||||
.alert-success { background-color:#38a169; }
|
||||
.alert-error { background-color:#e53e3e; }
|
||||
.feature-card { background-color: #2d3748; }
|
||||
.docs-container h3, .tos-container h2, #stats-content h2 { font-size: 1.5rem; font-weight: 600; color: #ffffff; margin-bottom: 1rem; }
|
||||
.docs-container h3, .tos-container h2 { border-bottom: 1px solid #4a5568; padding-bottom: 0.5rem; margin-top: 2rem; }
|
||||
.docs-container p, .docs-container li, .tos-container p, .tos-container li { color: #a0aec0; }
|
||||
.docs-container code { background-color: #1a202c; color: #f7fafc; padding: 0.2rem 0.4rem; border-radius: 0.25rem; font-family: 'Courier New', Courier, monospace; }
|
||||
.docs-container pre { background-color: #1a202c; padding: 1rem; border-radius: 0.5rem; overflow-x: auto; }
|
||||
.tos-container ul { list-style-type: disc; padding-left: 1.5rem; margin-bottom: 1rem; }
|
||||
.stat-card { background-color: #2d3748; border: 1px solid #4a5568; }
|
||||
.stat-value { color: #63b3ed; }
|
||||
.announcement-bar { background-color:#2563eb; border-bottom:1px solid #1e3a8a; }
|
||||
#main-container { transition: max-width 0.3s ease-in-out; }
|
||||
.reveal { display:none; }
|
||||
input[type="file"] {
|
||||
width:100%; padding:0.5rem 1rem; border-radius:0.375rem;
|
||||
background-color:#4a5568; border:1px solid #718096; color:#cbd5e0; cursor:pointer;
|
||||
}
|
||||
input[type="file"]::file-selector-button {
|
||||
background-color:#2d3748; color:#cbd5e0; border:none;
|
||||
padding:0.5rem 1rem; margin-right:1rem; border-radius:0.375rem; cursor:pointer;
|
||||
transition:background-color .3s ease;
|
||||
}
|
||||
input[type="file"]::file-selector-button:hover { background-color:#3a4a5a; }
|
||||
.stat-card {
|
||||
background-color:#2d3748; border:1px solid #4a5568; border-radius:0.5rem;
|
||||
}
|
||||
.stat-value { color:#63b3ed; }
|
||||
.label-with-icon {
|
||||
display:inline-flex; align-items:center; gap:0.5rem; font-size:0.875rem; color:#cbd5e0;
|
||||
}
|
||||
.label-with-icon svg {
|
||||
width:1rem; height:1rem; color:#4299e1; flex-shrink:0;
|
||||
}
|
||||
.feature-card {
|
||||
background-color: #2d3748;
|
||||
border: 1px solid #4a5568;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
/* API Docs Styling */
|
||||
.docs-container h3 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid #4a5568;
|
||||
}
|
||||
.docs-container p { margin-bottom: 1rem; color: #a0aec0; }
|
||||
.docs-container ul { list-style-position: inside; margin-bottom: 1rem; }
|
||||
.docs-container li { margin-bottom: 0.5rem; color: #cbd5e0;}
|
||||
.docs-container code {
|
||||
background-color:#1a202c;
|
||||
color:#f7fafc;
|
||||
padding:0.2rem 0.4rem;
|
||||
border-radius:0.25rem;
|
||||
font-family:monospace;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.docs-container pre {
|
||||
background-color:#1a202c;
|
||||
padding:1rem;
|
||||
border-radius:0.5rem;
|
||||
overflow-x:auto;
|
||||
color:#f7fafc;
|
||||
font-family:monospace;
|
||||
}
|
||||
</style>
|
||||
<noscript>
|
||||
<style>
|
||||
@ -36,10 +82,8 @@
|
||||
.tab-content{display:none!important;}
|
||||
#image-form,#paste-form{display:block!important;margin-bottom:2rem;}
|
||||
#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;}
|
||||
</style>
|
||||
</noscript>
|
||||
</head>
|
||||
@ -48,7 +92,8 @@
|
||||
{% if announcement_enabled and announcement_message %}
|
||||
<div id="announcement-bar" class="announcement-bar text-white text-center p-2 relative shadow-lg">
|
||||
<span>{{ announcement_message }}</span>
|
||||
<button id="close-announcement" class="absolute top-0 right-0 mt-2 mr-4 text-white hover:text-gray-200 text-2xl leading-none">×</button>
|
||||
<button id="close-announcement"
|
||||
class="absolute top-0 right-0 mt-2 mr-4 text-white hover:text-gray-200 text-2xl leading-none">×</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@ -56,275 +101,308 @@
|
||||
<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="{{ url_for('static', filename='images/stormycloud.svg') }}" alt="StormyCloud Logo" style="width: 550px; 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>
|
||||
<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>
|
||||
|
||||
<main>
|
||||
<div id="js-error-container" class="hidden alert-error text-white p-3 rounded-md shadow-lg mb-4" role="alert"></div>
|
||||
|
||||
<div id="js-error-container"
|
||||
class="hidden alert-error text-white p-3 rounded-md shadow-lg mb-4"
|
||||
role="alert"></div>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mb-4">
|
||||
{% for category,message in messages %}
|
||||
<div class="alert-{{ category }} text-white p-3 rounded-md shadow-lg" role="alert">
|
||||
{{ message }}
|
||||
</div>
|
||||
<div class="alert-{{category}} text-white p-3 rounded-md shadow-lg"
|
||||
role="alert">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="mb-4 border-b border-gray-700 tab-nav-container">
|
||||
<nav class="flex flex-wrap -mb-px" id="tab-nav">
|
||||
<a href="#image" class="tab active text-gray-300 py-4 px-6 block hover:text-white focus:outline-none">Image Uploader</a>
|
||||
<a href="#paste" class="tab text-gray-300 py-4 px-6 block hover:text-white focus:outline-none">Pastebin</a>
|
||||
<a href="#api" class="tab text-gray-300 py-4 px-6 block hover:text-white focus:outline-none">API</a>
|
||||
<a href="#stats" class="tab text-gray-300 py-4 px-6 block hover:text-white focus:outline-none">Stats</a>
|
||||
<a href="#tos" class="tab text-gray-300 py-4 px-6 block hover:text-white focus:outline-none">Terms of Service</a>
|
||||
<nav class="flex -mb-px" id="tab-nav">
|
||||
<a href="#image"
|
||||
class="tab active text-gray-300 py-4 px-6 block hover:text-white">Image Uploader</a>
|
||||
<a href="#paste"
|
||||
class="tab text-gray-300 py-4 px-6 block hover:text-white">Pastebin</a>
|
||||
<a href="#api"
|
||||
class="tab text-gray-300 py-4 px-6 block hover:text-white">API</a>
|
||||
<a href="#stats"
|
||||
class="tab text-gray-300 py-4 px-6 block hover:text-white">Stats</a>
|
||||
<a href="#tos"
|
||||
class="tab text-gray-300 py-4 px-6 block hover:text-white">Terms</a>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<div class="noscript-forms-container">
|
||||
<div id="image-form" class="content-container rounded-lg p-8 shadow-lg tab-content">
|
||||
<form id="image-upload-form" action="{{ url_for('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>
|
||||
|
||||
<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" class="w-full text-white rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" required>
|
||||
<p class="text-xs text-gray-500 mt-1">Max file size: 10MB. Images are converted to WebP.</p>
|
||||
<input type="file" name="file" id="image-file" required>
|
||||
<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>
|
||||
<select name="expiry" id="image-expiry" class="w-full p-2 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="15m">15 Minutes</option>
|
||||
<option value="1h" selected>1 Hour</option>
|
||||
<option value="2h">2 Hours</option>
|
||||
<option value="4h">4 Hours</option>
|
||||
<option value="8h">8 Hours</option>
|
||||
<option value="12h">12 Hours</option>
|
||||
<option value="24h">24 Hours</option>
|
||||
<option value="48h">48 Hours</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="15m">15 minutes</option>
|
||||
<option value="1h" selected>1 hour</option>
|
||||
<option value="2h">2 hours</option>
|
||||
<option value="4h">4 hours</option>
|
||||
<option value="8h">8 hours</option>
|
||||
<option value="12h">12 hours</option>
|
||||
<option value="24h">24 hours</option>
|
||||
<option value="48h">48 hours</option>
|
||||
</select>
|
||||
</div>
|
||||
<div><button type="submit" id="upload-button" class="btn w-full text-white font-bold py-3 px-5 rounded-md focus:shadow-outline">Upload Image</button></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>
|
||||
|
||||
<button type="submit" class="btn w-full text-white font-bold py-3 px-5 rounded-md focus:shadow-outline">Upload Image</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div id="paste-form" class="content-container rounded-lg p-8 shadow-lg hidden tab-content">
|
||||
<form action="/upload/paste" method="POST">
|
||||
<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><textarea name="content" id="paste-content" rows="10" class="w-full p-2 rounded-md text-white font-mono focus:outline-none focus:ring-2 focus:ring-blue-500" required></textarea></div>
|
||||
<div class="mb-6">
|
||||
<label for="paste-content" class="block text-gray-300 text-sm font-bold mb-2">Paste Content:</label>
|
||||
<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>
|
||||
<div class="mb-6">
|
||||
<label for="paste-language" class="block text-gray-300 text-sm font-bold mb-2">Language:</label>
|
||||
<select name="language" id="paste-language" class="w-full p-2 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<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="text">Plain Text</option>
|
||||
{% for lang in languages %}
|
||||
<option value="{{ lang|capitalize }}">{{ lang|capitalize }}</option>
|
||||
<option value="{{ lang }}">{{ lang|capitalize }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-6">
|
||||
<label for="paste-expiry" class="block text-gray-300 text-sm font-bold mb-2">Delete after:</label>
|
||||
<select name="expiry" id="paste-expiry" class="w-full p-2 rounded-md text-white focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||
<option value="15m">15 Minutes</option>
|
||||
<option value="1h" selected>1 Hour</option>
|
||||
<option value="2h">2 Hours</option>
|
||||
<option value="4h">4 Hours</option>
|
||||
<option value="8h">8 Hours</option>
|
||||
<option value="12h">12 Hours</option>
|
||||
<option value="24h">24 Hours</option>
|
||||
<option value="48h">48 Hours</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="15m">15 minutes</option>
|
||||
<option value="1h" selected>1 hour</option>
|
||||
<option value="2h">2 hours</option>
|
||||
<option value="4h">4 hours</option>
|
||||
<option value="8h">8 hours</option>
|
||||
<option value="12h">12 hours</option>
|
||||
<option value="24h">24 hours</option>
|
||||
<option value="48h">48 hours</option>
|
||||
</select>
|
||||
</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></div>
|
||||
</form>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<div id="api-docs" class="content-container docs-container rounded-lg p-8 shadow-lg hidden tab-content">
|
||||
<h3>Introduction</h3>
|
||||
<p>The API allows programmatic uploads. All endpoints are rate-limited. No API key is required.</p>
|
||||
|
||||
<h3>Uploading an Image</h3>
|
||||
<p>Send a `POST` request with `multipart/form-data`.</p>
|
||||
<ul class="list-disc list-inside my-4 space-y-2">
|
||||
<p>Send a <code>POST</code> request with <code>multipart/form-data</code>.</p>
|
||||
<ul>
|
||||
<li><strong>Endpoint:</strong> <code>POST /api/upload/image</code></li>
|
||||
<li><strong>Parameter <code>file</code>:</strong> (Required) The image file.</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>
|
||||
<li><strong>Parameter <code>password</code>:</strong> (Optional) A password to protect the content.</li>
|
||||
<li><strong>Parameter <code>max_views</code>:</strong> (Optional) An integer for auto-deletion after N views.</li>
|
||||
</ul>
|
||||
<pre><code>curl -X POST -F "file=@/path/to/image.jpg" http:{{ url_for('api_upload_image', _external=True, _scheme='') }}</code></pre>
|
||||
<pre>curl -X POST -F "file=@/path/to/image.jpg" http://{{ request.host }}/api/upload/image</pre>
|
||||
|
||||
<h3>Creating a Paste</h3>
|
||||
<p>Send a `POST` request with a JSON payload.</p>
|
||||
<ul class="list-disc list-inside my-4 space-y-2">
|
||||
<p>Send a <code>POST</code> request with a JSON payload.</p>
|
||||
<ul>
|
||||
<li><strong>Endpoint:</strong> <code>POST /api/upload/paste</code></li>
|
||||
<li><strong>JSON Field <code>content</code>:</strong> (Required) The paste text.</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>expiry</code>:</strong> (Optional) Same values as image expiry.</li>
|
||||
<li><strong>JSON Field <code>password</code>:</strong> (Optional) A password to protect the content.</li>
|
||||
<li><strong>JSON Field <code>max_views</code>:</strong> (Optional) An integer for auto-deletion after N views.</li>
|
||||
</ul>
|
||||
<pre><code>curl -X POST -H "Content-Type: application/json" -d '{"content": "Hello, World!"}' http:{{ url_for('api_upload_paste', _external=True, _scheme='') }}</code></pre>
|
||||
<pre>curl -X POST -H "Content-Type: application/json" \
|
||||
-d '{"content":"Hello World", "expiry":"1h"}' \
|
||||
http://{{ request.host }}/api/upload/paste</pre>
|
||||
</div>
|
||||
|
||||
<div id="stats-content" class="content-container rounded-lg p-8 shadow-lg hidden tab-content">
|
||||
<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 class="stat-card p-6 rounded-lg text-center"><h3 class="text-xl font-semibold text-white mb-2">Total Image Uploads</h3><p class="text-5xl font-bold stat-value">{{ stats.get('total_images', 0) }}</p></div>
|
||||
<div class="stat-card p-6 rounded-lg text-center"><h3 class="text-xl font-semibold text-white mb-2">Total Paste Uploads</h3><p class="text-5xl font-bold stat-value">{{ stats.get('total_pastes', 0) }}</p></div>
|
||||
<div class="stat-card p-6 rounded-lg text-center md:col-span-2 lg:col-span-1"><h3 class="text-xl font-semibold text-white mb-2">Total API Uploads</h3><p class="text-5xl font-bold stat-value">{{ stats.get('total_api_uploads', 0) }}</p></div>
|
||||
<div class="stat-card p-6 text-center">
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Total Image Uploads</h3>
|
||||
<p class="text-5xl font-bold stat-value">{{ stats.total_images }}</p>
|
||||
</div>
|
||||
<div class="stat-card p-6 text-center">
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Total Paste Uploads</h3>
|
||||
<p class="text-5xl font-bold stat-value">{{ stats.total_pastes }}</p>
|
||||
</div>
|
||||
<div class="stat-card p-6 text-center">
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Total API Uploads</h3>
|
||||
<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">
|
||||
<h2 class="text-3xl font-bold text-white mb-4">Terms of Service</h2>
|
||||
<p><strong>Last Updated: June 13, 2025</strong></p>
|
||||
<p>Welcome! By using this service, you agree to these terms. The service is provided "as-is" with a focus on privacy.</p>
|
||||
<h3>1. Privacy and Data Handling</h3>
|
||||
<ul>
|
||||
<li><strong>No Logs:</strong> We do not store personally identifiable information.</li>
|
||||
<li><strong>Encryption:</strong> All uploads are encrypted on our servers.</li>
|
||||
<li><strong>Data Deletion:</strong> Your data is permanently deleted after the selected expiration time.</li>
|
||||
<li><strong>Metadata Stripping:</strong> EXIF metadata is removed from all images.</li>
|
||||
<p><strong>Last Updated: June 20, 2025</strong></p>
|
||||
<p>By using this service you agree to these terms. The service is provided “as-is” with a focus on privacy.</p>
|
||||
<h3 class="mt-6 text-white text-xl font-semibold mb-2">1. Privacy & Data</h3>
|
||||
<ul class="list-disc list-inside text-gray-300 mb-4">
|
||||
<li>No logs of your identity or IP.</li>
|
||||
<li>All uploads are encrypted at rest.</li>
|
||||
<li>Data auto-deletes after expiry or view limit.</li>
|
||||
<li>EXIF metadata only kept if opted-in.</li>
|
||||
</ul>
|
||||
<h3>2. Acceptable Use</h3>
|
||||
<p>You agree not to upload illegal or harmful content. We reserve the right to remove content that violates these terms.</p>
|
||||
<h3>3. Limitation of Liability</h3>
|
||||
<p>This service is provided for free and without warranties. We are not responsible for any data loss.</p>
|
||||
<h3 class="mt-6 text-white text-xl font-semibold mb-2">2. Acceptable Use</h3>
|
||||
<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>
|
||||
<h3 class="mt-6 text-white text-xl font-semibold mb-2">3. Liability</h3>
|
||||
<p class="text-gray-300">This free service comes with no warranties. We are not responsible for data loss.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
<div class="text-center text-xs text-gray-500 mt-6 mb-8">
|
||||
<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>
|
||||
|
||||
<section class="mt-16 text-center features-section">
|
||||
<section class="text-center features-section">
|
||||
<h2 class="text-3xl font-bold text-white mb-8">Features</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
<div class="feature-card p-6 rounded-lg"><div class="flex items-center justify-center h-12 w-12 rounded-md bg-blue-500 text-white mx-auto mb-4"><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><h3 class="text-xl font-semibold text-white mb-2">Encrypted at Rest</h3><p class="text-gray-400">All uploaded files and pastes are fully encrypted on the server, ensuring your data is protected.</p></div>
|
||||
<div class="feature-card p-6 rounded-lg"><div class="flex items-center justify-center h-12 w-12 rounded-md bg-blue-500 text-white mx-auto mb-4"><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.356m-2.673-9.356C6.673 8.214 9.327 5.5 12 5.5c2.673 0 5.327 2.714 5.327 5.5s-1.009 6.786-2.673 9.356m-2.673-9.356h0z" /></svg></div><h3 class="text-xl font-semibold text-white mb-2">Anonymous by Design</h3><p class="text-gray-400">Image metadata (EXIF) is stripped and no unnecessary logs are kept. Built for the I2P network.</p></div>
|
||||
<div class="feature-card p-6 rounded-lg"><div class="flex items-center justify-center h-12 w-12 rounded-md bg-blue-500 text-white mx-auto mb-4"><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><h3 class="text-xl font-semibold text-white mb-2">StormyCloud Infrastructure</h3><p class="text-gray-400">A fast, reliable, and secure platform dedicated to the privacy of the I2P community.</p></div>
|
||||
<div class="feature-card p-6 rounded-lg">
|
||||
<div class="flex items-center justify-center h-12 w-12 rounded-md bg-blue-500 text-white mx-auto mb-4">
|
||||
<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>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Encrypted at Rest</h3>
|
||||
<p class="text-gray-400">All uploaded files and pastes are fully encrypted on the server, ensuring your data is protected.</p>
|
||||
</div>
|
||||
<div class="feature-card p-6 rounded-lg">
|
||||
<div class="flex items-center justify-center h-12 w-12 rounded-md bg-blue-500 text-white mx-auto mb-4">
|
||||
<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>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">Anonymous by Design</h3>
|
||||
<p class="text-gray-400">Image metadata (EXIF) is stripped and no unnecessary logs are kept. Built for the I2P network.</p>
|
||||
</div>
|
||||
<div class="feature-card p-6 rounded-lg">
|
||||
<div class="flex items-center justify-center h-12 w-12 rounded-md bg-blue-500 text-white mx-auto mb-4">
|
||||
<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>
|
||||
<h3 class="text-xl font-semibold text-white mb-2">StormyCloud Infrastructure</h3>
|
||||
<p class="text-gray-400">A fast, reliable, and secure platform dedicated to the privacy of the I2P community.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="text-center text-gray-500 mt-16 border-t border-gray-700 pt-8 pb-8">
|
||||
<a href="http://stormycloud.i2p" class="hover:text-gray-400">StormyCloud</a>
|
||||
<span class="mx-2">|</span>
|
||||
<a href="{{ url_for('donate_page') }}" class="hover:text-gray-400">Donate</a>
|
||||
<a href="/donate" class="hover:text-gray-400">Donate</a>
|
||||
</footer>
|
||||
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Make variables from backend available to JavaScript
|
||||
const allowedExtensions = {{ allowed_extensions|tojson }};
|
||||
const maxFileSize = {{ config.MAX_CONTENT_LENGTH }};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const contentDivs = {
|
||||
'#image': document.getElementById('image-form'),
|
||||
'#paste': document.getElementById('paste-form'),
|
||||
'#api': document.getElementById('api-docs'),
|
||||
'#stats': document.getElementById('stats-content'),
|
||||
'#tos': document.getElementById('tos-content')
|
||||
'#image': document.querySelector('#image-form'),
|
||||
'#paste': document.querySelector('#paste-form'),
|
||||
'#api': document.querySelector('#api-docs'),
|
||||
'#stats': document.querySelector('#stats-content'),
|
||||
'#tos': document.querySelector('#tos-content')
|
||||
};
|
||||
const tabs = document.querySelectorAll('#tab-nav a');
|
||||
const announcementBar = document.getElementById('announcement-bar');
|
||||
const closeButton = document.getElementById('close-announcement');
|
||||
function showTab(hash) {
|
||||
if (!hash || !contentDivs[hash]) hash = '#image';
|
||||
|
||||
const showTab = (hash) => {
|
||||
if (!hash || !contentDivs[hash]) {
|
||||
hash = '#image';
|
||||
}
|
||||
|
||||
Object.values(contentDivs).forEach(div => { if(div) div.classList.add('hidden'); });
|
||||
Object.values(contentDivs).forEach(d => {
|
||||
if(d) d.classList.add('hidden');
|
||||
});
|
||||
tabs.forEach(t => t.classList.remove('active'));
|
||||
|
||||
if(contentDivs[hash]) {
|
||||
contentDivs[hash].classList.remove('hidden');
|
||||
document.querySelector(`a[href="${hash}"]`).classList.add('active');
|
||||
}
|
||||
};
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', (e) => {
|
||||
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 hash = e.target.hash;
|
||||
window.history.replaceState(null, null, ' ' + hash);
|
||||
showTab(hash);
|
||||
});
|
||||
});
|
||||
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener('click', () => {
|
||||
announcementBar.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
const h = e.target.hash;
|
||||
window.history.replaceState(null, null, ' ' + h);
|
||||
showTab(h);
|
||||
}));
|
||||
showTab(window.location.hash);
|
||||
|
||||
// --- Upload Logic with Client-Side Validation ---
|
||||
const imageForm = document.getElementById('image-upload-form');
|
||||
const imageFileInput = document.getElementById('image-file');
|
||||
const uploadButton = document.getElementById('upload-button');
|
||||
const errorContainer = document.getElementById('js-error-container');
|
||||
|
||||
imageForm.addEventListener('submit', function(event) {
|
||||
event.preventDefault();
|
||||
|
||||
const file = imageFileInput.files[0];
|
||||
|
||||
// --- Start Validation ---
|
||||
errorContainer.classList.add('hidden'); // Hide old errors
|
||||
|
||||
if (!file) {
|
||||
errorContainer.textContent = 'Please select a file to upload.';
|
||||
errorContainer.classList.remove('hidden');
|
||||
return;
|
||||
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';
|
||||
}
|
||||
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 fileExtension = file.name.split('.').pop().toLowerCase();
|
||||
if (!allowedExtensions.includes(fileExtension)) {
|
||||
errorContainer.textContent = `Invalid file type. Please upload one of the following: ${allowedExtensions.join(', ')}`;
|
||||
errorContainer.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > maxFileSize) {
|
||||
errorContainer.textContent = `File is too large. Maximum size is ${maxFileSize / 1024 / 1024}MB.`;
|
||||
errorContainer.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
// --- End Validation ---
|
||||
|
||||
uploadButton.textContent = 'Uploading...';
|
||||
uploadButton.disabled = true;
|
||||
|
||||
const formData = new FormData(imageForm);
|
||||
|
||||
fetch("{{ url_for('api_upload_image') }}", {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => {
|
||||
return response.json().then(data => ({ ok: response.ok, status: response.status, data }));
|
||||
})
|
||||
.then(({ ok, status, data }) => {
|
||||
if (ok) {
|
||||
const urlParts = data.url.split('/');
|
||||
const filename = urlParts[urlParts.length - 1];
|
||||
window.location.href = `/image/${filename}`;
|
||||
} else {
|
||||
errorContainer.textContent = data.error || `Server responded with status: ${status}`;
|
||||
errorContainer.classList.remove('hidden');
|
||||
uploadButton.textContent = 'Upload Image';
|
||||
uploadButton.disabled = false;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
errorContainer.textContent = `An error occurred during the upload: ${error.message}`;
|
||||
errorContainer.classList.remove('hidden');
|
||||
uploadButton.textContent = 'Upload Image';
|
||||
uploadButton.disabled = false;
|
||||
console.error('Upload Error:', error);
|
||||
});
|
||||
});
|
||||
const closeBtn = document.getElementById('close-announcement');
|
||||
if (closeBtn) closeBtn.addEventListener('click', ()=>
|
||||
document.getElementById('announcement-bar').style.display = 'none'
|
||||
);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
@ -1,23 +1,27 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Image Uploaded - I2P Secure Share</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/tailwind.css') }}">
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>View Image - I2P Secure Share</title>
|
||||
<link rel="stylesheet" href="/static/css/tailwind.css"/>
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
|
||||
|
||||
<style>
|
||||
body { background-color: #1a202c; color: #cbd5e0; }
|
||||
.content-container { background-color: #2d3748; border: 1px solid #4a5568; }
|
||||
.link-box { background-color: #2d3748; border:1px solid #4a5568; word-break:break-all; }
|
||||
.btn { background-color: #4299e1; transition: background-color 0.3s ease; }
|
||||
.btn { background-color:#4299e1; transition:background-color .3s ease; }
|
||||
.btn:hover { background-color:#3182ce; }
|
||||
.thumbnail-container { border:2px dashed #4a5568; max-width:100%; }
|
||||
.thumbnail { max-width:100%; max-height:60vh; object-fit:contain; }
|
||||
.announcement-bar { background-color:#2563eb; border-bottom:1px solid #1e3a8a; }
|
||||
.alert-success { background-color:#38a169; }
|
||||
.alert-error { background-color:#e53e3e; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="font-sans">
|
||||
|
||||
<!-- Announcement Bar -->
|
||||
{% if announcement_enabled and announcement_message %}
|
||||
<div id="announcement-bar" class="announcement-bar text-white text-center p-2 relative shadow-lg">
|
||||
<span>{{ announcement_message }}</span>
|
||||
@ -27,72 +31,90 @@
|
||||
|
||||
<div class="flex items-center justify-center min-h-screen py-8">
|
||||
<div class="w-full max-w-2xl mx-auto p-4">
|
||||
{% if password_required %}
|
||||
<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">
|
||||
<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 bg-gray-700 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required>
|
||||
</div>
|
||||
<button type="submit" class="btn w-full text-white font-bold py-3 px-5 rounded-md">Unlock Image</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<header class="text-center mb-8">
|
||||
<a href="/" class="inline-block mb-4">
|
||||
<img src="{{ url_for('static', filename='images/stormycloud.svg') }}" alt="StormyCloud Logo" style="width: 550px; 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>
|
||||
<h1 class="text-3xl font-bold text-white">Image Uploaded Successfully</h1>
|
||||
<p class="text-gray-400 mt-2">Share the link below. The file will be deleted based on the expiration you selected.</p>
|
||||
<h1 class="text-3xl font-bold text-white">View Image</h1>
|
||||
<p class="text-gray-400 mt-2 text-xl">Expires in: {{ time_left }}</p>
|
||||
</header>
|
||||
|
||||
<main class="flex-grow">
|
||||
<!-- Image Thumbnail -->
|
||||
<main>
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mb-4">
|
||||
{% for category, message in messages %}
|
||||
<div class="alert-{{category}} text-white p-3 rounded-md shadow-lg"
|
||||
role="alert">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="thumbnail-container rounded-lg p-4 mb-6 flex justify-center items-center">
|
||||
<img src="{{ url_for('get_upload', filename=filename) }}" alt="Uploaded Image" class="thumbnail rounded-md">
|
||||
<img src="/uploads/{{ filename }}"
|
||||
alt="Uploaded Image" class="thumbnail rounded-md"/>
|
||||
</div>
|
||||
|
||||
<!-- Share Link -->
|
||||
<div class="link-box rounded-lg p-4">
|
||||
<div class="link-box rounded-lg p-4 mb-4">
|
||||
<label for="share-link" class="block text-gray-300 text-sm font-bold mb-2">Direct Image Link:</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="text" id="share-link" class="bg-gray-700 text-white w-full p-2 border border-gray-600 rounded-md" value="{{ url_for('get_upload', filename=filename, _external=True) }}" readonly>
|
||||
<button id="copy-button" class="btn text-white font-bold py-3 px-5 rounded-md focus:shadow-outline flex-shrink-0">Copy</button>
|
||||
<input type="text" id="share-link"
|
||||
class="bg-gray-700 text-white w-full p-2 border border-gray-600 rounded-md"
|
||||
value="{{ request.host_url }}uploads/{{ filename }}" readonly>
|
||||
<button id="copy-button"
|
||||
class="btn whitespace-nowrap flex-shrink-0 text-white font-bold py-3 px-5 rounded-md">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-8">
|
||||
<a href="{{ url_for('index') }}" class="text-blue-400 hover:text-blue-300">Upload another file</a>
|
||||
<a href="/" class="text-blue-400 hover:text-blue-300">Upload another file</a>
|
||||
</div>
|
||||
<div class="text-center mt-4 text-sm">
|
||||
<p class="text-gray-500">Find this service useful? <a href="/donate" class="text-blue-400 hover:underline">Consider supporting its future.</a></p>
|
||||
</div>
|
||||
|
||||
<!-- Subtle Donation Link -->
|
||||
<div class="text-center mt-4 text-sm">
|
||||
<p class="text-gray-500">Find this service useful? <a href="{{ url_for('donate_page') }}" class="text-blue-400 hover:underline">Consider supporting its future.</a></p>
|
||||
</div>
|
||||
</main>
|
||||
{% endif %}
|
||||
|
||||
<footer class="text-center text-gray-500 mt-16 border-t border-gray-700 pt-8 pb-8">
|
||||
<a href="http://stormycloud.i2p" class="hover:text-gray-400">StormyCloud</a>
|
||||
<span class="mx-2">|</span>
|
||||
<a href="{{ url_for('donate_page') }}" class="hover:text-gray-400">Donate</a>
|
||||
<a href="/donate" class="hover:text-gray-400">Donate</a>
|
||||
</footer>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('copy-button').addEventListener('click', () => {
|
||||
const linkInput = document.getElementById('share-link');
|
||||
const button = document.getElementById('copy-button');
|
||||
linkInput.select();
|
||||
linkInput.setSelectionRange(0, 99999);
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
button.textContent = 'Copied!';
|
||||
setTimeout(() => { button.textContent = 'Copy'; }, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text: ', err);
|
||||
button.textContent = 'Error';
|
||||
}
|
||||
window.getSelection().removeAllRanges();
|
||||
});
|
||||
|
||||
const announcementBar = document.getElementById('announcement-bar');
|
||||
const closeButton = document.getElementById('close-announcement');
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener('click', () => {
|
||||
announcementBar.style.display = 'none';
|
||||
const copyBtn = document.getElementById('copy-button');
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', () => {
|
||||
const inp = document.getElementById('share-link');
|
||||
inp.select(); document.execCommand('copy');
|
||||
copyBtn.textContent = 'Copied!';
|
||||
setTimeout(() => copyBtn.textContent = 'Copy', 2000);
|
||||
});
|
||||
}
|
||||
const closeBtn = document.getElementById('close-announcement');
|
||||
if (closeBtn) closeBtn.addEventListener('click', () =>
|
||||
document.getElementById('announcement-bar').style.display = 'none'
|
||||
);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
@ -1,37 +1,103 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Paste Created - I2P Secure Share</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/tailwind.css') }}">
|
||||
<meta charset="UTF-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
|
||||
<title>View Paste - I2P Secure Share</title>
|
||||
<link rel="stylesheet" href="/static/css/tailwind.css"/>
|
||||
<link rel="icon" href="{{ url_for('static', filename='favicon.ico') }}" type="image/x-icon">
|
||||
|
||||
<style>
|
||||
body { background-color: #1a202c; color: #cbd5e0; }
|
||||
.link-box { background-color: #2d3748; border:1px solid #4a5568; word-break:break-all; }
|
||||
.btn { background-color: #4299e1; transition: background-color 0.3s ease; }
|
||||
.btn { background-color:#4299e1; transition:background-color .3s ease; }
|
||||
.btn:hover { background-color:#3182ce; }
|
||||
|
||||
{{ css_styles|safe }}
|
||||
|
||||
.code-container {
|
||||
background-color: #2d3748;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #4a5568;
|
||||
overflow: hidden;
|
||||
background-color:#2d3748; border-radius:0.5rem; border:1px solid #4a5568; overflow:hidden;
|
||||
}
|
||||
|
||||
.syntax pre {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
margin:0; padding:1rem; white-space:pre-wrap; word-wrap:break-word;
|
||||
}
|
||||
.announcement-bar { background-color:#2563eb; border-bottom:1px solid #1e3a8a; }
|
||||
.wide-container { max-width: 85rem; }
|
||||
.alert-success { background-color:#38a169; }
|
||||
.alert-error { background-color:#e53e3e; }
|
||||
|
||||
/* Pygments 'monokai' theme CSS */
|
||||
.syntax .hll { background-color: #49483e }
|
||||
.syntax { background: #272822; color: #f8f8f2 }
|
||||
.syntax .c { color: #75715e } /* Comment */
|
||||
.syntax .err { color: #960050; background-color: #1e0010 } /* Error */
|
||||
.syntax .k { color: #66d9ef } /* Keyword */
|
||||
.syntax .l { color: #ae81ff } /* Literal */
|
||||
.syntax .n { color: #f8f8f2 } /* Name */
|
||||
.syntax .o { color: #f92672 } /* Operator */
|
||||
.syntax .p { color: #f8f8f2 } /* Punctuation */
|
||||
.syntax .ch { color: #75715e } /* Comment.Hashbang */
|
||||
.syntax .cm { color: #75715e } /* Comment.Multiline */
|
||||
.syntax .cp { color: #75715e } /* Comment.Preproc */
|
||||
.syntax .cpf { color: #75715e } /* Comment.PreprocFile */
|
||||
.syntax .c1 { color: #75715e } /* Comment.Single */
|
||||
.syntax .cs { color: #75715e } /* Comment.Special */
|
||||
.syntax .gd { color: #f92672 } /* Generic.Deleted */
|
||||
.syntax .ge { font-style: italic } /* Generic.Emph */
|
||||
.syntax .gi { color: #a6e22e } /* Generic.Inserted */
|
||||
.syntax .gs { font-weight: bold } /* Generic.Strong */
|
||||
.syntax .gu { color: #75715e } /* Generic.Subheading */
|
||||
.syntax .kc { color: #66d9ef } /* Keyword.Constant */
|
||||
.syntax .kd { color: #66d9ef } /* Keyword.Declaration */
|
||||
.syntax .kn { color: #f92672 } /* Keyword.Namespace */
|
||||
.syntax .kp { color: #66d9ef } /* Keyword.Pseudo */
|
||||
.syntax .kr { color: #66d9ef } /* Keyword.Reserved */
|
||||
.syntax .kt { color: #66d9ef } /* Keyword.Type */
|
||||
.syntax .ld { color: #e6db74 } /* Literal.Date */
|
||||
.syntax .m { color: #ae81ff } /* Literal.Number */
|
||||
.syntax .s { color: #e6db74 } /* Literal.String */
|
||||
.syntax .na { color: #a6e22e } /* Name.Attribute */
|
||||
.syntax .nb { color: #f8f8f2 } /* Name.Builtin */
|
||||
.syntax .nc { color: #a6e22e } /* Name.Class */
|
||||
.syntax .no { color: #66d9ef } /* Name.Constant */
|
||||
.syntax .nd { color: #a6e22e } /* Name.Decorator */
|
||||
.syntax .ni { color: #f8f8f2 } /* Name.Entity */
|
||||
.syntax .ne { color: #a6e22e } /* Name.Exception */
|
||||
.syntax .nf { color: #a6e22e } /* Name.Function */
|
||||
.syntax .nl { color: #f8f8f2 } /* Name.Label */
|
||||
.syntax .nn { color: #f8f8f2 } /* Name.Namespace */
|
||||
.syntax .nx { color: #a6e22e } /* Name.Other */
|
||||
.syntax .py { color: #f8f8f2 } /* Name.Property */
|
||||
.syntax .nt { color: #f92672 } /* Name.Tag */
|
||||
.syntax .nv { color: #f8f8f2 } /* Name.Variable */
|
||||
.syntax .ow { color: #f92672 } /* Operator.Word */
|
||||
.syntax .w { color: #f8f8f2 } /* Text.Whitespace */
|
||||
.syntax .mb { color: #ae81ff } /* Literal.Number.Bin */
|
||||
.syntax .mf { color: #ae81ff } /* Literal.Number.Float */
|
||||
.syntax .mh { color: #ae81ff } /* Literal.Number.Hex */
|
||||
.syntax .mi { color: #ae81ff } /* Literal.Number.Integer */
|
||||
.syntax .mo { color: #ae81ff } /* Literal.Number.Oct */
|
||||
.syntax .sa { color: #e6db74 } /* Literal.String.Affix */
|
||||
.syntax .sb { color: #e6db74 } /* Literal.String.Backtick */
|
||||
.syntax .sc { color: #e6db74 } /* Literal.String.Char */
|
||||
.syntax .dl { color: #e6db74 } /* Literal.String.Delimiter */
|
||||
.syntax .sd { color: #e6db74 } /* Literal.String.Doc */
|
||||
.syntax .s2 { color: #e6db74 } /* Literal.String.Double */
|
||||
.syntax .se { color: #ae81ff } /* Literal.String.Escape */
|
||||
.syntax .sh { color: #e6db74 } /* Literal.String.Heredoc */
|
||||
.syntax .si { color: #e6db74 } /* Literal.String.Interpol */
|
||||
.syntax .sx { color: #e6db74 } /* Literal.String.Other */
|
||||
.syntax .sr { color: #e6db74 } /* Literal.String.Regex */
|
||||
.syntax .s1 { color: #e6db74 } /* Literal.String.Single */
|
||||
.syntax .ss { color: #e6db74 } /* Literal.String.Symbol */
|
||||
.syntax .bp { color: #f8f8f2 } /* Name.Builtin.Pseudo */
|
||||
.syntax .fm { color: #a6e22e } /* Name.Function.Magic */
|
||||
.syntax .vc { color: #f8f8f2 } /* Name.Variable.Class */
|
||||
.syntax .vg { color: #f8f8f2 } /* Name.Variable.Global */
|
||||
.syntax .vi { color: #f8f8f2 } /* Name.Variable.Instance */
|
||||
.syntax .vm { color: #f8f8f2 } /* Name.Variable.Magic */
|
||||
.syntax .il { color: #ae81ff } /* Literal.Number.Integer.Long */
|
||||
</style>
|
||||
</head>
|
||||
<body class="font-sans">
|
||||
|
||||
<!-- Announcement Bar -->
|
||||
{% if announcement_enabled and announcement_message %}
|
||||
<div id="announcement-bar" class="announcement-bar text-white text-center p-2 relative shadow-lg">
|
||||
<span>{{ announcement_message }}</span>
|
||||
@ -40,72 +106,109 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="flex items-center justify-center min-h-screen py-8">
|
||||
<div class="w-full max-w-2xl mx-auto p-4">
|
||||
<div class="w-full wide-container mx-auto p-6">
|
||||
{% if password_required %}
|
||||
<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">
|
||||
<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 bg-gray-700 border border-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
required>
|
||||
</div>
|
||||
<button type="submit" class="btn w-full text-white font-bold py-3 px-5 rounded-md">Unlock Paste</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
<header class="text-center mb-8">
|
||||
<a href="/" class="inline-block mb-4">
|
||||
<img src="{{ url_for('static', filename='images/stormycloud.svg') }}" alt="StormyCloud Logo" style="width: 550px; 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>
|
||||
<h1 class="text-3xl font-bold text-white">Paste Created Successfully</h1>
|
||||
<p class="text-gray-400 mt-2">Share the link below. The paste will be deleted based on the expiration you selected.</p>
|
||||
<h1 class="text-3xl font-bold text-white">View Paste</h1>
|
||||
<p class="text-gray-400 mt-2 text-xl">Expires in: {{ time_left }}</p>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- Paste Content -->
|
||||
<div class="code-container mb-6">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
<div class="mb-4">
|
||||
{% for category, message in messages %}
|
||||
<div class="alert-{{category}} text-white p-3 rounded-md shadow-lg"
|
||||
role="alert">{{ message }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="flex justify-end mb-2">
|
||||
<form method="GET" action="" class="flex items-center space-x-2">
|
||||
<label for="language-switcher" class="text-sm text-gray-400">Syntax:</label>
|
||||
<select name="lang" id="language-switcher" onchange="this.form.submit()" class="text-sm rounded-md p-1 bg-gray-700 border border-gray-600 focus:outline-none focus:ring-1 focus:ring-blue-500 cursor-pointer">
|
||||
{% for lang in languages %}
|
||||
<option value="{{ lang }}" {% if lang == selected_language %}selected{% endif %}>
|
||||
{{ lang|capitalize }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<noscript><button type="submit" class="btn text-sm py-1 px-3">Update</button></noscript>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="code-container mb-6 syntax">
|
||||
{{ highlighted_content|safe }}
|
||||
</div>
|
||||
|
||||
<!-- Share Link -->
|
||||
<div class="link-box rounded-lg p-4">
|
||||
<div class="link-box rounded-lg p-4 mb-4">
|
||||
<label for="share-link" class="block text-gray-300 text-sm font-bold mb-2">Shareable Link:</label>
|
||||
<div class="flex items-center space-x-2">
|
||||
<input type="text" id="share-link" class="bg-gray-700 text-white w-full p-2 border border-gray-600 rounded-md" value="{{ request.url }}" readonly>
|
||||
<button id="copy-button" class="btn text-white font-bold py-3 px-5 rounded-md focus:shadow-outline flex-shrink-0">Copy</button>
|
||||
<input type="text" id="share-link"
|
||||
class="bg-gray-700 text-white w-full p-2 border border-gray-600 rounded-md"
|
||||
value="{{ request.url }}" readonly>
|
||||
<button id="copy-button"
|
||||
class="btn whitespace-nowrap flex-shrink-0 text-white font-bold py-3 px-5 rounded-md">
|
||||
Copy
|
||||
</button>
|
||||
<a href="/paste/{{ paste_id }}/raw"
|
||||
target="_blank"
|
||||
class="btn whitespace-nowrap flex-shrink-0 text-white font-bold py-3 px-5 rounded-md">
|
||||
Raw
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-center mt-8">
|
||||
<a href="{{ url_for('index') }}" class="text-blue-400 hover:text-blue-300">Create another paste</a>
|
||||
<a href="/" class="text-blue-400 hover:text-blue-300">Create another paste</a>
|
||||
</div>
|
||||
<div class="text-center mt-4 text-sm">
|
||||
<p class="text-gray-500">Find this service useful? <a href="/donate" class="text-blue-400 hover:underline">Consider supporting its future.</a></p>
|
||||
</div>
|
||||
|
||||
<!-- Subtle Donation Link -->
|
||||
<div class="text-center mt-4 text-sm">
|
||||
<p class="text-gray-500">Find this service useful? <a href="{{ url_for('donate_page') }}" class="text-blue-400 hover:underline">Consider supporting its future.</a></p>
|
||||
</div>
|
||||
</main>
|
||||
{% endif %}
|
||||
|
||||
<footer class="text-center text-gray-500 mt-16 border-t border-gray-700 pt-8 pb-8">
|
||||
<a href="http://stormycloud.i2p" class="hover:text-gray-400">StormyCloud</a>
|
||||
<span class="mx-2">|</span>
|
||||
<a href="{{ url_for('donate_page') }}" class="hover:text-gray-400">Donate</a>
|
||||
<a href="/donate" class="hover:text-gray-400">Donate</a>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('copy-button').addEventListener('click', () => {
|
||||
const linkInput = document.getElementById('share-link');
|
||||
const button = document.getElementById('copy-button');
|
||||
linkInput.select();
|
||||
linkInput.setSelectionRange(0, 99999);
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
button.textContent = 'Copied!';
|
||||
setTimeout(() => { button.textContent = 'Copy'; }, 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text: ', err);
|
||||
button.textContent = 'Error';
|
||||
}
|
||||
window.getSelection().removeAllRanges();
|
||||
});
|
||||
|
||||
const announcementBar = document.getElementById('announcement-bar');
|
||||
const closeButton = document.getElementById('close-announcement');
|
||||
if (closeButton) {
|
||||
closeButton.addEventListener('click', () => {
|
||||
announcementBar.style.display = 'none';
|
||||
const copyBtn = document.getElementById('copy-button');
|
||||
if (copyBtn) {
|
||||
copyBtn.addEventListener('click', () => {
|
||||
const inp = document.getElementById('share-link');
|
||||
inp.select(); document.execCommand('copy');
|
||||
copyBtn.textContent = 'Copied!';
|
||||
setTimeout(() => copyBtn.textContent = 'Copy', 2000);
|
||||
});
|
||||
}
|
||||
const closeBtn = document.getElementById('close-announcement');
|
||||
if (closeBtn) closeBtn.addEventListener('click', () =>
|
||||
document.getElementById('announcement-bar').style.display = 'none'
|
||||
);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
Reference in New Issue
Block a user