DTP Social Engine
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.
 
 
 
 
 

768 lines
21 KiB

// chat.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
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 moment = require('moment');
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);
}
async start ( ) {
const { user: userService, limiter: limiterService } = this.dtp.services;
await super.start();
this.populateChatMessage = [
{
path: 'channel',
},
{
path: 'author',
select: userService.USER_SELECT,
},
{
path: 'stickers',
},
];
this.populateChatRoom = [
{
path: 'owner',
select: userService.USER_SELECT,
},
{
path: 'members.member',
select: userService.USER_SELECT,
},
];
this.populateChatRoomInvite = [
{
path: 'room',
populate: [
{
path: 'owner',
select: userService.USER_SELECT,
},
],
},
{
path: 'member',
select: userService.USER_SELECT,
},
];
this.templates = {
chatMessage: this.loadViewTemplate('chat/components/message-standalone.pug'),
};
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,
};
this.chatMessageLimiter = limiterService.createRateLimiter({
points: 20,
duration: 60,
blockDuration: 60 * 3,
execEvenly: false,
keyPrefix: 'rl:chatmsg',
});
this.reactionLimiter = limiterService.createRateLimiter({
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);
this.queues = {
reeeper: await this.getJobQueue('reeeper'),
};
}
async renderTemplate (which, viewModel) {
if (!this.templates || !this.templates[which]) {
throw new Error('Chat service template does not exist');
}
viewModel = Object.assign(viewModel, this.dtp.app.locals);
return this.templates[which](viewModel);
}
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;
if (!roomDefinition.name || !roomDefinition.name.length) {
throw new SiteError(400, 'Must provide room name');
}
room.name = this.filterText(roomDefinition.name);
if (roomDefinition.description && (roomDefinition.description.length > 0)) {
room.description = this.filterText(roomDefinition.description);
}
if (roomDefinition.policy && (roomDefinition.policy.length > 0)) {
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: { },
};
if (!roomDefinition.name && !roomDefinition.name.length) {
throw new SiteError(400, 'Must provide room name');
}
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;
}
if (roomDefinition.policy && (roomDefinition.policy.length > 0)) {
updateOp.$set.policy = this.filterText(roomDefinition.policy);
} else {
updateOp.$unset.policy = 1;
}
if (!roomDefinition.visibility || !roomDefinition.visibility.length) {
throw new SiteError(400, 'Must specify room visibility');
}
updateOp.$set.visibility = roomDefinition.visibility.trim();
if (!roomDefinition.membershipPolicy || !roomDefinition.membershipPolicy.length) {
throw new SiteError(400, 'Must specify room membership policy');
}
updateOp.$set.membershipPolicy = roomDefinition.membershipPolicy.trim();
const response = await ChatRoom.findOneAndUpdate({ _id: room._id }, updateOp, { new: true });
this.log.debug('chat room update', { response });
return response;
}
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({ visibility: 'public' })
.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 deleteRoom (room) {
return this.queues.reeeper.add('chat-room-delete', { roomId: room._id });
}
async joinRoom (room, member) {
if (room.membershipPolicy !== 'open') {
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, inviteDefinition) {
const { coreNode: coreNodeService } = this.dtp.services;
const NOW = new Date();
if (await this.isRoomMember(room, member)) {
throw new SiteError(400, `${member.username} is already a member of ${room.name}`);
}
let invite = await ChatRoomInvite
.findOne({ room: room._id, member: member._id })
.populate(this.populateChatRoomInvite)
.lean();
if (invite) {
switch (invite.status) {
case 'new':
throw new SiteError(400, `${member.displayName || member.username} was invited to join ${moment(invite.created).fromNow()}, but has not yet responded.`);
case 'rejected':
throw new SiteError(400, `${member.displayName || member.username} rejected your invitation to join.`);
default:
this.log.alert('deleting damaged ChatRoomInvite document', { _id: invite._id });
await ChatRoomInvite.deleteOne({ _id: invite._id });
break; // create a new one by proceeding
}
}
invite = new ChatRoomInvite();
invite.created = NOW;
invite.room = room._id;
invite.memberType = member.type;
invite.member = member._id;
invite.status = 'new';
if (inviteDefinition && inviteDefinition.message) {
invite.message = this.filterText(inviteDefinition.message);
}
await invite.save();
invite = invite.toObject();
this.log.info('chat room invite created', {
roomId: room._id,
memberId: member._id,
inviteId: invite._id,
});
/*
* Send the invite notification using DTP Core services. It will figure out
* who needs to receive the event and how to get it to them.
*/
room.owner.type = room.ownerType;
const event = {
action: 'room-invite-create',
emitter: room.owner,
label: 'Chat Room Invitation',
content: invite.message || `Join my chat room on ${this.dtp.config.site.name}!`,
href: coreNodeService.getLocalUrl(`/chat/room/${room._id}/invite/${invite._id}`),
};
await coreNodeService.sendKaleidoscopeEvent(event, member);
return invite;
}
async getRoomInvites (room, status) {
const invites = await ChatRoomInvite
.find({ room: room._id, status })
.sort({ created: 1 })
.populate(this.populateChatRoomInvite)
.lean();
return invites;
}
async getRoomInviteById (inviteId) {
const invite = await ChatRoomInvite
.findById(inviteId)
.populate(this.populateChatRoomInvite)
.lean();
return invite;
}
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: { status: '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' },
},
);
}
/**
* Marks an invitation as deleted, but does not physically remove it from the
* database. They expire after 30 days and will self-delete. This serves as a
* guard against invite spam. The person asking you to change this behavior
* wants to use invite spam as a form of abuse. The answer is: No.
*
* @param {ChatRoomInvite} invite The invitation to be marked as deleted.
*/
async deleteRoomInvite (invite) {
if (invite.status !== 'new') {
throw new SiteError(400, "Can't delete selected room invite");
}
this.log.info('deleting chat room invite', { inviteId: invite._id });
await ChatRoomInvite.updateOne({ _id: invite._id }, { $set: { status: 'deleted' } });
}
async isRoomMember (room, userId) {
const member = await ChatRoom.findOne({ 'members.member': userId }).lean();
return !!member;
}
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}`);
}
try {
const userKey = author._id.toString();
await this.chatMessageLimiter.consume(userKey, 1);
} catch (error) {
throw new SiteError(429, 'You are sending chat messages too quickly');
}
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);
if (message.analysis.similarity > 3.0) {
throw new SiteError(429, 'Message rejected as spam (too repetitive)');
}
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,
created: message.created,
user: {
_id: author._id,
displayName: author.displayName,
username: author.username,
},
content: renderedContent,
stickers,
};
if (author.picture) {
payload.user.picture = { };
if (author.picture.large) {
payload.user.picture.large ={
_id: author.picture.large._id,
};
}
if (author.picture.small) {
payload.user.picture.small = {
_id: author.picture.small._id,
};
}
}
/*
* 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();
}
async getRoomMemberships (user, options) {
options = Object.assign({
withPopulate: true,
}, options || { });
const search = {
$or: [
{ owner: user._id },
{ 'members.member': user._id },
],
};
let q = ChatRoom.find(search).sort({ name: 1 });
if (options.pagination) {
q = q.skip(options.pagination.skip).limit(options.pagination.cpp);
}
if (options.withPopulate) {
q = q.populate(this.populateChatRoom);
}
const memberships = await q.lean();
return memberships;
}
/**
* This service is never called using user-supplied lists of room IDs. Don't
* do that, there is no membership check. Instead, every request knows the
* member's list of rooms owned and rooms joined. When this method was
* written, those arrays are being merged to build the list of roomIds.
* @param {Array} roomIds an array of Room._id values
* @param {*} pagination pagination params for the timeline
* @returns an array of messages in chronological order from all room IDs
* specified.
*/
async getMultiRoomTimeline (roomIds, pagination) {
const messages = await ChatMessage
.find({ room: { $in: roomIds } })
.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) {
if (typeof channel !== 'string') {
channel = channel.toString();
}
this.emitter.to(channel).emit(messageName, payload);
}
async sendSystemMessage (content, options) {
const NOW = new Date();
options = Object.assign({
type: 'info',
}, options || { });
const payload = {
created: NOW,
type: options.type,
content,
};
if (options.channelId) {
this.emitter.to(options.channelId).emit('system-message', payload);
return;
}
if (options.userId) {
this.emitter.to(options.userId).emit('system-message', payload);
}
}
async createEmojiReaction (user, reactionDefinition) {
const { user: userService } = this.dtp.services;
const NOW = new Date();
const userCheck = await userService.getUserAccount(user._id);
if (!userCheck || !userCheck.permissions || !userCheck.permissions.canChat) {
throw new SiteError(403, 'You are not permitted to chat');
}
try {
const userKey = user._id.toString();
await this.reactionLimiter.consume(userKey, 1);
} catch (error) {
throw new SiteError(429, 'You are sending reactions too quickly');
}
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); },
};