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

599 lines
15 KiB

// 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); },
};