365 lines
14 KiB
JavaScript
365 lines
14 KiB
JavaScript
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();
|
|
// Ensure Support group exists and all admins are members
|
|
const supportGroupId = getOrCreateSupportGroup();
|
|
if (supportGroupId) {
|
|
const db = getDb();
|
|
const admins = db.prepare("SELECT id FROM users WHERE role = 'admin' AND status = 'active'").all();
|
|
const insert = db.prepare('INSERT OR IGNORE INTO group_members (group_id, user_id) VALUES (?, ?)');
|
|
for (const a of admins) insert.run(supportGroupId, a.id);
|
|
}
|
|
|
|
// 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')(io));
|
|
app.use('/api/users', require('./routes/users'));
|
|
app.use('/api/groups', require('./routes/groups')(io));
|
|
app.use('/api/messages', require('./routes/messages')(io));
|
|
app.use('/api/usergroups', require('./routes/usergroups')(io));
|
|
app.use('/api/settings', require('./routes/settings'));
|
|
app.use('/api/about', require('./routes/about'));
|
|
app.use('/api/help', require('./routes/help'));
|
|
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);
|
|
|
|
// Record last_online timestamp
|
|
getDb().prepare("UPDATE users SET last_online = datetime('now') WHERE id = ?").run(userId);
|
|
|
|
// 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: '/',
|
|
groupId,
|
|
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 — format is @[display name], look up user by display_name or name
|
|
if (content) {
|
|
const mentionNames = [...new Set((content.match(/@\[([^\]]+)\]/g) || []).map(m => m.slice(2, -1)))];
|
|
for (const mentionName of mentionNames) {
|
|
const mentionedUser = db.prepare(
|
|
"SELECT id FROM users WHERE status = 'active' AND (LOWER(display_name) = LOWER(?) OR LOWER(name) = LOWER(?))"
|
|
).get(mentionName, mentionName);
|
|
const matchId = mentionedUser?.id?.toString();
|
|
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(/@\[([^\]]+)\]/g, '@$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);
|
|
getDb().prepare("UPDATE users SET last_online = datetime('now') WHERE id = ?").run(userId);
|
|
io.emit('user:offline', { userId });
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
server.listen(PORT, () => {
|
|
console.log(`jama server running on port ${PORT}`);
|
|
});
|