Sharelock / app.py
mike23415's picture
Update app.py
3d367a5 verified
import sys
import os
from flask import Flask, request, jsonify, send_file
from flask_cors import CORS
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
app = Flask(__name__)
CORS(app)
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=True, # Enable for debugging
engineio_logger=True, # 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 }
ANALYTICS = {} # { secret_id: [analytics_entries] }
SCREEN_SHARE_ROOMS = {} # { room_id: { host_hash, participants, settings, created_at, expires_at } }
CURSOR_STATES = {}
CHAT_ROOMS = {}
# 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 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 get_location_info(ip):
"""Get location information from IP (mock implementation)"""
# In production, use a real geolocation service like ipapi.co, ipstack.com, etc.
try:
# Mock data - replace with real API call
if ip == '127.0.0.1' or ip.startswith('192.168.'):
return {
'country': 'Local',
'city': 'Local',
'region': 'Local',
'timezone': 'Local'
}
# Example with ipapi.co (uncomment for production)
# response = requests.get(f'https://ipapi.co/{ip}/json/', timeout=5)
# if response.status_code == 200:
# data = response.json()
# return {
# 'country': data.get('country_name', 'Unknown'),
# 'city': data.get('city', 'Unknown'),
# 'region': data.get('region', 'Unknown'),
# 'timezone': data.get('timezone', 'Unknown')
# }
return {
'country': 'Unknown',
'city': 'Unknown',
'region': 'Unknown',
'timezone': 'Unknown'
}
except:
return {
'country': 'Unknown',
'city': 'Unknown',
'region': 'Unknown',
'timezone': 'Unknown'
}
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 record_access(secret_id, request):
"""Record access analytics"""
ip = get_client_ip(request)
location = get_location_info(ip)
user_agent = request.headers.get('User-Agent', '')
# Determine device type
device_type = 'desktop'
if any(mobile in user_agent.lower() for mobile in ['mobile', 'android', 'iphone', 'ipad']):
device_type = 'mobile'
analytics_entry = {
'timestamp': time.time(),
'ip': ip,
'location': location,
'user_agent': user_agent,
'device_type': device_type,
'referer': request.headers.get('Referer', ''),
'accept_language': request.headers.get('Accept-Language', '')
}
if secret_id not in ANALYTICS:
ANALYTICS[secret_id] = []
ANALYTICS[secret_id].append(analytics_entry)
return analytics_entry
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")
if not data:
return jsonify({"error": "Data is required"}), 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:
# 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'
# Only record access and increment count if NOT verify_only
if not verify_only:
# Record access analytics
analytics_entry = record_access(secret_id, request)
# Increment access count
secret["access_count"] += 1
# 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/analytics/<secret_id>")
def get_analytics(secret_id):
"""Get analytics for a specific secret - MODIFIED TO HANDLE verify_only"""
try:
# CHECK FOR verify_only PARAMETER
verify_only = request.args.get('verify_only', 'false').lower() == 'true'
# Verify secret exists or existed
if secret_id not in SECRETS and secret_id not in ANALYTICS:
return jsonify({"error": "Secret not found"}), 404
# Only record access if NOT verify_only
if not verify_only:
# Record access analytics for the analytics request itself
record_access(secret_id, request)
analytics_data = ANALYTICS.get(secret_id, [])
# Format analytics for frontend
formatted_analytics = []
for entry in analytics_data:
formatted_analytics.append({
"timestamp": entry["timestamp"],
"datetime": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(entry["timestamp"])),
"ip": entry["ip"],
"location": entry["location"],
"device_type": entry["device_type"],
"user_agent": entry["user_agent"][:100] + "..." if len(entry["user_agent"]) > 100 else entry["user_agent"]
})
return jsonify({
"secret_id": secret_id,
"total_accesses": len(formatted_analytics),
"analytics": formatted_analytics
})
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)
total_accesses = sum(len(analytics) for analytics in ANALYTICS.values())
# 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
@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:
@app.route("/api/chat/create", methods=["POST"])
def create_chat_room():
try:
form = request.form
ttl = int(form.get("ttl", 3600))
max_receivers = int(form.get("max_receivers", 5))
password = form.get("password", "")
allow_files = form.get("allow_files", "true").lower() == "true"
room_id = str(uuid.uuid4())
admin_session = str(uuid.uuid4())
CHAT_ROOMS[room_id] = {
"admin_session": admin_session,
"created_at": time.time(),
"expires_at": time.time() + ttl,
"settings": {
"max_receivers": max_receivers,
"password": password,
"allow_files": allow_files,
"burn_on_admin_exit": True
},
"active_sessions": {},
"receiver_counter": 0
}
# Return only IDs - let frontend create URLs
return jsonify({
"room_id": room_id,
"admin_session": admin_session,
"expires_at": CHAT_ROOMS[room_id]["expires_at"]
})
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/chat/join/<room_id>")
def join_chat_room(room_id):
try:
password = request.args.get("password", "")
admin_session = request.args.get("admin", "")
if room_id not in CHAT_ROOMS:
return jsonify({"error": "Chat room not found"}), 404
room = CHAT_ROOMS[room_id]
# Check if expired
if time.time() > room["expires_at"]:
return jsonify({"error": "Chat room has expired"}), 410
# Check password
if room["settings"]["password"] and password != room["settings"]["password"]:
return jsonify({"error": "Wrong password"}), 403
# FIXED: Proper role assignment
if admin_session and admin_session == room["admin_session"]:
# Only admin if the session matches the room's admin session
role = "admin"
session_id = admin_session
receiver_number = None
else:
# Everyone else is a receiver
active_receivers = sum(1 for s in room["active_sessions"].values() if s["role"] == "receiver")
if active_receivers >= room["settings"]["max_receivers"]:
return jsonify({"error": "Chat room is full"}), 403
role = "receiver"
session_id = str(uuid.uuid4())
room["receiver_counter"] += 1
receiver_number = room["receiver_counter"]
return jsonify({
"session_id": session_id,
"role": role,
"receiver_number": receiver_number,
"room_settings": room["settings"],
"expires_at": room["expires_at"]
})
except Exception as e:
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']
public_key = data.get('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'),
"public_key": public_key,
"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: # Don't include self
peer_list.append({
'session_id': sid,
'role': session['role'],
'receiver_number': session.get('receiver_number'),
'public_key': session.get('public_key')
})
emit('peer_list', {'peers': peer_list})
# CRITICAL FIX: Notify existing peers (NOT the new joiner!)
emit('peer_joined', {
'session_id': session_id,
'role': role,
'receiver_number': data.get('receiver_number'),
'public_key': public_key,
'active_count': len(CHAT_ROOMS[room_id]["active_sessions"])
}, room=room_id, include_self=False) # ← CRITICAL: include_self=False
@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):
"""Relay WebRTC offer for chat P2P connection"""
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
print(f"πŸ“€ Relaying offer: {from_session[:8]}... β†’ {to_session[:8]}...")
# Emit to the room, but skip the sender
emit('chat_peer_offer', {
'from_session': from_session,
'offer': offer
}, to=room_id, skip_sid=request.sid)
@socketio.on('chat_peer_answer')
def handle_chat_peer_answer(data):
"""Relay WebRTC answer for chat P2P connection"""
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
print(f"πŸ“€ Relaying answer: {from_session[:8]}... β†’ {to_session[:8]}...")
# Emit to the room, but skip the sender
emit('chat_peer_answer', {
'from_session': from_session,
'answer': answer
}, to=room_id, skip_sid=request.sid)
@socketio.on('chat_ice_candidate')
def handle_chat_ice_candidate(data):
"""Relay ICE candidates for chat connections"""
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
# Emit to the room, but skip the sender
emit('chat_ice_candidate', {
'from_session': from_session,
'candidate': candidate
}, to=room_id, skip_sid=request.sid)
@socketio.on('leave_chat')
def handle_leave_chat(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]
del 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]
if room_id in CHAT_MESSAGES:
del CHAT_MESSAGES[room_id]
else:
emit('user_left', {
'role': session["role"],
'receiver_number': session.get("receiver_number"),
'active_count': len(CHAT_ROOMS[room_id]["active_sessions"])
}, room=room_id)
leave_room(room_id)
@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
for room_id, room in list(CHAT_ROOMS.items()):
if username_hash in [s for s_id, s in room['active_sessions'].items()]:
# Find and remove session
for session_id, session in list(room['active_sessions'].items()):
if session.get('username_hash') == username_hash or \
(session['role'] == 'admin' and room['admin_session'] == session_id):
# If admin left, close room
if session['role'] == 'admin' and room['settings'].get('burn_on_admin_exit', True):
socketio.emit('room_closing', {'reason': 'Admin disconnected'}, room=room_id)
if room_id in CHAT_MESSAGES:
del CHAT_MESSAGES[room_id]
del CHAT_ROOMS[room_id]
else:
del room['active_sessions'][session_id]
socketio.emit('user_left', {
'role': session['role'],
'receiver_number': session.get('receiver_number'),
'active_count': len(room['active_sessions'])
}, room=room_id)
# 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()
# 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)