406 lines
18 KiB
JavaScript
406 lines
18 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, tenantMiddleware,
|
|
query, queryOne, queryResult, exec,
|
|
APP_TYPE, refreshTenantCache,
|
|
} = 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;
|
|
|
|
// ── Middleware ────────────────────────────────────────────────────────────────
|
|
app.use(cors());
|
|
app.use(express.json());
|
|
app.use(cookieParser());
|
|
app.use(tenantMiddleware);
|
|
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/schedule', require('./routes/schedule')(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);
|
|
|
|
// JAMA-HOST control plane — only registered when APP_TYPE=host
|
|
if (APP_TYPE === 'host') {
|
|
app.use('/api/host', require('./routes/host'));
|
|
console.log('[Server] JAMA-HOST control plane enabled at /api/host');
|
|
}
|
|
|
|
// ── 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 PWA manifest ──────────────────────────────────────────────────────
|
|
app.get('/manifest.json', async (req, res) => {
|
|
try {
|
|
const rows = await query(req.schema,
|
|
"SELECT key, value FROM settings WHERE key IN ('app_name','logo_url','pwa_icon_192','pwa_icon_512')"
|
|
);
|
|
const s = {};
|
|
for (const r of rows) s[r.key] = r.value;
|
|
|
|
const appName = s.app_name || process.env.APP_NAME || 'jama';
|
|
const icon192 = s.pwa_icon_192 || '/icons/icon-192.png';
|
|
const icon512 = s.pwa_icon_512 || '/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' },
|
|
];
|
|
|
|
res.setHeader('Content-Type', 'application/manifest+json');
|
|
res.setHeader('Cache-Control', 'no-cache');
|
|
res.json({
|
|
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,
|
|
});
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
});
|
|
|
|
// ── Frontend ──────────────────────────────────────────────────────────────────
|
|
app.use(express.static(path.join(__dirname, '../public')));
|
|
app.get('*', (req, res) => {
|
|
res.sendFile(path.join(__dirname, '../public/index.html'));
|
|
});
|
|
|
|
// ── Socket.io authentication ──────────────────────────────────────────────────
|
|
// Socket connections do not go through Express middleware, so we resolve
|
|
// schema from the handshake headers manually.
|
|
const { resolveSchema } = require('./models/db');
|
|
|
|
io.use(async (socket, next) => {
|
|
const token = socket.handshake.auth.token;
|
|
if (!token) return next(new Error('Unauthorized'));
|
|
try {
|
|
const decoded = jwt.verify(token, JWT_SECRET);
|
|
|
|
// Resolve tenant schema from socket handshake headers
|
|
const schema = resolveSchema({ headers: socket.handshake.headers });
|
|
|
|
const user = await queryOne(schema,
|
|
'SELECT id, name, display_name, avatar, role, status FROM users WHERE id = $1 AND status = $2',
|
|
[decoded.id, 'active']
|
|
);
|
|
if (!user) return next(new Error('User not found'));
|
|
|
|
const session = await queryOne(schema,
|
|
'SELECT * FROM active_sessions WHERE user_id = $1 AND token = $2',
|
|
[decoded.id, token]
|
|
);
|
|
if (!session) return next(new Error('Session displaced'));
|
|
|
|
socket.user = user;
|
|
socket.token = token;
|
|
socket.device = session.device;
|
|
socket.schema = schema;
|
|
next();
|
|
} catch (e) {
|
|
next(new Error('Invalid token'));
|
|
}
|
|
});
|
|
|
|
// ── Online user tracking ──────────────────────────────────────────────────────
|
|
// Key is `${schema}:${userId}` — user IDs are per-schema integers, so two tenants
|
|
// can have the same integer ID for completely different people. Without the schema
|
|
// prefix, tenant A's user 5 and tenant B's user 5 would collide: push notifications
|
|
// could be suppressed for the wrong user, and users:online would leak IDs across tenants.
|
|
const onlineUsers = new Map(); // `${schema}:${userId}` → Set<socketId>
|
|
|
|
io.on('connection', async (socket) => {
|
|
const userId = socket.user.id;
|
|
const schema = socket.schema;
|
|
// Prefix rooms with schema so tenant rooms never collide (IDs are per-schema only)
|
|
const R = (type, id) => `${schema}:${type}:${id}`;
|
|
// Scoped key for the onlineUsers map — must match schema for correct tenant isolation
|
|
const onlineKey = `${schema}:${userId}`;
|
|
|
|
if (!onlineUsers.has(onlineKey)) onlineUsers.set(onlineKey, new Set());
|
|
onlineUsers.get(onlineKey).add(socket.id);
|
|
|
|
// Update last_online
|
|
exec(schema, 'UPDATE users SET last_online = NOW() WHERE id = $1', [userId]).catch(() => {});
|
|
|
|
io.to(R('schema', 'all')).emit('user:online', { userId });
|
|
socket.join(R('user', userId));
|
|
socket.join(R('schema', 'all')); // tenant-scoped broadcast room for public group events
|
|
|
|
// Join socket rooms for all groups this user belongs to
|
|
try {
|
|
const publicGroups = await query(schema, "SELECT id FROM groups WHERE type = 'public'");
|
|
for (const g of publicGroups) socket.join(R('group', g.id));
|
|
|
|
const privateGroups = await query(schema,
|
|
'SELECT group_id FROM group_members WHERE user_id = $1', [userId]
|
|
);
|
|
for (const g of privateGroups) socket.join(R('group', g.group_id));
|
|
} catch (e) {
|
|
console.error('[Socket] Room join error:', e.message);
|
|
}
|
|
|
|
socket.on('group:join-room', ({ groupId }) => socket.join(R('group', groupId)));
|
|
socket.on('group:leave-room', ({ groupId }) => socket.leave(R('group', groupId)));
|
|
|
|
// ── New message ─────────────────────────────────────────────────────────────
|
|
socket.on('message:send', async (data) => {
|
|
const { groupId, content, replyToId, imageUrl, linkPreview } = data;
|
|
try {
|
|
const group = await queryOne(schema, 'SELECT * FROM groups WHERE id = $1', [groupId]);
|
|
if (!group) return;
|
|
if (group.is_readonly && socket.user.role !== 'admin') return;
|
|
|
|
if (group.type === 'private') {
|
|
const member = await queryOne(schema,
|
|
'SELECT id FROM group_members WHERE group_id = $1 AND user_id = $2',
|
|
[groupId, userId]
|
|
);
|
|
if (!member) return;
|
|
}
|
|
|
|
const mr = await queryResult(schema, `
|
|
INSERT INTO messages (group_id, user_id, content, image_url, type, reply_to_id, link_preview)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING id
|
|
`, [
|
|
groupId, userId,
|
|
content || null,
|
|
imageUrl || null,
|
|
imageUrl ? 'image' : 'text',
|
|
replyToId || null,
|
|
linkPreview ? JSON.stringify(linkPreview) : null,
|
|
]);
|
|
const msgId = mr.rows[0].id;
|
|
|
|
const message = await queryOne(schema, `
|
|
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 = $1
|
|
`, [msgId]);
|
|
|
|
message.reactions = [];
|
|
io.to(R('group', groupId)).emit('message:new', message);
|
|
|
|
// Push notifications for private groups
|
|
if (group.type === 'private') {
|
|
const members = await query(schema,
|
|
'SELECT user_id FROM group_members WHERE group_id = $1', [groupId]
|
|
);
|
|
const senderName = socket.user.display_name || socket.user.name || 'Someone';
|
|
for (const m of members) {
|
|
if (m.user_id === userId) continue;
|
|
const memberKey = `${schema}:${m.user_id}`;
|
|
if (!onlineUsers.has(memberKey)) {
|
|
sendPushToUser(schema, m.user_id, {
|
|
title: senderName,
|
|
body: (content || (imageUrl ? '📷 Image' : '')).slice(0, 100),
|
|
url: '/', groupId, badge: 1,
|
|
}).catch(() => {});
|
|
} else {
|
|
for (const sid of onlineUsers.get(memberKey)) {
|
|
io.to(sid).emit('notification:new', { type: 'private_message', groupId, fromUser: socket.user });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// @mention notifications
|
|
if (content) {
|
|
const mentionNames = [...new Set((content.match(/@\[([^\]]+)\]/g) || []).map(m => m.slice(2, -1)))];
|
|
for (const mentionName of mentionNames) {
|
|
const mentioned = await queryOne(schema,
|
|
"SELECT id FROM users WHERE status='active' AND (LOWER(display_name)=LOWER($1) OR LOWER(name)=LOWER($1))",
|
|
[mentionName]
|
|
);
|
|
if (!mentioned || mentioned.id === userId) continue;
|
|
|
|
const nr = await queryResult(schema,
|
|
"INSERT INTO notifications (user_id, type, message_id, group_id, from_user_id) VALUES ($1,'mention',$2,$3,$4) RETURNING id",
|
|
[mentioned.id, msgId, groupId, userId]
|
|
);
|
|
const notif = { id: nr.rows[0].id, type: 'mention', groupId, messageId: msgId, fromUser: socket.user };
|
|
const mentionedKey = `${schema}:${mentioned.id}`;
|
|
if (onlineUsers.has(mentionedKey)) {
|
|
for (const sid of onlineUsers.get(mentionedKey)) io.to(sid).emit('notification:new', notif);
|
|
}
|
|
const senderName = socket.user.display_name || socket.user.name || 'Someone';
|
|
sendPushToUser(schema, mentioned.id, {
|
|
title: `${senderName} mentioned you`,
|
|
body: (content || '').replace(/@\[([^\]]+)\]/g, '@$1').slice(0, 100),
|
|
url: '/', badge: 1,
|
|
}).catch(() => {});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error('[Socket] message:send error:', e.message);
|
|
}
|
|
});
|
|
|
|
// ── Reaction toggle ─────────────────────────────────────────────────────────
|
|
socket.on('reaction:toggle', async ({ messageId, emoji }) => {
|
|
try {
|
|
const message = await queryOne(schema,
|
|
'SELECT m.*, g.id AS gid FROM messages m JOIN groups g ON m.group_id=g.id WHERE m.id=$1 AND m.is_deleted=FALSE',
|
|
[messageId]
|
|
);
|
|
if (!message) return;
|
|
|
|
const existing = await queryOne(schema,
|
|
'SELECT * FROM reactions WHERE message_id=$1 AND user_id=$2',
|
|
[messageId, userId]
|
|
);
|
|
|
|
if (existing) {
|
|
if (existing.emoji === emoji) {
|
|
await exec(schema, 'DELETE FROM reactions WHERE id=$1', [existing.id]);
|
|
} else {
|
|
await exec(schema, 'UPDATE reactions SET emoji=$1 WHERE id=$2', [emoji, existing.id]);
|
|
}
|
|
} else {
|
|
await exec(schema,
|
|
'INSERT INTO reactions (message_id, user_id, emoji) VALUES ($1,$2,$3)',
|
|
[messageId, userId, emoji]
|
|
);
|
|
}
|
|
|
|
const reactions = await query(schema, `
|
|
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=$1
|
|
`, [messageId]);
|
|
|
|
io.to(R('group', message.group_id)).emit('reaction:updated', { messageId, reactions });
|
|
} catch (e) {
|
|
console.error('[Socket] reaction:toggle error:', e.message);
|
|
}
|
|
});
|
|
|
|
// ── Message delete ──────────────────────────────────────────────────────────
|
|
socket.on('message:delete', async ({ messageId }) => {
|
|
try {
|
|
const message = await queryOne(schema, `
|
|
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=$1
|
|
`, [messageId]);
|
|
if (!message) return;
|
|
|
|
const isAdmin = socket.user.role === 'admin';
|
|
const isOwner = message.group_owner_id === userId;
|
|
const isAuthor = message.user_id === userId;
|
|
let canDelete = isAuthor || isOwner;
|
|
|
|
if (!canDelete && isAdmin) {
|
|
if (message.group_type === 'public') {
|
|
canDelete = true;
|
|
} else {
|
|
const membership = await queryOne(schema,
|
|
'SELECT id FROM group_members WHERE group_id=$1 AND user_id=$2',
|
|
[message.group_id, userId]
|
|
);
|
|
if (membership) canDelete = true;
|
|
}
|
|
}
|
|
if (!canDelete) return;
|
|
|
|
await exec(schema,
|
|
'UPDATE messages SET is_deleted=TRUE, content=NULL, image_url=NULL WHERE id=$1',
|
|
[messageId]
|
|
);
|
|
io.to(R('group', message.group_id)).emit('message:deleted', { messageId, groupId: message.group_id });
|
|
} catch (e) {
|
|
console.error('[Socket] message:delete error:', e.message);
|
|
}
|
|
});
|
|
|
|
// ── Typing indicators ───────────────────────────────────────────────────────
|
|
socket.on('typing:start', ({ groupId }) => {
|
|
socket.to(R('group', groupId)).emit('typing:start', { userId, groupId, user: socket.user });
|
|
});
|
|
socket.on('typing:stop', ({ groupId }) => {
|
|
socket.to(R('group', groupId)).emit('typing:stop', { userId, groupId });
|
|
});
|
|
|
|
socket.on('users:online', () => {
|
|
// Return only the user IDs for this tenant by filtering keys matching this schema prefix
|
|
const prefix = `${schema}:`;
|
|
const userIds = [...onlineUsers.keys()]
|
|
.filter(k => k.startsWith(prefix))
|
|
.map(k => parseInt(k.slice(prefix.length), 10));
|
|
socket.emit('users:online', { userIds });
|
|
});
|
|
|
|
// ── Disconnect ──────────────────────────────────────────────────────────────
|
|
socket.on('disconnect', () => {
|
|
if (onlineUsers.has(onlineKey)) {
|
|
onlineUsers.get(onlineKey).delete(socket.id);
|
|
if (onlineUsers.get(onlineKey).size === 0) {
|
|
onlineUsers.delete(onlineKey);
|
|
exec(schema, 'UPDATE users SET last_online=NOW() WHERE id=$1', [userId]).catch(() => {});
|
|
io.to(R('schema', 'all')).emit('user:offline', { userId });
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
initDb().then(async () => {
|
|
if (APP_TYPE === 'host') {
|
|
try {
|
|
const tenants = await query('public', "SELECT * FROM tenants WHERE status='active'");
|
|
refreshTenantCache(tenants);
|
|
console.log(`[Server] Loaded ${tenants.length} tenant(s) into domain cache`);
|
|
} catch (e) {
|
|
console.warn('[Server] Could not load tenant cache:', e.message);
|
|
}
|
|
}
|
|
server.listen(PORT, () => console.log(`[Server] jama listening on port ${PORT}`));
|
|
}).catch(err => {
|
|
console.error('[Server] DB init failed:', err);
|
|
process.exit(1);
|
|
});
|
|
|
|
module.exports = { io };
|