Sharelock / app.py
mac9087's picture
Update app.py
559d2d6 verified
Raw History Blame Contribute Delete
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)
@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/<secret_id>")
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/<secret_id>", 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/<short_id>")
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>
"""
@app.route("/api/qr/<secret_id>")
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/<room_id>", 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)