// chat.js // Copyright (C) 2022 DTP Technologies, LLC // License: Apache-2.0 'use strict'; const Redis = require('ioredis'); const mongoose = require('mongoose'); const ChatRoom = mongoose.model('ChatRoom'); const ChatRoomInvite = mongoose.model('ChatRoomInvite'); const ChatMessage = mongoose.model('ChatMessage'); const EmojiReaction = mongoose.model('EmojiReaction'); const ioEmitter = require('socket.io-emitter'); const marked = require('marked'); const hljs = require('highlight.js'); const striptags = require('striptags'); const unzalgo = require('unzalgo'); const stringSimilarity = require('string-similarity'); const { SiteService, SiteError } = require('../../lib/site-lib'); class ChatService extends SiteService { constructor (dtp) { super(dtp, module.exports); const USER_SELECT = '_id username username_lc displayName picture'; this.populateChatMessage = [ { path: 'channel', }, { path: 'author', select: USER_SELECT, }, { path: 'stickers', }, ]; this.populateChatRoom = [ { path: 'owner', select: USER_SELECT, }, { path: 'members.member', select: USER_SELECT, }, ]; this.populateChatRoomInvite = [ { path: 'room', populate: [ { path: 'owner', select: USER_SELECT, }, { path: 'members.member', select: USER_SELECT, }, ], }, ]; } async start ( ) { this.markedRenderer = new marked.Renderer(); this.markedRenderer.link = (href, title, text) => { return text; }; this.markedRenderer.image = (href, title, text) => { return text; }; this.markedConfig = { renderer: this.markedRenderer, highlight: function(code, lang) { const language = hljs.getLanguage(lang) ? lang : 'plaintext'; return hljs.highlight(code, { language }).value; }, langPrefix: 'hljs language-', // highlight.js css expects a top-level 'hljs' class. pedantic: false, gfm: true, breaks: false, sanitize: false, smartLists: true, smartypants: false, xhtml: false, }; /* * The chat message rate limiter uses Redis to provide accurate atomic * accounting regardless of which host is currently hosting a user's chat * connection and session. */ const { RateLimiterRedis } = require('rate-limiter-flexible'); const rateLimiterRedisClient = new Redis({ host: process.env.REDIS_HOST, port: process.env.REDIS_PORT, password: process.env.REDIS_PASSWORD, keyPrefix: process.env.REDIS_KEY_PREFIX || 'dtp', lazyConnect: false, enableOfflineQueue: false, }); this.chatMessageLimiter = new RateLimiterRedis({ storeClient: rateLimiterRedisClient, points: 20, duration: 60, blockDuration: 60 * 3, execEvenly: false, keyPrefix: 'rl:chatmsg', }); this.reactionLimiter = new RateLimiterRedis({ storeClient: rateLimiterRedisClient, points: 60, duration: 60, blockDuration: 60 * 3, execEvenly: false, keyPrefix: 'rl:react', }); /* * The Redis Emitter is a Socket.io-compatible message emitter that operates * with greater efficiency than using Socket.io itself. */ this.emitter = ioEmitter(this.dtp.redis); } middleware (options) { options = Object.assign({ maxOwnedRooms: 10, maxJoinedRooms: 10, }); return async (req, res, next) => { try { res.locals.ownedChatRooms = await this.getRoomsForOwner(req.user, { skip: 0, cpp: options.maxOwnedRooms, }); res.locals.joinedChatRooms = await this.getRoomsForMember(req.user, { skip: 0, cpp: options.maxJoinedRooms, }); return next(); } catch (error) { this.log.error('failed to execute chat middleware', { error }); return next(error); } }; } async createRoom (owner, roomDefinition) { const NOW = new Date(); const room = new ChatRoom(); room.created = NOW; room.lastActivity = NOW; room.ownerType = owner.type; room.owner = owner._id; room.name = this.filterText(roomDefinition.name); if (roomDefinition.description) { room.description = this.filterText(roomDefinition.description); } if (roomDefinition.policy) { room.policy = this.filterText(roomDefinition.policy); } room.visibility = roomDefinition.visibility; room.membershipPolicy = roomDefinition.membershipPolicy; room.members = [ ]; await room.save(); return room.toObject(); } async updateRoom (room, roomDefinition) { const NOW = new Date(); const updateOp = { $set: { lastActivity: NOW, }, $unset: { }, }; updateOp.$set.name = this.filterText(roomDefinition.name); if (roomDefinition.description && roomDefinition.description.length > 0) { updateOp.$set.description = this.filterText(roomDefinition.description); } else { updateOp.$unset.description = 1; } await ChatRoom.updateOne({ _id: room._id }, updateOp); } async getRoomsForOwner (owner, pagination) { pagination = Object.assign({ skip: 0, cpp: 50 }, pagination); const rooms = await ChatRoom .find({ owner: owner._id }) .sort({ lastActivity: -1, created: -1 }) .skip(pagination.skip) .limit(pagination.cpp) .populate(this.populateChatRoom) .lean(); return rooms; } async getRoomsForMember (member, pagination) { pagination = Object.assign({ skip: 0, cpp: 50 }, pagination); const rooms = await ChatRoom .find({ 'members.member': member._id }) .sort({ lastActivity: -1, created: -1 }) .skip(pagination.skip) .limit(pagination.cpp) .populate(this.populateChatRoom) .lean(); return rooms; } async getPublicRooms (pagination) { pagination = Object.assign({ skip: 0, cpp: 50 }, pagination); const rooms = await ChatRoom .find({ 'flags.isPublic': true }) .sort({ lastActivity: -1, created: -1 }) .skip(pagination.skip) .limit(pagination.cpp) .populate(this.populateChatRoom) .lean(); return rooms; } async getRoomById (roomId) { const room = await ChatRoom .findById(roomId) .populate(this.populateChatRoom) .lean(); return room; } async joinRoom (room, member) { if (!room.flags.isOpen) { throw new SiteError(403, 'The room is not open'); } await ChatRoom.updateOne( { _id: room._id }, { $addToSet: { members: { memberType: member.type, member: member._id, }, }, }, ); } async leaveRoom (room, memberId) { await ChatRoom.updateOne( { _id: room._id }, { $pull: { members: { _id: memberId } }, }, ); } async sendRoomInvite (room, member) { const NOW = new Date(); /* * See if there's already an outstanding invite, and return it. */ let invite = await ChatRoomInvite .findOne({ room: room._id, member: member._id }) .populate(this.populateChatRoomInvite) .lean(); if (invite) { return invite; } /* * Create new invite */ invite = new ChatRoomInvite(); invite.created = NOW; invite.room = room._id; invite.memberType = member.type; invite.member = member._id; invite.status = 'new'; await invite.save(); this.log.info('chat room invite created', { roomId: room._id, memberId: member._id, inviteId: invite._id, }); return invite.toObject(); } async acceptRoomInvite (invite) { this.log.info('accepting invite to chat room', { roomId: invite.room._id, memberId: invite.member._id, }); await ChatRoom.updateOne( { _id: invite.room._id }, { $addToSet: { members: { memberType: invite.memberType, member: invite.member._id, }, }, }, ); this.log.info('updating chat invite', { inviteId: invite._id, status: 'accepted' }); await ChatRoomInvite.updateOne( { _id: invite._id }, { $set: { stats: 'accepted' }, }, ); } async rejectRoomInvite (invite) { this.log.info('rejecting chat room invite', { inviteId: invite._id, roomId: invite.room._id, memberId: invite.member._id, }); await ChatRoomInvite.updateOne( { _id: invite._id }, { $set: { status: 'rejected' } }, ); } async deleteRoomInvite (invite) { this.log.info('deleting chat room invite', { inviteId: invite._id }); await ChatRoomInvite.deleteOne({ _id: invite._id }); } async createMessage (author, messageDefinition) { const { sticker: stickerService, user: userService } = this.dtp.services; author = await userService.getUserAccount(author._id); if (!author || !author.permissions || !author.permissions.canChat) { throw new SiteError(403, `You are not permitted to chat at all on ${this.dtp.config.site.name}`); } const NOW = new Date(); /* * Record the chat message to the database */ let message = new ChatMessage(); message.created = NOW; message.channelType = messageDefinition.channelType; message.channel = mongoose.Types.ObjectId(messageDefinition.channel._id || messageDefinition.channel); message.authorType = author.type; message.author = author._id; message.content = this.filterText(messageDefinition.content); message.analysis = await this.analyzeContent(author, message.content); const stickerSlugs = this.findStickers(message.content); stickerSlugs.forEach((sticker) => { const re = new RegExp(`:${sticker}:`, 'gi'); message.content = message.content.replace(re, '').trim(); }); const stickers = await stickerService.resolveStickerSlugs(stickerSlugs); message.stickers = stickers.map((sticker) => sticker._id); await message.save(); message = message.toObject(); /* * Update room's latest message pointer */ await ChatRoom.updateOne( { _id: message.channel }, { $set: { latestMessage: message._id } }, ); /* * Prepare a message payload that can be transmitted over sockets to clients * and rendered for display. */ const renderedContent = this.renderMessageContent(message.content); const payload = { _id: message._id, user: { _id: author._id, displayName: author.displayName, username: author.username, picture: { large: { _id: author.picture.large._id, }, small: { _id: author.picture.small._id, }, }, }, content: renderedContent, stickers, }; /* * Return both things */ return { message, payload }; } renderMessageContent (content) { return marked.parse(content, this.markedConfig); } findStickers (content) { const tokens = content.split(' '); const stickers = [ ]; tokens.forEach((token) => { if ((token[0] !== ':') || (token[token.length -1 ] !== ':')) { return; } token = token.slice(1, token.length - 1 ).toLowerCase(); if (token.includes('/') || token.includes(':') || token.includes(' ')) { return; // trimmed token includes invalid characters } this.log.debug('found sticker request', { token }); if (!stickers.includes(token)) { stickers.push(striptags(token)); } }); return stickers.slice(0, 4); } async removeMessage (message) { await ChatMessage.deleteOne({ _id: message._id }); this.emitter(`site:${this.dtp.config.site.domainKey}:chat`, { command: 'removeMessage', params: { messageId: message._id }, }); } async getChannelHistory (channel, pagination) { const messages = await ChatMessage .find({ channel: channel._id }) .sort({ created: -1 }) .skip(pagination.skip) .limit(pagination.cpp) .populate(this.populateChatMessage) .lean(); return messages.reverse(); } async getUserHistory (user, pagination) { const messages = await ChatMessage .find({ author: user._id }) .sort({ created: -1 }) .skip(pagination.skip) .limit(pagination.cpp) .populate(this.populateChatMessage) .lean(); return messages.reverse(); } /** * Filters an input string to remove "zalgo" text and to strip all HTML tags. * This prevents cross-site scripting and the malicious destruction of text * layouts. * @param {String} content The text content to be filtered. * @returns the filtered text */ filterText (content) { return striptags(unzalgo.clean(content.trim())); } /** * Analyze an input chat message against a user's history for similarity and * other abusive content. Returns a response object with various scores * allowing the caller to implement various policies and make various * decisions. * @param {User} author The author of the chat message * @param {*} content The text of the chat message as would be distributed. * @returns response object with various scores indicating the results of * analyses performed. */ async analyzeContent (author, content) { const response = { similarity: 0.0 }; /* * Compare versus their recent chat messages, score for similarity, and * block based on repetition. Spammers are redundant. This stops them. */ const history = await ChatMessage .find({ author: author._id }) .sort({ created: -1 }) .select('content') .limit(10) .lean(); history.forEach((message) => { const similarity = stringSimilarity.compareTwoStrings(content, message.content); if (similarity > 0.9) { // 90% or greater match with history entry response.similarity += similarity; } }); return response; } async sendMessage (channel, messageName, payload) { this.emitter.to(channel).emit(messageName, payload); } async sendSystemMessage (socket, content, options) { const NOW = new Date(); options = Object.assign({ type: 'info', }, options || { }); const payload = { created: NOW, type: options.type, content, }; if (options.channelId) { socket.to(options.channelId).emit('system-message', payload); return; } socket.emit('system-message', payload); } async createEmojiReaction (user, reactionDefinition) { const NOW = new Date(); const reaction = new EmojiReaction(); reaction.created = NOW; reaction.subjectType = reactionDefinition.subjectType; reaction.subject = mongoose.Types.ObjectId(reactionDefinition.subject); reaction.userType = user.type; reaction.user = user._id; reaction.reaction = reactionDefinition.reaction; if (reactionDefinition.timestamp) { reaction.timestamp = reactionDefinition.timestamp; } await reaction.save(); return reaction.toObject(); } } module.exports = { slug: 'chat', name: 'chat', create: (dtp) => { return new ChatService(dtp); }, };