From 7dd2e2daf7c6cca2aa147d5311197273fd18cdb3 Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Sun, 28 Dec 2025 20:18:11 -0500 Subject: [PATCH] Added debug, additional comments, encoded file thumbnail view. --- Dockerfile.txt | 129 ++++ frontends/web/app.py | 99 ++- frontends/web/app.py_20251228 | 709 ++++++++++++++++++ frontends/web/static/style.css | 143 ++++ frontends/web/static/style.css_20251228 | 409 ++++++++++ frontends/web/templates/decode.html | 4 - frontends/web/templates/encode.html | 8 +- frontends/web/templates/encode_result.html | 16 +- .../web/templates/encode_result.html_20251228 | 95 +++ frontends/web/templates/index.html | 82 +- frontends/web/templates/index.html_20251228 | 108 +++ pyproject.toml | 4 + requirements.txt | 3 +- src/stegasoo/__init__.py | 44 +- src/stegasoo/constants.py | 2 +- src/stegasoo/debug.py | 180 +++++ src/stegasoo/keygen.py | 113 ++- src/stegasoo/steganography.py | 231 +++++- src/stegasoo/utils.py | 158 +++- src_20251228/__init__.py | 0 src_20251228/main.py | 11 + src_20251228/stegasoo/__init__.py | 577 ++++++++++++++ src_20251228/stegasoo/cli.py | 66 ++ src_20251228/stegasoo/constants.py | 131 ++++ src_20251228/stegasoo/crypto.py | 512 +++++++++++++ src_20251228/stegasoo/exceptions.py | 150 ++++ src_20251228/stegasoo/keygen.py | 228 ++++++ src_20251228/stegasoo/models.py | 177 +++++ src_20251228/stegasoo/qr_utils.py | 397 ++++++++++ src_20251228/stegasoo/steganography.py | 350 +++++++++ src_20251228/stegasoo/utils.py | 209 ++++++ src_20251228/stegasoo/validation.py | 426 +++++++++++ 32 files changed, 5618 insertions(+), 153 deletions(-) create mode 100644 Dockerfile.txt create mode 100644 frontends/web/app.py_20251228 create mode 100644 frontends/web/static/style.css_20251228 create mode 100644 frontends/web/templates/encode_result.html_20251228 create mode 100644 frontends/web/templates/index.html_20251228 create mode 100644 src/stegasoo/debug.py create mode 100644 src_20251228/__init__.py create mode 100644 src_20251228/main.py create mode 100644 src_20251228/stegasoo/__init__.py create mode 100644 src_20251228/stegasoo/cli.py create mode 100644 src_20251228/stegasoo/constants.py create mode 100644 src_20251228/stegasoo/crypto.py create mode 100644 src_20251228/stegasoo/exceptions.py create mode 100644 src_20251228/stegasoo/keygen.py create mode 100644 src_20251228/stegasoo/models.py create mode 100644 src_20251228/stegasoo/qr_utils.py create mode 100644 src_20251228/stegasoo/steganography.py create mode 100644 src_20251228/stegasoo/utils.py create mode 100644 src_20251228/stegasoo/validation.py diff --git a/Dockerfile.txt b/Dockerfile.txt new file mode 100644 index 0000000..da619e3 --- /dev/null +++ b/Dockerfile.txt @@ -0,0 +1,129 @@ +# Stegasoo Docker Image +# Multi-stage build for smaller image size + +FROM python:3.11-slim as base + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Install system dependencies +RUN apt-get update && apt-get install -y --no-install-recommends \ + gcc \ + libc-dev \ + libffi-dev \ + && rm -rf /var/lib/apt/lists/* + +# ============================================================================ +# Builder stage - install Python packages +# ============================================================================ +FROM base as builder + +WORKDIR /build + +# Copy package files (including README.md which pyproject.toml references) +COPY pyproject.toml README.md ./ +COPY src/ src/ +COPY data/ data/ + +# Install the package with web extras +RUN pip install --no-cache-dir ".[web]" + +# ============================================================================ +# Production stage - Web UI +# ============================================================================ +FROM base as web + +WORKDIR /app + +# Copy installed packages from builder +COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin + +# Copy application files +COPY src/ src/ +COPY data/ data/ +COPY frontends/web/ frontends/web/ + +# Create upload directory +RUN mkdir -p /tmp/stego_uploads + +# Create non-root user +RUN useradd -m -u 1000 stego && chown -R stego:stego /app /tmp/stego_uploads +USER stego + +# Set Python path +ENV PYTHONPATH=/app/src + +# Expose port +EXPOSE 5000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/')" || exit 1 + +# Run with gunicorn +WORKDIR /app/frontends/web +CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "60", "app:app"] + +# ============================================================================ +# API stage - REST API +# ============================================================================ +FROM base as api + +WORKDIR /app + +# Install API extras +COPY pyproject.toml README.md ./ +COPY src/ src/ +COPY data/ data/ +RUN pip install --no-cache-dir ".[api]" + +# Copy API files +COPY frontends/api/ frontends/api/ + +# Create non-root user +RUN useradd -m -u 1000 stego && chown -R stego:stego /app +USER stego + +# Set Python path +ENV PYTHONPATH=/app/src + +# Expose port +EXPOSE 8000 + +# Health check +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/')" || exit 1 + +# Run with uvicorn +WORKDIR /app/frontends/api +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] + +# ============================================================================ +# CLI stage - Command line tool +# ============================================================================ +FROM base as cli + +WORKDIR /app + +# Install CLI extras +COPY pyproject.toml README.md ./ +COPY src/ src/ +COPY data/ data/ +RUN pip install --no-cache-dir ".[cli]" + +# Copy CLI files +COPY frontends/cli/ frontends/cli/ + +# Create non-root user +RUN useradd -m -u 1000 stego && chown -R stego:stego /app +USER stego + +# Set Python path +ENV PYTHONPATH=/app/src + +# Default to help +WORKDIR /app/frontends/cli +ENTRYPOINT ["python", "main.py"] +CMD ["--help"] diff --git a/frontends/web/app.py b/frontends/web/app.py index 1bad8da..fc9db95 100644 --- a/frontends/web/app.py +++ b/frontends/web/app.py @@ -13,6 +13,7 @@ import secrets import mimetypes from pathlib import Path from datetime import datetime +from PIL import Image from flask import ( Flask, render_template, request, send_file, @@ -79,15 +80,49 @@ app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE # 10MB max upload # Temporary file storage for sharing (file_id -> {data, timestamp, filename}) TEMP_FILES: dict[str, dict] = {} +THUMBNAIL_FILES: dict[str, bytes] = {} TEMP_FILE_EXPIRY = 300 # 5 minutes +THUMBNAIL_SIZE = (250, 250) # Maximum dimensions for thumbnail + + +def generate_thumbnail(image_data: bytes, size: tuple = THUMBNAIL_SIZE) -> bytes: + """Generate thumbnail from image data.""" + try: + with Image.open(io.BytesIO(image_data)) as img: + # Convert to RGB if necessary + if img.mode in ('RGBA', 'LA', 'P'): + # Create white background for transparent images + background = Image.new('RGB', img.size, (255, 255, 255)) + if img.mode == 'P': + img = img.convert('RGBA') + background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + img = background + elif img.mode != 'RGB': + img = img.convert('RGB') + + # Create thumbnail + img.thumbnail(size, Image.Resampling.LANCZOS) + + # Save to bytes + buffer = io.BytesIO() + img.save(buffer, format='JPEG', quality=85, optimize=True) + return buffer.getvalue() + except Exception as e: + # Log error but don't crash + print(f"Thumbnail generation error: {e}") + return None def cleanup_temp_files(): """Remove expired temporary files.""" now = time.time() expired = [fid for fid, info in TEMP_FILES.items() if now - info['timestamp'] > TEMP_FILE_EXPIRY] + for fid in expired: TEMP_FILES.pop(fid, None) + # Also clean up corresponding thumbnail + thumb_id = f"{fid}_thumb" + THUMBNAIL_FILES.pop(thumb_id, None) def allowed_image(filename: str) -> bool: @@ -108,7 +143,6 @@ def format_size(size_bytes: int) -> str: return f"{size_bytes / (1024 * 1024):.1f} MB" - # ============================================================================ # ROUTES # ============================================================================ @@ -254,11 +288,6 @@ def generate_qr_download(token): return f"Error generating QR code: {e}", 500 -#@app.route('/generate/download-key', methods=['POST']) -#def download_key(): -# """Download RSA key as password-protected PEM file.""" -# key_pem = request.form.get('key_pem', '') - @app.route('/generate/download-key', methods=['POST']) def download_key(): """Download RSA key as password-protected PEM file.""" @@ -330,32 +359,6 @@ def extract_key_from_qr_route(): 'success': False, 'error': str(e) }), 500 - password = request.form.get('key_password', '') - - if not key_pem: - flash('No key to download', 'error') - return redirect(url_for('generate')) - - if not password or len(password) < 8: - flash('Password must be at least 8 characters', 'error') - return redirect(url_for('generate')) - - try: - private_key = load_rsa_key(key_pem.encode()) - encrypted_pem = export_rsa_key_pem(private_key, password=password) - - key_id = secrets.token_hex(4) - filename = f'stegasoo_key_{private_key.key_size}_{key_id}.pem' - - return send_file( - io.BytesIO(encrypted_pem), - mimetype='application/x-pem-file', - as_attachment=True, - download_name=filename - ) - except Exception as e: - flash(f'Error creating key file: {e}', 'error') - return redirect(url_for('generate')) @app.route('/encode', methods=['GET', 'POST']) @@ -516,9 +519,32 @@ def encode_result(file_id): return redirect(url_for('encode_page')) file_info = TEMP_FILES[file_id] + + # Generate thumbnail + thumbnail_data = generate_thumbnail(file_info['data']) + thumbnail_id = None + + if thumbnail_data: + thumbnail_id = f"{file_id}_thumb" + THUMBNAIL_FILES[thumbnail_id] = thumbnail_data + return render_template('encode_result.html', file_id=file_id, - filename=file_info['filename'] + filename=file_info['filename'], + thumbnail_url=url_for('encode_thumbnail', thumb_id=thumbnail_id) if thumbnail_id else None + ) + + +@app.route('/encode/thumbnail/') +def encode_thumbnail(thumb_id): + """Serve thumbnail image.""" + if thumb_id not in THUMBNAIL_FILES: + return "Thumbnail not found", 404 + + return send_file( + io.BytesIO(THUMBNAIL_FILES[thumb_id]), + mimetype='image/jpeg', + as_attachment=False ) @@ -556,6 +582,11 @@ def encode_file_route(file_id): def encode_cleanup(file_id): """Manually cleanup a file after sharing.""" TEMP_FILES.pop(file_id, None) + + # Also cleanup thumbnail if exists + thumb_id = f"{file_id}_thumb" + THUMBNAIL_FILES.pop(thumb_id, None) + return jsonify({'status': 'ok'}) @@ -706,4 +737,4 @@ def about(): # ============================================================================ if __name__ == '__main__': - app.run(host='0.0.0.0', port=5000, debug=False) + app.run(host='0.0.0.0', port=5000, debug=False) \ No newline at end of file diff --git a/frontends/web/app.py_20251228 b/frontends/web/app.py_20251228 new file mode 100644 index 0000000..1bad8da --- /dev/null +++ b/frontends/web/app.py_20251228 @@ -0,0 +1,709 @@ +#!/usr/bin/env python3 +""" +Stegasoo Web Frontend + +Flask-based web UI for steganography operations. +Supports both text messages and file embedding. +""" + +import io +import sys +import time +import secrets +import mimetypes +from pathlib import Path +from datetime import datetime + +from flask import ( + Flask, render_template, request, send_file, + jsonify, flash, redirect, url_for +) + +# Add parent to path for development +sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'src')) + +import stegasoo +from stegasoo import ( + encode, decode, generate_credentials, + export_rsa_key_pem, load_rsa_key, + validate_pin, validate_message, validate_image, + validate_rsa_key, validate_security_factors, + validate_file_payload, + get_today_day, generate_filename, + DAY_NAMES, __version__, + StegasooError, DecryptionError, CapacityError, + has_argon2, + FilePayload, + MAX_FILE_PAYLOAD_SIZE, +) +from stegasoo.constants import ( + MAX_MESSAGE_SIZE, MIN_PIN_LENGTH, MAX_PIN_LENGTH, + VALID_RSA_SIZES, MAX_FILE_SIZE, +) + +# QR Code support +try: + import qrcode + from qrcode.constants import ERROR_CORRECT_L, ERROR_CORRECT_M + HAS_QRCODE = True +except ImportError: + HAS_QRCODE = False + +# QR Code reading +try: + from pyzbar.pyzbar import decode as pyzbar_decode + HAS_QRCODE_READ = True +except ImportError: + HAS_QRCODE_READ = False + +import zlib +import base64 + +# Import QR utilities +from stegasoo.qr_utils import ( + compress_data, decompress_data, auto_decompress, + is_compressed, can_fit_in_qr, needs_compression, + generate_qr_code, read_qr_code, extract_key_from_qr, + has_qr_write, has_qr_read, + QR_MAX_BINARY, COMPRESSION_PREFIX +) + + +# ============================================================================ +# FLASK APP CONFIGURATION +# ============================================================================ + +app = Flask(__name__) +app.secret_key = secrets.token_hex(32) +app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE # 10MB max upload + +# Temporary file storage for sharing (file_id -> {data, timestamp, filename}) +TEMP_FILES: dict[str, dict] = {} +TEMP_FILE_EXPIRY = 300 # 5 minutes + + +def cleanup_temp_files(): + """Remove expired temporary files.""" + now = time.time() + expired = [fid for fid, info in TEMP_FILES.items() if now - info['timestamp'] > TEMP_FILE_EXPIRY] + for fid in expired: + TEMP_FILES.pop(fid, None) + + +def allowed_image(filename: str) -> bool: + """Check if file has allowed image extension.""" + if not filename or '.' not in filename: + return False + ext = filename.rsplit('.', 1)[1].lower() + return ext in {'png', 'jpg', 'jpeg', 'bmp', 'gif'} + + +def format_size(size_bytes: int) -> str: + """Format file size for display.""" + if size_bytes < 1024: + return f"{size_bytes} B" + elif size_bytes < 1024 * 1024: + return f"{size_bytes / 1024:.1f} KB" + else: + return f"{size_bytes / (1024 * 1024):.1f} MB" + + + +# ============================================================================ +# ROUTES +# ============================================================================ + +@app.route('/') +def index(): + return render_template('index.html') + + +@app.route('/generate', methods=['GET', 'POST']) +def generate(): + if request.method == 'POST': + words_per_phrase = int(request.form.get('words_per_phrase', 3)) + use_pin = request.form.get('use_pin') == 'on' + use_rsa = request.form.get('use_rsa') == 'on' + + if not use_pin and not use_rsa: + flash('You must select at least one security factor (PIN or RSA Key)', 'error') + return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE) + + pin_length = int(request.form.get('pin_length', 6)) + rsa_bits = int(request.form.get('rsa_bits', 2048)) + + # Clamp values + words_per_phrase = max(3, min(12, words_per_phrase)) + pin_length = max(MIN_PIN_LENGTH, min(MAX_PIN_LENGTH, pin_length)) + if rsa_bits not in VALID_RSA_SIZES: + rsa_bits = 2048 + + try: + creds = generate_credentials( + use_pin=use_pin, + use_rsa=use_rsa, + pin_length=pin_length, + rsa_bits=rsa_bits, + words_per_phrase=words_per_phrase + ) + + # Store RSA key temporarily for QR generation + qr_token = None + qr_needs_compression = False + qr_too_large = False + + if creds.rsa_key_pem and HAS_QRCODE: + # Check if key fits in QR code + if can_fit_in_qr(creds.rsa_key_pem, compress=False): + qr_needs_compression = False + elif can_fit_in_qr(creds.rsa_key_pem, compress=True): + qr_needs_compression = True + else: + qr_too_large = True + + if not qr_too_large: + qr_token = secrets.token_urlsafe(16) + cleanup_temp_files() + TEMP_FILES[qr_token] = { + 'data': creds.rsa_key_pem.encode(), + 'filename': 'rsa_key.pem', + 'timestamp': time.time(), + 'type': 'rsa_key', + 'compress': qr_needs_compression + } + + return render_template('generate.html', + phrases=creds.phrases, + pin=creds.pin, + days=DAY_NAMES, + generated=True, + words_per_phrase=words_per_phrase, + pin_length=pin_length if use_pin else None, + use_pin=use_pin, + use_rsa=use_rsa, + rsa_bits=rsa_bits, + rsa_key_pem=creds.rsa_key_pem, + phrase_entropy=creds.phrase_entropy, + pin_entropy=creds.pin_entropy, + rsa_entropy=creds.rsa_entropy, + total_entropy=creds.total_entropy, + has_qrcode=HAS_QRCODE, + qr_token=qr_token, + qr_needs_compression=qr_needs_compression, + qr_too_large=qr_too_large + ) + except Exception as e: + flash(f'Error generating credentials: {e}', 'error') + return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE) + + return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE) + + +@app.route('/generate/qr/') +def generate_qr(token): + """Generate QR code for RSA key.""" + if not HAS_QRCODE: + return "QR code support not available", 501 + + if token not in TEMP_FILES: + return "Token expired or invalid", 404 + + file_info = TEMP_FILES[token] + if file_info.get('type') != 'rsa_key': + return "Invalid token type", 400 + + try: + key_pem = file_info['data'].decode('utf-8') + compress = file_info.get('compress', False) + qr_png = generate_qr_code(key_pem, compress=compress) + + return send_file( + io.BytesIO(qr_png), + mimetype='image/png', + as_attachment=False + ) + except Exception as e: + return f"Error generating QR code: {e}", 500 + + +@app.route('/generate/qr-download/') +def generate_qr_download(token): + """Download QR code as PNG file.""" + if not HAS_QRCODE: + return "QR code support not available", 501 + + if token not in TEMP_FILES: + return "Token expired or invalid", 404 + + file_info = TEMP_FILES[token] + if file_info.get('type') != 'rsa_key': + return "Invalid token type", 400 + + try: + key_pem = file_info['data'].decode('utf-8') + compress = file_info.get('compress', False) + qr_png = generate_qr_code(key_pem, compress=compress) + + return send_file( + io.BytesIO(qr_png), + mimetype='image/png', + as_attachment=True, + download_name='stegasoo_rsa_key_qr.png' + ) + except Exception as e: + return f"Error generating QR code: {e}", 500 + + +#@app.route('/generate/download-key', methods=['POST']) +#def download_key(): +# """Download RSA key as password-protected PEM file.""" +# key_pem = request.form.get('key_pem', '') + +@app.route('/generate/download-key', methods=['POST']) +def download_key(): + """Download RSA key as password-protected PEM file.""" + key_pem = request.form.get('key_pem', '') + password = request.form.get('key_password', '') + + if not key_pem: + flash('No key to download', 'error') + return redirect(url_for('generate')) + + if not password or len(password) < 8: + flash('Password must be at least 8 characters', 'error') + return redirect(url_for('generate')) + + try: + private_key = load_rsa_key(key_pem.encode('utf-8')) + encrypted_pem = export_rsa_key_pem(private_key, password=password) + + key_id = secrets.token_hex(4) + filename = f'stegasoo_key_{private_key.key_size}_{key_id}.pem' + + return send_file( + io.BytesIO(encrypted_pem), + mimetype='application/x-pem-file', + as_attachment=True, + download_name=filename + ) + except Exception as e: + flash(f'Error creating key file: {e}', 'error') + return redirect(url_for('generate')) + + +@app.route('/extract-key-from-qr', methods=['POST']) +def extract_key_from_qr_route(): + """ + Extract RSA key from uploaded QR code image. + Returns JSON with the extracted key or error. + """ + if not HAS_QRCODE_READ: + return jsonify({ + 'success': False, + 'error': 'QR code reading not available. Install pyzbar and libzbar.' + }), 501 + + qr_image = request.files.get('qr_image') + if not qr_image: + return jsonify({ + 'success': False, + 'error': 'No QR image provided' + }), 400 + + try: + image_data = qr_image.read() + key_pem = extract_key_from_qr(image_data) + + if key_pem: + return jsonify({ + 'success': True, + 'key_pem': key_pem + }) + else: + return jsonify({ + 'success': False, + 'error': 'No valid RSA key found in QR code' + }), 400 + + except Exception as e: + return jsonify({ + 'success': False, + 'error': str(e) + }), 500 + password = request.form.get('key_password', '') + + if not key_pem: + flash('No key to download', 'error') + return redirect(url_for('generate')) + + if not password or len(password) < 8: + flash('Password must be at least 8 characters', 'error') + return redirect(url_for('generate')) + + try: + private_key = load_rsa_key(key_pem.encode()) + encrypted_pem = export_rsa_key_pem(private_key, password=password) + + key_id = secrets.token_hex(4) + filename = f'stegasoo_key_{private_key.key_size}_{key_id}.pem' + + return send_file( + io.BytesIO(encrypted_pem), + mimetype='application/x-pem-file', + as_attachment=True, + download_name=filename + ) + except Exception as e: + flash(f'Error creating key file: {e}', 'error') + return redirect(url_for('generate')) + + +@app.route('/encode', methods=['GET', 'POST']) +def encode_page(): + day_of_week = get_today_day() + max_payload_kb = MAX_FILE_PAYLOAD_SIZE // 1024 + + if request.method == 'POST': + try: + # Get files + ref_photo = request.files.get('reference_photo') + carrier = request.files.get('carrier') + rsa_key_file = request.files.get('rsa_key') + payload_file = request.files.get('payload_file') + + if not ref_photo or not carrier: + flash('Both reference photo and carrier image are required', 'error') + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) + + if not allowed_image(ref_photo.filename) or not allowed_image(carrier.filename): + flash('Invalid file type. Use PNG, JPG, or BMP', 'error') + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) + + # Get form data + message = request.form.get('message', '') + day_phrase = request.form.get('day_phrase', '') + pin = request.form.get('pin', '').strip() + rsa_password = request.form.get('rsa_password', '') + payload_type = request.form.get('payload_type', 'text') + + # Determine payload + if payload_type == 'file' and payload_file and payload_file.filename: + # File payload + file_data = payload_file.read() + + result = validate_file_payload(file_data, payload_file.filename) + if not result.is_valid: + flash(result.error_message, 'error') + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) + + mime_type, _ = mimetypes.guess_type(payload_file.filename) + payload = FilePayload( + data=file_data, + filename=payload_file.filename, + mime_type=mime_type + ) + else: + # Text message + result = validate_message(message) + if not result.is_valid: + flash(result.error_message, 'error') + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) + payload = message + + if not day_phrase: + flash('Day phrase is required', 'error') + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) + + # Read files + ref_data = ref_photo.read() + carrier_data = carrier.read() + + # Handle RSA key - can come from .pem file or QR code image + rsa_key_data = None + rsa_key_qr = request.files.get('rsa_key_qr') + rsa_key_from_qr = False # Track source for password handling + + if rsa_key_file and rsa_key_file.filename: + # RSA key from .pem file + rsa_key_data = rsa_key_file.read() + elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ: + # RSA key from QR code image + qr_image_data = rsa_key_qr.read() + key_pem = extract_key_from_qr(qr_image_data) + if key_pem: + rsa_key_data = key_pem.encode('utf-8') + rsa_key_from_qr = True # QR keys are never password-protected + else: + flash('Could not extract RSA key from QR code image. Make sure the image contains a valid QR code.', 'error') + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) + + # Validate security factors + result = validate_security_factors(pin, rsa_key_data) + if not result.is_valid: + flash(result.error_message, 'error') + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) + + # Validate PIN if provided + if pin: + result = validate_pin(pin) + if not result.is_valid: + flash(result.error_message, 'error') + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) + + # Determine key password - QR code keys are never password-protected + key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None) + + # Validate RSA key if provided + if rsa_key_data: + result = validate_rsa_key(rsa_key_data, key_password) + if not result.is_valid: + flash(result.error_message, 'error') + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) + + # Validate carrier image + result = validate_image(carrier_data, "Carrier image") + if not result.is_valid: + flash(result.error_message, 'error') + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) + + # Get date + client_date = request.form.get('client_date', '').strip() + if client_date and len(client_date) == 10 and client_date[4] == '-' and client_date[7] == '-': + date_str = client_date + else: + date_str = datetime.now().strftime('%Y-%m-%d') + + # Encode + encode_result = encode( + message=payload, + reference_photo=ref_data, + carrier_image=carrier_data, + day_phrase=day_phrase, + pin=pin, + rsa_key_data=rsa_key_data, + rsa_password=key_password, + date_str=date_str + ) + + # Store temporarily + file_id = secrets.token_urlsafe(16) + cleanup_temp_files() + TEMP_FILES[file_id] = { + 'data': encode_result.stego_image, + 'filename': encode_result.filename, + 'timestamp': time.time() + } + + return redirect(url_for('encode_result', file_id=file_id)) + + except CapacityError as e: + flash(str(e), 'error') + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) + except StegasooError as e: + flash(str(e), 'error') + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) + except Exception as e: + flash(f'Error: {e}', 'error') + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) + + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) + + +@app.route('/encode/result/') +def encode_result(file_id): + if file_id not in TEMP_FILES: + flash('File expired or not found. Please encode again.', 'error') + return redirect(url_for('encode_page')) + + file_info = TEMP_FILES[file_id] + return render_template('encode_result.html', + file_id=file_id, + filename=file_info['filename'] + ) + + +@app.route('/encode/download/') +def encode_download(file_id): + if file_id not in TEMP_FILES: + flash('File expired or not found.', 'error') + return redirect(url_for('encode_page')) + + file_info = TEMP_FILES[file_id] + return send_file( + io.BytesIO(file_info['data']), + mimetype='image/png', + as_attachment=True, + download_name=file_info['filename'] + ) + + +@app.route('/encode/file/') +def encode_file_route(file_id): + """Serve file for Web Share API.""" + if file_id not in TEMP_FILES: + return "Not found", 404 + + file_info = TEMP_FILES[file_id] + return send_file( + io.BytesIO(file_info['data']), + mimetype='image/png', + as_attachment=False, + download_name=file_info['filename'] + ) + + +@app.route('/encode/cleanup/', methods=['POST']) +def encode_cleanup(file_id): + """Manually cleanup a file after sharing.""" + TEMP_FILES.pop(file_id, None) + return jsonify({'status': 'ok'}) + + +@app.route('/decode', methods=['GET', 'POST']) +def decode_page(): + if request.method == 'POST': + try: + # Get files + ref_photo = request.files.get('reference_photo') + stego_image = request.files.get('stego_image') + rsa_key_file = request.files.get('rsa_key') + + if not ref_photo or not stego_image: + flash('Both reference photo and stego image are required', 'error') + return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + + # Get form data + day_phrase = request.form.get('day_phrase', '') + pin = request.form.get('pin', '').strip() + rsa_password = request.form.get('rsa_password', '') + + if not day_phrase: + flash('Day phrase is required', 'error') + return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + + # Read files + ref_data = ref_photo.read() + stego_data = stego_image.read() + + # Handle RSA key - can come from .pem file or QR code image + rsa_key_data = None + rsa_key_qr = request.files.get('rsa_key_qr') + rsa_key_from_qr = False # Track source for password handling + + if rsa_key_file and rsa_key_file.filename: + # RSA key from .pem file + rsa_key_data = rsa_key_file.read() + elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ: + # RSA key from QR code image + qr_image_data = rsa_key_qr.read() + key_pem = extract_key_from_qr(qr_image_data) + if key_pem: + rsa_key_data = key_pem.encode('utf-8') + rsa_key_from_qr = True # QR keys are never password-protected + else: + flash('Could not extract RSA key from QR code image. Make sure the image contains a valid QR code.', 'error') + return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + + # Validate security factors + result = validate_security_factors(pin, rsa_key_data) + if not result.is_valid: + flash(result.error_message, 'error') + return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + + # Validate PIN if provided + if pin: + result = validate_pin(pin) + if not result.is_valid: + flash(result.error_message, 'error') + return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + + # Determine key password - QR code keys are never password-protected + key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None) + + # Validate RSA key if provided + if rsa_key_data: + result = validate_rsa_key(rsa_key_data, key_password) + if not result.is_valid: + flash(result.error_message, 'error') + return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + + # Decode + decode_result = decode( + stego_image=stego_data, + reference_photo=ref_data, + day_phrase=day_phrase, + pin=pin, + rsa_key_data=rsa_key_data, + rsa_password=key_password + ) + + if decode_result.is_file: + # File content - store temporarily for download + file_id = secrets.token_urlsafe(16) + cleanup_temp_files() + + filename = decode_result.filename or 'decoded_file' + TEMP_FILES[file_id] = { + 'data': decode_result.file_data, + 'filename': filename, + 'mime_type': decode_result.mime_type, + 'timestamp': time.time() + } + + return render_template('decode.html', + decoded_file=True, + file_id=file_id, + filename=filename, + file_size=format_size(len(decode_result.file_data)), + mime_type=decode_result.mime_type + ) + else: + # Text content + return render_template('decode.html', decoded_message=decode_result.message) + + except DecryptionError: + flash('Decryption failed. Check your phrase, PIN, RSA key, and reference photo.', 'error') + return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + except StegasooError as e: + flash(str(e), 'error') + return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + except Exception as e: + flash(f'Error: {e}', 'error') + return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + + return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + + +@app.route('/decode/download/') +def decode_download(file_id): + """Download decoded file.""" + if file_id not in TEMP_FILES: + flash('File expired or not found.', 'error') + return redirect(url_for('decode_page')) + + file_info = TEMP_FILES[file_id] + mime_type = file_info.get('mime_type', 'application/octet-stream') + + return send_file( + io.BytesIO(file_info['data']), + mimetype=mime_type, + as_attachment=True, + download_name=file_info['filename'] + ) + + +@app.route('/about') +def about(): + return render_template('about.html', + has_argon2=has_argon2(), + has_qrcode_read=HAS_QRCODE_READ, + max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024 + ) + + +# ============================================================================ +# MAIN +# ============================================================================ + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, debug=False) diff --git a/frontends/web/static/style.css b/frontends/web/static/style.css index 9f8a840..95cc22a 100644 --- a/frontends/web/static/style.css +++ b/frontends/web/static/style.css @@ -47,6 +47,15 @@ body { border-bottom: none; } +.card-link .card-header.text-center { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + height: 4.5rem; /* Fixed height to match original */ + display: flex; + align-items: center; + justify-content: center; +} + .feature-card { transition: transform 0.3s ease, box-shadow 0.3s ease; } @@ -255,3 +264,137 @@ footer { .footer-icon { vertical-align: text-bottom; } + +/* ---------------------------------------------------------------------------- + Card Stuff Icons + ---------------------------------------------------------------------------- */ +.action-card { + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.action-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 20px rgba(0,0,0,0.1) !important; +} + +.feature-card { + border-radius: 10px; + overflow: hidden; +} + +/* Ensure buttons are easily tappable on mobile */ +@media (max-width: 768px) { + .btn-lg { + padding: 1rem 1.5rem; + font-size: 1.1rem; + } + + .card { + margin-bottom: 1rem; + } +} + +/* ---------------------------------------------------------------------------- + Dramatic Gold Glow Hover Effect + ---------------------------------------------------------------------------- */ +.embossed-icon { + color: #f8f8f8 !important; + text-shadow: + /* Subtle emboss only by default */ + 0 0.5px 0 rgba(255, 255, 255, 0.4), /* Soft top highlight */ + 0 -0.5px 0 rgba(0, 0, 0, 0.15), /* Very soft bottom shadow */ + 0 1px 2px rgba(0, 0, 0, 0.1); /* Minimal drop shadow */ + + position: relative; + display: inline-block; + transition: all 0.3s ease; + font-size: 3.5rem !important; + line-height: 1; + padding: 10px 0; +} + +.card-link:hover .embossed-icon { + color: #ffffff !important; + text-shadow: + /* GOLD GLOW LAYERS - Only on hover */ + 0 0 10px rgba(255, 215, 0, 0.5), /* Soft inner glow */ + 0 0 20px rgba(255, 215, 0, 0.3), /* Medium glow */ + 0 0 30px rgba(255, 215, 0, 0.2), /* Outer glow */ + 0 0 40px rgba(255, 215, 0, 0.1), /* Far outer glow */ + + /* Enhanced emboss on hover */ + 0 1px 0 rgba(255, 255, 255, 0.8), /* Bright top highlight */ + 0 -1px 0 rgba(0, 0, 0, 0.25), /* Deeper bottom shadow */ + 0 2px 4px rgba(0, 0, 0, 0.15), /* Soft drop shadow */ + + /* Gold accent shadows */ + 0 1px 2px rgba(255, 215, 0, 0.3), /* Gold highlight layer */ + 0 -1px 1px rgba(255, 215, 0, 0.15); /* Gold shadow layer */ + + filter: drop-shadow(0 0 8px rgba(255, 215, 0, 0.25)); + transform: scale(1.02); /* Slight grow on hover */ +} + +/* Card header adjustments for dramatic effect */ +.card-link .card-header.text-center { + padding-top: 1.25rem !important; + padding-bottom: 1.25rem !important; + min-height: 6.5rem; + display: flex; + align-items: center; + justify-content: center; + position: relative; + transition: all 0.3s ease; +} + +/* Enhance the gradient on hover for dramatic effect */ +.card-link:hover .card-header.text-center { + background: linear-gradient(135deg, + var(--gradient-start) 0%, + #5a67d8 20%, + var(--gradient-end) 80%, + #8a2be2 100%); + box-shadow: inset 0 0 20px rgba(255, 215, 0, 0.1); +} + +/* Mobile adjustments */ +@media (max-width: 768px) { + .embossed-icon { + font-size: 2.8rem !important; + padding: 8px 0; + } + + .card-link:hover .embossed-icon { + transform: scale(1.01); + } + + .card-link .card-header.text-center { + padding-top: 1rem !important; + padding-bottom: 1rem !important; + min-height: 5.5rem; + } +} + +/* ---------------------------------------------------------------------------- + Card Links + ---------------------------------------------------------------------------- */ +.card-link { + text-decoration: none; + color: inherit; + display: block; + height: 100%; +} + +.card-link:hover { + color: inherit; +} + +/* Optional: Add a slight scale effect to the entire card on hover */ +.card-link .feature-card { + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.card-link:hover .feature-card { + transform: translateY(-5px); + box-shadow: 0 10px 40px rgba(102, 126, 234, 0.3); +} \ No newline at end of file diff --git a/frontends/web/static/style.css_20251228 b/frontends/web/static/style.css_20251228 new file mode 100644 index 0000000..7d4e1e9 --- /dev/null +++ b/frontends/web/static/style.css_20251228 @@ -0,0 +1,409 @@ +/* ============================================================================ + Stegasoo - Main Stylesheet + ============================================================================ */ + +/* ---------------------------------------------------------------------------- + CSS Variables + ---------------------------------------------------------------------------- */ +:root { + --gradient-start: #667eea; + --gradient-end: #764ba2; + --bg-dark-1: #1a1a2e; + --bg-dark-2: #16213e; + --bg-dark-3: #0f3460; + --text-muted: rgba(255, 255, 255, 0.5); + --border-light: rgba(255, 255, 255, 0.1); + --overlay-dark: rgba(0, 0, 0, 0.3); + --overlay-light: rgba(255, 255, 255, 0.05); +} + +/* ---------------------------------------------------------------------------- + Base Styles + ---------------------------------------------------------------------------- */ +body { + min-height: 100vh; + background: linear-gradient(135deg, var(--bg-dark-1) 0%, var(--bg-dark-2) 50%, var(--bg-dark-3) 100%); +} + +/* ---------------------------------------------------------------------------- + Navigation + ---------------------------------------------------------------------------- */ +.navbar { + background: var(--overlay-dark) !important; + backdrop-filter: blur(10px); +} + +/* ---------------------------------------------------------------------------- + Cards + ---------------------------------------------------------------------------- */ +.card { + background: var(--overlay-light); + backdrop-filter: blur(10px); + border: 1px solid var(--border-light); +} + +.card-header { + background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); + border-bottom: none; +} + +.card-link .card-header.text-center { + padding-top: 0.5rem !important; + padding-bottom: 0.5rem !important; + height: 4.5rem; /* Fixed height to match original */ + display: flex; + align-items: center; + justify-content: center; +} + +.feature-card { + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.feature-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 40px rgba(102, 126, 234, 0.2); +} + +/* ---------------------------------------------------------------------------- + Buttons + ---------------------------------------------------------------------------- */ +.btn-primary { + background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); + border: none; +} + +.btn-primary:hover { + background: linear-gradient(135deg, var(--gradient-end), var(--gradient-start)); + transform: translateY(-2px); + box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4); +} + +/* ---------------------------------------------------------------------------- + Forms + ---------------------------------------------------------------------------- */ +.form-control, +.form-select { + background: var(--overlay-light); + border: 1px solid var(--border-light); + color: #fff; +} + +.form-control:focus, +.form-select:focus { + background: rgba(255, 255, 255, 0.1); + border-color: var(--gradient-start); + box-shadow: 0 0 0 0.25rem rgba(102, 126, 234, 0.25); + color: #fff; +} + +.form-control::placeholder { + color: var(--text-muted); +} + +/* Fix dropdown options for dark theme */ +.form-select option { + background: var(--bg-dark-1); + color: #fff; +} + +/* ---------------------------------------------------------------------------- + Hero & Icons + ---------------------------------------------------------------------------- */ +.hero-icon { + font-size: 4rem; + background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* ---------------------------------------------------------------------------- + Phrase Display + ---------------------------------------------------------------------------- */ +.phrase-display { + font-family: 'Courier New', monospace; + font-size: 1rem; + background: var(--overlay-dark); + padding: 0.5rem 0.75rem; + border-radius: 0.5rem; + border-left: 4px solid var(--gradient-start); + display: inline-block; + line-height: 1.6; + word-spacing: 0.3rem; +} + +/* ---------------------------------------------------------------------------- + PIN Display + ---------------------------------------------------------------------------- */ +.pin-display { + font-family: 'Courier New', monospace; + font-size: 3rem; + font-weight: bold; + letter-spacing: 0.75rem; + background: linear-gradient(135deg, #fef08a, #fcd34d, #fb923c); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + display: inline-block; + line-height: 1; +} + +.pin-container { + background: var(--overlay-dark); + border: 1px solid var(--border-light); + border-radius: 0.75rem; + padding: 1.5rem 2rem; + display: inline-block; +} + +/* ---------------------------------------------------------------------------- + Story Cards (Memory Aid) + ---------------------------------------------------------------------------- */ +.story-word { + color: #ff6b6b; + font-weight: bold; + text-transform: uppercase; +} + +.story-card { + background: rgba(0, 0, 0, 0.2); + border-left: 3px solid var(--gradient-start); + padding: 1rem; + margin-bottom: 0.75rem; + border-radius: 0.5rem; + font-size: 0.95rem; + line-height: 1.6; +} + +.story-card .day-label { + font-weight: bold; + color: var(--gradient-start); + margin-bottom: 0.5rem; +} + +/* ---------------------------------------------------------------------------- + Alert / Message Display + ---------------------------------------------------------------------------- */ +.alert-message { + background: var(--overlay-dark); + border: 1px solid var(--border-light); + border-radius: 0.5rem; + padding: 1.5rem; + white-space: pre-wrap; + font-family: 'Courier New', monospace; +} + +/* ---------------------------------------------------------------------------- + Drop Zone (Drag & Drop File Upload) + ---------------------------------------------------------------------------- */ +.drop-zone { + position: relative; + border: 2px dashed rgba(255, 255, 255, 0.2); + border-radius: 0.5rem; + padding: 1.5rem; + text-align: center; + transition: all 0.2s ease; +} + +.drop-zone.drag-over { + border-color: var(--gradient-start); + background: rgba(102, 126, 234, 0.1); +} + +.drop-zone input[type="file"] { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.drop-zone-label { + pointer-events: none; +} + +.drop-zone-preview { + max-height: 120px; + border-radius: 0.375rem; + margin-top: 0.75rem; +} + +/* ---------------------------------------------------------------------------- + Footer + ---------------------------------------------------------------------------- */ +footer { + background: rgba(0, 0, 0, 0.2); +} + +/* ---------------------------------------------------------------------------- + Custom Alert Variants + ---------------------------------------------------------------------------- */ +.alert-success-bright { + background: rgba(34, 197, 94, 0.2); + border-color: #22c55e; + color: #4ade80; +} + +/* ---------------------------------------------------------------------------- + Utility Classes + ---------------------------------------------------------------------------- */ +.bg-dark-subtle { + background: rgba(0, 0, 0, 0.2); +} + +.status-box { + background: rgba(0, 0, 0, 0.2); + padding: 1rem; + border-radius: 0.5rem; +} + +.result-icon { + font-size: 4rem; +} + +.footer-icon { + vertical-align: text-bottom; +} + + + +/* ---------------------------------------------------------------------------- + Card Stuff Icons + ---------------------------------------------------------------------------- */ + + +.action-card { + transition: transform 0.2s ease, box-shadow 0.2s ease; +} + +.action-card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 20px rgba(0,0,0,0.1) !important; +} + +.feature-card { + border-radius: 10px; + overflow: hidden; +} + +/* Ensure buttons are easily tappable on mobile */ +@media (max-width: 768px) { + .btn-lg { + padding: 1rem 1.5rem; + font-size: 1.1rem; + } + + .card { + margin-bottom: 1rem; + } +} + +/* ---------------------------------------------------------------------------- + Ethereal Gold Glow + Soft Emboss + ---------------------------------------------------------------------------- */ +.embossed-icon { + color: rgba(255, 255, 255, 0.95) !important; + text-shadow: + /* Layered gold glow - very feathered and transparent */ + 0 0 15px rgba(255, 215, 0, 0.4), + 0 0 30px rgba(255, 215, 0, 0.25), + 0 0 45px rgba(255, 215, 0, 0.15), + 0 0 60px rgba(255, 215, 0, 0.08), + + /* Soft emboss with minimal contrast */ + 0 0.5px 0 rgba(255, 255, 255, 0.4), + 0 -0.5px 0 rgba(0, 0, 0, 0.1), + + /* Extra glow layers for diffusion */ + 0 0 0 1px rgba(255, 215, 0, 0.1); + + position: relative; + display: inline-block; + transition: all 0.4s ease; + font-size: 3.5rem !important; + line-height: 1; + padding: 10px 0; + filter: + drop-shadow(0 0 10px rgba(255, 215, 0, 0.15)) + drop-shadow(0 0 20px rgba(255, 215, 0, 0.1)) + drop-shadow(0 0 30px rgba(255, 215, 0, 0.05)); +} + +.card-link:hover .embossed-icon { + color: rgba(255, 255, 255, 1) !important; + text-shadow: + 0 0 20px rgba(255, 215, 0, 0.5), + 0 0 40px rgba(255, 215, 0, 0.35), + 0 0 60px rgba(255, 215, 0, 0.2), + 0 0 80px rgba(255, 215, 0, 0.1), + 0 0.5px 0 rgba(255, 255, 255, 0.5), + 0 -0.5px 0 rgba(0, 0, 0, 0.15), + 0 0 0 1px rgba(255, 215, 0, 0.15); + + filter: + drop-shadow(0 0 15px rgba(255, 215, 0, 0.2)) + drop-shadow(0 0 30px rgba(255, 215, 0, 0.15)) + drop-shadow(0 0 45px rgba(255, 215, 0, 0.08)); +} + +/* Adjust card-header padding to give icons more vertical breathing room */ +.card-link .card-header.text-center { + padding-top: 1rem !important; /* Increased from 0.75rem */ + padding-bottom: 1rem !important; /* Increased from 0.75rem */ + min-height: 6rem; /* Increased from 5.5rem to accommodate padding */ + display: flex; + align-items: center; + justify-content: center; +} + +/* Alternatively, you could use relative padding based on icon size */ +/* This version makes the padding proportional to the icon size */ +/* +.card-link .card-header.text-center { + padding-top: calc(1rem + 0.5vh) !important; + padding-bottom: calc(1rem + 0.5vh) !important; + min-height: auto !important; + display: flex; + align-items: center; + justify-content: center; +} +*/ + +/* Mobile adjustments for larger icons */ +@media (max-width: 768px) { + .embossed-icon { + font-size: 2.8rem !important; + padding: 6px 0; /* Slightly less padding on mobile */ + } + + .card-link .card-header.text-center { + padding-top: 0.75rem !important; + padding-bottom: 0.75rem !important; + min-height: 5rem; /* Adjusted for mobile */ + } +} + + +/* ---------------------------------------------------------------------------- + Card Links + ---------------------------------------------------------------------------- */ +.card-link { + text-decoration: none; + color: inherit; + display: block; + height: 100%; +} + +.card-link:hover { + color: inherit; +} + +/* Optional: Add a slight scale effect to the entire card on hover */ +.card-link .feature-card { + transition: transform 0.3s ease, box-shadow 0.3s ease; +} + +.card-link:hover .feature-card { + transform: translateY(-5px); + box-shadow: 0 10px 40px rgba(102, 126, 234, 0.3); +} diff --git a/frontends/web/templates/decode.html b/frontends/web/templates/decode.html index bb80edc..3d8c165 100644 --- a/frontends/web/templates/decode.html +++ b/frontends/web/templates/decode.html @@ -133,7 +133,6 @@ - {% if has_qrcode_read %}