diff --git a/src/app.py b/src/app.py index 2205258..e02f980 100644 --- a/src/app.py +++ b/src/app.py @@ -41,6 +41,7 @@ room_keys = {} user_sessions = {} room_passwords = {} # Separate password storage with hashing room_creation_times = {} +room_metadata = {} # New: Store room titles and descriptions ip_room_count = {} # Track rooms created per IP failed_password_attempts = {} # Track failed password attempts message_hashes = {} # Store message hashes for duplicate detection @@ -151,6 +152,7 @@ def cleanup_room(room_id): room_keys.pop(room_id, None) room_passwords.pop(room_id, None) room_creation_times.pop(room_id, None) + room_metadata.pop(room_id, None) # Clean up metadata message_hashes.pop(room_id, None) def get_client_ip(): @@ -201,15 +203,58 @@ def get_room_info(room_id): return jsonify({'error': 'Invalid room ID'}), 400 if room_id in chat_rooms: - return jsonify({ + room_info = { 'exists': True, 'message_count': chat_rooms[room_id].get_message_count(), 'max_messages': MAX_MESSAGES, 'user_count': len(room_keys.get(room_id, {})), - 'max_users': MAX_USERS_PER_ROOM - }) + 'max_users': MAX_USERS_PER_ROOM, + 'is_password_protected': room_id in room_passwords, + 'created_at': room_creation_times.get(room_id) + } + + # Add metadata if available + if room_id in room_metadata: + room_info.update(room_metadata[room_id]) + + return jsonify(room_info) return jsonify({'exists': False}) +@app.route('/api/rooms/public') +def get_public_rooms(): + """Get list of public (non-password-protected) rooms""" + try: + # Optional query parameters for filtering/sorting + sort_by = request.args.get('sort', 'activity') # activity, created, users, messages + limit = min(int(request.args.get('limit', 50)), 100) # Max 100 rooms + min_users = int(request.args.get('min_users', 0)) + + rooms_data = get_public_rooms_data(sort_by, min_users, limit) + + return jsonify({ + 'rooms': rooms_data, + 'total_count': len(rooms_data), + 'sort_by': sort_by, + 'timestamp': time.time() + }) + + except ValueError as e: + return jsonify({'error': 'Invalid parameter values'}), 400 + except Exception as e: + logger.error(f"Error in get_public_rooms: {str(e)}") + return jsonify({'error': 'Internal server error'}), 500 + +@app.route('/api/rooms/stats') +def get_room_stats(): + """Get general statistics about rooms""" + try: + stats_data = get_room_stats_data() + return jsonify(stats_data) + + except Exception as e: + logger.error(f"Error in get_room_stats: {str(e)}") + return jsonify({'error': 'Internal server error'}), 500 + # Socket event handlers @socketio.on('connect') def handle_connect(): @@ -255,6 +300,10 @@ def handle_disconnect(): # Clean up empty rooms if room_id in room_keys and len(room_keys[room_id]) == 0: cleanup_room(room_id) + else: + # Broadcast public rooms update if this was a public room with remaining users + if room_id not in room_passwords: + broadcast_public_rooms_update() del user_sessions[request.sid] logger.info(f"User {user_id} disconnected") @@ -266,6 +315,8 @@ def handle_join_room(data): room_id = data.get('room_id', '').strip() public_key = data.get('public_key', '') password = data.get('password', '') + room_title = data.get('room_title', '').strip() + room_description = data.get('room_description', '').strip() # Validation if not is_valid_room_id(room_id): @@ -276,6 +327,15 @@ def handle_join_room(data): emit('room_joined', {'error': 'Invalid public key format'}) return + # Validate metadata length + if room_title and len(room_title) > 100: + emit('room_joined', {'error': 'Room title too long (max 100 characters)'}) + return + + if room_description and len(room_description) > 500: + emit('room_joined', {'error': 'Room description too long (max 500 characters)'}) + return + client_ip = get_client_ip() user_id = user_sessions[request.sid]['user_id'] @@ -295,6 +355,13 @@ def handle_join_room(data): room_creation_times[room_id] = time.time() message_hashes[room_id] = set() + # Store room metadata + room_metadata[room_id] = { + 'title': room_title or f'Room {room_id}', + 'description': room_description or 'A chat room', + 'creator_ip': client_ip # For potential moderation + } + # Store hashed password if provided if password: room_passwords[room_id] = hash_password(password) @@ -303,7 +370,7 @@ def handle_join_room(data): ip_room_count[client_ip] = rooms_by_ip + 1 is_first_user = True - logger.info(f"Room {room_id} created by {user_id}") + logger.info(f"Room {room_id} created by {user_id} with title: {room_title}") else: # Check room capacity if len(room_keys[room_id]) >= MAX_USERS_PER_ROOM: @@ -358,7 +425,8 @@ def handle_join_room(data): 'users': list(user_keys.keys()), 'user_keys': user_keys, 'message_count': chat_rooms[room_id].get_message_count(), - 'is_first_user': is_first_user + 'is_first_user': is_first_user, + 'room_metadata': room_metadata.get(room_id, {}) }) # Notify others about new user @@ -382,6 +450,10 @@ def handle_join_room(data): logger.info(f"User {new_user_id} joined room {room_id}") + # Broadcast public rooms update if this was a public room + if room_id not in room_passwords: + broadcast_public_rooms_update() + except Exception as e: logger.error(f"Error in join_room: {str(e)}") emit('room_joined', {'error': 'Internal server error'}) @@ -446,6 +518,10 @@ def handle_send_message(data): 'message_count': chat_rooms[room_id].get_message_count() }, room=room_id) + # Broadcast public rooms update if this was a public room + if room_id not in room_passwords: + broadcast_public_rooms_update() + except Exception as e: logger.error(f"Error in send_message: {str(e)}") @@ -495,12 +571,104 @@ def handle_share_session_key(data): except Exception as e: logger.error(f"Error in share_session_key: {str(e)}") +@socketio.on('request_public_rooms') +def handle_request_public_rooms(data): + """Handle request for public rooms data via WebSocket""" + try: + sort_by = data.get('sort', 'activity') + min_users = data.get('min_users', 0) + limit = data.get('limit', 50) + + # Validate parameters + if sort_by not in ['activity', 'created', 'users', 'messages']: + sort_by = 'activity' + + min_users = max(0, min(int(min_users), MAX_USERS_PER_ROOM)) + limit = max(1, min(int(limit), 100)) + + rooms_data = get_public_rooms_data(sort_by, min_users, limit) + stats_data = get_room_stats_data() + + emit('public_rooms_data', { + 'rooms': rooms_data, + 'stats': stats_data, + 'sort_by': sort_by, + 'timestamp': time.time() + }) + + except Exception as e: + logger.error(f"Error in request_public_rooms: {str(e)}") + emit('public_rooms_error', {'error': 'Failed to load rooms'}) + +@socketio.on('subscribe_public_rooms') +def handle_subscribe_public_rooms(): + """Subscribe client to public rooms updates""" + if request.sid not in user_sessions: + return + + user_sessions[request.sid]['subscribed_to_public_rooms'] = True + logger.info(f"User {user_sessions[request.sid]['user_id']} subscribed to public rooms updates") + +@socketio.on('unsubscribe_public_rooms') +def handle_unsubscribe_public_rooms(): + """Unsubscribe client from public rooms updates""" + if request.sid in user_sessions: + user_sessions[request.sid]['subscribed_to_public_rooms'] = False + logger.info(f"User {user_sessions[request.sid]['user_id']} unsubscribed from public rooms updates") + +@socketio.on('request_public_rooms') +def handle_request_public_rooms(data): + """Handle request for public rooms data via WebSocket""" + try: + sort_by = data.get('sort', 'activity') + min_users = data.get('min_users', 0) + limit = data.get('limit', 50) + + # Validate parameters + if sort_by not in ['activity', 'created', 'users', 'messages']: + sort_by = 'activity' + + min_users = max(0, min(int(min_users), MAX_USERS_PER_ROOM)) + limit = max(1, min(int(limit), 100)) + + rooms_data = get_public_rooms_data(sort_by, min_users, limit) + stats_data = get_room_stats_data() + + emit('public_rooms_data', { + 'rooms': rooms_data, + 'stats': stats_data, + 'sort_by': sort_by, + 'timestamp': time.time() + }) + + except Exception as e: + logger.error(f"Error in request_public_rooms: {str(e)}") + emit('public_rooms_error', {'error': 'Failed to load rooms'}) + +@socketio.on('subscribe_public_rooms') +def handle_subscribe_public_rooms(): + """Subscribe client to public rooms updates""" + if request.sid not in user_sessions: + return + + user_sessions[request.sid]['subscribed_to_public_rooms'] = True + logger.info(f"User {user_sessions[request.sid]['user_id']} subscribed to public rooms updates") + +@socketio.on('unsubscribe_public_rooms') +def handle_unsubscribe_public_rooms(): + """Unsubscribe client from public rooms updates""" + if request.sid in user_sessions: + user_sessions[request.sid]['subscribed_to_public_rooms'] = False + logger.info(f"User {user_sessions[request.sid]['user_id']} unsubscribed from public rooms updates") + # Background cleanup task def start_cleanup_task(): def cleanup_worker(): while True: try: cleanup_expired_rooms() + # Broadcast updated room stats after cleanup + broadcast_public_rooms_update() time.sleep(ROOM_CLEANUP_INTERVAL) except Exception as e: logger.error(f"Error in cleanup task: {str(e)}") @@ -508,6 +676,112 @@ def start_cleanup_task(): cleanup_thread = threading.Thread(target=cleanup_worker, daemon=True) cleanup_thread.start() +def get_public_rooms_data(sort_by='activity', min_users=0, limit=50): + """Get public rooms data - shared function for API and WebSocket""" + try: + public_rooms = [] + current_time = time.time() + + for room_id, room_buffer in chat_rooms.items(): + # Skip password-protected rooms + if room_id in room_passwords: + continue + + # Skip expired rooms + if room_buffer.is_expired(): + continue + + user_count = len(room_keys.get(room_id, {})) + + # Apply user count filters + if user_count < min_users: + continue + + room_data = { + 'room_id': room_id, + 'user_count': user_count, + 'message_count': room_buffer.get_message_count(), + 'created_at': room_creation_times.get(room_id, current_time), + 'last_activity': room_buffer.last_activity, + 'is_password_protected': False + } + + # Add metadata if available + if room_id in room_metadata: + room_data.update(room_metadata[room_id]) + else: + room_data.update({ + 'title': f'Room {room_id}', + 'description': 'A public chat room' + }) + + # Calculate activity metrics + room_data['minutes_since_activity'] = int((current_time - room_buffer.last_activity) / 60) + room_data['hours_since_created'] = int((current_time - room_data['created_at']) / 3600) + + public_rooms.append(room_data) + + # Sort rooms + if sort_by == 'activity': + public_rooms.sort(key=lambda x: x['last_activity'], reverse=True) + elif sort_by == 'created': + public_rooms.sort(key=lambda x: x['created_at'], reverse=True) + elif sort_by == 'users': + public_rooms.sort(key=lambda x: x['user_count'], reverse=True) + elif sort_by == 'messages': + public_rooms.sort(key=lambda x: x['message_count'], reverse=True) + + return public_rooms[:limit] + + except Exception as e: + logger.error(f"Error getting public rooms data: {str(e)}") + return [] + +def get_room_stats_data(): + """Get room statistics - shared function for API and WebSocket""" + try: + current_time = time.time() + + total_rooms = len(chat_rooms) + public_rooms = sum(1 for room_id in chat_rooms.keys() if room_id not in room_passwords) + private_rooms = total_rooms - public_rooms + + total_users = sum(len(users) for users in room_keys.values()) + total_messages = sum(room.get_message_count() for room in chat_rooms.values()) + + # Active rooms (activity in last hour) + active_rooms = sum(1 for room in chat_rooms.values() + if current_time - room.last_activity < 3600) + + return { + 'total_rooms': total_rooms, + 'public_rooms': public_rooms, + 'private_rooms': private_rooms, + 'active_rooms': active_rooms, + 'total_users': total_users, + 'total_messages': total_messages, + 'timestamp': current_time + } + + except Exception as e: + logger.error(f"Error getting room stats: {str(e)}") + return {} + +def broadcast_public_rooms_update(): + """Broadcast updated public rooms data to all connected clients""" + try: + rooms_data = get_public_rooms_data() + stats_data = get_room_stats_data() + + socketio.emit('public_rooms_updated', { + 'rooms': rooms_data, + 'stats': stats_data, + 'timestamp': time.time() + }, broadcast=True) + + except Exception as e: + logger.error(f"Error broadcasting public rooms update: {str(e)}") + # Error handlers @app.errorhandler(404) def not_found(e): diff --git a/src/static/rooms.js b/src/static/rooms.js new file mode 100644 index 0000000..13546db --- /dev/null +++ b/src/static/rooms.js @@ -0,0 +1,319 @@ +let publicRoomsData = []; +let roomsRefreshInterval = null; +let currentSortBy = 'activity'; +let currentMinUsers = 0; +let isSubscribedToRooms = false; + +// Show the public rooms browser +function showPublicRoomsBrowser() { + document.getElementById('publicRoomsBrowser').style.display = 'block'; + loadPublicRoomsWebSocket(); + subscribeToPublicRooms(); +} + +// Close the public rooms browser +function closePublicRoomsBrowser() { + document.getElementById('publicRoomsBrowser').style.display = 'none'; + unsubscribeFromPublicRooms(); + + // Clear auto-refresh + if (roomsRefreshInterval) { + clearInterval(roomsRefreshInterval); + roomsRefreshInterval = null; + } +} + +// Subscribe to live public rooms updates +function subscribeToPublicRooms() { + if (typeof socket !== 'undefined' && socket.connected && !isSubscribedToRooms) { + socket.emit('subscribe_public_rooms'); + isSubscribedToRooms = true; + console.log('Subscribed to public rooms updates'); + } +} + +// Unsubscribe from public rooms updates +function unsubscribeFromPublicRooms() { + if (typeof socket !== 'undefined' && socket.connected && isSubscribedToRooms) { + socket.emit('unsubscribe_public_rooms'); + isSubscribedToRooms = false; + console.log('Unsubscribed from public rooms updates'); + } +} + +// Load public rooms via WebSocket +function loadPublicRoomsWebSocket() { + try { + showLoadingState(); + + currentSortBy = document.getElementById('roomsSortSelect').value; + currentMinUsers = document.getElementById('minUsersFilter').value || 0; + + if (typeof socket !== 'undefined' && socket.connected) { + socket.emit('request_public_rooms', { + sort: currentSortBy, + min_users: currentMinUsers, + limit: 50 + }); + } else { + // Fallback to HTTP API if WebSocket not available + loadPublicRoomsHTTP(); + } + + } catch (error) { + console.error('Error requesting public rooms via WebSocket:', error); + loadPublicRoomsHTTP(); // Fallback to HTTP + } +} + +// Fallback HTTP method +async function loadPublicRoomsHTTP() { + try { + showLoadingState(); + + const sortBy = document.getElementById('roomsSortSelect').value; + const minUsers = document.getElementById('minUsersFilter').value || 0; + + const response = await fetch(`/api/rooms/public?sort=${sortBy}&min_users=${minUsers}&limit=50`); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + publicRoomsData = data.rooms || []; + + // Also load stats + const statsResponse = await fetch('/api/rooms/stats'); + if (statsResponse.ok) { + const stats = await statsResponse.json(); + updateStatsDisplay(stats); + } + + displayRooms(); + + } catch (error) { + console.error('Error loading public rooms via HTTP:', error); + showErrorState(); + } +} + +// Refresh public rooms +function refreshPublicRooms() { + loadPublicRoomsWebSocket(); +} + +// Update stats display +function updateStatsDisplay(stats) { + const statsElement = document.getElementById('roomsStats'); + statsElement.textContent = `${stats.public_rooms} public rooms • ${stats.total_users} users online`; +} + +// Show loading state +function showLoadingState() { + document.getElementById('roomsLoading').style.display = 'flex'; + document.getElementById('roomsList').style.display = 'none'; + document.getElementById('roomsEmpty').style.display = 'none'; +} + +// Show error state +function showErrorState() { + document.getElementById('roomsLoading').style.display = 'none'; + document.getElementById('roomsList').style.display = 'none'; + document.getElementById('roomsEmpty').style.display = 'flex'; + + const emptyElement = document.getElementById('roomsEmpty'); + emptyElement.innerHTML = ` +
Unable to connect to the server. Please try again.
+ + `; +} + +// Display rooms +function displayRooms() { + const roomsList = document.getElementById('roomsList'); + const roomsLoading = document.getElementById('roomsLoading'); + const roomsEmpty = document.getElementById('roomsEmpty'); + + roomsLoading.style.display = 'none'; + + if (publicRoomsData.length === 0) { + roomsEmpty.style.display = 'flex'; + roomsList.style.display = 'none'; + return; + } + + roomsEmpty.style.display = 'none'; + roomsList.style.display = 'block'; + + roomsList.innerHTML = publicRoomsData.map(room => createRoomCard(room)).join(''); +} + +// Create room card HTML +function createRoomCard(room) { + const activityClass = getActivityClass(room.minutes_since_activity); + const timeAgo = formatTimeAgo(room.minutes_since_activity); + + return ` +