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 = ` +
⚠️
+

Failed to Load Rooms

+

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 ` +
+
+ ${escapeHtml(room.title || room.room_id)} + +
+
+ ${escapeHtml(room.description || 'No description')} +
+
+
+ 👥 + ${room.user_count} user${room.user_count !== 1 ? 's' : ''} +
+
+ 💬 + ${room.message_count} message${room.message_count !== 1 ? 's' : ''} +
+
+ 🕐 + Active ${timeAgo} +
+
+ 🆔 + ${room.room_id} +
+
+
+ `; +} + +// Get activity indicator class +function getActivityClass(minutesAgo) { + if (minutesAgo <= 5) return 'activity-active'; + if (minutesAgo <= 30) return 'activity-recent'; + return 'activity-old'; +} + +// Format time ago +function formatTimeAgo(minutes) { + if (minutes < 1) return 'just now'; + if (minutes < 60) return `${minutes}m ago`; + + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + + const days = Math.floor(hours / 24); + return `${days}d ago`; +} + +// Escape HTML to prevent XSS +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +// Join a public room - integrated with your existing system +function joinPublicRoom(roomId) { + console.log(`Joining public room: ${roomId}`); + + // Close the browser + closePublicRoomsBrowser(); + + // Fill in the room input with the selected room + document.getElementById('roomInput').value = roomId; + + // Clear password field since these are public rooms + document.getElementById('roomPasswordInput').value = ''; + + // Trigger your existing join room functionality + const joinButton = document.getElementById('joinRoomBtn'); + if (joinButton) { + joinButton.click(); + } +} + +// Event listeners for controls +document.addEventListener('DOMContentLoaded', function() { + document.getElementById('roomsSortSelect').addEventListener('change', refreshPublicRooms); + document.getElementById('minUsersFilter').addEventListener('change', refreshPublicRooms); +}); + +// Close on escape key +document.addEventListener('keydown', function(e) { + if (e.key === 'Escape' && document.getElementById('publicRoomsBrowser').style.display === 'block') { + closePublicRoomsBrowser(); + } +}); + +// Close on backdrop click +document.getElementById('publicRoomsBrowser').addEventListener('click', function(e) { + if (e.target === this) { + closePublicRoomsBrowser(); + } +}); + +// WebSocket event handlers for real-time updates +function setupPublicRoomsWebSocketHandlers() { + if (typeof socket === 'undefined') { + console.log('Socket not available, using HTTP fallback'); + return; + } + + // Handle public rooms data response + socket.on('public_rooms_data', function(data) { + console.log('Received public rooms data via WebSocket:', data); + publicRoomsData = data.rooms || []; + + if (data.stats) { + updateStatsDisplay(data.stats); + } + + displayRooms(); + }); + + // Handle live updates to public rooms + socket.on('public_rooms_updated', function(data) { + console.log('Received live public rooms update:', data); + + // Only update if the browser is currently open and we're subscribed + if (document.getElementById('publicRoomsBrowser').style.display === 'block' && isSubscribedToRooms) { + publicRoomsData = data.rooms || []; + + if (data.stats) { + updateStatsDisplay(data.stats); + } + + displayRooms(); + } + }); + + // Handle WebSocket errors for public rooms + socket.on('public_rooms_error', function(data) { + console.error('Public rooms WebSocket error:', data); + showErrorState(); + }); + + console.log('Public rooms WebSocket handlers attached'); +} + +// Auto-setup WebSocket handlers when page loads +function waitForSocketAndSetupHandlers() { + if (typeof socket !== 'undefined' && socket.connected) { + setupPublicRoomsWebSocketHandlers(); + } else { + setTimeout(waitForSocketAndSetupHandlers, 100); + } +} + +// Start the setup process +document.addEventListener('DOMContentLoaded', function() { + waitForSocketAndSetupHandlers(); + + // Auto-refresh every 30 seconds when browser is open + setInterval(function() { + if (document.getElementById('publicRoomsBrowser').style.display === 'block') { + refreshPublicRooms(); + } + }, 30000); +}); diff --git a/src/static/stylesheet.css b/src/static/stylesheet.css index a515a5f..54156ab 100644 --- a/src/static/stylesheet.css +++ b/src/static/stylesheet.css @@ -616,4 +616,78 @@ ul.release-list a:hover { .laptopimg { max-width: 100%; object-fit: contain; +} + +/* discovery */ +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.room-card { + background: #252525; + border: 1px solid #333; + border-radius: 8px; + padding: 1rem; + margin-bottom: 0.75rem; + transition: all 0.2s ease; + cursor: pointer; +} + +.room-card:hover { + background: #2a2a2a; + border-color: #00ff88; + transform: translateY(-1px); +} + +.room-title { + font-size: 1.1rem; + font-weight: 600; + margin: 0 0 0.5rem 0; + color: #ffffff; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.room-description { + color: #b0b0b0; + font-size: 0.9rem; + margin: 0 0 0.75rem 0; + line-height: 1.4; +} + +.room-stats { + display: flex; + gap: 1rem; + flex-wrap: wrap; + font-size: 0.8rem; + color: #888; +} + +.room-stat { + display: flex; + align-items: center; + gap: 0.25rem; +} + +.activity-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; +} + +.activity-active { background: #00ff88; } +.activity-recent { background: #ffa500; } +.activity-old { background: #666; } + +@media (max-width: 768px) { + .room-stats { + gap: 0.5rem; + } + + .room-card { + padding: 0.75rem; + } } \ No newline at end of file diff --git a/src/templates/chat.html b/src/templates/chat.html index df1bc31..1188b7e 100644 --- a/src/templates/chat.html +++ b/src/templates/chat.html @@ -7,13 +7,12 @@ - - +
@@ -61,6 +60,12 @@
+ +
+ +
@@ -77,13 +82,6 @@ alt="ByteChat Desktop Application" style="width: clamp(200px, 50vw, 400px); height: auto; border-radius: 12px; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); transition: transform 0.3s ease, box-shadow 0.3s ease; margin-bottom: 1.5rem; display: block;"> - -

Desktop Application

@@ -120,6 +118,180 @@
+
+
+
+ +
+
+

Public Rooms

+

Browse and join active public rooms

+
+ +
+ + +
+ + + + + +
+ + +
+
+
+ Loading public rooms... +
+
+ + +
+ +
+ + +
+
+

… No Public Rooms Found

+

Check back later or create your own room

+
+
+
+
+
- - + \ No newline at end of file