const express = require('express'); const path = require('path'); const router = express.Router(); // Serve uploaded files with proper mapping from database paths to actual file locations router.get('/files/uploads/*', async (req, res) => { try { // Extract the path from the URL (everything after /files/uploads/) const requestPath = req.params[0]; // The URL path will be like: ufs/AmazonS3:Uploads/274Mf9CyHNG72oF86/filename.jpg // We need to extract the mongo_id (274Mf9CyHNG72oF86) from this path const pathParts = requestPath.split('/'); let mongoId = null; // Find the mongo_id in the path structure for (let i = 0; i < pathParts.length; i++) { if (pathParts[i].includes('AmazonS3:Uploads') && i + 1 < pathParts.length) { mongoId = pathParts[i + 1]; break; } // Sometimes the mongo_id might be the last part of ufs/AmazonS3:Uploads/mongoId if (pathParts[i] === 'AmazonS3:Uploads' && i + 1 < pathParts.length) { mongoId = pathParts[i + 1]; break; } } if (!mongoId) { // Try to get mongo_id from database by matching the full path const result = await global.pool.query(` SELECT mongo_id, name, type FROM uploads WHERE path = $1 OR url = $1 LIMIT 1 `, [`/ufs/AmazonS3:Uploads/${requestPath}`, `/ufs/AmazonS3:Uploads/${requestPath}`]); if (result.rows.length > 0) { mongoId = result.rows[0].mongo_id; } } if (!mongoId) { return res.status(404).json({ error: 'File not found' }); } // The actual file is stored with just the mongo_id as filename const filePath = path.join(__dirname, 'db-convert/db/files/uploads', mongoId); // Get file info from database for proper content-type const fileInfo = await global.pool.query(` SELECT name, type FROM uploads WHERE mongo_id = $1 LIMIT 1 `, [mongoId]); if (fileInfo.rows.length === 0) { return res.status(404).json({ error: 'File metadata not found' }); } const { name, type } = fileInfo.rows[0]; // Set proper content type if (type) { res.set('Content-Type', type); } // Set content disposition with original filename if (name) { res.set('Content-Disposition', `inline; filename="${name}"`); } // Send the file res.sendFile(filePath, (err) => { if (err) { console.error('Error serving file:', err); if (!res.headersSent) { res.status(404).json({ error: 'File not found on disk' }); } } }); } catch (error) { console.error('Error serving upload:', error); res.status(500).json({ error: 'Server error' }); } }); // Also serve files directly by mongo_id for simpler access router.get('/files/by-id/:mongoId', async (req, res) => { try { const { mongoId } = req.params; // Get file info from database const fileInfo = await global.pool.query(` SELECT name, type FROM uploads WHERE mongo_id = $1 LIMIT 1 `, [mongoId]); if (fileInfo.rows.length === 0) { return res.status(404).json({ error: 'File not found' }); } const { name, type } = fileInfo.rows[0]; const filePath = path.join(__dirname, 'db-convert/db/files/uploads', mongoId); // Set proper content type and filename if (type) { res.set('Content-Type', type); } if (name) { res.set('Content-Disposition', `inline; filename="${name}"`); } // Send the file res.sendFile(filePath, (err) => { if (err) { console.error('Error serving file:', err); if (!res.headersSent) { res.status(404).json({ error: 'File not found on disk' }); } } }); } catch (error) { console.error('Error serving upload by ID:', error); res.status(500).json({ error: 'Server error' }); } }); // Serve avatars router.use('/files/avatars', express.static(path.join(__dirname, 'db-convert/db/files/avatars'))); // Get all active users for the "view as" dropdown router.get('/users', async (req, res) => { try { const result = await global.pool.query(` SELECT id, username, name, type, active, status, lastlogin, statustext, utcoffset, statusconnection FROM users WHERE active = true AND type = 'user' ORDER BY CASE WHEN status = 'online' THEN 1 WHEN status = 'away' THEN 2 WHEN status = 'busy' THEN 3 ELSE 4 END, name ASC `); res.json({ status: 'success', users: result.rows }); } catch (error) { console.error('Error fetching users:', error); res.status(500).json({ status: 'error', error: 'Failed to fetch users', details: error.message }); } }); // Get rooms for a specific user with enhanced room names for direct messages router.get('/users/:userId/rooms', async (req, res) => { const { userId } = req.params; try { // Get the current user's mongo_id for filtering const userResult = await global.pool.query(` SELECT mongo_id, username FROM users WHERE id = $1 `, [userId]); if (userResult.rows.length === 0) { return res.status(404).json({ status: 'error', error: 'User not found' }); } const currentUserMongoId = userResult.rows[0].mongo_id; const currentUsername = userResult.rows[0].username; // Get rooms where the user is a member with proper naming from subscription table const result = await global.pool.query(` SELECT DISTINCT r.id, r.mongo_id as room_mongo_id, r.name, r.fname, r.t as type, r.msgs, r.lm as last_message_date, r.usernames, r.uids, r.userscount, r.description, -- Use the subscription's name for direct messages (excludes current user) -- For channels/groups, use room's fname or name CASE WHEN r.t = 'd' THEN COALESCE(s.fname, s.name, 'Unknown User') ELSE COALESCE(r.fname, r.name, 'Unnamed Room') END as display_name FROM room r JOIN subscription s ON s.rid = r.mongo_id WHERE s.u->>'_id' = $1 AND r.archived IS NOT TRUE AND s.open = true ORDER BY r.lm DESC NULLS LAST LIMIT 50 `, [currentUserMongoId]); res.json({ status: 'success', rooms: result.rows }); } catch (error) { console.error('Error fetching user rooms:', error); res.status(500).json({ status: 'error', error: 'Failed to fetch user rooms', details: error.message }); } }); // Get room details including participants router.get('/rooms/:roomId', async (req, res) => { const { roomId } = req.params; try { const result = await global.pool.query(` SELECT r.id, r.name, r.fname, r.t as type, r.msgs, r.description, r.lm as last_message_date, r.usernames, r.uids, r.userscount FROM room r WHERE r.id = $1 `, [roomId]); if (result.rows.length === 0) { return res.status(404).json({ status: 'error', error: 'Room not found' }); } const room = result.rows[0]; // Get room participants for direct messages if (room.type === 'd' && room.uids) { const participantResult = await global.pool.query(` SELECT username, name FROM users WHERE mongo_id = ANY($1::text[]) `, [room.uids]); room.participants = participantResult.rows; } res.json({ status: 'success', room: room }); } catch (error) { console.error('Error fetching room details:', error); res.status(500).json({ status: 'error', error: 'Failed to fetch room details', details: error.message }); } }); // Get messages for a specific room (fast, without attachments) router.get('/rooms/:roomId/messages', async (req, res) => { const { roomId } = req.params; const { limit = 50, offset = 0, before } = req.query; try { // Fast query - just get messages without expensive attachment joins let query = ` SELECT m.id, m.msg, m.ts, m.u, m._updatedat, m.urls, m.mentions, m.md FROM message m JOIN room r ON m.rid = r.mongo_id WHERE r.id = $1 `; const params = [roomId]; if (before) { query += ` AND m.ts < $${params.length + 1}`; params.push(before); } query += ` ORDER BY m.ts DESC LIMIT $${params.length + 1} OFFSET $${params.length + 2}`; params.push(limit, offset); const result = await global.pool.query(query, params); // Add empty attachments array for now - attachments will be loaded separately if needed const messages = result.rows.map(msg => ({ ...msg, attachments: [] })); res.json({ status: 'success', messages: messages.reverse() // Reverse to show oldest first }); } catch (error) { console.error('Error fetching messages:', error); res.status(500).json({ status: 'error', error: 'Failed to fetch messages', details: error.message }); } }); // Get attachments for specific messages (called separately for performance) router.post('/messages/attachments', async (req, res) => { const { messageIds } = req.body; if (!messageIds || !Array.isArray(messageIds) || messageIds.length === 0) { return res.json({ status: 'success', attachments: {} }); } try { // Get room mongo_id from first message to limit search scope const roomQuery = await global.pool.query(` SELECT r.mongo_id as room_mongo_id FROM message m JOIN room r ON m.rid = r.mongo_id WHERE m.id = $1 LIMIT 1 `, [messageIds[0]]); if (roomQuery.rows.length === 0) { return res.json({ status: 'success', attachments: {} }); } const roomMongoId = roomQuery.rows[0].room_mongo_id; // Get messages and their upload timestamps const messagesQuery = await global.pool.query(` SELECT m.id, m.ts, m.u->>'_id' as user_id FROM message m WHERE m.id = ANY($1::int[]) `, [messageIds]); if (messagesQuery.rows.length === 0) { return res.json({ status: 'success', attachments: {} }); } // Build a map of user_id -> array of message timestamps for efficient lookup const userTimeMap = {}; const messageMap = {}; messagesQuery.rows.forEach(msg => { if (!userTimeMap[msg.user_id]) { userTimeMap[msg.user_id] = []; } userTimeMap[msg.user_id].push(msg.ts); messageMap[msg.id] = { ts: msg.ts, user_id: msg.user_id }; }); // Get attachments for this room and these users const uploadsQuery = await global.pool.query(` SELECT mongo_id, name, size, type, url, path, typegroup, identify, userid, uploadedat FROM uploads WHERE rid = $1 AND userid = ANY($2::text[]) ORDER BY uploadedat `, [roomMongoId, Object.keys(userTimeMap)]); // Match attachments to messages based on timestamp proximity (within 5 minutes) const attachmentsByMessage = {}; uploadsQuery.rows.forEach(upload => { const uploadTime = new Date(upload.uploadedat).getTime(); // Find the closest message from this user within 5 minutes let closestMessageId = null; let closestTimeDiff = Infinity; Object.entries(messageMap).forEach(([msgId, msgData]) => { if (msgData.user_id === upload.userid) { const msgTime = new Date(msgData.ts).getTime(); const timeDiff = Math.abs(uploadTime - msgTime); if (timeDiff < 300000 && timeDiff < closestTimeDiff) { // 5 minutes = 300000ms closestMessageId = msgId; closestTimeDiff = timeDiff; } } }); if (closestMessageId) { if (!attachmentsByMessage[closestMessageId]) { attachmentsByMessage[closestMessageId] = []; } attachmentsByMessage[closestMessageId].push({ id: upload.id, mongo_id: upload.mongo_id, name: upload.name, size: upload.size, type: upload.type, url: upload.url, path: upload.path, typegroup: upload.typegroup, identify: upload.identify }); } }); res.json({ status: 'success', attachments: attachmentsByMessage }); } catch (error) { console.error('Error fetching message attachments:', error); res.status(500).json({ status: 'error', error: 'Failed to fetch attachments', details: error.message }); } }); // Search messages in accessible rooms for a user router.get('/users/:userId/search', async (req, res) => { const { userId } = req.params; const { q, limit = 20 } = req.query; if (!q || q.length < 2) { return res.status(400).json({ status: 'error', error: 'Search query must be at least 2 characters' }); } try { const userResult = await global.pool.query(` SELECT mongo_id FROM users WHERE id = $1 `, [userId]); if (userResult.rows.length === 0) { return res.status(404).json({ status: 'error', error: 'User not found' }); } const currentUserMongoId = userResult.rows[0].mongo_id; const result = await global.pool.query(` SELECT m.id, m.msg, m.ts, m.u, r.id as room_id, r.name as room_name, r.fname as room_fname, r.t as room_type FROM message m JOIN room r ON m.rid = r.mongo_id JOIN subscription s ON s.rid = r.mongo_id AND s.u->>'_id' = $1 WHERE m.msg ILIKE $2 AND r.archived IS NOT TRUE ORDER BY m.ts DESC LIMIT $3 `, [currentUserMongoId, `%${q}%`, limit]); res.json({ status: 'success', results: result.rows }); } catch (error) { console.error('Error searching messages:', error); res.status(500).json({ status: 'error', error: 'Failed to search messages', details: error.message }); } }); module.exports = router;