You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

478 lines
14 KiB
JavaScript

'use strict';
const _ = require('lodash');
const validator = require('validator');
const winston = require('winston');
const db = require('../database');
const user = require('../user');
const groups = require('../groups');
const plugins = require('../plugins');
const privileges = require('../privileges');
const meta = require('../meta');
const io = require('../socket.io');
const cache = require('../cache');
const cacheCreate = require('../cacheCreate');
const roomUidCache = cacheCreate({
name: 'chat:room:uids',
max: 500,
ttl: 0,
});
const intFields = [
'roomId', 'timestamp', 'userCount',
];
module.exports = function (Messaging) {
Messaging.getRoomData = async (roomId, fields = []) => {
const data = await db.getObject(`chat:room:${roomId}`, fields);
if (!data) {
throw new Error('[[error:no-chat-room]]');
}
modifyRoomData([data], fields);
return data;
};
Messaging.getRoomsData = async (roomIds, fields = []) => {
const roomData = await db.getObjects(
roomIds.map(roomId => `chat:room:${roomId}`),
fields
);
modifyRoomData(roomData, fields);
return roomData;
};
function modifyRoomData(rooms, fields) {
rooms.forEach((data) => {
if (data) {
db.parseIntFields(data, intFields, fields);
data.roomName = validator.escape(String(data.roomName || ''));
data.public = parseInt(data.public, 10) === 1;
if (data.hasOwnProperty('groupChat')) {
data.groupChat = parseInt(data.groupChat, 10) === 1;
}
if (data.hasOwnProperty('groups') || !fields.length || fields.includes('groups')) {
try {
data.groups = JSON.parse(data.groups || '[]');
} catch (err) {
winston.error(err.stack);
data.groups = [];
}
}
}
});
}
Messaging.newRoom = async (uid, data) => {
// backwards compat. remove in 4.x
if (Array.isArray(data)) { // old usage second param used to be toUids
data = { uids: data };
}
const now = Date.now();
const roomId = await db.incrObjectField('global', 'nextChatRoomId');
const room = {
owner: uid,
roomId: roomId,
timestamp: now,
};
if (data.hasOwnProperty('roomName') && data.roomName) {
room.roomName = String(data.roomName);
}
if (Array.isArray(data.groups) && data.groups.length) {
room.groups = JSON.stringify(data.groups);
}
const isPublic = data.type === 'public';
if (isPublic) {
room.public = 1;
}
await Promise.all([
db.setObject(`chat:room:${roomId}`, room),
db.sortedSetAdd('chat:rooms', now, roomId),
db.sortedSetsAdd([
`chat:room:${roomId}:uids`,
`chat:room:${roomId}:uids:online`,
], now, uid),
]);
await Promise.all([
Messaging.addUsersToRoom(uid, data.uids, roomId),
isPublic ?
db.sortedSetAddBulk([
['chat:rooms:public', now, roomId],
['chat:rooms:public:order', roomId, roomId],
]) :
Messaging.addRoomToUsers(roomId, [uid].concat(data.uids), now),
]);
cache.del([
'chat:rooms:public:all',
'chat:rooms:public:order:all',
]);
if (!isPublic) {
// chat owner should also get the user-join system message
await Messaging.addSystemMessage('user-join', uid, roomId);
}
return roomId;
};
Messaging.deleteRooms = async (roomIds) => {
if (!roomIds) {
throw new Error('[[error:invalid-data]]');
}
if (!Array.isArray(roomIds)) {
roomIds = [roomIds];
}
await Promise.all(roomIds.map(async (roomId) => {
const uids = await db.getSortedSetMembers(`chat:room:${roomId}:uids`);
const keys = uids
.map(uid => `uid:${uid}:chat:rooms`)
.concat(uids.map(uid => `uid:${uid}:chat:rooms:unread`));
await db.sortedSetsRemove(keys, roomId);
}));
await Promise.all([
db.deleteAll([
...roomIds.map(id => `chat:room:${id}`),
...roomIds.map(id => `chat:room:${id}:uids`),
...roomIds.map(id => `chat:room:${id}:uids:online`),
]),
db.sortedSetRemove('chat:rooms', roomIds),
db.sortedSetRemove('chat:rooms:public', roomIds),
db.sortedSetRemove('chat:rooms:public:order', roomIds),
]);
cache.del([
'chat:rooms:public:all',
'chat:rooms:public:order:all',
]);
};
Messaging.isUserInRoom = async (uid, roomIds) => {
let single = false;
if (!Array.isArray(roomIds)) {
roomIds = [roomIds];
single = true;
}
const inRooms = await db.isMemberOfSortedSets(
roomIds.map(id => `chat:room:${id}:uids`),
uid
);
const data = await Promise.all(roomIds.map(async (roomId, idx) => {
const data = await plugins.hooks.fire('filter:messaging.isUserInRoom', {
uid: uid,
roomId: roomId,
inRoom: inRooms[idx],
});
return data.inRoom;
}));
return single ? data.pop() : data;
};
Messaging.isUsersInRoom = async (uids, roomId) => {
let single = false;
if (!Array.isArray(uids)) {
uids = [uids];
single = true;
}
const inRooms = await db.isSortedSetMembers(
`chat:room:${roomId}:uids`,
uids,
);
const data = await plugins.hooks.fire('filter:messaging.isUsersInRoom', {
uids: uids,
roomId: roomId,
inRooms: inRooms,
});
return single ? data.inRooms.pop() : data.inRooms;
};
Messaging.roomExists = async roomId => db.exists(`chat:room:${roomId}`);
Messaging.getUserCountInRoom = async roomId => db.sortedSetCard(`chat:room:${roomId}:uids`);
Messaging.isRoomOwner = async (uids, roomId) => {
const isArray = Array.isArray(uids);
if (!isArray) {
uids = [uids];
}
const owner = await db.getObjectField(`chat:room:${roomId}`, 'owner');
const isOwners = uids.map(uid => parseInt(uid, 10) === parseInt(owner, 10));
const result = await Promise.all(isOwners.map(async (isOwner, index) => {
const payload = await plugins.hooks.fire('filter:messaging.isRoomOwner', { uid: uids[index], roomId, owner, isOwner });
return payload.isOwner;
}));
return isArray ? result : result[0];
};
Messaging.isRoomPublic = async function (roomId) {
return parseInt(await db.getObjectField(`chat:room:${roomId}`, 'public'), 10) === 1;
};
Messaging.addUsersToRoom = async function (uid, uids, roomId) {
uids = _.uniq(uids);
const inRoom = await Messaging.isUserInRoom(uid, roomId);
const payload = await plugins.hooks.fire('filter:messaging.addUsersToRoom', { uid, uids, roomId, inRoom });
if (!payload.inRoom) {
throw new Error('[[error:cant-add-users-to-chat-room]]');
}
await addUidsToRoom(payload.uids, roomId);
};
async function addUidsToRoom(uids, roomId) {
const now = Date.now();
const timestamps = uids.map(() => now);
await Promise.all([
db.sortedSetAdd(`chat:room:${roomId}:uids`, timestamps, uids),
db.sortedSetAdd(`chat:room:${roomId}:uids:online`, timestamps, uids),
]);
await updateUserCount([roomId]);
await Promise.all(uids.map(uid => Messaging.addSystemMessage('user-join', uid, roomId)));
}
Messaging.removeUsersFromRoom = async (uid, uids, roomId) => {
const [isOwner, userCount] = await Promise.all([
Messaging.isRoomOwner(uid, roomId),
Messaging.getUserCountInRoom(roomId),
]);
const payload = await plugins.hooks.fire('filter:messaging.removeUsersFromRoom', { uid, uids, roomId, isOwner, userCount });
if (!payload.isOwner) {
throw new Error('[[error:cant-remove-users-from-chat-room]]');
}
await Messaging.leaveRoom(payload.uids, payload.roomId);
};
Messaging.isGroupChat = async function (roomId) {
return (await Messaging.getRoomData(roomId)).groupChat;
};
async function updateUserCount(roomIds) {
const userCounts = await db.sortedSetsCard(roomIds.map(roomId => `chat:room:${roomId}:uids`));
const countMap = _.zipObject(roomIds, userCounts);
const groupChats = roomIds.filter((roomId, index) => userCounts[index] > 2);
const privateChats = roomIds.filter((roomId, index) => userCounts[index] <= 2);
await db.setObjectBulk([
...groupChats.map(id => [`chat:room:${id}`, { groupChat: 1, userCount: countMap[id] }]),
...privateChats.map(id => [`chat:room:${id}`, { groupChat: 0, userCount: countMap[id] }]),
]);
roomUidCache.del(roomIds.map(id => `chat:room:${id}:users`));
}
Messaging.leaveRoom = async (uids, roomId) => {
const isInRoom = await Promise.all(uids.map(uid => Messaging.isUserInRoom(uid, roomId)));
uids = uids.filter((uid, index) => isInRoom[index]);
const keys = uids
.map(uid => `uid:${uid}:chat:rooms`)
.concat(uids.map(uid => `uid:${uid}:chat:rooms:unread`));
await Promise.all([
db.sortedSetRemove([
`chat:room:${roomId}:uids`,
`chat:room:${roomId}:uids:online`,
], uids),
db.sortedSetsRemove(keys, roomId),
]);
await Promise.all(uids.map(uid => Messaging.addSystemMessage('user-leave', uid, roomId)));
await updateOwner(roomId);
await updateUserCount([roomId]);
};
Messaging.leaveRooms = async (uid, roomIds) => {
const isInRoom = await Promise.all(roomIds.map(roomId => Messaging.isUserInRoom(uid, roomId)));
roomIds = roomIds.filter((roomId, index) => isInRoom[index]);
const roomKeys = [
...roomIds.map(roomId => `chat:room:${roomId}:uids`),
...roomIds.map(roomId => `chat:room:${roomId}:uids:online`),
];
await Promise.all([
db.sortedSetsRemove(roomKeys, uid),
db.sortedSetRemove([
`uid:${uid}:chat:rooms`,
`uid:${uid}:chat:rooms:unread`,
], roomIds),
]);
await Promise.all(
roomIds.map(roomId => updateOwner(roomId))
.concat(roomIds.map(roomId => Messaging.addSystemMessage('user-leave', uid, roomId)))
);
await updateUserCount(roomIds);
};
async function updateOwner(roomId) {
const uids = await db.getSortedSetRange(`chat:room:${roomId}:uids`, 0, 0);
const newOwner = uids[0] || 0;
await db.setObjectField(`chat:room:${roomId}`, 'owner', newOwner);
}
Messaging.getAllUidsInRoomFromSet = async function (set) {
const cacheKey = `${set}:all`;
let uids = roomUidCache.get(cacheKey);
if (uids !== undefined) {
return uids;
}
uids = await Messaging.getUidsInRoomFromSet(set, 0, -1);
roomUidCache.set(cacheKey, uids);
return uids;
};
Messaging.getUidsInRoomFromSet = async (set, start, stop, reverse = false) => db[
reverse ? 'getSortedSetRevRange' : 'getSortedSetRange'
](set, start, stop);
Messaging.getUidsInRoom = async (roomId, start, stop, reverse = false) => db[
reverse ? 'getSortedSetRevRange' : 'getSortedSetRange'
](`chat:room:${roomId}:uids`, start, stop);
Messaging.getUsersInRoom = async (roomId, start, stop, reverse = false) => {
const users = await Messaging.getUsersInRoomFromSet(
`chat:room:${roomId}:uids`, roomId, start, stop, reverse
);
return users;
};
Messaging.getUsersInRoomFromSet = async (set, roomId, start, stop, reverse = false) => {
const uids = await Messaging.getUidsInRoomFromSet(set, start, stop, reverse);
const [users, isOwners] = await Promise.all([
user.getUsersFields(uids, ['uid', 'username', 'picture', 'status']),
Messaging.isRoomOwner(uids, roomId),
]);
return users.map((user, index) => {
user.index = start + index;
user.isOwner = isOwners[index];
return user;
});
};
Messaging.renameRoom = async function (uid, roomId, newName) {
if (!newName) {
throw new Error('[[error:invalid-data]]');
}
newName = newName.trim();
if (newName.length > 75) {
throw new Error('[[error:chat-room-name-too-long]]');
}
const payload = await plugins.hooks.fire('filter:chat.renameRoom', {
uid: uid,
roomId: roomId,
newName: newName,
});
const isOwner = await Messaging.isRoomOwner(payload.uid, payload.roomId);
if (!isOwner) {
throw new Error('[[error:no-privileges]]');
}
await db.setObjectField(`chat:room:${payload.roomId}`, 'roomName', payload.newName);
await Messaging.addSystemMessage(`room-rename, ${payload.newName.replace(',', '&#44;')}`, payload.uid, payload.roomId);
plugins.hooks.fire('action:chat.renameRoom', {
roomId: payload.roomId,
newName: payload.newName,
});
};
Messaging.canReply = async (roomId, uid) => {
const inRoom = await db.isSortedSetMember(`chat:room:${roomId}:uids`, uid);
const data = await plugins.hooks.fire('filter:messaging.canReply', { uid: uid, roomId: roomId, inRoom: inRoom, canReply: inRoom });
return data.canReply;
};
Messaging.loadRoom = async (uid, data) => {
const { roomId } = data;
const [room, inRoom, canChat, isAdmin, isGlobalMod] = await Promise.all([
Messaging.getRoomData(roomId),
Messaging.isUserInRoom(uid, roomId),
privileges.global.can('chat', uid),
user.isAdministrator(uid),
user.isGlobalModerator(uid),
]);
if (!canChat) {
throw new Error('[[error:no-privileges]]');
}
if (!room ||
(!room.public && !inRoom) ||
(room.public && (
Array.isArray(room.groups) && room.groups.length && !(await groups.isMemberOfAny(uid, room.groups)))
)
) {
return null;
}
// add user to public room onload
if (room.public && !inRoom) {
await addUidsToRoom([uid], roomId);
room.userCount += 1;
} else if (inRoom) {
await db.sortedSetAdd(`chat:room:${roomId}:uids:online`, Date.now(), uid);
}
const [canReply, users, messages, settings, isOwner, onlineUids] = await Promise.all([
Messaging.canReply(roomId, uid),
Messaging.getUsersInRoomFromSet(`chat:room:${roomId}:uids:online`, roomId, 0, 39, true),
Messaging.getMessages({
callerUid: uid,
uid: data.uid || uid,
roomId: roomId,
isNew: false,
}),
user.getSettings(uid),
Messaging.isRoomOwner(uid, roomId),
io.getUidsInRoom(`chat_room_${roomId}`),
]);
users.forEach((user) => {
if (user) {
user.online = parseInt(user.uid, 10) === parseInt(uid, 10) || onlineUids.includes(String(user.uid));
}
});
room.messages = messages;
room.isOwner = isOwner;
room.users = users;
room.canReply = canReply;
room.groupChat = room.hasOwnProperty('groupChat') ? room.groupChat : users.length > 2;
room.icon = Messaging.getRoomIcon(room);
room.usernames = Messaging.generateUsernames(users, uid);
room.chatWithMessage = await Messaging.generateChatWithMessage(users, uid, settings.userLang);
room.maximumUsersInChatRoom = meta.config.maximumUsersInChatRoom;
room.maximumChatMessageLength = meta.config.maximumChatMessageLength;
room.showUserInput = !room.maximumUsersInChatRoom || room.maximumUsersInChatRoom > 2;
room.isAdminOrGlobalMod = isAdmin || isGlobalMod;
room.isAdmin = isAdmin;
const payload = await plugins.hooks.fire('filter:messaging.loadRoom', { uid, data, room });
return payload.room;
};
const globalUserGroups = [
'registered-users', 'verified-users', 'unverified-users', 'banned-users',
];
Messaging.getRoomIcon = function (roomData) {
const hasGroups = Array.isArray(roomData.groups) && roomData.groups.length;
return !hasGroups || roomData.groups.some(group => globalUserGroups.includes(group)) ? 'fa-hashtag' : 'fa-lock';
};
};