v0.11.9 fixed tenant isolation bug

This commit is contained in:
2026-03-21 12:53:00 -04:00
parent e0e800012c
commit c5a8d728d2
8 changed files with 85 additions and 60 deletions

View File

@@ -137,36 +137,45 @@ io.use(async (socket, next) => {
});
// ── Online user tracking ──────────────────────────────────────────────────────
const onlineUsers = new Map(); // userId → Set<socketId>
// 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(userId)) onlineUsers.set(userId, new Set());
onlineUsers.get(userId).add(socket.id);
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.emit('user:online', { userId });
socket.join(`user:${userId}`);
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(`group:${g.id}`);
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(`group:${g.group_id}`);
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(`group:${groupId}`));
socket.on('group:leave-room', ({ groupId }) => socket.leave(`group:${groupId}`));
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) => {
@@ -213,7 +222,7 @@ io.on('connection', async (socket) => {
`, [msgId]);
message.reactions = [];
io.to(`group:${groupId}`).emit('message:new', message);
io.to(R('group', groupId)).emit('message:new', message);
// Push notifications for private groups
if (group.type === 'private') {
@@ -223,14 +232,15 @@ io.on('connection', async (socket) => {
const senderName = socket.user.display_name || socket.user.name || 'Someone';
for (const m of members) {
if (m.user_id === userId) continue;
if (!onlineUsers.has(m.user_id)) {
sendPushToUser(m.user_id, {
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(m.user_id)) {
for (const sid of onlineUsers.get(memberKey)) {
io.to(sid).emit('notification:new', { type: 'private_message', groupId, fromUser: socket.user });
}
}
@@ -252,11 +262,12 @@ io.on('connection', async (socket) => {
[mentioned.id, msgId, groupId, userId]
);
const notif = { id: nr.rows[0].id, type: 'mention', groupId, messageId: msgId, fromUser: socket.user };
if (onlineUsers.has(mentioned.id)) {
for (const sid of onlineUsers.get(mentioned.id)) io.to(sid).emit('notification:new', notif);
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(mentioned.id, {
sendPushToUser(schema, mentioned.id, {
title: `${senderName} mentioned you`,
body: (content || '').replace(/@\[([^\]]+)\]/g, '@$1').slice(0, 100),
url: '/', badge: 1,
@@ -301,7 +312,7 @@ io.on('connection', async (socket) => {
WHERE r.message_id=$1
`, [messageId]);
io.to(`group:${message.group_id}`).emit('reaction:updated', { messageId, reactions });
io.to(R('group', message.group_id)).emit('reaction:updated', { messageId, reactions });
} catch (e) {
console.error('[Socket] reaction:toggle error:', e.message);
}
@@ -338,7 +349,7 @@ io.on('connection', async (socket) => {
'UPDATE messages SET is_deleted=TRUE, content=NULL, image_url=NULL WHERE id=$1',
[messageId]
);
io.to(`group:${message.group_id}`).emit('message:deleted', { messageId, groupId: message.group_id });
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);
}
@@ -346,24 +357,29 @@ io.on('connection', async (socket) => {
// ── Typing indicators ───────────────────────────────────────────────────────
socket.on('typing:start', ({ groupId }) => {
socket.to(`group:${groupId}`).emit('typing:start', { userId, groupId, user: socket.user });
socket.to(R('group', groupId)).emit('typing:start', { userId, groupId, user: socket.user });
});
socket.on('typing:stop', ({ groupId }) => {
socket.to(`group:${groupId}`).emit('typing:stop', { userId, groupId });
socket.to(R('group', groupId)).emit('typing:stop', { userId, groupId });
});
socket.on('users:online', () => {
socket.emit('users:online', { userIds: [...onlineUsers.keys()] });
// 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(userId)) {
onlineUsers.get(userId).delete(socket.id);
if (onlineUsers.get(userId).size === 0) {
onlineUsers.delete(userId);
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.emit('user:offline', { userId });
io.to(R('schema', 'all')).emit('user:offline', { userId });
}
}
});