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 user avatars by mongo_id router.get('/avatar/:mongoId', async (req, res) => { try { const { mongoId } = req.params; console.log(`[Avatar Debug] Looking up avatar for user mongo_id: ${mongoId}`); // First try to find avatar by user's avataretag const userResult = await global.pool.query(` SELECT avataretag, username FROM users WHERE mongo_id = $1 `, [mongoId]); let avatarPath = null; if (userResult.rows.length > 0) { const username = userResult.rows[0].username; const avataretag = userResult.rows[0].avataretag; // Try method 1: Look up by avataretag -> etag (for users with avataretag set) if (avataretag) { console.log(`[Avatar Debug] Found user ${username} with avataretag: ${avataretag}`); const avatarResult = await global.pool.query(` SELECT url, path FROM avatars WHERE etag = $1 `, [avataretag]); if (avatarResult.rows.length > 0) { const dbPath = avatarResult.rows[0].path || avatarResult.rows[0].url; console.log(`[Avatar Debug] Found avatar record with path: ${dbPath}`); if (dbPath) { const pathParts = dbPath.split('/'); for (let i = 0; i < pathParts.length; i++) { if (pathParts[i].includes('AmazonS3:Avatars') && i + 1 < pathParts.length) { const avatarMongoId = pathParts[i + 1]; avatarPath = path.join(__dirname, 'db-convert/db/files/avatars', avatarMongoId); console.log(`[Avatar Debug] Extracted avatar mongo_id: ${avatarMongoId}, full path: ${avatarPath}`); break; } } } } else { console.log(`[Avatar Debug] No avatar record found for etag: ${avataretag}`); } } // Try method 2: Look up by userid directly (for users without avataretag) if (!avatarPath) { console.log(`[Avatar Debug] Trying direct userid lookup for user ${username} (${mongoId})`); const avatarResult = await global.pool.query(` SELECT url, path FROM avatars WHERE userid = $1 `, [mongoId]); if (avatarResult.rows.length > 0) { const dbPath = avatarResult.rows[0].path || avatarResult.rows[0].url; console.log(`[Avatar Debug] Found avatar record by userid with path: ${dbPath}`); if (dbPath) { const pathParts = dbPath.split('/'); for (let i = 0; i < pathParts.length; i++) { if (pathParts[i].includes('AmazonS3:Avatars') && i + 1 < pathParts.length) { const avatarMongoId = pathParts[i + 1]; avatarPath = path.join(__dirname, 'db-convert/db/files/avatars', avatarMongoId); console.log(`[Avatar Debug] Extracted avatar mongo_id: ${avatarMongoId}, full path: ${avatarPath}`); break; } } } } else { console.log(`[Avatar Debug] No avatar record found for userid: ${mongoId}`); } } } else { console.log(`[Avatar Debug] No user found for mongo_id: ${mongoId}`); } // Fallback: try direct lookup by user mongo_id if (!avatarPath) { avatarPath = path.join(__dirname, 'db-convert/db/files/avatars', mongoId); console.log(`[Avatar Debug] Using fallback path: ${avatarPath}`); } // Set proper content type for images res.set('Content-Type', 'image/jpeg'); // Most avatars are likely JPEG // Send the file res.sendFile(avatarPath, (err) => { if (err) { // If avatar doesn't exist, send a default 404 or generate initials console.log(`[Avatar Debug] Avatar file not found at path: ${avatarPath}, error:`, err.message); if (!res.headersSent) { res.status(404).json({ error: 'Avatar not found' }); } } else { console.log(`[Avatar Debug] Successfully served avatar from: ${avatarPath}`); } }); } catch (error) { console.error('Error serving avatar:', error); res.status(500).json({ error: 'Server error' }); } }); // Serve avatars statically as fallback router.use('/files/avatars', express.static(path.join(__dirname, 'db-convert/db/files/avatars'))); // Get all users for the "view as" dropdown (active and inactive) router.get('/users', async (req, res) => { try { const result = await global.pool.query(` SELECT id, username, name, type, active, status, lastlogin, statustext, utcoffset, statusconnection, mongo_id, avataretag FROM users WHERE type = 'user' ORDER BY active DESC, -- Active users first 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 // Include archived and closed rooms but sort them at the bottom 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, r.teamid, r.archived, s.open, -- 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 ORDER BY s.open DESC NULLS LAST, -- Open rooms first r.archived NULLS FIRST, -- Non-archived first (nulls treated as false) r.lm DESC NULLS LAST LIMIT 50 `, [currentUserMongoId]); // Enhance rooms with participant information for direct messages const enhancedRooms = await Promise.all(result.rows.map(async (room) => { if (room.type === 'd' && room.uids) { // Get participant info (excluding current user) for direct messages const participantResult = await global.pool.query(` SELECT u.username, u.name, u.mongo_id, u.avataretag FROM users u WHERE u.mongo_id = ANY($1::text[]) AND u.mongo_id != $2 `, [room.uids, currentUserMongoId]); room.participants = participantResult.rows; } return room; })); res.json({ status: 'success', rooms: enhancedRooms }); } 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; const { userId } = req.query; // Accept current user ID as query parameter 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, r.teamid 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]; // For direct messages, get the proper display name based on current user if (room.type === 'd' && room.uids && userId) { // Get current user's mongo_id const userResult = await global.pool.query(` SELECT mongo_id FROM users WHERE id = $1 `, [userId]); if (userResult.rows.length > 0) { const currentUserMongoId = userResult.rows[0].mongo_id; // Get display name from subscription table for this user // Use room mongo_id to match with subscription.rid const roomMongoResult = await global.pool.query(` SELECT mongo_id FROM room WHERE id = $1 `, [roomId]); if (roomMongoResult.rows.length > 0) { const roomMongoId = roomMongoResult.rows[0].mongo_id; const subscriptionResult = await global.pool.query(` SELECT fname, name FROM subscription WHERE rid = $1 AND u->>'_id' = $2 `, [roomMongoId, currentUserMongoId]); if (subscriptionResult.rows.length > 0) { const sub = subscriptionResult.rows[0]; room.display_name = sub.fname || sub.name || 'Unknown User'; } } } // Get all participants for additional info const participantResult = await global.pool.query(` SELECT username, name FROM users WHERE mongo_id = ANY($1::text[]) `, [room.uids]); room.participants = participantResult.rows; } else { // For channels/groups, use room's fname or name room.display_name = room.fname || room.name || 'Unnamed Room'; } 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;