const express = require('express'); const http = require('http'); const { Server } = require('socket.io'); const cors = require('cors'); const axios = require('axios'); require('dotenv').config(); const app = express(); const server = http.createServer(app); const io = new Server(server, { cors: { origin: '*', methods: ['GET', 'POST'] } }); const authRoutes = require('./routes/auth'); const channelRoutes = require('./routes/channels'); const uploadRoutes = require('./routes/upload'); const inviteRoutes = require('./routes/invites'); const rolesRoutes = require('./routes/roles'); const dmRoutes = require('./routes/dms'); app.use(cors()); app.use(express.json()); // Attach IO to request app.use((req, res, next) => { req.io = io; next(); }); // Serve uploads folder based on relative path from server.js const path = require('path'); app.use('/uploads', express.static(path.join(__dirname, 'uploads'))); app.use('/api/auth', authRoutes); app.use('/api/channels', channelRoutes); app.use('/api/upload', uploadRoutes); app.use('/api/invites', inviteRoutes); app.use('/api/invites', inviteRoutes); app.use('/api/roles', rolesRoutes); app.use('/api/dms', dmRoutes); app.use('/api/voice', require('./routes/voice')); app.get('/', (req, res) => { res.send('Secure Chat Backend Running'); }); // GIF Routes const gifCategories = require('./data/gif_categories.json'); app.get('/api/gifs/categories', (req, res) => { res.json(gifCategories); }); app.get('/api/gifs/search', async (req, res) => { const { q, limit = 8 } = req.query; const apiKey = process.env.TENOR_API_KEY; if (!apiKey) { // Return mock data or error if no key console.warn('TENOR_API_KEY missing in .env'); // Return dummy response to prevent crash return res.json({ results: [] }); } try { const response = await axios.get(`https://tenor.googleapis.com/v2/search`, { params: { q, key: apiKey, limit } }); res.json(response.data); } catch (err) { console.error('Tenor API Error:', err.message); res.status(500).json({ error: 'Failed to fetch GIFs' }); } }); const redisClient = require('./redis'); const db = require('./db'); io.on('connection', (socket) => { console.log('User connected:', socket.id); socket.on('join_channel', async (data) => { // Handle both simple string (legacy) and object payload const channelId = typeof data === 'object' ? data.channelId : data; const userId = typeof data === 'object' ? data.userId : null; socket.join(channelId); console.log(`User ${socket.id} (ID: ${userId}) joined channel ${channelId}`); // Load recent messages with reactions try { const query = ` SELECT m.*, u.username, u.public_signing_key, ( SELECT json_object_agg(res.emoji, res.data) FROM ( SELECT r.emoji, json_build_object( 'count', COUNT(*)::int, 'me', BOOL_OR(r.user_id = $2) ) as data FROM message_reactions r WHERE r.message_id = m.id GROUP BY r.emoji ) res ) as reactions FROM messages m JOIN users u ON m.sender_id = u.id WHERE m.channel_id = $1 ORDER BY m.created_at DESC LIMIT 50 `; const result = await db.query(query, [channelId, userId]); socket.emit('recent_messages', result.rows.reverse()); } catch (err) { console.error('Error fetching messages:', err); } }); socket.on('send_message', async (data) => { // data: { channelId, senderId, ciphertext, nonce, signature, keyVersion } const { channelId, senderId, ciphertext, nonce, signature, keyVersion } = data; try { // Store in DB const result = await db.query( `INSERT INTO messages (channel_id, sender_id, ciphertext, nonce, signature, key_version) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id, created_at`, [channelId, senderId, ciphertext, nonce, signature, keyVersion] ); const message = { id: result.rows[0].id, created_at: result.rows[0].created_at, ...data }; // Get username and signing key for display/verification const userRes = await db.query('SELECT username, public_signing_key FROM users WHERE id = $1', [senderId]); if (userRes.rows.length > 0) { message.username = userRes.rows[0].username; message.public_signing_key = userRes.rows[0].public_signing_key; } // Broadcast to channel io.to(channelId).emit('new_message', message); } catch (err) { console.error('Error saving message:', err); } }); socket.on('typing_start', (data) => { io.to(data.channelId).emit('typing_start', data); }); socket.on('typing_stop', (data) => { io.to(data.channelId).emit('typing_stop', data); }); // ... Message handling ... // Voice State Handling // Stores: channelId -> Set(serializedUser: {userId, username}) // For simplicity: channelId -> [{userId, username}] // We need a global variable outside the connection loop, but for this file structure 'socket.on' is inside io.on // So we need to define it outside. // See defining it at top of file logic. // Reactions socket.on('add_reaction', async ({ channelId, messageId, userId, emoji }) => { try { if (!messageId || !userId || !emoji) return; const result = await db.query( `INSERT INTO message_reactions (message_id, user_id, emoji) VALUES ($1, $2, $3) ON CONFLICT (message_id, user_id, emoji) DO NOTHING`, [messageId, userId, emoji] ); if (result.rowCount > 0) { io.to(channelId).emit('reaction_added', { messageId, userId, emoji }); } } catch (err) { console.error('Error adding reaction:', err); } }); socket.on('remove_reaction', async ({ channelId, messageId, userId, emoji }) => { try { if (!messageId || !userId || !emoji) return; const result = await db.query( `DELETE FROM message_reactions WHERE message_id = $1 AND user_id = $2 AND emoji = $3`, [messageId, userId, emoji] ); if (result.rowCount > 0) { io.to(channelId).emit('reaction_removed', { messageId, userId, emoji }); } } catch (err) { console.error('Error removing reaction:', err); } }); socket.on('join_voice', (channelId) => { }); // Deprecated/Unused placeholder socket.on('voice_state_change', (data) => { // data: { channelId, userId, username, action: 'joined' | 'left' | 'state_update', isMuted, isDeafened } console.log(`Voice State Update: ${data.username} (${data.userId}) ${data.action} ${data.channelId}`); // Update Server State if (!global.voiceStates) global.voiceStates = {}; const currentUsers = global.voiceStates[data.channelId] || []; if (data.action === 'joined') { const existingUser = currentUsers.find(u => u.userId === data.userId); if (!existingUser) { currentUsers.push({ userId: data.userId, username: data.username, isMuted: data.isMuted || false, isDeafened: data.isDeafened || false }); } else { // Update existing on re-join (or just state sync) existingUser.isMuted = data.isMuted || false; existingUser.isDeafened = data.isDeafened || false; } global.voiceStates[data.channelId] = currentUsers; } else if (data.action === 'left') { const index = currentUsers.findIndex(u => u.userId === data.userId); if (index !== -1) { currentUsers.splice(index, 1); global.voiceStates[data.channelId] = currentUsers; } } else if (data.action === 'state_update') { const user = currentUsers.find(u => u.userId === data.userId); if (user) { if (data.isMuted !== undefined) user.isMuted = data.isMuted; if (data.isDeafened !== undefined) user.isDeafened = data.isDeafened; global.voiceStates[data.channelId] = currentUsers; } } io.emit('voice_state_update', data); }); socket.on('request_voice_state', () => { socket.emit('full_voice_state', global.voiceStates || {}); }); socket.on('disconnect', () => { console.log('User disconnected:', socket.id); // TODO: Auto-leave logic would require mapping socketId -> userId/channelId }); }); const PORT = process.env.PORT || 3000; server.listen(PORT, () => { console.log(`Server running on port ${PORT}`); });