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) @app.after_request 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""" @wraps(f) 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 @app.route("/") 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" ] }) @app.route("/api/store", methods=["POST"]) 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 @app.route("/api/fetch/") 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 @app.route("/api/secrets") 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 @app.route("/api/delete/", methods=["DELETE"]) 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 @app.route("/s/") 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""" Sharelock - Redirecting...

Redirecting to secure message...

If you are not redirected automatically, click here.

""" @app.route("/api/qr/") 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 @app.route("/api/stats") 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 @app.route('/ping', methods=['GET']) def ping(): """Simple ping endpoint to keep the service alive""" return jsonify({ "status": "alive", "timestamp": datetime.utcnow().isoformat(), "message": "Pong! Service is awake." }), 200 @app.route("/api/cleanup", methods=["POST"]) 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 ============= @app.route("/api/chat/create", methods=["POST"]) 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 @socketio.on('register_receiver') 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)}) @socketio.on('register_admin') 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)}) @socketio.on('request_challenge') 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)}) @socketio.on('verify_signature') 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)}) @app.route("/api/chat/delete/", methods=["DELETE"]) 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: @socketio.on('join_chat') 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] @socketio.on('start_typing') 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) @socketio.on('stop_typing') 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) @socketio.on('chat_peer_offer') 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']) @socketio.on('chat_peer_answer') 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']) @socketio.on('chat_ice_candidate') 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']) @socketio.on('leave_chat') 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]}...") @app.route("/api/auth/signup", methods=["POST"]) 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 @app.route("/api/auth/login", methods=["POST"]) 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 @app.route("/api/auth/check-username", methods=["POST"]) 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 @app.route("/api/auth/me", methods=["GET"]) @require_auth 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 @app.route("/api/auth/logout", methods=["POST"]) def api_logout(): """Logout user (client-side token deletion)""" return jsonify({"message": "Logged out successfully"}) @socketio.on('user_online') 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]}") @socketio.on('user_offline') 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') @socketio.on('check_friends_online') 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 --- @socketio.on('screen_share_invite') 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]}") @socketio.on('screen_share_offer') 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]}") @socketio.on('screen_share_answer') 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]}") @socketio.on('ice_candidate') 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) @socketio.on('join_screen_room') 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) @socketio.on('leave_screen_room') 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) @socketio.on('screen_share_end') 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 --- @socketio.on('cursor_enable_request') 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]}") @socketio.on('cursor_enable_response') 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 --- @socketio.on('file_transfer_start') 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)") @socketio.on('file_transfer_complete') 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]}") @socketio.on('disconnect') 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 ============= @socketio.on('user_speaking') 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) @socketio.on('user_stopped_speaking') 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) @socketio.on('user_mic_state') 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) ============= @socketio.on('request_viewer_list') 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}) @socketio.on('notify_audio_peer_join') 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]}") @socketio.on('audio_peer_offer') 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") @socketio.on('audio_peer_answer') 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") @socketio.on('audio_ice_candidate') 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 @app.errorhandler(404) def not_found(error): return jsonify({"error": "Endpoint not found"}), 404 @app.errorhandler(500) 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)