Download app.py from mac9087/Sharelock: direct link, hf CLI and curl.
- Browser
- Download file 81.2 kB
-
https://huggingface.co/spaces/mac9087/Sharelock/resolve/main/app.py
- Command line
-
hf download hf://spaces/mac9087/Sharelock/app.py
-
curl -L -o app.py https://huggingface.co/spaces/mac9087/Sharelock/resolve/main/app.py
81.2 kB
| import sys | |
| import os | |
| from flask import Flask, request, jsonify, send_file | |
| from flask_cors import CORS | |
| from flask_limiter import Limiter | |
| from flask_limiter.util import get_remote_address | |
| from collections import defaultdict | |
| from werkzeug.utils import secure_filename | |
| import tempfile | |
| import uuid | |
| import io | |
| import base64 | |
| import time | |
| import json | |
| import hashlib | |
| import qrcode | |
| from PIL import Image | |
| import requests | |
| import random | |
| import string | |
| from flask_socketio import SocketIO, emit, join_room, leave_room | |
| import threading | |
| from database import init_supabase, get_supabase | |
| from auth import signup_user, login_user, check_username_exists, verify_token | |
| from functools import wraps | |
| from datetime import datetime, timedelta | |
| from cryptography.hazmat.primitives import hashes, serialization | |
| from cryptography.hazmat.primitives.asymmetric import ec | |
| from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicNumbers, SECP256R1 | |
| from cryptography.hazmat.backends import default_backend | |
| from cryptography.exceptions import InvalidSignature | |
| import secrets | |
| import builtins | |
| import logging | |
| # ============= π DISABLE ALL CONSOLE LOGS ============= | |
| ENABLE_LOGS = False # Set to True for debugging | |
| if not ENABLE_LOGS: | |
| # Disable print() | |
| def noop(*args, **kwargs): | |
| pass | |
| builtins.print = noop | |
| # Disable WebSocket logs | |
| logging.getLogger('socketio').setLevel(logging.ERROR) | |
| logging.getLogger('engineio').setLevel(logging.ERROR) | |
| logging.getLogger('werkzeug').setLevel(logging.ERROR) | |
| logging.getLogger().setLevel(logging.ERROR) | |
| # ======================================================= | |
| app = Flask(__name__) | |
| CORS(app) | |
| def add_security_headers(response): | |
| """Add security headers""" | |
| response.headers['X-Content-Type-Options'] = 'nosniff' | |
| response.headers['X-Frame-Options'] = 'DENY' | |
| response.headers['X-XSS-Protection'] = '1; mode=block' | |
| response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin' | |
| # Cache control for sensitive data | |
| if '/api/' in request.path: | |
| response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, private' | |
| response.headers['Pragma'] = 'no-cache' | |
| return response | |
| print("Initializing Supabase...") | |
| init_supabase() | |
| # Validate environment variables | |
| if not os.getenv("SUPABASE_URL") or not os.getenv("SUPABASE_KEY"): | |
| print("β οΈ WARNING: SUPABASE_URL or SUPABASE_KEY not set!") | |
| print("β οΈ Authentication features will not work properly") | |
| else: | |
| print(f"β Supabase URL configured: {os.getenv('SUPABASE_URL')[:30]}...") | |
| # FIX: Configure SocketIO for production deployment | |
| # NEW (production-ready): | |
| socketio = SocketIO( | |
| app, | |
| cors_allowed_origins="*", | |
| async_mode='eventlet', # Changed from 'threading' | |
| logger=False, # Enable for debugging | |
| engineio_logger=False, # Enable for debugging | |
| ping_timeout=60, | |
| ping_interval=25, | |
| transports=['websocket', 'polling'] # Explicitly allow both | |
| ) | |
| # In-memory storage | |
| SECRETS = {} # { id: { data, file_data, file_type, expire_at, view_once, theme, analytics, etc. } } | |
| SHORT_LINKS = {} # { short_id: full_id } | |
| SCREEN_SHARE_ROOMS = {} # { room_id: { host_hash, participants, settings, created_at, expires_at } } | |
| CURSOR_STATES = {} | |
| CHAT_ROOMS = {} | |
| PENDING_ICE_CANDIDATES = {} | |
| # NEW: Rate limiting storage | |
| SESSION_REQUESTS = defaultdict(list) # {uuid: [timestamp1, timestamp2, ...]} | |
| SEEN_NONCES = {} # {nonce: timestamp} | |
| MESSAGE_VALIDITY_WINDOW = 300 # 5 minutes | |
| # Configuration | |
| MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB | |
| ALLOWED_EXTENSIONS = { | |
| 'image': ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg'], | |
| 'video': ['mp4', 'avi', 'mov', 'wmv', 'flv', 'webm'], | |
| 'audio': ['mp3', 'wav', 'ogg', 'aac', 'm4a'], | |
| 'document': ['pdf', 'txt', 'doc', 'docx', 'rtf', 'odt'] | |
| } | |
| ONLINE_USERS = {} | |
| FILE_TRANSFERS = {} | |
| def import_ecdsa_public_key(base64_key): | |
| """Import ECDSA P-256 public key from base64 (RAW format from Web Crypto API)""" | |
| try: | |
| # Web Crypto API exports ECDSA public keys in RAW format (65 bytes for P-256) | |
| # Format: 0x04 || X (32 bytes) || Y (32 bytes) | |
| key_bytes = base64.b64decode(base64_key) | |
| print(f"π Public key bytes length: {len(key_bytes)}") | |
| # Expected: 65 bytes for uncompressed P-256 point | |
| if len(key_bytes) != 65: | |
| print(f"β Invalid key length: expected 65, got {len(key_bytes)}") | |
| return None | |
| # Check format marker (should be 0x04 for uncompressed point) | |
| if key_bytes[0] != 0x04: | |
| print(f"β Invalid key format marker: {hex(key_bytes[0])}") | |
| return None | |
| # Extract X and Y coordinates (32 bytes each) | |
| x = int.from_bytes(key_bytes[1:33], byteorder='big') | |
| y = int.from_bytes(key_bytes[33:65], byteorder='big') | |
| # Create ECDSA public key from coordinates | |
| from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicNumbers, SECP256R1 | |
| public_numbers = EllipticCurvePublicNumbers(x, y, SECP256R1()) | |
| public_key = public_numbers.public_key(default_backend()) | |
| print(f"β Successfully imported ECDSA public key") | |
| return public_key | |
| except Exception as e: | |
| print(f"β Failed to import ECDSA public key: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| return None | |
| def verify_rsa_signature(public_key_base64, challenge, signature_base64): | |
| """Verify RSA signature for challenge-response auth""" | |
| try: | |
| print(f"\n=== RSA SIGNATURE VERIFICATION ===") | |
| print(f"Challenge (base64): {challenge[:30]}...") | |
| print(f"Signature (base64): {signature_base64[:30]}...") | |
| print(f"Public Key (base64): {public_key_base64[:50]}...") | |
| # Import RSA public key (SPKI format) | |
| from cryptography.hazmat.primitives import serialization | |
| from cryptography.hazmat.primitives.asymmetric import padding | |
| from cryptography.hazmat.primitives import hashes | |
| # Decode public key | |
| key_bytes = base64.b64decode(public_key_base64) | |
| public_key = serialization.load_der_public_key(key_bytes, backend=default_backend()) | |
| print(f"β Imported RSA public key") | |
| # Decode signature from base64 | |
| signature_bytes = base64.b64decode(signature_base64) | |
| print(f"π Signature bytes length: {len(signature_bytes)}") | |
| # Challenge as UTF-8 bytes | |
| challenge_bytes = challenge.encode('utf-8') | |
| print(f"π Challenge bytes length: {len(challenge_bytes)}") | |
| # Verify signature using RSA-PSS with SHA-256 | |
| public_key.verify( | |
| signature_bytes, | |
| challenge_bytes, | |
| padding.PSS( | |
| mgf=padding.MGF1(hashes.SHA256()), | |
| salt_length=padding.PSS.MAX_LENGTH | |
| ), | |
| hashes.SHA256() | |
| ) | |
| print("β RSA signature verification successful!") | |
| print("=====================================\n") | |
| return True | |
| except InvalidSignature: | |
| print("β Invalid RSA signature - cryptographic verification failed") | |
| print("=====================================\n") | |
| return False | |
| except Exception as e: | |
| print(f"β RSA verification error: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| print("=====================================\n") | |
| return False | |
| # Challenge storage for ECDSA auth (temporary, expires in 60s) | |
| CHALLENGE_STORE = {} # {room_id: {challenge, created_at}} | |
| def cleanup_old_challenges(): | |
| """Remove expired challenges""" | |
| now = time.time() | |
| expired = [k for k, v in CHALLENGE_STORE.items() if now - v['created_at'] > 60] | |
| for key in expired: | |
| del CHALLENGE_STORE[key] | |
| def assign_cursor_color(existing_participants): | |
| """Assign unique cursor color to new participant""" | |
| import random | |
| colors = ['blue', 'green', 'red', 'yellow', 'purple', 'pink', 'orange', 'cyan'] | |
| used_colors = [p.get('cursor_color') for p in existing_participants if p.get('cursor_color')] | |
| available_colors = [c for c in colors if c not in used_colors] | |
| if available_colors: | |
| return available_colors[0] | |
| else: | |
| return random.choice(colors) | |
| def get_file_type(filename): | |
| """Determine file type based on extension""" | |
| if not filename: | |
| return 'unknown' | |
| ext = filename.rsplit('.', 1)[1].lower() if '.' in filename else '' | |
| for file_type, extensions in ALLOWED_EXTENSIONS.items(): | |
| if ext in extensions: | |
| return file_type | |
| return 'unknown' | |
| def generate_short_id(): | |
| """Generate a short, unique ID""" | |
| return ''.join(random.choices(string.ascii_lowercase + string.digits, k=6)) | |
| def get_client_ip(request): | |
| """Get client IP address""" | |
| if request.headers.get('X-Forwarded-For'): | |
| return request.headers.get('X-Forwarded-For').split(',')[0].strip() | |
| elif request.headers.get('X-Real-IP'): | |
| return request.headers.get('X-Real-IP') | |
| else: | |
| return request.remote_addr | |
| def check_rate_limit(session_uuid, limit, window_seconds): | |
| """ | |
| Check if session_uuid exceeded rate limit | |
| Returns: (is_allowed: bool, message: str) | |
| """ | |
| now = time.time() | |
| if not session_uuid: | |
| return False, "Session UUID required" | |
| # Get request history for this session | |
| requests = SESSION_REQUESTS[session_uuid] | |
| # Remove old requests outside window | |
| requests = [ts for ts in requests if now - ts < window_seconds] | |
| # Check limit | |
| if len(requests) >= limit: | |
| return False, f"Rate limit exceeded: {limit} requests per {window_seconds}s" | |
| # Add current request | |
| requests.append(now) | |
| SESSION_REQUESTS[session_uuid] = requests | |
| return True, "OK" | |
| def verify_nonce_and_timestamp(nonce, timestamp): | |
| """ | |
| Prevent replay attacks | |
| Returns: (is_valid: bool, message: str) | |
| """ | |
| current_time = time.time() | |
| if not nonce: | |
| return False, "Nonce required" | |
| # Check if message is too old | |
| if current_time - timestamp > MESSAGE_VALIDITY_WINDOW: | |
| return False, "Message expired (older than 5 minutes)" | |
| # Check if message is from the future | |
| if timestamp > current_time + 60: | |
| return False, "Invalid timestamp" | |
| # Check if nonce was already used | |
| if nonce in SEEN_NONCES: | |
| return False, "Replay attack detected" | |
| # Store nonce | |
| SEEN_NONCES[nonce] = timestamp | |
| # Cleanup old nonces | |
| cleanup_old_nonces(current_time) | |
| return True, "OK" | |
| def cleanup_old_nonces(current_time): | |
| """Remove expired nonces""" | |
| expired = [k for k, v in SEEN_NONCES.items() if current_time - v > MESSAGE_VALIDITY_WINDOW] | |
| for key in expired: | |
| del SEEN_NONCES[key] | |
| def generate_qr_code(data): | |
| """Generate QR code for the given data""" | |
| qr = qrcode.QRCode( | |
| version=1, | |
| error_correction=qrcode.constants.ERROR_CORRECT_L, | |
| box_size=10, | |
| border=4, | |
| ) | |
| qr.add_data(data) | |
| qr.make(fit=True) | |
| img = qr.make_image(fill_color="black", back_color="white") | |
| # Convert to base64 | |
| buffer = io.BytesIO() | |
| img.save(buffer, format='PNG') | |
| img_str = base64.b64encode(buffer.getvalue()).decode() | |
| return f"data:image/png;base64,{img_str}" | |
| def require_auth(f): | |
| """Decorator to require authentication""" | |
| def decorated_function(*args, **kwargs): | |
| token = request.headers.get('Authorization') | |
| if not token: | |
| return jsonify({'error': 'No token provided'}), 401 | |
| user = verify_token(token) | |
| if not user: | |
| return jsonify({'error': 'Invalid token'}), 401 | |
| # Add user to request context | |
| request.current_user = user | |
| return f(*args, **kwargs) | |
| return decorated_function | |
| def index(): | |
| """Health check endpoint""" | |
| return jsonify({ | |
| "status": "running", | |
| "service": "Sharelock Backend", | |
| "version": "2.0.0", | |
| "features": [ | |
| "End-to-end encryption", | |
| "File uploads (5MB max)", | |
| "QR code generation", | |
| "Analytics tracking", | |
| "Short URLs", | |
| "Self-destruct messages", | |
| "Real-time chat rooms" | |
| ] | |
| }) | |
| def store(): | |
| """Store encrypted secret with enhanced features""" | |
| try: | |
| form = request.form | |
| data = form.get("data") | |
| session_uuid = form.get("session_uuid") | |
| nonce = form.get("nonce") | |
| timestamp_str = form.get("timestamp") | |
| # Validate required fields | |
| if not data: | |
| return jsonify({"error": "Data is required"}), 400 | |
| if not session_uuid: | |
| return jsonify({"error": "Session UUID required"}), 400 | |
| if not nonce: | |
| return jsonify({"error": "Nonce required"}), 400 | |
| if not timestamp_str: | |
| return jsonify({"error": "Timestamp required"}), 400 | |
| try: | |
| timestamp = float(timestamp_str) | |
| except ValueError: | |
| return jsonify({"error": "Invalid timestamp"}), 400 | |
| # Check rate limit (10 secrets per minute) | |
| allowed, message = check_rate_limit(session_uuid, limit=10, window_seconds=60) | |
| if not allowed: | |
| print(f"[RateLimit] Blocked {session_uuid[:8]}... - {message}") | |
| return jsonify({"error": message}), 429 | |
| # Verify nonce and timestamp (prevent replay attacks) | |
| valid, message = verify_nonce_and_timestamp(nonce, timestamp) | |
| if not valid: | |
| print(f"[Security] Blocked - {message}") | |
| return jsonify({"error": message}), 400 | |
| # Parse parameters | |
| ttl = int(form.get("ttl", 300)) | |
| view_once = form.get("view_once", "false").lower() == "true" | |
| delay_seconds = int(form.get("delay_seconds", 0)) | |
| theme = form.get("theme", "default") | |
| password_hint = form.get("password_hint", "") | |
| # Handle file upload | |
| file_data = None | |
| file_type = None | |
| file_name = None | |
| if 'file' in request.files: | |
| file = request.files['file'] | |
| if file and file.filename: | |
| # Check file size | |
| file.seek(0, os.SEEK_END) | |
| file_size = file.tell() | |
| file.seek(0) | |
| if file_size > MAX_FILE_SIZE: | |
| return jsonify({"error": f"File too large. Max size: {MAX_FILE_SIZE/1024/1024:.1f}MB"}), 400 | |
| # Process file | |
| file_name = secure_filename(file.filename) | |
| file_type = get_file_type(file_name) | |
| if file_type == 'unknown': | |
| return jsonify({"error": "File type not supported"}), 400 | |
| # Read and encode file | |
| file_content = file.read() | |
| file_data = base64.b64encode(file_content).decode('utf-8') | |
| # Generate IDs | |
| secret_id = str(uuid.uuid4()) | |
| short_id = generate_short_id() | |
| # Ensure short_id is unique | |
| while short_id in SHORT_LINKS: | |
| short_id = generate_short_id() | |
| # Store secret | |
| SECRETS[secret_id] = { | |
| "data": data, | |
| "file_data": file_data, | |
| "file_type": file_type, | |
| "file_name": file_name, | |
| "expire_at": time.time() + ttl, | |
| "view_once": view_once, | |
| "delay_seconds": delay_seconds, | |
| "theme": theme, | |
| "password_hint": password_hint, | |
| "created_at": time.time(), | |
| "creator_ip": get_client_ip(request), | |
| "access_count": 0 | |
| } | |
| # Store short link mapping | |
| SHORT_LINKS[short_id] = secret_id | |
| # Generate QR code | |
| base_url = request.host_url.rstrip('/') | |
| secret_url = f"{base_url}/tools/sharelock?id={secret_id}" | |
| qr_code = generate_qr_code(secret_url) | |
| return jsonify({ | |
| "id": secret_id, | |
| "short_id": short_id, | |
| "short_url": f"{base_url}/s/{short_id}", | |
| "qr_code": qr_code, | |
| "expires_at": SECRETS[secret_id]["expire_at"], | |
| "has_file": file_data is not None | |
| }) | |
| except Exception as e: | |
| return jsonify({"error": str(e)}), 500 | |
| def fetch(secret_id): | |
| """Fetch and decrypt secret with analytics - MODIFIED TO HANDLE verify_only""" | |
| try: | |
| # ADD THIS SECTION FOR RATE LIMITING: | |
| session_uuid = request.args.get("session_uuid") | |
| if session_uuid: | |
| allowed, message = check_rate_limit(session_uuid, limit=20, window_seconds=60) | |
| if not allowed: | |
| print(f"[RateLimit] Fetch blocked {session_uuid[:8]}... - {message}") | |
| return jsonify({"error": message}), 429 | |
| # Check if it's a short link | |
| if secret_id in SHORT_LINKS: | |
| secret_id = SHORT_LINKS[secret_id] | |
| secret = SECRETS.get(secret_id) | |
| if not secret: | |
| return jsonify({"error": "Secret not found"}), 404 | |
| # Check expiration | |
| if time.time() > secret["expire_at"]: | |
| # Clean up expired secret | |
| if secret_id in SECRETS: | |
| del SECRETS[secret_id] | |
| # Clean up short link | |
| for short_id, full_id in list(SHORT_LINKS.items()): | |
| if full_id == secret_id: | |
| del SHORT_LINKS[short_id] | |
| return jsonify({"error": "Secret has expired"}), 410 | |
| # CHECK FOR verify_only PARAMETER | |
| verify_only = request.args.get('verify_only', 'false').lower() == 'true' | |
| # Prepare response | |
| response = { | |
| "data": secret["data"], | |
| "theme": secret.get("theme", "default"), | |
| "delay_seconds": secret.get("delay_seconds", 0), | |
| "password_hint": secret.get("password_hint", ""), | |
| "access_count": secret["access_count"] | |
| } | |
| # Include file data if present | |
| if secret.get("file_data"): | |
| response["file_data"] = secret["file_data"] | |
| response["file_type"] = secret.get("file_type", "unknown") | |
| response["file_name"] = secret.get("file_name", "unknown") | |
| # Handle view-once deletion (only if not verify_only) | |
| if secret["view_once"] and not verify_only: | |
| # Delete the secret | |
| del SECRETS[secret_id] | |
| # Clean up short link | |
| for short_id, full_id in list(SHORT_LINKS.items()): | |
| if full_id == secret_id: | |
| del SHORT_LINKS[short_id] | |
| break | |
| return jsonify(response) | |
| except Exception as e: | |
| return jsonify({"error": str(e)}), 500 | |
| def list_secrets(): | |
| """List all active secrets (for dashboard)""" | |
| try: | |
| current_time = time.time() | |
| active_secrets = [] | |
| for secret_id, secret in SECRETS.items(): | |
| if current_time <= secret["expire_at"]: | |
| # Find short link | |
| short_id = None | |
| for s_id, full_id in SHORT_LINKS.items(): | |
| if full_id == secret_id: | |
| short_id = s_id | |
| break | |
| active_secrets.append({ | |
| "id": secret_id, | |
| "short_id": short_id, | |
| "created_at": secret["created_at"], | |
| "expires_at": secret["expire_at"], | |
| "view_once": secret["view_once"], | |
| "has_file": secret.get("file_data") is not None, | |
| "file_type": secret.get("file_type"), | |
| "theme": secret.get("theme", "default"), | |
| "access_count": secret.get("access_count", 0), | |
| "preview": secret["data"][:100] + "..." if len(secret["data"]) > 100 else secret["data"] | |
| }) | |
| return jsonify({ | |
| "secrets": active_secrets, | |
| "total": len(active_secrets) | |
| }) | |
| except Exception as e: | |
| return jsonify({"error": str(e)}), 500 | |
| def delete_secret(secret_id): | |
| """Manually delete a secret - MODIFIED TO HANDLE verify_only""" | |
| try: | |
| # CHECK FOR verify_only PARAMETER | |
| verify_only = request.args.get('verify_only', 'false').lower() == 'true' | |
| if secret_id not in SECRETS: | |
| return jsonify({"error": "Secret not found"}), 404 | |
| # Only record access if NOT verify_only | |
| if not verify_only: | |
| # Record access analytics for the delete request | |
| record_access(secret_id, request) | |
| # Delete secret | |
| del SECRETS[secret_id] | |
| # Clean up short link | |
| for short_id, full_id in list(SHORT_LINKS.items()): | |
| if full_id == secret_id: | |
| del SHORT_LINKS[short_id] | |
| break | |
| return jsonify({"message": "Secret deleted successfully"}) | |
| except Exception as e: | |
| return jsonify({"error": str(e)}), 500 | |
| def redirect_short_link(short_id): | |
| """Redirect short link to full URL""" | |
| if short_id not in SHORT_LINKS: | |
| return jsonify({"error": "Short link not found"}), 404 | |
| secret_id = SHORT_LINKS[short_id] | |
| base_url = request.host_url.rstrip('/') | |
| return f""" | |
| <!DOCTYPE html> | |
| <html> | |
| <head> | |
| <title>Sharelock - Redirecting...</title> | |
| <meta http-equiv="refresh" content="0;url={base_url}/tools/sharelock?id={secret_id}"> | |
| </head> | |
| <body> | |
| <p>Redirecting to secure message...</p> | |
| <p>If you are not redirected automatically, <a href="{base_url}/tools/sharelock?id={secret_id}">click here</a>.</p> | |
| </body> | |
| </html> | |
| """ | |
| def get_qr_code(secret_id): | |
| """Generate QR code for a secret""" | |
| try: | |
| if secret_id not in SECRETS: | |
| return jsonify({"error": "Secret not found"}), 404 | |
| base_url = request.host_url.rstrip('/') | |
| secret_url = f"{base_url}/tools/sharelock?id={secret_id}" | |
| qr_code = generate_qr_code(secret_url) | |
| return jsonify({"qr_code": qr_code}) | |
| except Exception as e: | |
| return jsonify({"error": str(e)}), 500 | |
| def get_stats(): | |
| """Get overall statistics""" | |
| try: | |
| total_secrets = len(SECRETS) | |
| # Count by file type | |
| file_types = {} | |
| for secret in SECRETS.values(): | |
| file_type = secret.get("file_type", "text") | |
| file_types[file_type] = file_types.get(file_type, 0) + 1 | |
| # Count by theme | |
| themes = {} | |
| for secret in SECRETS.values(): | |
| theme = secret.get("theme", "default") | |
| themes[theme] = themes.get(theme, 0) + 1 | |
| return jsonify({ | |
| "total_secrets": total_secrets, | |
| "total_accesses": total_accesses, | |
| "file_types": file_types, | |
| "themes": themes, | |
| "active_short_links": len(SHORT_LINKS) | |
| }) | |
| except Exception as e: | |
| return jsonify({"error": str(e)}), 500 | |
| # NEW ENDPOINT: Ping endpoint for uptime monitoring | |
| def ping(): | |
| """Simple ping endpoint to keep the service alive""" | |
| return jsonify({ | |
| "status": "alive", | |
| "timestamp": datetime.utcnow().isoformat(), | |
| "message": "Pong! Service is awake." | |
| }), 200 | |
| def cleanup_expired(): | |
| """Clean up expired secrets""" | |
| try: | |
| current_time = time.time() | |
| expired_count = 0 | |
| # Find expired secrets | |
| expired_secrets = [] | |
| for secret_id, secret in SECRETS.items(): | |
| if current_time > secret["expire_at"]: | |
| expired_secrets.append(secret_id) | |
| # Delete expired secrets | |
| for secret_id in expired_secrets: | |
| del SECRETS[secret_id] | |
| expired_count += 1 | |
| # Clean up short links | |
| for short_id, full_id in list(SHORT_LINKS.items()): | |
| if full_id == secret_id: | |
| del SHORT_LINKS[short_id] | |
| break | |
| return jsonify({ | |
| "message": f"Cleaned up {expired_count} expired secrets", | |
| "expired_count": expired_count | |
| }) | |
| except Exception as e: | |
| return jsonify({"error": str(e)}), 500 | |
| # ADD THESE CHAT ENDPOINTS: | |
| # ============= NEW: WEBSOCKET-BASED REGISTRATION ============= | |
| def create_chat_room(): | |
| try: | |
| session_uuid = request.form.get("session_uuid") | |
| if session_uuid: | |
| allowed, message = check_rate_limit( | |
| session_uuid, limit=5, window_seconds=60 | |
| ) | |
| if not allowed: | |
| return jsonify({"error": message}), 429 | |
| form = request.form | |
| ttl = int(form.get("ttl", 3600)) | |
| max_receivers = int(form.get("max_receivers", 5)) | |
| allow_files = form.get("allow_files", "true").lower() == "true" | |
| admin_public_key = form.get("admin_public_key", "") # NEW: ECDSA public key | |
| if not admin_public_key: | |
| return jsonify({"error": "Admin public key required"}), 400 | |
| room_id = str(uuid.uuid4()) | |
| admin_session = str(uuid.uuid4()) # Still used for admin link | |
| # Generate individual receiver tokens | |
| receiver_tokens = [] | |
| for i in range(max_receivers): | |
| receiver_tokens.append({ | |
| "token": str(uuid.uuid4()), | |
| "receiver_number": i + 1, | |
| "claimed": False, | |
| "public_key": None, # NEW: Will be set when receiver joins | |
| "claimed_at": None | |
| }) | |
| expires_at = time.time() + ttl | |
| CHAT_ROOMS[room_id] = { | |
| "admin_session": admin_session, | |
| "admin_ecdsa_public_key": admin_public_key, | |
| "created_at": time.time(), | |
| "expires_at": expires_at, | |
| "settings": { | |
| "max_receivers": max_receivers, | |
| "allow_files": allow_files, | |
| "burn_on_admin_exit": True | |
| }, | |
| "receiver_tokens": receiver_tokens, | |
| "active_sessions": {}, | |
| "receiver_counter": 0 | |
| } | |
| print(f"β Room created: {room_id[:8]}... (TTL: {ttl}s, Expires: {time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(expires_at))})") | |
| print(f"π Admin public key stored (length: {len(admin_public_key)})") | |
| # Return IDs + receiver tokens | |
| return jsonify({ | |
| "room_id": room_id, | |
| "admin_session": admin_session, | |
| "receiver_tokens": [{"token": t["token"], "receiver_number": t["receiver_number"]} for t in receiver_tokens], | |
| "expires_at": expires_at # Return as Unix timestamp (frontend converts to local time) | |
| }) | |
| except Exception as e: | |
| print(f"β Error creating chat room: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| return jsonify({"error": str(e)}), 500 | |
| def handle_register_receiver(data): | |
| """Receiver first-time registration via WebSocket""" | |
| try: | |
| room_id = data.get("room_id") | |
| receiver_token = data.get("receiver_token") | |
| ecdsa_public_key = data.get("ecdsa_public_key") | |
| print(f"\n{'='*60}") | |
| print(f"π΅ WEBSOCKET: register_receiver") | |
| print(f"{'='*60}") | |
| print(f"Room ID: {room_id[:8] if room_id else 'None'}...") | |
| print(f"Receiver Token: {receiver_token[:8] if receiver_token else 'None'}...") | |
| print(f"ECDSA Public Key: {ecdsa_public_key[:50] if ecdsa_public_key else 'None'}...") | |
| print(f"{'='*60}\n") | |
| if not room_id or not receiver_token or not ecdsa_public_key: | |
| emit('registration_error', {"error": "Missing required fields"}) | |
| return | |
| if room_id not in CHAT_ROOMS: | |
| emit('registration_error', {"error": "Chat room not found"}) | |
| return | |
| room = CHAT_ROOMS[room_id] | |
| # Check if expired | |
| if time.time() > room["expires_at"]: | |
| emit('registration_error', {"error": "Chat room has expired"}) | |
| return | |
| # Find the token | |
| token_data = None | |
| for token in room["receiver_tokens"]: | |
| if token["token"] == receiver_token: | |
| token_data = token | |
| break | |
| if not token_data: | |
| emit('registration_error', {"error": "Invalid receiver token"}) | |
| return | |
| # Check if already claimed | |
| if token_data["claimed"]: | |
| emit('registration_error', {"error": "Token already claimed. Use challenge-response to rejoin."}) | |
| return | |
| # First time claim - BIND ECDSA public key | |
| token_data["claimed"] = True | |
| token_data["ecdsa_public_key"] = ecdsa_public_key | |
| token_data["claimed_at"] = time.time() | |
| # β VERIFICATION LOGGING | |
| print(f"\n{'='*60}") | |
| print(f"π ECDSA KEY STORED") | |
| print(f"{'='*60}") | |
| print(f"Receiver #{token_data['receiver_number']}") | |
| print(f"Token: {receiver_token[:8]}...") | |
| print(f"ECDSA Key (stored): {token_data['ecdsa_public_key'][:50]}...") | |
| print(f"Claimed: {token_data['claimed']}") | |
| print(f"{'='*60}\n") | |
| # Return session data | |
| session_id = str(uuid.uuid4()) | |
| emit('registration_success', { | |
| "session_id": session_id, | |
| "role": "receiver", | |
| "receiver_number": token_data["receiver_number"], | |
| "room_settings": room["settings"], | |
| "expires_at": room["expires_at"] | |
| }) | |
| print(f"β Receiver #{token_data['receiver_number']} registered successfully") | |
| except Exception as e: | |
| print(f"β Error in register_receiver: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| emit('registration_error', {"error": str(e)}) | |
| def handle_register_admin(data): | |
| """Admin first-time registration via WebSocket""" | |
| try: | |
| room_id = data.get("room_id") | |
| admin_session = data.get("admin_session") | |
| ecdsa_public_key = data.get("ecdsa_public_key") | |
| print(f"\n{'='*60}") | |
| print(f"π΅ WEBSOCKET: register_admin") | |
| print(f"{'='*60}") | |
| print(f"Room ID: {room_id[:8] if room_id else 'None'}...") | |
| print(f"Admin Session: {admin_session[:8] if admin_session else 'None'}...") | |
| print(f"ECDSA Public Key: {ecdsa_public_key[:50] if ecdsa_public_key else 'None'}...") | |
| print(f"{'='*60}\n") | |
| if not room_id or not admin_session or not ecdsa_public_key: | |
| emit('registration_error', {"error": "Missing required fields"}) | |
| return | |
| if room_id not in CHAT_ROOMS: | |
| emit('registration_error', {"error": "Chat room not found"}) | |
| return | |
| room = CHAT_ROOMS[room_id] | |
| # Check if expired | |
| if time.time() > room["expires_at"]: | |
| emit('registration_error', {"error": "Chat room has expired"}) | |
| return | |
| # Verify admin session | |
| if room["admin_session"] != admin_session: | |
| emit('registration_error', {"error": "Invalid admin session"}) | |
| return | |
| # Check if this is first join (key should match) | |
| if ecdsa_public_key == room["admin_ecdsa_public_key"]: | |
| # First join - keys match | |
| print(f"β Admin first join with registered ECDSA key") | |
| emit('registration_success', { | |
| "session_id": admin_session, | |
| "role": "admin", | |
| "receiver_number": None, | |
| "room_settings": room["settings"], | |
| "expires_at": room["expires_at"] | |
| }) | |
| else: | |
| # Different key - should use challenge-response | |
| emit('registration_error', {"error": "Use challenge-response for rejoin"}) | |
| return | |
| except Exception as e: | |
| print(f"β Error in register_admin: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| emit('registration_error', {"error": str(e)}) | |
| def handle_request_challenge(data): | |
| """Generate challenge for ECDSA authentication (rejoin) - via WebSocket""" | |
| try: | |
| room_id = data.get("room_id") | |
| role = data.get("role") | |
| identifier = data.get("identifier") # admin_session or receiver_token | |
| print(f"\n{'='*60}") | |
| print(f"π² CHALLENGE REQUEST") | |
| print(f"{'='*60}") | |
| print(f"Room: {room_id[:8] if room_id else 'None'}...") | |
| print(f"Role: {role}") | |
| print(f"Identifier: {identifier[:8] if identifier else 'None'}...") | |
| if not room_id or not role or not identifier: | |
| emit('challenge_error', {"error": "Missing required fields"}) | |
| return | |
| if room_id not in CHAT_ROOMS: | |
| emit('challenge_error', {"error": "Chat room not found"}) | |
| return | |
| room = CHAT_ROOMS[room_id] | |
| # Check if expired | |
| if time.time() > room["expires_at"]: | |
| emit('challenge_error', {"error": "Chat room has expired"}) | |
| return | |
| # Verify identifier exists | |
| if role == "admin": | |
| if identifier != room["admin_session"]: | |
| emit('challenge_error', {"error": "Invalid admin session"}) | |
| return | |
| print(f"β Admin session valid") | |
| elif role == "receiver": | |
| # Find token | |
| token_found = False | |
| for token in room["receiver_tokens"]: | |
| if token["token"] == identifier: | |
| if not token["claimed"]: | |
| emit('challenge_error', {"error": "Token not yet registered"}) | |
| return | |
| if not token.get("ecdsa_public_key"): | |
| emit('challenge_error', {"error": "No public key registered"}) | |
| return | |
| # β ADD THIS DETAILED LOGGING: | |
| print(f"β Found receiver token") | |
| print(f"Token data:") | |
| print(f" - Receiver #: {token['receiver_number']}") | |
| print(f" - Claimed: {token['claimed']}") | |
| print(f" - Claimed at: {token.get('claimed_at')}") | |
| print(f" - ECDSA key exists: {token.get('ecdsa_public_key') is not None}") | |
| if token.get('ecdsa_public_key'): | |
| ecdsa_key = token['ecdsa_public_key'] | |
| print(f" - ECDSA key length: {len(ecdsa_key)}") | |
| print(f" - ECDSA key (first 50): {ecdsa_key[:50]}") | |
| print(f" - ECDSA key (last 20): {ecdsa_key[-20:]}") | |
| print(f" - Full ECDSA key: {ecdsa_key}") | |
| print(f"β Found receiver token, public_key exists") | |
| token_found = True | |
| break | |
| if not token_found: | |
| emit('challenge_error', {"error": "Invalid receiver token"}) | |
| return | |
| else: | |
| emit('challenge_error', {"error": "Invalid role"}) | |
| return | |
| # Generate random challenge (32 bytes) | |
| challenge_bytes = secrets.token_bytes(32) | |
| challenge_b64 = base64.b64encode(challenge_bytes).decode() | |
| # Store challenge temporarily (expires in 60 seconds) | |
| challenge_key = f"{room_id}_{identifier}" | |
| CHALLENGE_STORE[challenge_key] = { | |
| "challenge": challenge_b64, | |
| "created_at": time.time(), | |
| "role": role | |
| } | |
| print(f"β Generated challenge") | |
| print(f" - Challenge (base64): {challenge_b64}") | |
| print(f" - Challenge length: {len(challenge_b64)} chars") | |
| print(f" - Challenge bytes: {len(challenge_bytes)} bytes") | |
| print(f"{'='*60}\n") | |
| emit('challenge_response', {"challenge": challenge_b64}) | |
| except Exception as e: | |
| print(f"β Error generating challenge: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| emit('challenge_error', {"error": str(e)}) | |
| def handle_verify_signature(data): | |
| """Verify ECDSA signature and authenticate - via WebSocket""" | |
| try: | |
| room_id = data.get("room_id") | |
| role = data.get("role") | |
| identifier = data.get("identifier") | |
| signature_b64 = data.get("signature") | |
| print(f"\n{'='*60}") | |
| print(f"π SIGNATURE VERIFICATION") | |
| print(f"{'='*60}") | |
| print(f"Room: {room_id[:8] if room_id else 'None'}...") | |
| print(f"Role: {role}") | |
| print(f"Identifier: {identifier[:8] if identifier else 'None'}...") | |
| print(f"Signature (first 30): {signature_b64[:30] if signature_b64 else 'None'}...") | |
| print(f"Signature (full): {signature_b64}") | |
| if not room_id or not role or not identifier or not signature_b64: | |
| emit('verification_error', {"error": "Missing required fields"}) | |
| return | |
| if room_id not in CHAT_ROOMS: | |
| emit('verification_error', {"error": "Chat room not found"}) | |
| return | |
| room = CHAT_ROOMS[room_id] | |
| # Check if expired | |
| if time.time() > room["expires_at"]: | |
| emit('verification_error', {"error": "Chat room has expired"}) | |
| return | |
| # Retrieve challenge | |
| challenge_key = f"{room_id}_{identifier}" | |
| print(f"Looking for challenge key: {challenge_key[:50]}...") | |
| if challenge_key not in CHALLENGE_STORE: | |
| print(f"β Challenge not found!") | |
| emit('verification_error', {"error": "No challenge found or challenge expired"}) | |
| return | |
| challenge_data = CHALLENGE_STORE[challenge_key] | |
| # Check challenge expiry (60 seconds) | |
| if time.time() - challenge_data["created_at"] > 60: | |
| del CHALLENGE_STORE[challenge_key] | |
| emit('verification_error', {"error": "Challenge expired"}) | |
| return | |
| challenge = challenge_data["challenge"] | |
| print(f"β Found challenge: {challenge[:30]}...") | |
| # Get ECDSA public key based on role | |
| ecdsa_public_key_b64 = None | |
| receiver_number = None | |
| if role == "admin": | |
| if identifier != room["admin_session"]: | |
| emit('verification_error', {"error": "Invalid admin session"}) | |
| return | |
| ecdsa_public_key_b64 = room["admin_ecdsa_public_key"] | |
| print(f"β Using admin ECDSA public key") | |
| print(f" Public key (first 50): {ecdsa_public_key_b64[:50]}...") | |
| elif role == "receiver": | |
| token_found = False | |
| for token in room["receiver_tokens"]: | |
| if token["token"] == identifier: | |
| if not token["claimed"]: | |
| emit('verification_error', {"error": "Token not claimed"}) | |
| return | |
| ecdsa_public_key_b64 = token["ecdsa_public_key"] | |
| receiver_number = token["receiver_number"] | |
| # β ADD THIS DETAILED LOGGING: | |
| print(f"\nβ Found receiver token for verification") | |
| print(f"Token data:") | |
| print(f" - Receiver #: {receiver_number}") | |
| print(f" - ECDSA key exists: {ecdsa_public_key_b64 is not None}") | |
| if ecdsa_public_key_b64: | |
| print(f" - ECDSA key length: {len(ecdsa_public_key_b64)}") | |
| print(f" - ECDSA key (first 50): {ecdsa_public_key_b64[:50]}") | |
| print(f" - ECDSA key (last 20): {ecdsa_public_key_b64[-20:]}") | |
| print(f" - Full ECDSA key: {ecdsa_public_key_b64}") | |
| token_found = True | |
| break | |
| if not token_found: | |
| emit('verification_error', {"error": "Invalid receiver token"}) | |
| return | |
| if not ecdsa_public_key_b64: | |
| emit('verification_error', {"error": "No ECDSA public key found"}) | |
| return | |
| else: | |
| emit('verification_error', {"error": "Invalid role"}) | |
| return | |
| # Verify RSA signature | |
| print(f"π Calling verify_rsa_signature...") | |
| print(f" Challenge: {challenge}") | |
| print(f" Signature: {signature_b64}") | |
| is_valid = verify_rsa_signature(ecdsa_public_key_b64, challenge, signature_b64) | |
| # Delete challenge (prevent replay) | |
| del CHALLENGE_STORE[challenge_key] | |
| if not is_valid: | |
| print(f"β Signature verification FAILED") | |
| emit('verification_error', {"error": "Invalid signature"}) | |
| return | |
| print(f"β Signature verified successfully!") | |
| # Generate session | |
| session_id = str(uuid.uuid4()) | |
| emit('verification_success', { | |
| "session_id": session_id, | |
| "role": role, | |
| "receiver_number": receiver_number, | |
| "room_settings": room["settings"], | |
| "expires_at": room["expires_at"] | |
| }) | |
| except Exception as e: | |
| print(f"β Error verifying signature: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| emit('verification_error', {"error": str(e)}) | |
| def delete_chat_room(room_id): | |
| try: | |
| print(f"\nποΈ DELETE ROOM REQUEST") | |
| print(f"Room ID: {room_id[:8]}...") | |
| auth_header = request.headers.get('Authorization', '') | |
| admin_session = auth_header.replace('Bearer ', '') | |
| print(f"Admin session: {admin_session[:8] if admin_session else 'None'}...") | |
| if not admin_session: | |
| print("β No admin session in Authorization header") | |
| return jsonify({"error": "Admin session required"}), 401 | |
| if room_id not in CHAT_ROOMS: | |
| print(f"β Room {room_id[:8]}... not found in CHAT_ROOMS") | |
| print(f"Available rooms: {[r[:8] for r in CHAT_ROOMS.keys()]}") | |
| return jsonify({"error": "Chat room not found (may have already expired)"}), 404 | |
| room = CHAT_ROOMS[room_id] | |
| if room["admin_session"] != admin_session: | |
| print(f"β Admin session mismatch!") | |
| print(f" Expected: {room['admin_session'][:8]}...") | |
| print(f" Received: {admin_session[:8]}...") | |
| return jsonify({"error": "Unauthorized - not admin"}), 403 | |
| # Notify all participants | |
| socketio.emit('room_closing', { | |
| 'room_id': room_id, | |
| 'reason': 'Admin deleted the room' | |
| }, room=room_id) | |
| print(f"π’ Notified participants in room {room_id[:8]}...") | |
| # Delete room | |
| del CHAT_ROOMS[room_id] | |
| print(f"β Room {room_id[:8]}... deleted successfully") | |
| return jsonify({"message": "Room deleted successfully"}) | |
| except Exception as e: | |
| print(f"β Error deleting room: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| return jsonify({"error": str(e)}), 500 | |
| # ADD WEBSOCKET EVENTS: | |
| def handle_join_chat(data): | |
| room_id = data['room_id'] | |
| session_id = data['session_id'] | |
| role = data['role'] | |
| rsa_public_key = data.get('public_key') | |
| pss_public_key = data.get('pss_public_key') | |
| if room_id not in CHAT_ROOMS: | |
| return | |
| join_room(room_id) | |
| # Add to active sessions with public key | |
| CHAT_ROOMS[room_id]["active_sessions"][session_id] = { | |
| "role": role, | |
| "receiver_number": data.get('receiver_number'), | |
| "rsa_public_key": rsa_public_key, | |
| "pss_public_key": pss_public_key, | |
| "socket_id": request.sid, | |
| "joined_at": time.time(), | |
| "last_seen": time.time() | |
| } | |
| # Send list of existing peers with their public keys to new joiner | |
| peer_list = [] | |
| for sid, session in CHAT_ROOMS[room_id]["active_sessions"].items(): | |
| if sid != session_id: | |
| peer_list.append({ | |
| 'session_id': sid, | |
| 'role': session['role'], | |
| 'receiver_number': session.get('receiver_number'), | |
| 'public_key': session.get('rsa_public_key'), | |
| 'pss_public_key': session.get('pss_public_key') | |
| }) | |
| emit('peer_list', {'peers': peer_list}) | |
| # Notify existing peers about new joiner | |
| emit('peer_joined', { | |
| 'session_id': session_id, | |
| 'role': role, | |
| 'receiver_number': data.get('receiver_number'), | |
| 'public_key': rsa_public_key, | |
| 'pss_public_key': pss_public_key, | |
| 'active_count': len(CHAT_ROOMS[room_id]["active_sessions"]) | |
| }, room=room_id, include_self=False) | |
| # Flush any ICE candidates that arrived before this session joined | |
| if session_id in PENDING_ICE_CANDIDATES: | |
| for buffered in PENDING_ICE_CANDIDATES[session_id]: | |
| emit('chat_ice_candidate', buffered) | |
| del PENDING_ICE_CANDIDATES[session_id] | |
| def handle_start_typing(data): | |
| room_id = data['room_id'] | |
| session_id = data['session_id'] | |
| if room_id in CHAT_ROOMS and session_id in CHAT_ROOMS[room_id]["active_sessions"]: | |
| session = CHAT_ROOMS[room_id]["active_sessions"][session_id] | |
| emit('user_typing', { | |
| 'role': session["role"], | |
| 'receiver_number': session.get("receiver_number") | |
| }, room=room_id, include_self=False) | |
| def handle_stop_typing(data): | |
| room_id = data['room_id'] | |
| session_id = data['session_id'] | |
| if room_id in CHAT_ROOMS and session_id in CHAT_ROOMS[room_id]["active_sessions"]: | |
| session = CHAT_ROOMS[room_id]["active_sessions"][session_id] | |
| emit('user_stopped_typing', { | |
| 'role': session["role"], | |
| 'receiver_number': session.get("receiver_number") | |
| }, room=room_id, include_self=False) | |
| def handle_chat_peer_offer(data): | |
| room_id = data.get('room_id') | |
| to_session = data.get('to_session') | |
| from_session = data.get('from_session') | |
| offer = data.get('offer') | |
| if room_id not in CHAT_ROOMS: | |
| return | |
| # Target specific peer directly by socket ID | |
| target = CHAT_ROOMS[room_id]["active_sessions"].get(to_session) | |
| if not target: | |
| return | |
| emit('chat_peer_offer', { | |
| 'from_session': from_session, | |
| 'offer': offer | |
| }, to=target['socket_id']) | |
| def handle_chat_peer_answer(data): | |
| room_id = data.get('room_id') | |
| to_session = data.get('to_session') | |
| from_session = data.get('from_session') | |
| answer = data.get('answer') | |
| if room_id not in CHAT_ROOMS: | |
| return | |
| # Target specific peer directly by socket ID | |
| target = CHAT_ROOMS[room_id]["active_sessions"].get(to_session) | |
| if not target: | |
| return | |
| emit('chat_peer_answer', { | |
| 'from_session': from_session, | |
| 'answer': answer | |
| }, to=target['socket_id']) | |
| def handle_chat_ice_candidate(data): | |
| room_id = data.get('room_id') | |
| to_session = data.get('to_session') | |
| from_session = data.get('from_session') | |
| candidate = data.get('candidate') | |
| if room_id not in CHAT_ROOMS: | |
| return | |
| # Try to find the target session's socket | |
| target = CHAT_ROOMS[room_id]["active_sessions"].get(to_session) | |
| if not target: | |
| # Target hasn't called join_chat yet β buffer the candidate | |
| # It will be flushed when they join | |
| if to_session not in PENDING_ICE_CANDIDATES: | |
| PENDING_ICE_CANDIDATES[to_session] = [] | |
| PENDING_ICE_CANDIDATES[to_session].append({ | |
| 'from_session': from_session, | |
| 'candidate': candidate | |
| }) | |
| return | |
| # Target is ready β deliver directly to their socket | |
| emit('chat_ice_candidate', { | |
| 'from_session': from_session, | |
| 'candidate': candidate, | |
| 'buffered_at': time.time() | |
| }, to=target['socket_id']) | |
| def handle_leave_chat(data): | |
| room_id = data['room_id'] | |
| session_id = data['session_id'] | |
| if room_id not in CHAT_ROOMS: | |
| print(f"β οΈ Room {room_id[:8]}... already deleted") | |
| return | |
| if session_id not in CHAT_ROOMS[room_id]["active_sessions"]: | |
| print(f"β οΈ Session {session_id[:8]}... not in room") | |
| return | |
| session = CHAT_ROOMS[room_id]["active_sessions"][session_id] | |
| # If admin left and burn_on_admin_exit is true | |
| if session["role"] == "admin" and CHAT_ROOMS[room_id]["settings"]["burn_on_admin_exit"]: | |
| emit('room_closing', {'reason': 'Admin left the room'}, room=room_id) | |
| del CHAT_ROOMS[room_id] # β Delete entire room | |
| leave_room(room_id) | |
| print(f"π₯ Room {room_id[:8]}... burned (admin left)") | |
| else: | |
| # Emit BEFORE deleting | |
| emit('user_left', { | |
| 'session_id': session_id, | |
| 'role': session["role"], | |
| 'receiver_number': session.get("receiver_number"), | |
| 'active_count': len(CHAT_ROOMS[room_id]["active_sessions"]) - 1 | |
| }, room=room_id) | |
| # Delete session | |
| del CHAT_ROOMS[room_id]["active_sessions"][session_id] | |
| leave_room(room_id) | |
| print(f"π Session {session_id[:8]}... left room {room_id[:8]}...") | |
| def api_signup(): | |
| """Register new user""" | |
| try: | |
| data = request.get_json() | |
| username = data.get("username") | |
| password = data.get("password") | |
| if not username or not password: | |
| return jsonify({"error": "Username and password required"}), 400 | |
| result, status_code = signup_user(username, password) | |
| return jsonify(result), status_code | |
| except Exception as e: | |
| return jsonify({"error": str(e)}), 500 | |
| def api_login(): | |
| """Login existing user""" | |
| try: | |
| data = request.get_json() | |
| username = data.get("username") | |
| password = data.get("password") | |
| if not username or not password: | |
| return jsonify({"error": "Username and password required"}), 400 | |
| result, status_code = login_user(username, password) | |
| return jsonify(result), status_code | |
| except Exception as e: | |
| return jsonify({"error": str(e)}), 500 | |
| def api_check_username(): | |
| """Check if username exists""" | |
| try: | |
| data = request.get_json() | |
| username = data.get("username") | |
| if not username: | |
| return jsonify({"error": "Username required"}), 400 | |
| # Check if Supabase is available | |
| supabase = get_supabase() | |
| if supabase is None: | |
| print("β οΈ Supabase not available, allowing all usernames") | |
| # Fallback: accept all usernames when DB is down | |
| return jsonify({"exists": True, "username": username}), 200 | |
| result, status_code = check_username_exists(username) | |
| return jsonify(result), status_code | |
| except Exception as e: | |
| print(f"β Check username error: {e}") | |
| import traceback | |
| traceback.print_exc() | |
| # Fallback on error | |
| return jsonify({"exists": True, "username": username}), 200 | |
| def api_get_current_user(): | |
| """Get current user from JWT token""" | |
| try: | |
| user = request.current_user | |
| return jsonify({ | |
| "user_id": user['user_id'], | |
| "username_hash": user['username_hash'] | |
| }) | |
| except Exception as e: | |
| return jsonify({"error": str(e)}), 500 | |
| def api_logout(): | |
| """Logout user (client-side token deletion)""" | |
| return jsonify({"message": "Logged out successfully"}) | |
| def handle_user_online(data): | |
| """Track user coming online""" | |
| username_hash = data.get('username_hash') | |
| if username_hash: | |
| ONLINE_USERS[username_hash] = request.sid | |
| print(f"π€ User online: {username_hash[:8]}... (SID: {request.sid})") | |
| print(f"π Total online users: {len(ONLINE_USERS)}") | |
| print(f"π Online users: {[h[:8] for h in ONLINE_USERS.keys()]}") | |
| # CRITICAL: Notify this user's friends that they came online | |
| notify_friends_of_online_status(username_hash, 'online') | |
| def notify_friends_of_online_status(username_hash, status): | |
| """Notify all online friends that a user came online/offline""" | |
| print(f"\nπ Notifying friends of {username_hash[:8]} status: {status}") | |
| # For each online user, check if they are friends with this user | |
| for other_user_hash, other_sid in ONLINE_USERS.items(): | |
| if other_user_hash == username_hash: | |
| continue # Skip self | |
| # Emit a friend status change event | |
| emit('friend_status_changed', { | |
| 'username_hash': username_hash, | |
| 'status': status | |
| }, room=other_sid) | |
| print(f" βοΈ Notified {other_user_hash[:8]} about {username_hash[:8]}") | |
| def handle_user_offline(data): | |
| """Track user going offline""" | |
| username_hash = data.get('username_hash') | |
| if username_hash and username_hash in ONLINE_USERS: | |
| del ONLINE_USERS[username_hash] | |
| print(f"π€ User offline: {username_hash[:8]}...") | |
| print(f"π Total online users: {len(ONLINE_USERS)}") | |
| # CRITICAL: Notify friends that user went offline | |
| notify_friends_of_online_status(username_hash, 'offline') | |
| def handle_check_friends_online(data): | |
| """Check which friends are online""" | |
| friend_hashes = data.get('friend_hashes', []) | |
| print(f"\n=== FRIENDS ONLINE CHECK ===") | |
| print(f"π¨ Received from SID: {request.sid}") | |
| print(f"π Checking {len(friend_hashes)} friends") | |
| print(f"π Friend hashes to check: {[h[:8] for h in friend_hashes]}") | |
| print(f"π Currently online: {[h[:8] for h in ONLINE_USERS.keys()]}") | |
| online_friends = [] | |
| for friend_hash in friend_hashes: | |
| if friend_hash in ONLINE_USERS: | |
| online_friends.append({ | |
| 'username_hash': friend_hash, | |
| 'status': 'online' | |
| }) | |
| print(f"β Friend {friend_hash[:8]} is ONLINE") | |
| else: | |
| print(f"β Friend {friend_hash[:8]} is OFFLINE") | |
| print(f"π€ Sending back {len(online_friends)} online friends") | |
| print(f"=========================\n") | |
| # CRITICAL: Send response back to the REQUESTING client only | |
| emit('friends_status', {'friends': online_friends}) | |
| # --- Screen Share WebRTC Signaling Events --- | |
| def handle_screen_share_invite(data): | |
| """Host sends screen share invite to viewer""" | |
| room_id = data.get('room_id') | |
| to_hash = data.get('to_hash') | |
| from_hash = data.get('from_hash') | |
| print(f"\nπΊ Screen Share Invite") | |
| print(f"From: {from_hash[:8]}...") | |
| print(f"To: {to_hash[:8]}...") | |
| print(f"Room: {room_id[:8] if room_id else 'None'}...") | |
| # Check if recipient is online | |
| if to_hash not in ONLINE_USERS: | |
| print(f"β Recipient {to_hash[:8]} is OFFLINE") | |
| emit('error', {'message': 'Friend is offline'}) | |
| return | |
| recipient_sid = ONLINE_USERS[to_hash] | |
| print(f"β Recipient is ONLINE (SID: {recipient_sid})") | |
| # Forward invite to recipient | |
| emit('screen_share_invite', { | |
| 'room_id': room_id, | |
| 'from_hash': from_hash | |
| }, room=recipient_sid) | |
| print(f"β Invite forwarded to {to_hash[:8]}") | |
| def handle_screen_share_offer(data): | |
| """Viewer sends WebRTC offer to host""" | |
| room_id = data.get('room_id') | |
| to_hash = data.get('to_hash') | |
| from_hash = data.get('from_hash') | |
| offer = data.get('offer') | |
| print(f"\nπ‘ WebRTC Offer") | |
| print(f"From: {from_hash[:8]}... (viewer)") | |
| print(f"To: {to_hash[:8]}... (host)") | |
| print(f"Room: {room_id[:8] if room_id else 'None'}...") | |
| # Check if host is online | |
| if to_hash not in ONLINE_USERS: | |
| print(f"β Host {to_hash[:8]} is OFFLINE") | |
| emit('error', {'message': 'Host is offline'}) | |
| return | |
| host_sid = ONLINE_USERS[to_hash] | |
| print(f"β Host is ONLINE (SID: {host_sid})") | |
| # Forward offer to host | |
| emit('screen_share_offer', { | |
| 'room_id': room_id, | |
| 'from_hash': from_hash, | |
| 'offer': offer | |
| }, room=host_sid) | |
| print(f"β Offer forwarded to host {to_hash[:8]}") | |
| def handle_screen_share_answer(data): | |
| """Host sends WebRTC answer back to viewer""" | |
| room_id = data.get('room_id') | |
| to_hash = data.get('to_hash') | |
| from_hash = data.get('from_hash') | |
| answer = data.get('answer') | |
| print(f"\nπ‘ WebRTC Answer") | |
| print(f"From: {from_hash[:8]}... (host)") | |
| print(f"To: {to_hash[:8]}... (viewer)") | |
| # Check if viewer is online | |
| if to_hash not in ONLINE_USERS: | |
| print(f"β Viewer {to_hash[:8]} is OFFLINE") | |
| return | |
| viewer_sid = ONLINE_USERS[to_hash] | |
| print(f"β Viewer is ONLINE (SID: {viewer_sid})") | |
| # Forward answer to viewer | |
| emit('screen_share_answer', { | |
| 'room_id': room_id, | |
| 'from_hash': from_hash, | |
| 'answer': answer | |
| }, room=viewer_sid) | |
| print(f"β Answer forwarded to viewer {to_hash[:8]}") | |
| def handle_ice_candidate(data): | |
| """Forward ICE candidates between peers""" | |
| to_hash = data.get('to_hash') | |
| from_hash = data.get('from_hash') | |
| candidate = data.get('candidate') | |
| print(f"π§ ICE candidate: {from_hash[:8]} β {to_hash[:8]}") | |
| if to_hash in ONLINE_USERS: | |
| recipient_sid = ONLINE_USERS[to_hash] | |
| emit('ice_candidate', { | |
| 'from_hash': from_hash, | |
| 'candidate': candidate | |
| }, room=recipient_sid) | |
| def handle_join_screen_room(data): | |
| """User joins screen share room""" | |
| room_id = data.get('room_id') | |
| username_hash = data.get('username_hash') | |
| print(f"\nπͺ User joining screen room") | |
| print(f"User: {username_hash[:8]}...") | |
| print(f"Room: {room_id[:8] if room_id else 'None'}...") | |
| if not room_id: | |
| return | |
| join_room(room_id) | |
| # Create or update room | |
| if room_id not in SCREEN_SHARE_ROOMS: | |
| SCREEN_SHARE_ROOMS[room_id] = { | |
| 'host_hash': username_hash, | |
| 'participants': [], | |
| 'settings': { | |
| 'allow_files': True, | |
| 'allow_cursors': True | |
| }, | |
| 'created_at': time.time(), | |
| 'expires_at': time.time() + 3600 | |
| } | |
| print(f"β Room created (host)") | |
| else: | |
| # Add as participant | |
| room = SCREEN_SHARE_ROOMS[room_id] | |
| if username_hash != room['host_hash']: | |
| if not any(p['username_hash'] == username_hash for p in room['participants']): | |
| room['participants'].append({ | |
| 'username_hash': username_hash, | |
| 'socket_id': request.sid, | |
| 'cursor_enabled': False, | |
| 'cursor_color': assign_cursor_color(room['participants']), | |
| 'joined_at': time.time() | |
| }) | |
| print(f"β User added as participant") | |
| # Notify others | |
| socketio.emit('user_joined_room', { | |
| 'username_hash': username_hash, | |
| 'participant_count': len(room['participants']) + 1 | |
| }, room=room_id, include_self=False) | |
| def handle_leave_screen_room(data): | |
| """User leaves screen share room""" | |
| room_id = data.get('room_id') | |
| username_hash = data.get('username_hash') | |
| print(f"πͺ User leaving room: {username_hash[:8]}...") | |
| if room_id in SCREEN_SHARE_ROOMS: | |
| room = SCREEN_SHARE_ROOMS[room_id] | |
| # Remove from participants | |
| room['participants'] = [p for p in room['participants'] if p['username_hash'] != username_hash] | |
| # Notify others | |
| socketio.emit('user_left_room', { | |
| 'username_hash': username_hash, | |
| 'participant_count': len(room['participants']) | |
| }, room=room_id) | |
| leave_room(room_id) | |
| def handle_screen_share_end(data): | |
| """Host ends screen share""" | |
| room_id = data.get('room_id') | |
| print(f"π Screen share ending: {room_id[:8] if room_id else 'None'}...") | |
| if room_id in SCREEN_SHARE_ROOMS: | |
| # Notify all participants | |
| socketio.emit('screen_share_ended', { | |
| 'room_id': room_id, | |
| 'reason': 'Host ended the session' | |
| }, room=room_id) | |
| # Cleanup | |
| del SCREEN_SHARE_ROOMS[room_id] | |
| if room_id in CURSOR_STATES: | |
| del CURSOR_STATES[room_id] | |
| # --- Cursor Events --- | |
| def handle_cursor_enable_request(data): | |
| """Viewer requests to enable cursor""" | |
| room_id = data.get('room_id') | |
| username_hash = data.get('username_hash') | |
| room = SCREEN_SHARE_ROOMS.get(room_id) | |
| if not room: | |
| emit('error', {'message': 'Room not found'}) | |
| return | |
| host_hash = room['host_hash'] | |
| # Forward request to host | |
| if host_hash in ONLINE_USERS: | |
| host_sid = ONLINE_USERS[host_hash] | |
| emit('cursor_enable_request', { | |
| 'room_id': room_id, | |
| 'username_hash': username_hash, | |
| 'cursor_color': data.get('cursor_color', 'blue') | |
| }, room=host_sid) | |
| print(f"π±οΈ Cursor request: {username_hash[:8]} in room {room_id[:8]}") | |
| def handle_cursor_enable_response(data): | |
| """Host approves/denies cursor""" | |
| room_id = data.get('room_id') | |
| username_hash = data.get('username_hash') | |
| approved = data.get('approved', False) | |
| room = SCREEN_SHARE_ROOMS.get(room_id) | |
| if not room: | |
| return | |
| # Update participant cursor status | |
| if approved: | |
| for participant in room['participants']: | |
| if participant['username_hash'] == username_hash: | |
| participant['cursor_enabled'] = True | |
| break | |
| # Notify requester | |
| if username_hash in ONLINE_USERS: | |
| user_sid = ONLINE_USERS[username_hash] | |
| emit('cursor_enabled', { | |
| 'room_id': room_id, | |
| 'approved': approved | |
| }, room=user_sid) | |
| print(f"π±οΈ Cursor {'approved' if approved else 'denied'}: {username_hash[:8]}") | |
| # --- File Transfer Coordination Events --- | |
| def handle_file_transfer_start(data): | |
| """Initiator starts file transfer""" | |
| room_id = data.get('room_id') | |
| file_id = data.get('file_id') | |
| from_hash = data.get('from_hash') | |
| to_hashes = data.get('to_hashes', []) | |
| file_size = data.get('file_size', 0) | |
| # Validate file size | |
| if file_size > 104857600: # 100 MB | |
| emit('file_transfer_error', {'error': 'File too large (max 100 MB)'}) | |
| return | |
| room = SCREEN_SHARE_ROOMS.get(room_id) | |
| if not room or not room['settings']['allow_files']: | |
| emit('file_transfer_error', {'error': 'File sharing not allowed'}) | |
| return | |
| # Resolve "all" to actual hashes | |
| if to_hashes == ["all"]: | |
| to_hashes = [p['username_hash'] for p in room['participants'] | |
| if p['username_hash'] != from_hash] | |
| # Create transfer record | |
| FILE_TRANSFERS[file_id] = { | |
| 'from_hash': from_hash, | |
| 'to_hashes': to_hashes, | |
| 'file_name': data.get('file_name'), | |
| 'file_size': file_size, | |
| 'mime_type': data.get('mime_type'), | |
| 'chunks_total': data.get('chunks_total', 0), | |
| 'chunks_received': {h: 0 for h in to_hashes}, | |
| 'status': 'transferring', | |
| 'started_at': time.time() | |
| } | |
| # Notify recipients | |
| for to_hash in to_hashes: | |
| if to_hash in ONLINE_USERS: | |
| recipient_sid = ONLINE_USERS[to_hash] | |
| emit('file_transfer_incoming', { | |
| 'file_id': file_id, | |
| 'from_hash': from_hash, | |
| 'file_name': data.get('file_name'), | |
| 'file_size': file_size, | |
| 'mime_type': data.get('mime_type'), | |
| 'chunks_total': data.get('chunks_total') | |
| }, room=recipient_sid) | |
| print(f"π File transfer started: {data.get('file_name')} ({file_size} bytes)") | |
| def handle_file_transfer_complete(data): | |
| """Recipient confirms complete transfer""" | |
| file_id = data.get('file_id') | |
| to_hash = data.get('to_hash') | |
| if file_id in FILE_TRANSFERS: | |
| transfer = FILE_TRANSFERS[file_id] | |
| # Notify sender | |
| from_hash = transfer['from_hash'] | |
| if from_hash in ONLINE_USERS: | |
| sender_sid = ONLINE_USERS[from_hash] | |
| emit('file_transfer_complete', { | |
| 'file_id': file_id, | |
| 'to_hash': to_hash, | |
| 'file_name': transfer['file_name'] | |
| }, room=sender_sid) | |
| print(f"β File transfer complete: {transfer['file_name']} to {to_hash[:8]}") | |
| def handle_disconnect(): | |
| username_hash = None | |
| print(f"\nπ Disconnect event - SID: {request.sid}") | |
| # Find user by socket ID | |
| for user_hash, socket_id in list(ONLINE_USERS.items()): | |
| if socket_id == request.sid: | |
| username_hash = user_hash | |
| del ONLINE_USERS[user_hash] | |
| print(f"π€ User disconnected: {username_hash[:8]}...") | |
| print(f"π Remaining online: {len(ONLINE_USERS)} users") | |
| # CRITICAL: Notify friends | |
| notify_friends_of_online_status(username_hash, 'offline') | |
| break | |
| if not username_hash: | |
| print(f"β οΈ Unknown user disconnected (SID: {request.sid})") | |
| if username_hash: | |
| print(f"π€ User disconnected: {username_hash[:8]}...") | |
| # Remove from chat rooms | |
| # Remove from chat rooms and notify others | |
| for room_id, room in list(CHAT_ROOMS.items()): | |
| for session_id, session in list(room['active_sessions'].items()): | |
| # Check if this session matches the disconnected socket | |
| if session.get('socket_id') == request.sid: | |
| print(f"[Disconnect] User {session_id} disconnected from room {room_id}") | |
| # CRITICAL: Notify BEFORE deleting | |
| socketio.emit('user_left', { | |
| 'session_id': session_id, | |
| 'role': session['role'], | |
| 'receiver_number': session.get('receiver_number'), | |
| 'active_count': len(room['active_sessions']) - 1 | |
| }, room=room_id) | |
| # If admin disconnected, close room | |
| if session['role'] == 'admin' and room['settings'].get('burn_on_admin_exit', True): | |
| print(f"[Disconnect] Admin disconnected, closing room {room_id}") | |
| socketio.emit('room_closing', {'reason': 'Admin disconnected'}, room=room_id) | |
| del CHAT_ROOMS[room_id] | |
| else: | |
| del room['active_sessions'][session_id] | |
| break | |
| # Remove from screen share rooms | |
| for room_id, room in list(SCREEN_SHARE_ROOMS.items()): | |
| # Check if user is in participants | |
| room['participants'] = [ | |
| p for p in room['participants'] | |
| if p.get('socket_id') != request.sid and p.get('username_hash') != username_hash | |
| ] | |
| # If host disconnected, end room | |
| if room.get('host_hash') == username_hash: | |
| socketio.emit('screen_share_ended', { | |
| 'room_id': room_id, | |
| 'reason': 'Host disconnected' | |
| }, room=room_id) | |
| del SCREEN_SHARE_ROOMS[room_id] | |
| if room_id in CURSOR_STATES: | |
| del CURSOR_STATES[room_id] | |
| else: | |
| # Notify others user left | |
| socketio.emit('user_left_room', { | |
| 'username_hash': username_hash, | |
| 'participant_count': len(room['participants']) | |
| }, room=room_id) | |
| # ============= SCREEN AUDIO SOCKET EVENTS ============= | |
| def handle_user_speaking(data): | |
| """User started speaking""" | |
| room_id = data.get('room_id') | |
| username_hash = data.get('username_hash') | |
| socketio.emit('user_speaking', { | |
| 'username_hash': username_hash | |
| }, room=room_id, include_self=False) | |
| def handle_user_stopped_speaking(data): | |
| """User stopped speaking""" | |
| room_id = data.get('room_id') | |
| username_hash = data.get('username_hash') | |
| socketio.emit('user_stopped_speaking', { | |
| 'username_hash': username_hash | |
| }, room=room_id, include_self=False) | |
| def handle_user_mic_state(data): | |
| """User muted/unmuted mic""" | |
| room_id = data.get('room_id') | |
| username_hash = data.get('username_hash') | |
| muted = data.get('muted') | |
| socketio.emit('user_mic_state', { | |
| 'username_hash': username_hash, | |
| 'muted': muted | |
| }, room=room_id, include_self=False) | |
| # ============= AUDIO MESH SIGNALING (ADD TO app.py) ============= | |
| def handle_request_viewer_list(data): | |
| """Send list of all viewers in room to requester""" | |
| room_id = data.get('room_id') | |
| requester_hash = data.get('username_hash') | |
| print(f"\nπ Viewer list requested") | |
| print(f"Room: {room_id[:8] if room_id else 'None'}...") | |
| print(f"Requester: {requester_hash[:8]}...") | |
| if room_id not in SCREEN_SHARE_ROOMS: | |
| emit('viewer_list', {'viewers': []}) | |
| return | |
| room = SCREEN_SHARE_ROOMS[room_id] | |
| # Get all participants except the requester and host | |
| viewers = [ | |
| p['username_hash'] | |
| for p in room['participants'] | |
| if p['username_hash'] != requester_hash | |
| ] | |
| # Don't include host in viewer list | |
| if room['host_hash'] in viewers: | |
| viewers.remove(room['host_hash']) | |
| print(f"β Sending {len(viewers)} viewers: {[v[:8] for v in viewers]}") | |
| emit('viewer_list', {'viewers': viewers}) | |
| def handle_notify_audio_peer_join(data): | |
| """Notify existing viewers that a new viewer joined (for audio mesh)""" | |
| room_id = data.get('room_id') | |
| new_viewer_hash = data.get('username_hash') | |
| print(f"\nπ Notifying room of new audio peer") | |
| print(f"Room: {room_id[:8] if room_id else 'None'}...") | |
| print(f"New viewer: {new_viewer_hash[:8]}...") | |
| if room_id not in SCREEN_SHARE_ROOMS: | |
| return | |
| room = SCREEN_SHARE_ROOMS[room_id] | |
| host_hash = room['host_hash'] | |
| # Notify all participants EXCEPT the new joiner and the host | |
| for participant in room['participants']: | |
| p_hash = participant['username_hash'] | |
| if p_hash == new_viewer_hash or p_hash == host_hash: | |
| continue # Skip self and host | |
| if p_hash in ONLINE_USERS: | |
| recipient_sid = ONLINE_USERS[p_hash] | |
| emit('audio_peer_joined', { | |
| 'viewer_hash': new_viewer_hash | |
| }, room=recipient_sid) | |
| print(f" βοΈ Notified {p_hash[:8]}") | |
| def handle_audio_peer_offer(data): | |
| """Relay WebRTC offer between two viewers (audio only)""" | |
| room_id = data.get('room_id') | |
| to_hash = data.get('to_hash') | |
| from_hash = data.get('from_hash') | |
| offer = data.get('offer') | |
| print(f"\nπ€ Audio Offer") | |
| print(f"From: {from_hash[:8]}...") | |
| print(f"To: {to_hash[:8]}...") | |
| # Forward to recipient | |
| if to_hash in ONLINE_USERS: | |
| recipient_sid = ONLINE_USERS[to_hash] | |
| emit('audio_peer_offer', { | |
| 'from_hash': from_hash, | |
| 'offer': offer | |
| }, room=recipient_sid) | |
| print(f"β Audio offer relayed") | |
| else: | |
| print(f"β Recipient {to_hash[:8]} not online") | |
| def handle_audio_peer_answer(data): | |
| """Relay WebRTC answer between two viewers""" | |
| room_id = data.get('room_id') | |
| to_hash = data.get('to_hash') | |
| from_hash = data.get('from_hash') | |
| answer = data.get('answer') | |
| print(f"\nπ€ Audio Answer") | |
| print(f"From: {from_hash[:8]}...") | |
| print(f"To: {to_hash[:8]}...") | |
| if to_hash in ONLINE_USERS: | |
| recipient_sid = ONLINE_USERS[to_hash] | |
| emit('audio_peer_answer', { | |
| 'from_hash': from_hash, | |
| 'answer': answer | |
| }, room=recipient_sid) | |
| print(f"β Audio answer relayed") | |
| else: | |
| print(f"β Recipient {to_hash[:8]} not online") | |
| def handle_audio_ice_candidate(data): | |
| """Relay ICE candidates between two viewers (audio connections)""" | |
| room_id = data.get('room_id') | |
| to_hash = data.get('to_hash') | |
| from_hash = data.get('from_hash') | |
| candidate = data.get('candidate') | |
| print(f"π§ Audio ICE: {from_hash[:8]} β {to_hash[:8]}") | |
| if to_hash in ONLINE_USERS: | |
| recipient_sid = ONLINE_USERS[to_hash] | |
| emit('audio_ice_candidate', { | |
| 'from_hash': from_hash, | |
| 'candidate': candidate | |
| }, room=recipient_sid) | |
| # Error handlers | |
| def not_found(error): | |
| return jsonify({"error": "Endpoint not found"}), 404 | |
| def internal_error(error): | |
| return jsonify({"error": "Internal server error"}), 500 | |
| def periodic_cleanup(): | |
| """Run periodic cleanup of expired data""" | |
| while True: | |
| time.sleep(300) # Run every 5 minutes | |
| try: | |
| now = time.time() | |
| # ADD THIS: Cleanup old nonces | |
| cleanup_old_nonces(now) | |
| # ADD THIS: Cleanup old session requests | |
| for uuid in list(SESSION_REQUESTS.keys()): | |
| requests = [ts for ts in SESSION_REQUESTS[uuid] if now - ts < 300] | |
| if requests: | |
| SESSION_REQUESTS[uuid] = requests | |
| else: | |
| del SESSION_REQUESTS[uuid] | |
| # 0. Cleanup old challenges | |
| cleanup_old_challenges() | |
| # Cleanup stale pending ICE candidates (older than 30 seconds, session never joined) | |
| now_time = time.time() | |
| stale_pending = [] | |
| for session_id, candidates in PENDING_ICE_CANDIDATES.items(): | |
| # If we have candidates buffered for more than 30s, session likely never joined | |
| if candidates and now_time - candidates[0].get('buffered_at', now_time) > 30: | |
| stale_pending.append(session_id) | |
| for session_id in stale_pending: | |
| del PENDING_ICE_CANDIDATES[session_id] | |
| # 1. Cleanup expired chat rooms | |
| expired_chat_rooms = [] | |
| for room_id, room in list(CHAT_ROOMS.items()): | |
| if now > room['expires_at']: | |
| expired_chat_rooms.append(room_id) | |
| # Notify participants | |
| socketio.emit('room_closing', { | |
| 'room_id': room_id, | |
| 'reason': 'Room expired' | |
| }, room=room_id) | |
| # Delete room | |
| del CHAT_ROOMS[room_id] | |
| if expired_chat_rooms: | |
| print(f"[Cleanup] Expired {len(expired_chat_rooms)} chat room(s)") | |
| # 1. Cleanup expired screen share rooms | |
| expired_rooms = [] | |
| for room_id, room in list(SCREEN_SHARE_ROOMS.items()): | |
| if now > room['expires_at']: | |
| expired_rooms.append(room_id) | |
| # Notify participants | |
| socketio.emit('room_expired', { | |
| 'room_id': room_id, | |
| 'reason': 'Room expired' | |
| }, room=room_id) | |
| # Cleanup | |
| del SCREEN_SHARE_ROOMS[room_id] | |
| if room_id in CURSOR_STATES: | |
| del CURSOR_STATES[room_id] | |
| # 2. Cleanup stale file transfers (older than 5 minutes) | |
| stale_transfers = [] | |
| for file_id, transfer in FILE_TRANSFERS.items(): | |
| if transfer['status'] == 'transferring': | |
| if now - transfer['started_at'] > 300: | |
| transfer['status'] = 'timeout' | |
| stale_transfers.append(file_id) | |
| # 3. Cleanup old cursor states (not updated in 30 seconds) | |
| cursor_cleaned = 0 | |
| for room_id in list(CURSOR_STATES.keys()): | |
| for username_hash in list(CURSOR_STATES[room_id].keys()): | |
| state = CURSOR_STATES[room_id][username_hash] | |
| if now - state['last_update'] > 30: | |
| del CURSOR_STATES[room_id][username_hash] | |
| cursor_cleaned += 1 | |
| # Remove empty room entries | |
| if not CURSOR_STATES[room_id]: | |
| del CURSOR_STATES[room_id] | |
| # 4. Your existing cleanup for SECRETS (keep this!) | |
| # ... your existing cleanup code ... | |
| if expired_rooms or stale_transfers or cursor_cleaned: | |
| print(f"[Cleanup] Expired rooms: {len(expired_rooms)}, " | |
| f"Stale transfers: {len(stale_transfers)}, " | |
| f"Stale cursors: {cursor_cleaned}") | |
| except Exception as e: | |
| print(f"[Cleanup] Error: {e}") | |
| # Start cleanup thread | |
| cleanup_thread = threading.Thread(target=periodic_cleanup, daemon=True) | |
| cleanup_thread.start() | |
| # FIX: Modified startup section | |
| if __name__ == "__main__": | |
| print("π Sharelock Backend Starting...") | |
| print("π Features enabled:") | |
| print(" β End-to-end encryption") | |
| print(" β File uploads (5MB max)") | |
| print(" β QR code generation") | |
| print(" β Analytics tracking") | |
| print(" β Short URLs") | |
| print(" β Self-destruct messages") | |
| print(" β Multiple themes") | |
| print(" β Password hints") | |
| print(" β verify_only parameter support") | |
| print(" β Real-time chat rooms") | |
| print("π Server running on http://0.0.0.0:7860") | |
| # FIX: Add allow_unsafe_werkzeug=True for HuggingFace Spaces | |
| socketio.run(app, host="0.0.0.0", port=7860, debug=False, allow_unsafe_werkzeug=True) |