// --- START: Additions/Modifications in app.js --- const express = require('express'); const http = require('http'); const socketIo = require('socket.io'); const path = require('path'); const fs = require('fs').promises; // Use promise-based fs // const bcrypt = require('bcrypt'); // Still commented out, needed for secure apps // Create express app and server const app = express(); const server = http.createServer(app); const io = socketIo(server); // --- Configuration --- const PUBLIC_DIR = path.join(__dirname, 'public'); const DATA_DIR = path.join(__dirname, 'data'); // Directory for data files const USERS_FILE_PATH = path.join(DATA_DIR, 'users.json'); // Path to users file // Serve static files from the 'public' directory app.use(express.static(PUBLIC_DIR)); // --- State --- let registeredUsers = {}; // Will be loaded from file // onlineUsers structure: { socketId: { username, isTyping, inCallWith: null | string (socketId) } } const onlineUsers = {}; const usernameToSocketId = {}; // Helper map: { username: socketId } // --- File Helper Functions (loadUsers, saveUsers - remain the same) --- // ... (Keep existing loadUsers and saveUsers functions) ... async function loadUsers() { try { await fs.mkdir(DATA_DIR, { recursive: true }); const data = await fs.readFile(USERS_FILE_PATH, 'utf8'); return JSON.parse(data); } catch (error) { if (error.code === 'ENOENT') { console.log('users.json not found, starting with empty user list.'); return {}; } else if (error instanceof SyntaxError) { console.error('Error parsing users.json:', error); return {}; } else { console.error('Error loading users.json:', error); return {}; } } } async function saveUsers(usersData) { try { await fs.mkdir(DATA_DIR, { recursive: true }); const data = JSON.stringify(usersData, null, 2); await fs.writeFile(USERS_FILE_PATH, data, 'utf8'); console.log('User data saved to users.json'); } catch (error) { console.error('Error saving users.json:', error); } } // --- Socket.IO Logic --- io.on('connection', (socket) => { console.log('A user connected:', socket.id); // Note: currentUsername is only valid *after* successful login within this closure. // Use onlineUsers[socket.id]?.username safely elsewhere. // --- Handle Login/Registration Attempt (Modified to track usernameToSocketId) --- socket.on('attempt_login', async (credentials) => { if (!credentials || !credentials.username || !credentials.password) { socket.emit('login_fail', 'Username and password are required.'); return; } const { username, password } = credentials; const isAlreadyOnline = usernameToSocketId[username]; // Check map directly if (isAlreadyOnline) { socket.emit('login_fail', `User "${username}" is already logged in.`); return; } if (registeredUsers.hasOwnProperty(username)) { if (registeredUsers[username] === password) { // INSECURE console.log(`Login success: ${username} (${socket.id})`); loginUser(socket, username); } else { console.log(`Login fail: Invalid password for ${username} (${socket.id})`); socket.emit('login_fail', 'Invalid username or password.'); } } else { console.log(`Registering new user: ${username} (${socket.id})`); registeredUsers[username] = password; // INSECURE await saveUsers(registeredUsers); loginUser(socket, username); } }); // Modified loginUser to update maps and state function loginUser(socketInstance, username) { // Store user info onlineUsers[socketInstance.id] = { username: username, isTyping: false, inCallWith: null // Initially not in a call }; usernameToSocketId[username] = socketInstance.id; // Add to lookup map socketInstance.emit('login_success', username); io.emit('user_join', username); io.emit('user_list', getUsersPublicInfo()); } // --- Message Handling (remains the same) --- socket.on('message', (data) => { const senderInfo = onlineUsers[socket.id]; if (!senderInfo || !data || !data.text) return; if (senderInfo.isTyping) { senderInfo.isTyping = false; // Don't need to exclude sender ID here as getUsersTyping doesn't include self by default logic socket.broadcast.emit('user_typing_status', getUsersTyping()); } io.emit('message', { username: senderInfo.username, text: data.text, timestamp: new Date().toISOString() // Use ISO string for consistency }); }); // --- Typing Indicator Handling (remains the same) --- socket.on('typing', () => { const userInfo = onlineUsers[socket.id]; if (userInfo && !userInfo.isTyping) { userInfo.isTyping = true; socket.broadcast.emit('user_typing_status', getUsersTyping(socket.id)); // Exclude self } }); socket.on('stop_typing', () => { const userInfo = onlineUsers[socket.id]; if (userInfo && userInfo.isTyping) { userInfo.isTyping = false; socket.broadcast.emit('user_typing_status', getUsersTyping(socket.id)); // Exclude self } }); // --- Disconnect Handling (Modified to handle calls and maps) --- socket.on('disconnect', () => { const userInfo = onlineUsers[socket.id]; if (userInfo) { const username = userInfo.username; const userInCallWithSocketId = userInfo.inCallWith; console.log(`${username} (${socket.id}) disconnected`); // --- Hang up any active call --- if (userInCallWithSocketId && onlineUsers[userInCallWithSocketId]) { console.log(`Disconnect: Hanging up call between ${username} and ${onlineUsers[userInCallWithSocketId].username}`); io.to(userInCallWithSocketId).emit('call-ended'); // Notify the other user onlineUsers[userInCallWithSocketId].inCallWith = null; // Clear call state for the other user } // --- End hang up --- delete onlineUsers[socket.id]; // Remove from online list delete usernameToSocketId[username]; // Remove from lookup map socket.broadcast.emit('user_leave', username); io.emit('user_list', getUsersPublicInfo()); // Send updated list // Update typing status if the disconnected user was typing if (userInfo.isTyping) { socket.broadcast.emit('user_typing_status', getUsersTyping()); } } else { console.log('User disconnected before login:', socket.id); } }); // --- START: WebRTC Signaling Handlers --- /** * Initiates a call request from caller to target. * data: { targetUsername: string } */ socket.on('request-call', (data) => { const callerInfo = onlineUsers[socket.id]; if (!callerInfo) return; // Should not happen if logged in const targetUsername = data.targetUsername; const targetSocketId = usernameToSocketId[targetUsername]; const targetInfo = targetSocketId ? onlineUsers[targetSocketId] : null; console.log(`Call request from ${callerInfo.username} (${socket.id}) to ${targetUsername}`); if (!targetInfo) { console.log(`Call target ${targetUsername} not found or offline.`); socket.emit('call-denied', { reason: 'User is offline.' }); return; } if (targetInfo.inCallWith) { console.log(`Call target ${targetUsername} is already in a call.`); socket.emit('call-denied', { reason: `${targetUsername} is busy.` }); return; } if (callerInfo.inCallWith) { console.log(`Caller ${callerInfo.username} is already in a call.`); socket.emit('call-denied', { reason: `You are already in a call.` }); return; } // Send call offer prompt to the target user console.log(`Sending incoming call notification to ${targetUsername} (${targetSocketId})`); io.to(targetSocketId).emit('incoming-call', { callerUsername: callerInfo.username }); }); /** * Target user accepts the call. * data: { callerUsername: string } */ socket.on('accept-call', (data) => { const acceptorInfo = onlineUsers[socket.id]; // The user accepting the call if (!acceptorInfo) return; const callerUsername = data.callerUsername; const callerSocketId = usernameToSocketId[callerUsername]; const callerInfo = callerSocketId ? onlineUsers[callerSocketId] : null; console.log(`${acceptorInfo.username} accepted call from ${callerUsername}`); if (!callerInfo) { console.log(`Original caller ${callerUsername} not found.`); socket.emit('call-denied', { reason: 'Caller went offline.' }); return; } if (callerInfo.inCallWith || acceptorInfo.inCallWith) { console.log(`Call conflict: ${callerUsername} or ${acceptorInfo.username} already in call.`); // Notify both potential parties that call cannot proceed socket.emit('call-denied', { reason: 'Call conflict or one party is busy.' }); io.to(callerSocketId).emit('call-denied', { reason: 'Call conflict or the other party became busy.'}); return; } // Mark both users as in call with each other acceptorInfo.inCallWith = callerSocketId; callerInfo.inCallWith = socket.id; // socket.id is the acceptor's socket id // Notify the original caller to start the WebRTC offer process io.to(callerSocketId).emit('call-accepted', { acceptorUsername: acceptorInfo.username }); // Notify the acceptor to wait for the offer socket.emit('prepare-for-offer', { callerUsername: callerInfo.username }); console.log(`Call established between ${callerUsername} (${callerSocketId}) and ${acceptorInfo.username} (${socket.id})`); }); /** * Target user rejects the call. * data: { callerUsername: string } */ socket.on('reject-call', (data) => { const rejectorInfo = onlineUsers[socket.id]; if (!rejectorInfo) return; const callerUsername = data.callerUsername; const callerSocketId = usernameToSocketId[callerUsername]; console.log(`${rejectorInfo.username} rejected call from ${callerUsername}`); if (callerSocketId) { io.to(callerSocketId).emit('call-rejected', { rejectorUsername: rejectorInfo.username }); } }); /** * Relays the WebRTC Offer from caller to acceptor. * data: { targetUsername: string, offer: RTCSessionDescriptionInit } */ socket.on('webrtc-offer', (data) => { const callerInfo = onlineUsers[socket.id]; if (!callerInfo) return; const targetUsername = data.targetUsername; const targetSocketId = usernameToSocketId[targetUsername]; if (targetSocketId && onlineUsers[targetSocketId]?.inCallWith === socket.id) { console.log(`Relaying WebRTC offer from ${callerInfo.username} to ${targetUsername}`); io.to(targetSocketId).emit('webrtc-offer', { offer: data.offer, callerUsername: callerInfo.username // Send caller username with the offer }); } else { console.warn(`WebRTC Offer: Target ${targetUsername} not found or not in call with ${callerInfo.username}`); } }); /** * Relays the WebRTC Answer from acceptor to caller. * data: { targetUsername: string (original caller), answer: RTCSessionDescriptionInit } */ socket.on('webrtc-answer', (data) => { const acceptorInfo = onlineUsers[socket.id]; if (!acceptorInfo) return; const targetUsername = data.targetUsername; // This is the original caller const targetSocketId = usernameToSocketId[targetUsername]; if (targetSocketId && onlineUsers[targetSocketId]?.inCallWith === socket.id) { console.log(`Relaying WebRTC answer from ${acceptorInfo.username} to ${targetUsername}`); io.to(targetSocketId).emit('webrtc-answer', { answer: data.answer, acceptorUsername: acceptorInfo.username // Send acceptor username with the answer }); } else { console.warn(`WebRTC Answer: Target ${targetUsername} not found or not in call with ${acceptorInfo.username}`); } }); /** * Relays ICE Candidates between peers. * data: { targetUsername: string, candidate: RTCIceCandidateInit } */ socket.on('webrtc-ice-candidate', (data) => { const senderInfo = onlineUsers[socket.id]; if (!senderInfo) return; const targetUsername = data.targetUsername; const targetSocketId = usernameToSocketId[targetUsername]; // Check if the target is actually the one sender is in call with if (targetSocketId && senderInfo.inCallWith === targetSocketId) { // console.log(`Relaying ICE candidate from ${senderInfo.username} to ${targetUsername}`); // Too noisy io.to(targetSocketId).emit('webrtc-ice-candidate', { candidate: data.candidate }); } else { // console.warn(`ICE Candidate: Target ${targetUsername} not found or sender ${senderInfo.username} not in call with them.`); } }); /** * Handles user explicitly hanging up the call. * data: {} (might include target if needed, but server knows from inCallWith) */ socket.on('hangup-call', () => { const userInfo = onlineUsers[socket.id]; if (!userInfo || !userInfo.inCallWith) { // Not in a call, nothing to hang up return; } const targetSocketId = userInfo.inCallWith; const targetInfo = onlineUsers[targetSocketId]; console.log(`${userInfo.username} is hanging up call with ${targetInfo?.username || 'unknown'}`); // Notify the other user if (targetInfo) { io.to(targetSocketId).emit('call-ended'); targetInfo.inCallWith = null; // Clear target's call state } // Clear caller's call state userInfo.inCallWith = null; // Optional: send confirmation back to the hanger-upper socket.emit('call-ended'); }); // --- END: WebRTC Signaling Handlers --- }); // --- Helper Functions (Modified getUsersTyping) --- function getUsersPublicInfo() { // Return username and potentially busy status? For now just usernames. return Object.values(onlineUsers).map(u => u.username); } // Modified to exclude the caller from the list sent back to them function getUsersTyping(excludeSocketId = null) { return Object.entries(onlineUsers) .filter(([id, user]) => id !== excludeSocketId && user.isTyping) .map(([id, user]) => user.username); } // --- Find Local IP (remains the same) --- function getLocalIp() { // ... (Keep existing getLocalIp function) ... const { networkInterfaces } = require('os'); const nets = networkInterfaces(); for (const name of Object.keys(nets)) { for (const net of nets[name]) { if (net.family === 'IPv4' && !net.internal) { return net.address; } } } return '127.0.0.1'; } const localIp = getLocalIp(); // --- Start Server (IIFE remains the same) --- (async () => { registeredUsers = await loadUsers(); console.log(`Loaded ${Object.keys(registeredUsers).length} registered users.`); // const PORT = process.env.PORT || 3000; // Find this line or similar const PORT = process.env.PORT || 7860; // Change 3000 to 7860 server.listen(PORT, () => { console.log(`Server running on port ${PORT}`); console.log(`Access it locally at: http://localhost:${PORT}`); if (localIp !== '127.0.0.1') { console.log(`Access on local network (e.g., mobile): http://${localIp}:${PORT}`); } console.log(`Serving files from: ${PUBLIC_DIR}`); console.log(`User data file: ${USERS_FILE_PATH}`); console.warn('--- SECURITY WARNING: Storing plain text passwords in users.json! ---'); console.warn('--- WebRTC Note: Using public STUN servers. TURN server might be needed for reliability. ---'); }); server.on('error', (error) => { console.error('Server failed to start:', error); process.exit(1); }); })(); // --- END: Additions/Modifications in app.js ---