const express = require('express'); const http = require('http'); const { Server } = require('socket.io'); const cookieParser = require('cookie-parser'); const cors = require('cors'); const path = require('path'); const jwt = require('jsonwebtoken'); const { initDb, seedAdmin, getOrCreateSupportGroup, getDb } = require('./models/db'); const { router: pushRouter, sendPushToUser } = require('./routes/push'); const { getLinkPreview } = require('./utils/linkPreview'); const app = express(); const server = http.createServer(app); const io = new Server(server, { cors: { origin: '*', methods: ['GET', 'POST'] } }); const JWT_SECRET = process.env.JWT_SECRET || 'changeme_super_secret'; const PORT = process.env.PORT || 3000; // Init DB initDb(); seedAdmin(); getOrCreateSupportGroup(); // Ensure Support group exists // Middleware app.use(cors()); app.use(express.json()); app.use(cookieParser()); app.use('/uploads', express.static('/app/uploads')); // API Routes app.use('/api/auth', require('./routes/auth')); app.use('/api/users', require('./routes/users')); app.use('/api/groups', require('./routes/groups')(io)); app.use('/api/messages', require('./routes/messages')); app.use('/api/settings', require('./routes/settings')); app.use('/api/about', require('./routes/about')); app.use('/api/push', pushRouter); // Link preview proxy app.get('/api/link-preview', async (req, res) => { const { url } = req.query; if (!url) return res.status(400).json({ error: 'URL required' }); const preview = await getLinkPreview(url); res.json({ preview }); }); // Health check app.get('/api/health', (req, res) => res.json({ ok: true })); // Dynamic manifest — must be before express.static so it takes precedence app.get('/manifest.json', (req, res) => { const db = getDb(); const rows = db.prepare("SELECT key, value FROM settings WHERE key IN ('app_name', 'logo_url', 'pwa_icon_192', 'pwa_icon_512')").all(); const s = {}; for (const r of rows) s[r.key] = r.value; const appName = s.app_name || process.env.APP_NAME || 'jama'; const pwa192 = s.pwa_icon_192 || ''; const pwa512 = s.pwa_icon_512 || ''; // Use uploaded+resized icons if they exist, else fall back to bundled PNGs. // Chrome requires explicit pixel sizes (not "any") to use icons for PWA shortcuts. const icon192 = pwa192 || '/icons/icon-192.png'; const icon512 = pwa512 || '/icons/icon-512.png'; const icons = [ { src: icon192, sizes: '192x192', type: 'image/png', purpose: 'any' }, { src: icon192, sizes: '192x192', type: 'image/png', purpose: 'maskable' }, { src: icon512, sizes: '512x512', type: 'image/png', purpose: 'any' }, { src: icon512, sizes: '512x512', type: 'image/png', purpose: 'maskable' }, ]; const manifest = { name: appName, short_name: appName.length > 12 ? appName.substring(0, 12) : appName, description: `${appName} - Team messaging`, start_url: '/', scope: '/', display: 'standalone', orientation: 'portrait-primary', background_color: '#ffffff', theme_color: '#1a73e8', icons, }; res.setHeader('Content-Type', 'application/manifest+json'); res.setHeader('Cache-Control', 'no-cache'); res.json(manifest); }); // Serve frontend app.use(express.static(path.join(__dirname, '../public'))); app.get('*', (req, res) => { res.sendFile(path.join(__dirname, '../public/index.html')); }); // Socket.io authentication io.use((socket, next) => { const token = socket.handshake.auth.token; if (!token) return next(new Error('Unauthorized')); try { const decoded = jwt.verify(token, JWT_SECRET); const db = getDb(); const user = db.prepare('SELECT id, name, display_name, avatar, role, status FROM users WHERE id = ? AND status = ?').get(decoded.id, 'active'); if (!user) return next(new Error('User not found')); // Per-device enforcement: token must match an active session row const session = db.prepare('SELECT * FROM active_sessions WHERE user_id = ? AND token = ?').get(decoded.id, token); if (!session) return next(new Error('Session displaced')); socket.user = user; socket.token = token; socket.device = session.device; next(); } catch (e) { next(new Error('Invalid token')); } }); // Track online users: userId -> Set of socketIds const onlineUsers = new Map(); io.on('connection', (socket) => { const userId = socket.user.id; if (!onlineUsers.has(userId)) onlineUsers.set(userId, new Set()); onlineUsers.get(userId).add(socket.id); // Broadcast online status io.emit('user:online', { userId }); // Join personal room for direct notifications socket.join(`user:${userId}`); // Join rooms for all user's groups const db = getDb(); const publicGroups = db.prepare("SELECT id FROM groups WHERE type = 'public'").all(); for (const g of publicGroups) socket.join(`group:${g.id}`); const privateGroups = db.prepare("SELECT group_id FROM group_members WHERE user_id = ?").all(userId); for (const g of privateGroups) socket.join(`group:${g.group_id}`); // When a new group is created and pushed to this socket, join its room socket.on('group:join-room', ({ groupId }) => { socket.join(`group:${groupId}`); }); // When a user leaves a group, remove them from the socket room socket.on('group:leave-room', ({ groupId }) => { socket.leave(`group:${groupId}`); }); // Handle new message socket.on('message:send', async (data) => { const { groupId, content, replyToId, imageUrl, linkPreview } = data; const db = getDb(); const group = db.prepare('SELECT * FROM groups WHERE id = ?').get(groupId); if (!group) return; if (group.is_readonly && socket.user.role !== 'admin') return; // Check access if (group.type === 'private') { const member = db.prepare('SELECT id FROM group_members WHERE group_id = ? AND user_id = ?').get(groupId, userId); if (!member) return; } const result = db.prepare(` INSERT INTO messages (group_id, user_id, content, image_url, type, reply_to_id, link_preview) VALUES (?, ?, ?, ?, ?, ?, ?) `).run(groupId, userId, content || null, imageUrl || null, imageUrl ? 'image' : 'text', replyToId || null, linkPreview ? JSON.stringify(linkPreview) : null); const message = db.prepare(` SELECT m.*, u.name as user_name, u.display_name as user_display_name, u.avatar as user_avatar, u.role as user_role, u.status as user_status, u.hide_admin_tag as user_hide_admin_tag, u.about_me as user_about_me, rm.content as reply_content, rm.image_url as reply_image_url, rm.is_deleted as reply_is_deleted, ru.name as reply_user_name, ru.display_name as reply_user_display_name FROM messages m JOIN users u ON m.user_id = u.id LEFT JOIN messages rm ON m.reply_to_id = rm.id LEFT JOIN users ru ON rm.user_id = ru.id WHERE m.id = ? `).get(result.lastInsertRowid); message.reactions = []; io.to(`group:${groupId}`).emit('message:new', message); // For private groups: push notify members who are offline // (reuse `group` already fetched above) if (group?.type === 'private') { const members = db.prepare('SELECT user_id FROM group_members WHERE group_id = ?').all(groupId); const senderName = socket.user?.display_name || socket.user?.name || 'Someone'; for (const m of members) { if (m.user_id === userId) continue; // don't notify sender if (!onlineUsers.has(m.user_id)) { // User is offline — send push sendPushToUser(m.user_id, { title: senderName, body: (content || (imageUrl ? '📷 Image' : '')).slice(0, 100), url: '/', badge: 1, }).catch(() => {}); } else { // User is online but not necessarily in this group — send socket notification const notif = { type: 'private_message', groupId, fromUser: socket.user }; for (const sid of onlineUsers.get(m.user_id)) { io.to(sid).emit('notification:new', notif); } } } } // Process @mentions if (content) { const mentions = content.match(/@\[([^\]]+)\]\((\d+)\)/g) || []; for (const mention of mentions) { const matchId = mention.match(/\((\d+)\)/)?.[1]; if (matchId && parseInt(matchId) !== userId) { const notifResult = db.prepare(` INSERT INTO notifications (user_id, type, message_id, group_id, from_user_id) VALUES (?, 'mention', ?, ?, ?) `).run(parseInt(matchId), result.lastInsertRowid, groupId, userId); // Notify mentioned user — socket if online, push if not const mentionedUserId = parseInt(matchId); const notif = { id: notifResult.lastInsertRowid, type: 'mention', groupId, messageId: result.lastInsertRowid, fromUser: socket.user, }; if (onlineUsers.has(mentionedUserId)) { for (const sid of onlineUsers.get(mentionedUserId)) { io.to(sid).emit('notification:new', notif); } } // Always send push (badge even when app is open) const senderName = socket.user?.display_name || socket.user?.name || 'Someone'; sendPushToUser(mentionedUserId, { title: `${senderName} mentioned you`, body: (content || '').replace(/@\[[^\]]+\]\(\d+\)/g, (m) => '@' + m.match(/\[([^\]]+)\]/)?.[1]).slice(0, 100), url: '/', badge: 1, }).catch(() => {}); } } } }); // Handle reaction — one reaction per user; same emoji toggles off, different emoji replaces socket.on('reaction:toggle', (data) => { const { messageId, emoji } = data; const db = getDb(); const message = db.prepare('SELECT m.*, g.id as gid FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ? AND m.is_deleted = 0').get(messageId); if (!message) return; // Find any existing reaction by this user on this message const existing = db.prepare('SELECT * FROM reactions WHERE message_id = ? AND user_id = ?').get(messageId, userId); if (existing) { if (existing.emoji === emoji) { // Same emoji — toggle off (remove) db.prepare('DELETE FROM reactions WHERE id = ?').run(existing.id); } else { // Different emoji — replace db.prepare('UPDATE reactions SET emoji = ? WHERE id = ?').run(emoji, existing.id); } } else { // No existing reaction — insert db.prepare('INSERT INTO reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(messageId, userId, emoji); } const reactions = db.prepare(` SELECT r.emoji, r.user_id, u.name as user_name FROM reactions r JOIN users u ON r.user_id = u.id WHERE r.message_id = ? `).all(messageId); io.to(`group:${message.group_id}`).emit('reaction:updated', { messageId, reactions }); }); // Handle message delete socket.on('message:delete', (data) => { const { messageId } = data; const db = getDb(); const message = db.prepare(` SELECT m.*, g.type as group_type, g.owner_id as group_owner_id, g.is_direct FROM messages m JOIN groups g ON m.group_id = g.id WHERE m.id = ? `).get(messageId); if (!message) return; const isAdmin = socket.user.role === 'admin'; const isOwner = message.group_owner_id === userId; const isAuthor = message.user_id === userId; // Rules: // 1. Author can always delete their own message // 2. Admin can delete in any public group or any group they're a member of // 3. Group owner can delete any message in their group // 4. In direct messages: author + owner rules apply (no blanket block) let canDelete = isAuthor || isOwner; if (!canDelete && isAdmin) { if (message.group_type === 'public') { canDelete = true; } else { // Admin can delete in private/direct groups they're a member of const membership = db.prepare('SELECT id FROM group_members WHERE group_id = ? AND user_id = ?').get(message.group_id, userId); if (membership) canDelete = true; } } if (!canDelete) return; db.prepare("UPDATE messages SET is_deleted = 1, content = null, image_url = null WHERE id = ?").run(messageId); io.to(`group:${message.group_id}`).emit('message:deleted', { messageId, groupId: message.group_id }); }); // Handle typing socket.on('typing:start', ({ groupId }) => { socket.to(`group:${groupId}`).emit('typing:start', { userId, groupId, user: socket.user }); }); socket.on('typing:stop', ({ groupId }) => { socket.to(`group:${groupId}`).emit('typing:stop', { userId, groupId }); }); // Get online users socket.on('users:online', () => { socket.emit('users:online', { userIds: [...onlineUsers.keys()] }); }); // Handle disconnect socket.on('disconnect', () => { if (onlineUsers.has(userId)) { onlineUsers.get(userId).delete(socket.id); if (onlineUsers.get(userId).size === 0) { onlineUsers.delete(userId); io.emit('user:offline', { userId }); } } }); }); server.listen(PORT, () => { console.log(`jama server running on port ${PORT}`); });