diff --git a/.env.default b/.env.default index a436018..eacaa4d 100644 --- a/.env.default +++ b/.env.default @@ -13,6 +13,16 @@ DTP_CORE_AUTH_SCHEME=http DTP_CORE_AUTH_HOST=localhost:3000 DTP_CORE_AUTH_PASSWORD_LEN=64 +DTP_IMAGE_WORK_PATH=/tmp/yourapp/image-work +DTP_VIDEO_WORK_PATH=/tmp/yourapp/video-work +DTP_STICKER_WORK_PATH=/tmp/yourapp/sticker-work + +# +# Set this to "enabled" to use NVIDIA GPU acceleration. Setting this to enabled +# without a properly-configured NVIDIA GPU will cause processing jobs to fail. +# +DTP_GPU_ACCELERATION=disabled + # # Host Cache configuration # @@ -98,4 +108,4 @@ DTP_LOG_DEBUG=enabled DTP_LOG_INFO=enabled DTP_LOG_WARN=enabled -DTP_LOG_HTTP_FORMAT=combined \ No newline at end of file +DTP_LOG_HTTP_FORMAT=combined diff --git a/app/controllers/admin/job-queue.js b/app/controllers/admin/job-queue.js index 77a769b..8136d96 100644 --- a/app/controllers/admin/job-queue.js +++ b/app/controllers/admin/job-queue.js @@ -1,6 +1,6 @@ // admin/job-queue.js // Copyright (C) 2022 DTP Technologies, LLC -// All Rights Reserved +// License: Apache-2.0 'use strict'; diff --git a/app/controllers/chat.js b/app/controllers/chat.js new file mode 100644 index 0000000..2cef875 --- /dev/null +++ b/app/controllers/chat.js @@ -0,0 +1,127 @@ +// email.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const express = require('express'); + +const { SiteController,/*, SiteError*/ +SiteError} = require('../../lib/site-lib'); + +class ChatController extends SiteController { + + constructor (dtp) { + super(dtp, module.exports); + } + + async start ( ) { + const { + chat: chatService, + limiter: limiterService, + session: sessionService, + } = this.dtp.services; + + const router = express.Router(); + this.dtp.app.use('/chat', router); + + router.use( + sessionService.authCheckMiddleware({ requireLogin: true }), + chatService.middleware({ maxOwnedRooms: 25, maxJoinedRooms: 50 }), + async (req, res, next) => { + res.locals.currentView = 'chat'; + return next(); + }, + ); + + router.param('roomId', this.populateRoomId.bind(this)); + + router.post( + '/:roomId', + limiterService.create(limiterService.config.chat.postRoomUpdate), + this.postRoomUpdate.bind(this), + ); + + router.post( + '/', + limiterService.create(limiterService.config.chat.postRoomCreate), + this.postRoomCreate.bind(this), + ); + + router.get( + '/:roomId', + limiterService.create(limiterService.config.chat.getRoomView), + this.getRoomView.bind(this), + ); + + router.get( + '/', + limiterService.create(limiterService.config.chat.getHome), + this.getHome.bind(this), + ); + + return router; + } + + async populateRoomId (req, res, next, roomId) { + const { chat: chatService } = this.dtp.services; + try { + res.locals.room = await chatService.getRoomById(roomId); + if (!res.locals.room) { + throw new SiteError(404, 'Room not found'); + } + return next(); + } catch (error) { + this.log.error('failed to populate roomId', { roomId, error }); + return next(error); + } + } + + async postRoomUpdate (req, res, next) { + const { chat: chatService } = this.dtp.services; + try { + res.locals.room = await chatService.updateRoom(res.locals.room, req.body); + res.redirect(`/chat/${res.locals.room._id}`); + } catch (error) { + this.log.error('failed to update chat room', { roomId: res.locals.room._id, error }); + return next(error); + } + } + + async postRoomCreate (req, res, next) { + const { chat: chatService } = this.dtp.services; + try { + res.locals.room = await chatService.createRoom(req.user, req.body); + res.redirect(`/chat/${res.locals.room._id}`); + } catch (error) { + this.log.error('failed to create chat room', { error }); + return next(error); + } + } + + async getRoomView (req, res, next) { + const { chat: chatService } = this.dtp.services; + try { + res.locals.pageTitle = res.locals.room.name; + + const pagination = { skip: 0, cpp: 20 }; + res.locals.chatMessages = await chatService.getChannelHistory(res.locals.room, pagination); + + res.render('chat/view'); + } catch (error) { + this.log.error('failed to render chat room view', { roomId: req.params.roomId, error }); + return next(error); + } + } + + async getHome (req, res) { + res.locals.pageTitle = 'Chat Home'; + res.render('chat/index'); + } +} + +module.exports = { + slug: 'chat', + name: 'chat', + create: async (dtp) => { return new ChatController(dtp); }, +}; \ No newline at end of file diff --git a/app/models/chat-message.js b/app/models/chat-message.js index 0a691fd..78235e2 100644 --- a/app/models/chat-message.js +++ b/app/models/chat-message.js @@ -8,10 +8,22 @@ const mongoose = require('mongoose'); const Schema = mongoose.Schema; +/* + * The intent is for forked apps to give meaning to "channel" in their + * apps. Set the channelType to the name of your channel model, and set + * channel to the _id of the channel. The model will then correctly populate. + */ + const ChatMessageSchema = new Schema({ created: { type: Date, default: Date.now, required: true, index: -1, expires: '10d' }, - author: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, - content: { type: String }, + channelType: { type: String }, + channel: { type: Schema.ObjectId, refPath: 'channelType' }, + authorType: { type: String, enum: ['User', 'CoreUser'], required: true }, + author: { type: Schema.ObjectId, required: true, index: 1, refPath: 'authorType' }, + content: { type: String, maxlength: 1000 }, + analysis: { + similarity: { type: Number }, + }, stickers: { type: [String] }, }); diff --git a/app/models/chat-room-invite.js b/app/models/chat-room-invite.js new file mode 100644 index 0000000..4d9408c --- /dev/null +++ b/app/models/chat-room-invite.js @@ -0,0 +1,19 @@ +// chat-room-invite.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); + +const Schema = mongoose.Schema; + +const ChatRoomInviteSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: -1, expires: '30d' }, + room: { type: Schema.ObjectId, required: true, ref: 'ChatRoom' }, + memberType: { type: String, required: true }, + member: { type: Schema.ObjectId, required: true, index: 1, refPath: 'memberType' }, + status: { type: String, enum: ['new', 'accepted', 'rejected'], required: true }, +}); + +module.exports = mongoose.model('ChatRoomInvite', ChatRoomInviteSchema); \ No newline at end of file diff --git a/app/models/chat-room.js b/app/models/chat-room.js new file mode 100644 index 0000000..8eda8eb --- /dev/null +++ b/app/models/chat-room.js @@ -0,0 +1,44 @@ +// chat-room.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); + +const Schema = mongoose.Schema; + +const RoomMemberSchema = new Schema({ + memberType: { type: String, required: true }, + member: { type: Schema.ObjectId, refPath: 'memberType' }, +}); + +const ROOM_VISIBILITY_LIST = ['public', 'private']; +const ROOM_MEMBERSHIP_POICY_LIST = ['open', 'closed']; + +const ChatRoomSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: -1 }, + lastActivity: { type: Date, default: Date.now, required: true, index: -1 }, + ownerType: { type: String, required: true }, + owner: { type: Schema.ObjectId, required: true, index: 1, refPath: 'ownerType' }, + name: { type: String, required: true, maxlength: 100 }, + description: { type: String, maxlength: 500 }, + policy: { type: String }, + latestMessage: { type: Schema.ObjectId, ref: 'ChatMessage' }, + visibility: { type: String, enum: ROOM_VISIBILITY_LIST, default: 'public', required: true, index: 1 }, + membershipPolicy: { type: String, enum: ROOM_MEMBERSHIP_POICY_LIST, default: 'open', required: true, index: 1 }, + members: { type: [RoomMemberSchema], default: [ ], required: true }, +}); + +ChatRoomSchema.index({ + visibility: 1, + membershipPolicy: 1, +}, { + partialFilterExpression: { + visibility: 'public', + membershipPolicy: 'open', + }, + name: 'chatroom_public_open_idx', +}); + +module.exports = mongoose.model('ChatRoom', ChatRoomSchema); \ No newline at end of file diff --git a/app/models/emoji-reaction.js b/app/models/emoji-reaction.js new file mode 100644 index 0000000..6ce84a2 --- /dev/null +++ b/app/models/emoji-reaction.js @@ -0,0 +1,39 @@ +// emoji-reaction.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); + +const Schema = mongoose.Schema; + +const REACTION_LIST = [ + 'clap', + 'fire', + 'happy', + 'laugh', + 'angry', + 'honk', +]; + +const EmojiReactionSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: -1, expires: '30d' }, + + /* + * The thing for which a reaction is being filed. + */ + subjectType: { type: String, required: true }, + subject: { type: Schema.ObjectId, required: true, index: 1, refPath: 'subjectType' }, + + /* + * The user creating the reaction + */ + userType: { type: String, required: true }, + user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, + + reaction: { type: String, enum: REACTION_LIST, required: true }, + timestamp: { type: Number }, +}); + +module.exports = mongoose.model('EmojiReaction', EmojiReactionSchema); \ No newline at end of file diff --git a/app/models/sticker.js b/app/models/sticker.js new file mode 100644 index 0000000..5e33840 --- /dev/null +++ b/app/models/sticker.js @@ -0,0 +1,46 @@ +// sticker.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); + +const Schema = mongoose.Schema; + +const STICKER_STATUS_LIST = [ + 'processing', // the sticker is in the processing queue + 'live', // the sticker is available for use + 'rejected', // the sticker was rejected (by proccessing queue) + 'retired', // the sticker has been retired +]; + +const StickerMediaSchema = new Schema( + { + bucket: { type: String, required: true }, + key: { type: String, required: true }, + type: { type: String, required: true }, + size: { type: Number, required: true }, + }, + { + _id: false, + }, +); + +/* + * The intention is for sticker ownership to be defined by forked applications + * and implemented by things like User, CoreUser, Channel, or whatever can + * "own" a sticker in your app. + */ +const StickerSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: -1 }, + status: { type: String, enum: STICKER_STATUS_LIST, default: 'processing', required: true, index: 1 }, + rejectedReason: { type: String }, + ownerType: { type: String, required: true, index: 1 }, + owner: { type: Schema.ObjectId, required: true, index: 1, refPath: 'ownerType' }, + slug: { type: String, required: true, maxlength: 20, unique: true, index: 1 }, + original: { type: StickerMediaSchema, required: true, select: false }, + encoded: { type: StickerMediaSchema }, +}); + +module.exports = mongoose.model('Sticker', StickerSchema); \ No newline at end of file diff --git a/app/services/announcement.js b/app/services/announcement.js index de78d5b..a5e812c 100644 --- a/app/services/announcement.js +++ b/app/services/announcement.js @@ -1,6 +1,6 @@ // announcement.js // Copyright (C) 2022 DTP Technologies, LLC -// All Rights Reserved +// License: Apache-2.0 'use strict'; diff --git a/app/services/chat.js b/app/services/chat.js index f8e8a25..e8c32ad 100644 --- a/app/services/chat.js +++ b/app/services/chat.js @@ -4,30 +4,68 @@ '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 } = require('../../lib/site-lib'); +const { SiteService, SiteError } = require('../../lib/site-lib'); class ChatService extends SiteService { constructor (dtp) { super(dtp, module.exports); - this.populateContentReport = [ + + const USER_SELECT = '_id username username_lc displayName picture'; + this.populateChatMessage = [ + { + path: 'channel', + }, { - path: 'user', - select: '_id username username_lc displayName picture', + path: 'author', + select: USER_SELECT, }, { - path: 'resource', + path: 'stickers', + }, + ]; + + this.populateChatRoom = [ + { + path: 'owner', + select: USER_SELECT, + }, + { + path: 'members.member', + select: USER_SELECT, + }, + ]; + + this.populateChatRoomInvite = [ + { + path: 'room', populate: [ { - path: 'author', - select: '_id username username_lc displayName picture', + path: 'owner', + select: USER_SELECT, + }, + { + path: 'members.member', + select: USER_SELECT, }, ], }, @@ -35,9 +73,403 @@ class ChatService extends SiteService { } 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`, { @@ -46,6 +478,28 @@ class ChatService extends SiteService { }); } + 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 @@ -56,6 +510,86 @@ class ChatService extends SiteService { 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 = { diff --git a/app/services/sticker.js b/app/services/sticker.js new file mode 100644 index 0000000..2f153a4 --- /dev/null +++ b/app/services/sticker.js @@ -0,0 +1,204 @@ +// sticker.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const slug = require('slug'); + +const mongoose = require('mongoose'); + +const Sticker = mongoose.model('Sticker'); +const User = mongoose.model('User'); + +const { SiteService, SiteError, SiteAsync } = require('../../lib/site-lib'); + +const MAX_CHANNEL_STICKERS = 50; +const MAX_USER_STICKERS = 10; + +class StickerService extends SiteService { + + constructor (dtp) { + super(dtp, module.exports); + this.populateSticker = [ + { + path: 'owner', + select: '-email -passwordSalt -password', + }, + ]; + } + + async start ( ) { + const { jobQueue: jobQueueService } = this.dtp.services; + this.jobQueue = jobQueueService.getJobQueue('sticker-ingest', { + attempts: 3, + }); + + this.stickerTemplate = this.loadViewTemplate('sticker/components/sticker-standalone.pug'); + } + + async createSticker (ownerType, owner, file, stickerDefinition) { + const { minio: minioService } = this.dtp.services; + const NOW = new Date(); + + this.log.debug('received sticker', { file, stickerDefinition }); + + // this is faster than Model.countDocuments() + const currentStickers = await Sticker.find({ owner: owner._id }).select('_id').lean(); + + switch (ownerType) { + case 'Channel': + if (currentStickers.length >= MAX_CHANNEL_STICKERS) { + throw new SiteError(508, `You have ${MAX_CHANNEL_STICKERS} stickers. Please remove a sticker before adding a new one.`); + } + break; + + case 'User': + if (currentStickers.length >= MAX_USER_STICKERS) { + throw new SiteError(508, `You have ${MAX_USER_STICKERS} stickers. Please remove a sticker before adding a new one one.`); + } + break; + } + + const sticker = new Sticker(); + sticker.created = NOW; + sticker.status = 'processing'; + sticker.ownerType = ownerType; + sticker.owner = owner._id; + sticker.slug = slug(stickerDefinition.slug.toLowerCase().trim()); + + const bucket = process.env.MINIO_VIDEO_BUCKET; + const key = `/stickers/${sticker._id.toString().slice(0, 3)}/${sticker._id}`; + + sticker.original = { + bucket, key, + type: file.mimetype, + size: file.size, + }; + + await minioService.uploadFile({ + bucket, key, + filePath: file.path, + metadata: { + 'Content-Type': file.mimetype, + 'Content-Length': file.size, + }, + }); + + await sticker.save(); + + await this.jobQueue.add('sticker-ingest', { stickerId: sticker._id }); + + return sticker.toObject(); + } + + async getForChannel (channel) { + const stickers = await Sticker + .find({ status: 'live', ownerType: 'Channel', owner: channel._id }) + .sort({ created: -1 }) + .populate(this.populateSticker) + .lean(); + return stickers; + } + + async getForUser (user) { + const stickers = await Sticker + .find({ status: 'live', ownerType: 'User', owner: user._id }) + .sort({ created: -1 }) + .populate(this.populateSticker) + .lean(); + return stickers; + } + + async getStickers (pagination) { + const stickers = await Sticker + .find() + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateSticker) + .lean(); + return stickers; + } + + async getBySlug (slug) { + const sticker = await Sticker + .findOne({ slug }) + .populate(this.populateSticker) + .lean(); + return sticker; + } + + async getById (stickerId, includeOriginal = false) { + let query = Sticker.findOne({ _id: stickerId }); + if (includeOriginal) { + query = query.select('+original'); + } + const sticker = await query + .populate(this.populateSticker) + .lean(); + return sticker; + } + + async setStickerStatus (sticker, status) { + await Sticker.updateOne({ _id: sticker._id }, { $set: { status } }); + } + + async resolveStickerSlugs (slugs) { + const stickers = [ ]; + await SiteAsync.each(slugs, async (slug) => { + const sticker = await Sticker.findOne({ slug: slug }); + if (!sticker) { + return; + } + stickers.push(sticker); + }); + return stickers; + } + + async removeSticker (sticker) { + const stickerId = sticker._id; + this.log.info('creating sticker delete job', { stickerId }); + await this.jobQueue.add('sticker-delete', { stickerId }); + } + + async render (sticker, stickerOptions) { + return this.stickerTemplate({ sticker, stickerOptions }); + } + + async addFavorite (user, stickerId) { + stickerId = mongoose.Types.ObjectId(stickerId); + const sticker = await Sticker.findById(stickerId); + if (!sticker) { + throw new SiteError(404, 'Sticker not found'); + } + await User.updateOne( + { _id: user._id }, + { + $addToSet: { favoriteStickers: sticker._id }, + }, + ); + } + + async removeFavorite (user, stickerId) { + stickerId = mongoose.Types.ObjectId(stickerId); + await User.updateOne( + { _id: user._id }, + { + $pull: { favoriteStickers: stickerId }, + }, + ); + } + + async getFavorites (user) { + const stickers = await Sticker + .populate(user.favoriteStickers, this.populateSticker); + return stickers; + } +} + +module.exports = { + slug: 'sticker', + name: 'sticker', + create: (dtp) => { return new StickerService(dtp); }, +}; \ No newline at end of file diff --git a/app/views/chat/components/input-form.pug b/app/views/chat/components/input-form.pug new file mode 100644 index 0000000..b058114 --- /dev/null +++ b/app/views/chat/components/input-form.pug @@ -0,0 +1,151 @@ +include reaction-button +mixin renderChatInputForm (room, options = { }) + form( + id="chat-input-form", + data-room-id= room._id, + onsubmit="return window.dtp.app.chat.sendUserChat(event);", + ).uk-form + + input(type="hidden", name="roomType", value= "ChatRoom") + input(type="hidden", name="room", value= room._id) + + .uk-card.uk-card-secondary.uk-card-body.uk-padding-small(style="border-top: solid 1px #3a3a3a;") + textarea( + id="chat-input-text", + name="content", + rows="2", + hidden, + ).uk-textarea.uk-margin-small + + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-auto + button( + type= "button", + title= "Insert emoji", + data-target-element= "chat-input-text", + uk-tooltip={ title: 'Add Emoji', delay: 500 }, + onclick= "return dtp.app.showEmojiPicker(event);", + ).uk-button.dtp-button-default.uk-button-small + span + i.far.fa-laugh-beam + + .uk-width-auto + button( + type= "button", + title= "Sticker Picker", + uk-toggle={ target: '#sticker-picker'}, + uk-tooltip={ title: 'Add Sticker', delay: 500 }, + onclick="return dtp.app.chat.openChatInput();", + ).uk-button.dtp-button-default.uk-button-small + span + i.far.fa-image + #sticker-picker(uk-modal) + .uk-modal-dialog.uk-modal-body + button(type="button", uk-close).uk-modal-close-default + h4.uk-text-center Sticker Picker 9000™ + ul(uk-tab).uk-flex-center + li.uk-active + a(href="")= user.displayName || user.username + li + a(href="")= room.name + li + a(href="") Favorites + ul.uk-switcher.chat-sticker-picker + //- Personal stickers + li + if Array.isArray(userStickers) && (userStickers.length > 0) + div(uk-grid).uk-grid-small.uk-flex-center + each sticker in userStickers + .uk-width-auto + button( + type="button", + data-sticker-id= sticker._id, + data-sticker-slug= sticker.slug, + onclick="return dtp.app.chat.insertChatSticker(event);", + ).uk-button.uk-button-text + +renderSticker(sticker) + else + .uk-text-center You haven't uploaded any #[a(href="/sticker") Stickers] yet + + //- Channel stickers + li + if Array.isArray(roomStickers) && (roomStickers.length > 0) + div(uk-grid).uk-grid-small.uk-flex-center + each sticker in roomStickers + .uk-width-auto + button( + type="button", + data-sticker-id= sticker._id, + data-sticker-slug= sticker.slug, + onclick="return dtp.app.chat.insertChatSticker(event);", + ).uk-button.uk-button-text + +renderSticker(sticker) + else + .uk-text-center This room hasn't uploaded any #[a(href="/sticker") Stickers] yet + + //- Favorite/Saved stickers + li + if Array.isArray(favoriteStickers) && (favoriteStickers.length > 0) + div(uk-grid).uk-grid-small.uk-flex-center + each sticker in favoriteStickers + .uk-width-auto + button( + type="button", + data-sticker-id= sticker._id, + data-sticker-slug= sticker.slug, + onclick="return dtp.app.chat.insertChatSticker(event);", + ).uk-button.uk-button-text + +renderSticker(sticker) + else + .uk-text-center You haven't saved any Favorite stickers + + //- .uk-width-auto + //- button( + //- type= "button", + //- title= "Attach image", + //- onclick="return dtp.app.chat.attachChatImage(event);", + //- ).uk-button.dtp-button-default.uk-button-small + //- span + //- i.fas.fa-file-image + + .uk-width-expand + if !options.hideHomeNav + .uk-text-small.uk-text-center.uk-text-truncate + a(href="/chat", uk-tooltip={ title: "Chat Home", delay: 500 }).uk-button.dtp-button-secondary.uk-button-small + span + i.fas.fa-home + + .uk-width-auto + button( + id="chat-input-btn", + type="button", + onclick="return dtp.app.chat.toggleChatInput(event);", + uk-tooltip={ title: "Toggle Chat Input", delay: 500 }, + ).uk-button.dtp-button-secondary.uk-button-small + span + i.fas.fa-edit + + .uk-width-auto + button( + id="chat-send-btn", + type="submit", + uk-tooltip={ title: "Send Message", delay: 500 }, + ).uk-button.dtp-button-primary.uk-button-small + span + i.far.fa-paper-plane + + div(uk-grid).uk-flex-between.uk-grid-small + .uk-width-auto + +renderReactionButton('Applaud/clap', '👏', 'clap') + .uk-width-auto + +renderReactionButton("On Fire!", '🔥', 'fire') + .uk-width-auto + +renderReactionButton("Happy", "🤗", "happy") + .uk-width-auto + +renderReactionButton("Laugh", "🤣", "laugh") + .uk-width-auto + +renderReactionButton("Angry", "🤬", "angry") + .uk-width-auto + +renderReactionButton("Honk", "🤡", "honk") + + //- .chat-menubar \ No newline at end of file diff --git a/app/views/chat/components/menubar.pug b/app/views/chat/components/menubar.pug new file mode 100644 index 0000000..e69de29 diff --git a/app/views/chat/components/message.pug b/app/views/chat/components/message.pug new file mode 100644 index 0000000..255d583 --- /dev/null +++ b/app/views/chat/components/message.pug @@ -0,0 +1,33 @@ +include ../../sticker/components/sticker +mixin renderChatMessage (message, options = { }) + - var authorName = message.author.displayName || message.author.username; + div(data-message-id= message._id, data-author-id= message.author._id).chat-message + div(uk-grid).uk-grid-small.uk-flex-bottom + .uk-width-expand + .uk-text-small.chat-username.uk-text-truncate= authorName + + if message.author.picture && message.author.picture.small + .uk-width-auto + img(src=`/image/${message.author.picture.small._id}`, alt= `${authorName}'s profile picture`).chat-author-image + + if !options.hideMenu && !message.author._id.equals(user._id) + .uk-width-auto.chat-user-menu + button(type="button").uk-button.uk-button-link.chat-menu-button + i.fas.fa-ellipsis-h + div(data-message-id= message._id, uk-dropdown="mode: click").dtp-chatmsg-menu + ul.uk-nav.uk-dropdown-nav + li + a( + href="", + data-message-id= message._id, + data-user-id= message.author._id, + data-username= message.author.username, + onclick="return dtp.app.muteChatUser(event);", + ) Mute #{authorName} + + .chat-content.uk-text-break!= marked.parse(message.content) + .chat-timestamp(data-created= message.created).uk-text-small + + if Array.isArray(message.stickers) && (message.stickers.length > 0) + each sticker in message.stickers + +renderSticker(sticker, { hideSlug: true }) \ No newline at end of file diff --git a/app/views/chat/components/reaction-button.pug b/app/views/chat/components/reaction-button.pug new file mode 100644 index 0000000..1b577d4 --- /dev/null +++ b/app/views/chat/components/reaction-button.pug @@ -0,0 +1,8 @@ +mixin renderReactionButton (title, emoji, reaction) + button( + uk-tooltip={ title, delay: 500 }, + data-reaction= reaction, + onclick="return dtp.app.chat.sendReaction(event);", + ).dtp-button-reaction + span.button-icon= emoji + span(class="uk-visible@l").count-label \ No newline at end of file diff --git a/app/views/chat/index.pug b/app/views/chat/index.pug new file mode 100644 index 0000000..92d63a6 --- /dev/null +++ b/app/views/chat/index.pug @@ -0,0 +1,54 @@ +extends layouts/room +block content + + form(method="POST", action="/chat").uk-form + .uk-card.uk-card-default.uk-card-small + .uk-card-header + h1.uk-card-title Create Chat Room + .uk-card-body + .uk-margin + label(for="name").uk-form-label Room name + input(id="name", name="name", type="text", placeholder="Enter room name").uk-input + + .uk-margin + label(for="description").uk-form-label Room description + textarea(id="description", name="description", rows="2", placeholder="Enter room description").uk-textarea + + .uk-margin + label(for="policy").uk-form-label Room policy + textarea(id="policy", name="policy", rows="2", placeholder="Enter room use policy").uk-textarea + + .uk-margin + div(uk-grid) + .uk-width-auto + fieldset + legend Room Visibility + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-auto + label + input(id="is-public", name="visibility", type="radio", value="public", checked).uk-radio + | Public + .ui-width-auto + label + input(id="is-private", name="visibility", type="radio", value="private").uk-radio + | Private + + .uk-width-auto + fieldset + legend Membership Policy + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-auto + label + input(id="membership-open", name="policy", type="radio", value="open", checked).uk-radio + | Open + .uk-width-auto + label + input(id="membership-closed", name="policy", type="radio", value="closed").uk-radio + | Closed + + .uk-card-footer + div(uk-grid) + .uk-width-expand + +renderBackButton() + .uk-width-auto + button(type="submit").uk-button.uk-button-primary.uk-border-rounded Create room \ No newline at end of file diff --git a/app/views/chat/layouts/room.pug b/app/views/chat/layouts/room.pug new file mode 100644 index 0000000..23eacc6 --- /dev/null +++ b/app/views/chat/layouts/room.pug @@ -0,0 +1,50 @@ +extends ../../layouts/main +block content-container + + mixin renderRoomList (rooms) + each room in ownedChatRooms + li.uk-active + a(href=`/chat/${room._id}`)= room.name + + + section.uk-section.uk-section-default.uk-section-small + .uk-container.uk-container-expand + div(uk-grid) + div(class="uk-width-1-1 uk-width-1-5@l uk-flex-first@l").uk-flex-last + .content-block.uk-border-rounded.uk-margin + if Array.isArray(ownedChatRooms) && (ownedChatRooms.length > 0) + ul#room-list.uk-nav.uk-nav-default + li.uk-nav-header Your Rooms + +renderRoomList(ownedChatRooms) + else + div You don't own any chat rooms. + + .content-block.uk-border-rounded + if Array.isArray(joinedChatRooms) && (joinedChatRooms.length > 0) + ul#room-list.uk-nav.uk-nav-default + li.uk-nav-header Joined Rooms + +renderRoomList(ownedChatRooms) + else + div You haven't joined any chat rooms. + + div(class="uk-width-1-1 uk-width-expand@l") + #chat-room + block content + + div(class="uk-width-1-1 uk-width-1-5@l") + .content-block.uk-border-rounded + if chatRoom + if Array.isArray(chatRoom.members) && (chatRoom.members.length > 0) + ul#room-member-list.uk-nav.uk-nav-default + li.uk-nav-header Room Members + each member in chatRoom.members + li + a(href="")= member.displayName || member.username + else + div The room has no members + else + div Not in a room + +block viewjs + script. + window.dtp.room = !{JSON.stringify(room)}; \ No newline at end of file diff --git a/app/views/chat/view.pug b/app/views/chat/view.pug new file mode 100644 index 0000000..9268f33 --- /dev/null +++ b/app/views/chat/view.pug @@ -0,0 +1,57 @@ +extends layouts/room +block content + + include components/input-form + include components/message + + .uk-card.uk-card-default.uk-card-small + .uk-card-header + div(uk-grid).uk-flex-middle.chat-menubar + div(uk-tooltip="Room details").uk-width-expand + h1.uk-card-title.uk-margin-remove= room.name + div= room.description + + div(uk-tooltip="Active Members").uk-width-auto.no-select + span + i.fas.fa-user + span(data-room-id= room._id).uk-margin-small-left.active-member-count= numeral(room.members.length).format('0,0') + + div(uk-tooltip="Total Members", class="uk-hidden@m").uk-width-auto.no-select + span + i.fas.fa-user + span.uk-margin-small-left= formatCount(room.members.length) + + + .uk-width-auto + button( + type="button", + data-room-id= room._id, + onclick="return dtp.app.chat.leaveRoom(event);", + ).uk-button.uk-button-small.uk-border-pill.uk-text-bold + span + i.fas.fa-user + span.uk-margin-small-left Leave Room + .uk-width-auto + .uk-inline + button(type="button").uk-button.uk-button-link.uk-button-small + i.fas.fa-ellipsis-h + div(uk-dropdown={ pos: 'bottom-right', mode: 'click' }) + ul.uk-nav.uk-dropdown-nav + li.uk-nav-heading= room.name + li.uk-nav-divider + li + a(href=`/chat/${room._id}/pop-out`, target="_blank") Pop-Out Chat + + .uk-card-body + #site-chat-container.uk-flex.uk-flex-column + #chat-message-list-wrapper + #chat-reactions + #chat-message-list + each message in chatMessages || [ ] + +renderChatMessage(message) + + .chat-message-menu + button(type="button", onclick="return dtp.app.chat.resumeChatScroll(event);").chat-scroll-return Resume scrolling + + .uk-card-footer + +renderChatInputForm(room) \ No newline at end of file diff --git a/app/views/components/off-canvas.pug b/app/views/components/off-canvas.pug index 4a535b1..fcc04e4 100644 --- a/app/views/components/off-canvas.pug +++ b/app/views/components/off-canvas.pug @@ -23,6 +23,14 @@ mixin renderMenuItem (iconClass, label) if user li.uk-nav-header Member Menu + li(class={ "uk-active": (currentView === 'chat') }) + a(href=`/chat`).uk-display-block + div(uk-grid).uk-grid-collapse + .uk-width-auto + .app-menu-icon + i.fas.fa-comment-alt + .uk-width-expand Chat + li(class={ "uk-active": (currentView === 'user-settings') }) a(href=`/user/${user._id}`).uk-display-block div(uk-grid).uk-grid-collapse diff --git a/app/views/layouts/main.pug b/app/views/layouts/main.pug index 6bd2a53..954403a 100644 --- a/app/views/layouts/main.pug +++ b/app/views/layouts/main.pug @@ -95,8 +95,9 @@ html(lang='en') if user script. - window.dtp.user = !{JSON.stringify(safeUser, null, 2)} - window.dtp.domain = !{JSON.stringify(site.domain)} + window.dtp.user = !{JSON.stringify(safeUser, null, 2)}; + window.dtp.domain = !{JSON.stringify(site.domain)}; + window.dtp.env = !{JSON.stringify(env.NODE_ENV)}; if channel script. diff --git a/app/views/sticker/components/sticker-standalone.pug b/app/views/sticker/components/sticker-standalone.pug new file mode 100644 index 0000000..e03daa3 --- /dev/null +++ b/app/views/sticker/components/sticker-standalone.pug @@ -0,0 +1,2 @@ +include sticker ++renderSticker(sticker, stickerOptions || { }) \ No newline at end of file diff --git a/app/views/sticker/components/sticker.pug b/app/views/sticker/components/sticker.pug new file mode 100644 index 0000000..13e4c44 --- /dev/null +++ b/app/views/sticker/components/sticker.pug @@ -0,0 +1,19 @@ +mixin renderSticker (sticker, options = { }) + if sticker && sticker.encoded + div( + title= `:${sticker.slug}:`, + data-sticker-id= sticker._id, + data-sticker-slug= sticker.slug, + onclick= 'return dtp.app.showStickerMenu(event);', + ).chat-sticker.uk-text-center + case sticker.encoded.type + when 'video/mp4' + video(playsinline, autoplay, muted, loop) + source(src=`/sticker/${sticker._id}/media`) + when 'image/png' + img(src=`/sticker/${sticker._id}/media`) + when 'image/jpg' + img(src=`/sticker/${sticker._id}/media`) + + if !options.hideSlug + .uk-text-small.uk-text-muted :#{sticker.slug}: \ No newline at end of file diff --git a/app/views/sticker/index.pug b/app/views/sticker/index.pug new file mode 100644 index 0000000..8151283 --- /dev/null +++ b/app/views/sticker/index.pug @@ -0,0 +1,69 @@ +extends ../layouts/main +block content + + include ../sticker/components/sticker + + mixin renderStickerList (stickers) + div(uk-grid).uk-grid-small + each sticker in stickers + div(class="uk-width-1-1 uk-width-auto@s") + a(href=`/sticker/${sticker._id}`).uk-display-block.uk-text-center + +renderSticker(sticker) + + mixin renderStickerUploadForm (actionUrl, channel) + form(method="POST", action= actionUrl, enctype="multipart/form-data").uk-form + if channel + input(id="channel-id", type="hidden", name="channel", value= channel._id) + + .uk-margin + input(id="sticker-slug", name="slug", type="text", placeholder= "Enter sticker name").uk-input + + div(uk-grid).uk-grid-small + .uk-width-auto + .uk-form-custom + input(id="sticker-file", name="stickerFile", type="file") + button(type="button").uk-button.dtp-button-default + span + i.far.fa-image + span.uk-margin-small-left Select File + + .uk-width-auto + button(type="submit").uk-button.dtp-button-primary + span + i.fas.fa-plus + span.uk-margin-small-left Add sticker + + section.uk-section.uk-section-default.uk-section-small + .uk-container + div(uk-grid) + div(class="uk-width-1-1 uk-width-2-3@l") + h2 #{user.displayName || user.username}'s stickers + + .uk-margin + +renderStickerUploadForm('/sticker') + + .uk-margin + if Array.isArray(userStickers) && (userStickers.length > 0) + +renderStickerList(userStickers) + else + div #{user.displayName || user.username} has no stickers. + + if channel + section.uk-section.uk-section-default.uk-section-small + .uk-container + h2 #{channel.name}'s stickers + + .uk-margin + +renderStickerUploadForm('/sticker', channel) + + .uk-margin + if Array.isArray(channelStickers) && (channelStickers.length > 0) + +renderStickerList(channelStickers) + else + div #{channel.name} has no stickers. + + div(class="uk-width-1-1 uk-width-1-3@l") + h1 Stickers + p Stickers accepts PNG, JPEG, GIF and MP4 files. Transparency/alpha is supported in the PNG format. Animations will be transcoded to MP4. + p Stickers can be up to 2MB, and animated stickers are limited to 10 seconds in duration regardless of format. Resolutions from 100x100 to 320x100 are accepted. Larger Stickers will be scaled to cover an aspect-correct reduction, if possible. Some cropping may occur. + p Stickers may not contain pornography. Animated stickers with audio will have the audio data removed as part of their conversion, all Stickers are silent loops. \ No newline at end of file diff --git a/app/views/sticker/menu.pug b/app/views/sticker/menu.pug new file mode 100644 index 0000000..6d25206 --- /dev/null +++ b/app/views/sticker/menu.pug @@ -0,0 +1,2 @@ +h1 This is the sticker menu +//- pre= JSON.stringify(user, null, 2) \ No newline at end of file diff --git a/app/views/sticker/view.pug b/app/views/sticker/view.pug new file mode 100644 index 0000000..4abcd0e --- /dev/null +++ b/app/views/sticker/view.pug @@ -0,0 +1,26 @@ +extends ../layouts/main +block content + + include ../sticker/components/sticker + + section.uk-section.uk-section-default.uk-section-small + .uk-container + .uk-card.uk-card-default + .uk-card-header.uk-text-center + h1.uk-card-title :#{sticker.slug}: + .uk-card-body + .uk-margin-small + +renderSticker(sticker) + .uk-text-small.uk-text-center + div #{sticker.encoded.type}, orig: #{numeral(sticker.original.size).format('0,0.0a')}, encoded: #{numeral(sticker.encoded.size).format('0,0.0a')} + .uk-card-footer + div(uk-grid).uk-flex-center + .uk-width-auto + button( + type="button", + data-sticker-id= sticker._id, + data-sticker-slug= sticker.slug, + onclick="return dtp.app.deleteSticker(event);", + ).uk-button.dtp-button-danger Remove Sticker + .uk-width-auto + include ../components/back-button \ No newline at end of file diff --git a/app/workers/host-services.js b/app/workers/host-services.js index ffedc5a..8b2f9ab 100644 --- a/app/workers/host-services.js +++ b/app/workers/host-services.js @@ -32,7 +32,7 @@ module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); module.config = { environment: process.env.NODE_ENV, root: module.rootPath, - component: { name: 'DTP Host Services', slug: 'host-services' }, + component: { name: 'hostServicesWorker', slug: 'host-services-worker' }, site: require(path.join(module.rootPath, 'config', 'site')), http: require(path.join(module.rootPath, 'config', 'http')), }; diff --git a/app/workers/newsletter.js b/app/workers/newsletter.js index 5a75f32..9efb0a3 100644 --- a/app/workers/newsletter.js +++ b/app/workers/newsletter.js @@ -13,7 +13,7 @@ const { SiteWorker, SiteLog } = require(path.join(__dirname, '..', '..', 'lib', module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); module.config = { - component: { name: 'Newsletter Worker', slug: 'newsletter' }, + component: { name: 'newsletterWorker', slug: 'newsletter-worker' }, root: path.resolve(__dirname, '..', '..'), }; diff --git a/app/workers/reeeper.js b/app/workers/reeeper.js index 4e0d299..2103a9c 100644 --- a/app/workers/reeeper.js +++ b/app/workers/reeeper.js @@ -23,7 +23,7 @@ module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); module.config = { environment: process.env.NODE_ENV, root: module.rootPath, - component: { name: 'DTP Reeeper', slug: 'reeeper' }, + component: { name: 'reeeper', slug: 'reeeper' }, }; module.config.site = require(path.join(module.rootPath, 'config', 'site')); diff --git a/app/workers/sample-worker.js b/app/workers/sample-worker.js index 34e1cac..c5298ec 100644 --- a/app/workers/sample-worker.js +++ b/app/workers/sample-worker.js @@ -20,7 +20,7 @@ module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); module.config = { environment: process.env.NODE_ENV, root: module.rootPath, - component: { name: 'Sample Worker', slug: 'sample-worker' }, + component: { name: 'sampleWorker', slug: 'sample-worker' }, }; module.config.site = require(path.join(module.rootPath, 'config', 'site')); diff --git a/app/workers/stickers.js b/app/workers/stickers.js new file mode 100644 index 0000000..cce69c3 --- /dev/null +++ b/app/workers/stickers.js @@ -0,0 +1,356 @@ +// stickers.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const DTP_STICKER_HEIGHT = 100; + +const path = require('path'); +const fs = require('fs'); +require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); + +const mongoose = require('mongoose'); + +const { SitePlatform, SiteLog, SiteWorker } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); + +const sharp = require('sharp'); + +module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); +module.config = { + environment: process.env.NODE_ENV, + root: path.resolve(__dirname, '..', '..'), + component: { name: 'stickersWorker', slug: 'stickers-worker' }, +}; + +class StickerWorker extends SiteWorker { + + constructor (dtp) { + super(dtp, dtp.config.component); + + this.processors = { + processStickerSharp: this.processStickerSharp.bind(this), + processStickerFFMPEG: this.processStickerFFMPEG.bind(this), + }; + } + + async start ( ) { + await super.start(); + const { jobQueue: jobQueueService } = this.dtp.services; + + this.log.info('registering sticker-ingest job processor', { + config: this.dtp.config.jobQueues['sticker-ingest'], + }); + this.stickerProcessingQueue = jobQueueService.getJobQueue( + 'sticker-ingest', + this.dtp.config.jobQueues['sticker-ingest'], + ); + + this.stickerProcessingQueue.process('sticker-ingest', 1, this.processStickerIngest.bind(this)); + this.stickerProcessingQueue.process('sticker-delete', 1, this.processStickerDelete.bind(this)); + } + + async stop ( ) { + if (this.stickerProcessingQueue) { + try { + this.log.info('closing sticker-ingest job queue'); + await this.stickerProcessingQueue.close(); + delete this.stickerProcessingQueue; + } catch (error) { + this.log.error('failed to close sticker ingest job queue', { error }); + // fall through + } + } + await super.stop(); + } + + async processStickerIngest (job) { + try { + this.log.info('received sticker ingest job', { id: job.id, data: job.data }); + await this.fetchSticker(job); // defines jobs.data.processor + await this.resetSticker(job); + + // call the chosen file processor to render the sticker for distribution + await this.processors[job.data.processor](job); + + //TODO: emit a completion event which should cause a refresh of the + // creator's view to display the processed sticker + } catch (error) { + this.log.error('failed to process sticker', { stickerId: job.data.stickerId, error }); + throw error; + } finally { + if (job.data.workPath) { + this.log.info('cleaning up sticker work path', { workPath: job.data.workPath }); + await fs.promises.rm(job.data.workPath, { recursive: true }); + } + } + } + + async fetchSticker (job) { + const { minio: minioService, sticker: stickerService } = this.dtp.services; + job.data.sticker = await stickerService.getById(job.data.stickerId, true); + + job.data.workPath = path.join( + process.env.DTP_STICKER_WORK_PATH, + this.dtp.config.component.slug, + job.data.sticker._id.toString(), + ); + + this.jobLog(job, 'creating work directory', { worthPath: job.data.workPath }); + await fs.promises.mkdir(job.data.workPath, { recursive: true }); + + switch (job.data.sticker.original.type) { + case 'image/jpeg': + job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.jpg`); + job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.jpg`); + job.data.processor = 'processStickerSharp'; + job.data.sharpFormat = 'jpeg'; + job.data.sharpFormatParameters = { quality: 85 }; + break; + + case 'image/png': + job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.png`); + job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.png`); + job.data.processor = 'processStickerSharp'; + job.data.sharpFormat = 'png'; + job.data.sharpFormatParameters = { compression: 9 }; + break; + + case 'image/gif': + job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.gif`); + job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.mp4`); + job.data.processor = 'processStickerFFMPEG'; + break; + + case 'image/webp': // process as PNG + job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.webp`); + job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.png`); + job.data.processor = 'processStickerSharp'; + job.data.sharpFormat = 'png'; + job.data.sharpFormatParameters = { compression: 9 }; + break; + + case 'image/webm': // process as MP4 + job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.webm`); + job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.mp4`); + job.data.processor = 'processStickerFFMPEG'; + break; + + case 'video/mp4': + job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.mp4`); + job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.mp4`); + job.data.processor = 'processStickerFFMPEG'; + break; + + default: + throw new Error(`unsupported sticker type: ${job.data.sticker.original.type}`); + } + + this.jobLog(job, 'fetching original media', { // blah 62096adcb69874552a0f87bc + stickerId: job.data.sticker._id, + slug: job.data.sticker.slug, + type: job.data.sticker.original.type, + worthPath: job.data.origFilePath, + }); + await minioService.downloadFile({ + bucket: job.data.sticker.original.bucket, + key: job.data.sticker.original.key, + filePath: job.data.origFilePath, + }); + } + + async resetSticker (job) { + const { minio: minioService } = this.dtp.services; + const { sticker } = job.data; + + if (!sticker.encoded) { + return; + } + + this.log.info('removing existing encoded sticker media', { media: sticker.encoded }); + await minioService.removeObject(sticker.encoded.bucket, sticker.encoded.key); + + // switch sticker back to 'processing' status to prevent use in the app + const Sticker = mongoose.model('Sticker'); + await Sticker.updateOne( + { _id: job.data.sticker._id }, + { + $set: { + status: 'processing', + }, + $unset: { + encoded: '', + }, + }, + ); + + delete sticker.encoded; + } + + async processStickerSharp (job) { + const { minio: minioService } = this.dtp.services; + + const sharpImage = sharp(job.data.origFilePath); + const metadata = await sharpImage.metadata(); + this.log.info('sticker metadata from Sharp', { stickerId: job.data.sticker._id, metadata }); + + let chain = sharpImage + .clone() + .toColorspace('srgb') + .resize({ height: DTP_STICKER_HEIGHT }); + + chain = chain[job.data.sharpFormat](job.data.sharpFormatParameters); + + await chain.toFile(job.data.outFilePath); + + job.data.outFileStat = await fs.promises.stat(job.data.outFilePath); + + const bucket = process.env.MINIO_VIDEO_BUCKET; + const key = `/stickers/${job.data.sticker._id.toString().slice(0, 3)}/${job.data.sticker._id}.encoded.${job.data.sharpFormat}`; + + await minioService.uploadFile({ + bucket, + key, + filePath: job.data.outFilePath, + metadata: { + 'Content-Type': `image/${job.data.sharpFormat}`, + 'Content-Length': job.data.outFileStat.size, + }, + }); + + const Sticker = mongoose.model('Sticker'); + await Sticker.updateOne( + { _id: job.data.sticker._id }, + { + $set: { + status: 'live', + encoded: { + bucket, + key, + type: `image/${job.data.sharpFormat}`, + size: job.data.outFileStat.size, + } + }, + }, + ); + } + + async processStickerFFMPEG (job) { + const { media: mediaService, minio: minioService } = this.dtp.services; + + const codecVideo = (process.env.DTP_GPU_ACCELERATION === 'enabled') ? 'h264_nvenc' : 'libx264'; + + // generate the encoded sticker + // Output height is 100 lines by [aspect] width with width and height being + // padded to be divisible by 2. The video stream is given a bit rate of + // 128Kbps, and the media is flagged for +faststart. Audio is stripped if + // present. + + const ffmpegStickerArgs = [ + '-y', '-i', job.data.origFilePath, + '-vf', `scale=-1:${DTP_STICKER_HEIGHT},pad=ceil(iw/2)*2:ceil(ih/2)*2`, + '-pix_fmt', 'yuv420p', + '-c:v', codecVideo, + '-b:v', '128k', + '-movflags', '+faststart', + '-an', + job.data.outFilePath, + ]; + + this.jobLog(job, `transcoding motion sticker: ${job.data.sticker.slug}`); + this.log.debug('transcoding motion sticker', { ffmpegStickerArgs }); + await mediaService.ffmpeg(ffmpegStickerArgs); + + job.data.outFileStat = await fs.promises.stat(job.data.outFilePath); + + const bucket = process.env.MINIO_VIDEO_BUCKET; + const key = `/stickers/${job.data.sticker._id.toString().slice(0, 3)}/${job.data.sticker._id}.encoded.mp4`; + + this.jobLog(job, 'uploading encoded media file'); + await minioService.uploadFile({ + bucket, key, + filePath: job.data.outFilePath, + metadata: { + 'Content-Type': 'video/mp4', + 'Content-Length': job.data.outFileStat.size, + }, + }); + + this.jobLog(job, 'updating Sticker to live status'); + + const Sticker = mongoose.model('Sticker'); + await Sticker.updateOne( + { _id: job.data.sticker._id }, + { + $set: { + status: 'live', + encoded: { + bucket, + key, + type: 'video/mp4', + size: job.data.outFileStat.size, + }, + }, + }, + ); + } + + async processStickerDelete (job) { + const { minio: minioService, sticker: stickerService } = this.dtp.services; + const Sticker = mongoose.model('Sticker'); + try { + const sticker = await stickerService.getById(job.data.stickerId, true); + + this.log.info('removing original media', { stickerId: sticker._id, slug: sticker.slug }); + await minioService.removeObject(sticker.original.bucket, sticker.original.key); + + if (sticker.encoded) { + this.log.info('removing encoded media', { stickerId: sticker._id, slug: sticker.slug }); + await minioService.removeObject(sticker.encoded.bucket, sticker.encoded.key); + } + + this.log.info('removing sticker', { stickerId: sticker._id, slug: sticker.slug }); + await Sticker.deleteOne({ _id: sticker._id }); + } catch (error) { + this.log.error('failed to delete sticker', { stickerId: job.data.stickerId, error }); + throw error; // for job report + } + } + + async jobLog (job, message, data = { }) { + job.log(message); + this.log.info(message, { jobId: job.id, ...data }); + } +} + +(async ( ) => { + try { + module.log = new SiteLog(module, module.config.componentName); + + /* + * Platform startup + */ + await SitePlatform.startPlatform(module, module.config.component); + + module.worker = new StickerWorker(module); + await module.worker.start(); + + /* + * Worker startup + */ + + if (process.argv[2]) { + const stickerId = mongoose.Types.ObjectId(process.argv[2]); + this.log.info('creating sticker processing job', { stickerId }); + await module.worker.stickerProcessingQueue.add('sticker-ingest', { stickerId }); + } + + module.log.info(`${module.pkg.name} v${module.pkg.version} ${module.config.component.name} started`); + } catch (error) { + module.log.error('failed to start worker', { + component: module.config.component, + error, + }); + process.exit(-1); + } +})(); \ No newline at end of file diff --git a/client/js/site-app.js b/client/js/site-app.js index ef739cc..7ccf1c6 100644 --- a/client/js/site-app.js +++ b/client/js/site-app.js @@ -14,6 +14,7 @@ import QRCode from 'qrcode'; import Cropper from 'cropperjs'; import { EmojiButton } from '@joeattardi/emoji-button'; +import SiteChat from './site-chat'; const GRID_COLOR = 'rgb(64, 64, 64)'; const GRID_TICK_COLOR = 'rgb(192,192,192)'; @@ -27,26 +28,24 @@ export default class DtpSiteApp extends DtpApp { constructor (user) { super(DTP_COMPONENT, user); - this.log.debug('constructor', 'app instance created'); - - this.chat = { - form: document.querySelector('#chat-input-form'), - messageList: document.querySelector('#chat-message-list'), - messages: [ ], - messageMenu: document.querySelector('.chat-message-menu'), - input: document.querySelector('#chat-input-text'), - isAtBottom: true, - }; + + if (dtp.env === 'production') { + const isSafari = !!navigator.userAgent.match(/Version\/[\d\.]+.*Safari/); + const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; + this.isIOS = isSafari || isIOS; + } else { + this.isIOS = false; + } + + this.log.debug('constructor', 'app instance created', { + env: dtp.env, + isIOS: this.isIOS, + }); this.emojiPicker = new EmojiButton({ theme: 'dark' }); this.emojiPicker.on('emoji', this.onEmojiSelected.bind(this)); - if (this.chat.messageList) { - this.chat.messageList.addEventListener('scroll', this.onChatMessageListScroll.bind(this)); - } - if (this.chat.input) { - this.chat.input.addEventListener('keydown', this.onChatInputKeyDown.bind(this)); - } + this.chat = new SiteChat(this); this.charts = {/* will hold rendered charts */}; @@ -71,99 +70,16 @@ export default class DtpSiteApp extends DtpApp { if (this.user) { const { socket } = this.socket; socket.on('user-chat', this.onUserChat.bind(this)); + socket.on('user-react', this.onUserReact.bind(this)); } } - async onChatInputKeyDown (event) { - this.log.info('onChatInputKeyDown', 'chat input received', { event }); - if (event.key === 'Enter' && !event.shiftKey) { - return this.sendUserChat(event); - } - } - - async sendUserChat (event) { - event.preventDefault(); - - if (!dtp.channel || !dtp.channel._id) { - UIkit.modal.alert('There is a problem with Chat. Please refresh the page.'); - return; - } - - const channelId = dtp.channel._id; - this.log.info('chat form', channelId); - - const content = this.chat.input.value; - this.chat.input.value = ''; - - if (content.length === 0) { - return true; - } - - this.log.info('sendUserChat', 'sending chat message', { channel: this.user._id, content }); - this.socket.sendUserChat(channelId, content); - - // set focus back to chat input - this.chat.input.focus(); - - return true; - } - async onUserChat (message) { - this.log.info('onUserChat', 'message received', { user: message.user, content: message.content }); - - const chatMessage = document.createElement('div'); - chatMessage.classList.add('uk-margin-small'); - chatMessage.classList.add('chat-message'); - - const chatUser = document.createElement('div'); - chatUser.classList.add('uk-text-small'); - chatUser.classList.add('chat-username'); - chatUser.textContent = message.user.username; - chatMessage.appendChild(chatUser); - - const chatContent = document.createElement('div'); - chatContent.classList.add('chat-content'); - chatContent.innerHTML = message.content; - chatMessage.appendChild(chatContent); - - if (Array.isArray(message.stickers) && message.stickers.length) { - message.stickers.forEach((sticker) => { - const chatContent = document.createElement('div'); - chatContent.classList.add('chat-sticker'); - chatContent.innerHTML = ``; - chatMessage.appendChild(chatContent); - }); - } - - this.chat.messageList.appendChild(chatMessage); - this.chat.messages.push(chatMessage); - - while (this.chat.messages.length > 50) { - const message = this.chat.messages.shift(); - this.chat.messageList.removeChild(message); - } - if (this.chat.isAtBottom) { - this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight); - } + await this.chat.appendUserChat(message); } - async onChatMessageListScroll (/* event */) { - const prevBottom = this.chat.isAtBottom; - const scrollPos = this.chat.messageList.scrollTop + this.chat.messageList.clientHeight; - - this.chat.isAtBottom = scrollPos >= this.chat.messageList.scrollHeight; - if (this.chat.isAtBottom !== prevBottom) { - this.log.info('onChatMessageListScroll', 'at-bottom status change', { atBottom: this.chat.isAtBottom }); - if (this.chat.isAtBottom) { - this.chat.messageMenu.classList.remove('chat-menu-visible'); - } else { - this.chat.messageMenu.classList.add('chat-menu-visible'); - } - } - } - - async resumeChatScroll ( ) { - this.chat.messageList.scrollTop = this.chat.messageList.scrollHeight; + async onUserReact (message) { + await this.chat.createEmojiReact(message); } async goBack ( ) { @@ -547,7 +463,9 @@ export default class DtpSiteApp extends DtpApp { async showEmojiPicker (event) { const targetElementName = (event.currentTarget || event.target).getAttribute('data-target-element'); this.emojiTargetElement = document.getElementById(targetElementName); - + if (!this.emojiTargetElement) { + return UIkit.modal.alert('Emoji picker target element does not exist'); + } this.emojiPicker.togglePicker(this.emojiTargetElement); } diff --git a/client/js/site-chat.js b/client/js/site-chat.js new file mode 100644 index 0000000..0a345e0 --- /dev/null +++ b/client/js/site-chat.js @@ -0,0 +1,347 @@ +// site-chat.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const DTP_COMPONENT = { name: 'Site Chat', slug: 'site-chat' }; +const dtp = window.dtp = window.dtp || { }; // jshint ignore:line + +const EMOJI_EXPLOSION_DURATION = 8000; +const EMOJI_EXPLOSION_INTERVAL = 100; + +import DtpLog from 'dtp/dtp-log.js'; +import SiteReactions from './site-reactions.js'; + +export default class SiteChat { + + constructor (app) { + this.app = app; + this.log = new DtpLog(DTP_COMPONENT); + + this.ui = { + form: document.querySelector('#chat-input-form'), + messageList: document.querySelector('#chat-message-list'), + messages: [ ], + messageMenu: document.querySelector('.chat-message-menu'), + input: document.querySelector('#chat-input-text'), + isAtBottom: true, + isModifying: false, + }; + + if (this.ui.messageList) { + this.ui.messageList.addEventListener('scroll', this.onChatMessageListScroll.bind(this)); + this.updateTimestamps(); + setTimeout(( ) => { + this.log.info('constructor', 'scrolling chat', { top: this.ui.messageList.scrollHeight }); + this.ui.messageList.scrollTo({ top: this.ui.messageList.scrollHeight, behavior: 'instant' }); + }, 100); + this.ui.reactions = new SiteReactions(); + this.lastReaction = new Date(); + } + if (this.ui.input) { + this.ui.input.addEventListener('keydown', this.onChatInputKeyDown.bind(this)); + } + + this.lastReaction = new Date(); + + if (window.localStorage) { + this.mutedUsers = window.localStorage.mutedUsers ? JSON.parse(window.localStorage.mutedUsers) : [ ]; + this.filterChatView(); + } + } + + async filterChatView ( ) { + this.mutedUsers.forEach((block) => { + document.querySelectorAll(`.chat-message[data-author-id="${block.userId}"]`).forEach((message) => { + message.parentElement.removeChild(message); + }); + }); + } + + async toggleChatInput (event) { + event.preventDefault(); + event.stopPropagation(); + + this.ui.input.toggleAttribute('hidden'); + if (this.ui.input.getAttribute('hidden')) { + this.ui.input.focus(); + } + + return true; + } + + async openChatInput ( ) { + if (this.ui.input.hasAttribute('hidden')) { + this.ui.input.removeAttribute('hidden'); + } + return true; + } + + async onChatInputKeyDown (event) { + if (event.key === 'Enter' && !event.shiftKey) { + return this.sendUserChat(event); + } + } + + async onChatMessageListScroll (/* event */) { + const prevBottom = this.ui.isAtBottom; + const scrollPos = this.ui.messageList.scrollTop + this.ui.messageList.clientHeight; + + this.ui.isAtBottom = scrollPos >= (this.ui.messageList.scrollHeight - 8); + if (this.ui.isAtBottom !== prevBottom) { + this.log.info('onChatMessageListScroll', 'at-bottom status change', { atBottom: this.ui.isAtBottom }); + if (this.ui.isAtBottom) { + this.ui.messageMenu.classList.remove('chat-menu-visible'); + } else { + this.ui.messageMenu.classList.add('chat-menu-visible'); + } + } + } + + async resumeChatScroll ( ) { + this.ui.messageList.scrollTop = this.ui.messageList.scrollHeight; + } + + async sendUserChat (event) { + event.preventDefault(); + + if (!dtp.room || !dtp.room._id) { + UIkit.modal.alert('There is a problem with Chat. Please refresh the page.'); + return; + } + + const roomId = dtp.room._id; + const content = this.ui.input.value; + this.ui.input.value = ''; + + if (content.length === 0) { + return true; + } + + this.log.info('sendUserChat', 'sending chat message', { roomId, content }); + this.app.socket.emit('user-chat', { + channelType: 'ChatRoom', + channel: roomId, + content, + }); + + // set focus back to chat input + this.ui.input.focus(); + + return true; + } + + async sendReaction (event) { + const NOW = new Date(); + if (NOW - this.lastReaction < 1000) { + return; + } + this.lastReaction = NOW; + + const target = event.currentTarget || event.target; + if (!target) { + return; + } + + const reaction = target.getAttribute('data-reaction'); + this.log.info('sendReaction', 'sending user reaction', { reaction }); + this.app.socket.emit('user-react', { + subjectType: 'ChatRoom', + subject: dtp.room._id, + reaction, + }); + } + + async appendUserChat (message) { + const isAtBottom = this.ui.isAtBottom; + + this.log.info('appendUserChat', 'message received', { user: message.user, content: message.content }); + if (this.mutedUsers.find((block) => block.userId === message.user._id)) { + this.log.info('appendUserChat', 'message is from blocked user', { + _id: message.user._id, + username: message.user.username, + }); + return; // sender is blocked by local user on this device + } + + const chatMessage = document.createElement('div'); + chatMessage.setAttribute('data-message-id', message._id); + chatMessage.setAttribute('data-author-id', message.user._id); + chatMessage.classList.add('uk-margin-small'); + chatMessage.classList.add('chat-message'); + + const userGrid = document.createElement('div'); + userGrid.setAttribute('uk-grid', ''); + userGrid.classList.add('uk-grid-small'); + userGrid.classList.add('uk-flex-middle'); + chatMessage.appendChild(userGrid); + + const usernameColumn = document.createElement('div'); + usernameColumn.classList.add('uk-width-expand'); + userGrid.appendChild(usernameColumn); + + const chatUser = document.createElement('div'); + const authorName = message.user.displayName || message.user.username; + chatUser.classList.add('uk-text-small'); + chatUser.classList.add('chat-username'); + chatUser.textContent = authorName; + usernameColumn.appendChild(chatUser); + + if (message.user.picture && message.user.picture.small) { + const chatUserPictureColumn = document.createElement('div'); + chatUserPictureColumn.classList.add('uk-width-auto'); + userGrid.appendChild(chatUserPictureColumn); + + const chatUserPicture = document.createElement('img'); + chatUserPicture.classList.add('chat-author-image'); + chatUserPicture.setAttribute('src', `/image/${message.user.picture.small._id}`); + chatUserPicture.setAttribute('alt', `${authorName}'s profile picture`); + chatUserPictureColumn.appendChild(chatUserPicture); + } + + if (dtp.user && (dtp.user._id !== message.user._id)) { + const menuColumn = document.createElement('div'); + menuColumn.classList.add('uk-width-auto'); + menuColumn.classList.add('chat-user-menu'); + userGrid.appendChild(menuColumn); + + const menuButton = document.createElement('button'); + menuButton.setAttribute('type', 'button'); + menuButton.classList.add('uk-button'); + menuButton.classList.add('uk-button-link'); + menuButton.classList.add('uk-button-small'); + menuColumn.appendChild(menuButton); + + const menuIcon = document.createElement('i'); + menuIcon.classList.add('fas'); + menuIcon.classList.add('fa-ellipsis-h'); + menuButton.appendChild(menuIcon); + + const menuDropdown = document.createElement('div'); + menuDropdown.setAttribute('data-message-id', message._id); + menuDropdown.setAttribute('uk-dropdown', 'mode: click'); + menuColumn.appendChild(menuDropdown); + + const dropdownList = document.createElement('ul'); + dropdownList.classList.add('uk-nav'); + dropdownList.classList.add('uk-dropdown-nav'); + menuDropdown.appendChild(dropdownList); + + let dropdownListItem = document.createElement('li'); + dropdownList.appendChild(dropdownListItem); + + let link = document.createElement('a'); + link.setAttribute('href', ''); + link.setAttribute('data-message-id', message._id); + link.setAttribute('data-user-id', message.user._id); + link.setAttribute('data-username', message.user.username); + link.setAttribute('onclick', "return dtp.app.muteChatUser(event);"); + link.textContent = `Mute ${message.user.displayName || message.user.username}`; + dropdownListItem.appendChild(link); + } + + const chatContent = document.createElement('div'); + chatContent.classList.add('chat-content'); + chatContent.classList.add('uk-text-break'); + chatContent.innerHTML = message.content; + chatMessage.appendChild(chatContent); + + const chatTimestamp = document.createElement('div'); + chatTimestamp.classList.add('chat-timestamp'); + chatTimestamp.classList.add('uk-text-small'); + chatTimestamp.textContent = moment(message.created).format('hh:mm:ss a'); + chatMessage.appendChild(chatTimestamp); + + if (Array.isArray(message.stickers) && message.stickers.length) { + message.stickers.forEach((sticker) => { + const chatContent = document.createElement('div'); + chatContent.classList.add('chat-sticker'); + chatContent.setAttribute('title', `:${sticker.slug}:`); + chatContent.setAttribute('data-sticker-id', sticker._id); + switch (sticker.encoded.type) { + case 'video/mp4': + chatContent.innerHTML = ``; + break; + case 'image/png': + chatContent.innerHTML = ``; + break; + case 'image/jpeg': + chatContent.innerHTML = ``; + break; + } + chatMessage.appendChild(chatContent); + }); + } + + this.ui.isModifying = true; + this.ui.messageList.appendChild(chatMessage); + this.ui.messages.push(chatMessage); + + while (this.ui.messages.length > 50) { + const message = this.ui.messages.shift(); + this.ui.messageList.removeChild(message); + } + + if (isAtBottom) { + /* + * This is jank. I don't know why I had to add this jank, but it is jank. + * The browser started emitting a scroll event *after* I issue this scroll + * command to return to the bottom of the view. So, I have to issue the + * scroll, let it fuck up, and issue the scroll again. I don't care why. + */ + this.ui.messageList.scrollTo(0, this.ui.messageList.scrollHeight); + setTimeout(( ) => { + this.ui.isAtBottom = true; + this.ui.messageList.scrollTo(0, this.ui.messageList.scrollHeight); + this.ui.isModifying = false; + }, 25); + } + } + + updateTimestamps ( ) { + const timestamps = document.querySelectorAll('div.chat-timestamp[data-created]'); + timestamps.forEach((timestamp) => { + const created = timestamp.getAttribute('data-created'); + timestamp.textContent = moment(created).format('hh:mm:ss a'); + }); + } + + createEmojiReact (message) { + this.ui.reactions.create(message.reaction); + } + + triggerEmojiExplosion ( ) { + const reactions = ['happy', 'angry', 'honk', 'clap', 'fire', 'laugh']; + const stopHandler = this.stopEmojiExplosion.bind(this); + + if (this.emojiExplosionTimeout && this.emojiExplosionInterval) { + clearTimeout(this.emojiExplosionTimeout); + this.emojiExplosionTimeout = setTimeout(stopHandler, EMOJI_EXPLOSION_DURATION); + return; + } + + // spawn 10 emoji reacts per second until told to stop + this.emojiExplosionInterval = setInterval(( ) => { + // choose a random reaction from the list of available reactions and + // spawn it. + const reaction = reactions[Math.floor(Math.random() * reactions.length)]; + this.ui.reactions.create({ reaction }); + }, EMOJI_EXPLOSION_INTERVAL); + + // set a timeout to stop the explosion + this.emojiExplosionTimeout = setTimeout(stopHandler, EMOJI_EXPLOSION_DURATION); + } + + stopEmojiExplosion ( ) { + if (!this.emojiExplosionTimeout || !this.emojiExplosionInterval) { + return; + } + + clearTimeout(this.emojiExplosionTimeout); + delete this.emojiExplosionTimeout; + + clearInterval(this.emojiExplosionInterval); + delete this.emojiExplosionInterval; + } +} \ No newline at end of file diff --git a/client/js/site-reactions.js b/client/js/site-reactions.js new file mode 100644 index 0000000..a6116fb --- /dev/null +++ b/client/js/site-reactions.js @@ -0,0 +1,149 @@ +// site-reactions.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const DTP_COMPONENT = { name: 'Site Reactions', slug: 'site-reactions' }; +const dtp = window.dtp = window.dtp || { }; // jshint ignore:line + +import DtpLog from 'dtp/dtp-log'; + +class Reaction { + + constructor (container, reaction) { + this.container = container; + this.reaction = reaction; + + this.element = document.createElement('span'); + this.element.classList.add('reaction-icon'); + + switch (this.reaction.reaction) { + case 'clap': + this.element.textContent = '👏'; + break; + + case 'fire': + this.element.textContent = '🔥'; + break; + + case 'happy': + this.element.textContent = '🤗'; + break; + + case 'laugh': + this.element.textContent = '🤣'; + break; + + case 'angry': + this.element.textContent = '🤬'; + break; + + case 'honk': + this.element.textContent = '🤡'; + break; + } + + this.position = { + x: Math.random() * (this.container.offsetWidth - 32.0), + y: this.container.clientHeight, + }; + + this.opacity = 1.0; + this.moveSpeed = 150.0 + (Math.random() * 50.0); + + this.rotation = 0.0; + this.rotationDelta = 60.0 + (Math.random() * 15.0); + + this.container.appendChild(this.element); + } + + update (elapsed) { + const scale = elapsed / 1000.0; + this.position.y -= this.moveSpeed * scale; + this.rotation += this.rotationDelta * scale; + if (this.rotation > 30 || this.rotation < -30) { + this.rotationDelta = -this.rotationDelta; + } + + const adjustedY = this.position.y + this.element.offsetHeight; + if (adjustedY > 100) { + return; + } + if (adjustedY === 0) { + this.opacity = 0.0; + return; + } + + this.opacity = adjustedY / 100.0; + } + + render ( ) { + this.element.style.left = `${this.position.x}px`; + this.element.style.top = `${this.position.y}px`; + + if (this.opacity > 0.8) { this.opacity = 0.8; } + this.element.style.opacity = this.opacity; + + const transform = `rotate(${this.rotation}deg)`; + this.element.style.transform = transform; + } + + destroy ( ) { + this.container.removeChild(this.element); + } +} + +export default class SiteReactions { + + constructor ( ) { + this.log = new DtpLog(DTP_COMPONENT); + + this.container = document.querySelector('#chat-reactions'); + this.reactions = [ ]; + + this.updateHandler = this.onUpdate.bind(this); + } + + create (reaction) { + const react = new Reaction(this.container, reaction); + this.reactions.push(react); + + if (this.reactions.length === 1) { + this.lastUpdate = new Date(); + requestAnimationFrame(this.updateHandler); + } + } + + onUpdate ( ) { + const NOW = new Date(); + const elapsed = NOW - this.lastUpdate; + const expired = [ ]; + + for (const reaction of this.reactions) { + reaction.update(elapsed); + if (reaction.position.y <= -(reaction.element.offsetHeight)) { + expired.push(reaction); + } else { + reaction.render(); + } + } + + expired.forEach((react) => { + const idx = this.reactions.indexOf(react); + if (idx === -1) { + return; + } + react.destroy(); + this.reactions.splice(idx, 1); + }); + + if (this.reactions.length > 0) { + requestAnimationFrame(this.updateHandler); + } + + this.lastUpdate = NOW; + } +} + +dtp.SiteReactions = SiteReactions; \ No newline at end of file diff --git a/client/less/site/button.less b/client/less/site/button.less index a4a1cc1..99c6eac 100644 --- a/client/less/site/button.less +++ b/client/less/site/button.less @@ -150,3 +150,17 @@ button.uk-button.dtp-button-danger { } } +.dtp-button-reaction { + background: none; + border: none; + outline: none; + cursor: pointer; + + span.button-icon { + font-size: 18px; + } + + span.count-label { + color: #e8e8e8; + } +} \ No newline at end of file diff --git a/client/less/site/chat.less b/client/less/site/chat.less index 3b7df3e..0771e68 100644 --- a/client/less/site/chat.less +++ b/client/less/site/chat.less @@ -1,24 +1,235 @@ +#site-chat-container { + align-self: stretch; + + .chat-menubar { + padding: 4px 16px; + } + + #chat-input-form { + textarea.uk-textarea { + padding: 2px 6px; + resize: none; + } + } + + .fundraising-progress-overlay { + position: absolute; + top: 40px; right: 0; left: 0; + width: 100%; + overflow: hidden; + background: black; + + &.hidden { + display: none; + } + } + + #chat-message-list-wrapper { + position: relative; + flex: 1; + + #chat-reactions { + position: absolute; + top: 0; right: 0; bottom: 0; left: 0; + background: transparent; + overflow: hidden; + + span.reaction-icon { + display: block; + position: absolute; + font-size: 24px; + opacity: 0.6; + transform: rotate(0deg); + } + } + + #chat-message-list { + position: relative; + display: flex; + flex-direction: column; + + height: 410px; + resize: vertical; + + overflow: auto; + box-shadow: var(--dtp-chat-shadow); + scroll-behavior: auto; + + &::-webkit-scrollbar { + display: none; /* Chrome */ + } + + scrollbar-width: none; /* Firefox */ + + .chat-message { + + .chat-user-menu { + + button.chat-menu-button { + padding: 0; + margin: 0; + background: transparent; + outline: none; + border: none; + line-height: 1; + } + } + } + } + + .chat-message-menu { + position: absolute; + display: none; + right: 4px; bottom: 20px; left: 4px; + text-align: center; + + &.chat-menu-visible { + display: block; + } + + button.chat-scroll-return { + padding: 4px 8px; + background: rgba(0,0,0, 0.6); + color: #c8c8c8; + border: solid 2px @site-brand-color; + border-radius: 8px; + outline: none; + cursor: pointer; + + &:hover { + color: white; + } + &:active { + background: rgba(160, 0, 0, 0.9); + color: white; + } + } + } + } +} + +/* + * OBS Chat Widget specializations + */ + +body[data-obs-widget="chat"] { + +.site-player-view { + + #site-chat-container { + + #chat-message-list-wrapper { + background-color: transparent; + + #chat-reactions { + background-color: transparent; + } + } + } +} +} + +/* + * Mobile view layout + */ +@media screen and (max-width: 959px) { + + body[data-current-view="channel-broadcast"], + body[data-current-view="dvr-player"] { + position: fixed; + top: 0; right: 0; bottom: 0; left: 0; + padding: 0; + margin: 0; + width: 100%; + height: 100%; + + .site-player-view { + position: absolute; + top: 64px; right: 0; bottom: 0; left: 0; + overflow: hidden; + + display: flex; + flex-direction: column; + flex-wrap: nowrap; + + #site-video-container { + flex: 0; + } + + #site-chat-container { + position: relative; + flex: 1; + + #chat-message-list { + flex: 1; + } + + #chat-input-form { + flex: 0; + } + } + } + } +} + .chat-message { + color: var(--dtp-chat-color); + background: var(--dtp-chat-background); + border-radius: 8px; + font-size: var(--dtp-chat-font-size); + margin-bottom: 5px; + + &.system-message { + background: rgba(255,255,255, 0.1); + } .chat-username { - color: #c8c8c8; - margin-right: 4px; font-weight: bold; + font-size: var(--dtp-chat-font-size); + line-height: 1; + color: var(--dtp-chat-username-color); + } + + img.chat-author-image { + width: auto; + height: 2em; + border-radius: 4px; } .chat-content { - color: #a8a8a8; - em { color: inherit; } - strong { color: #c8c8c8; } + line-height: 1.2em; + font-size: var(--dtp-chat-font-size); + color: inherit; + overflow-wrap: break-word; + + p:last-child { + margin-bottom: 0; + } + } + + .chat-timestamp { + color: var(--dtp-chat-timestamp-color); } .chat-sticker { display: inline-block; + margin-top: 4px; margin-right: 8px; + color: inherit; video { width: auto; height: 100px; } } +} + +body[data-obs-widget="chat"] { + + .chat-message { + + .chat-user-menu { + display: none; + } + } } \ No newline at end of file diff --git a/config/limiter.js b/config/limiter.js index 86ddcc6..1fb8146 100644 --- a/config/limiter.js +++ b/config/limiter.js @@ -51,6 +51,32 @@ module.exports = { }, }, + /* + * ChatController + */ + chat: { + postRoomUpdate: { + total: 10, + expire: ONE_MINUTE, + message: 'You are updating chat rooms too quickly', + }, + postRoomCreate: { + total: 1, + expire: ONE_MINUTE * 5, + message: 'You are creating chat rooms too quickly', + }, + getRoomView: { + total: 15, + expire: ONE_MINUTE, + message: 'You are loading chat rooms too quickly', + }, + getHome: { + total: 30, + expire: ONE_MINUTE, + message: 'You are loading chat home too quickly', + }, + }, + comment: { deleteComment: { total: 1, diff --git a/config/reserved-names.js b/config/reserved-names.js index 3a73f70..ae3f5d5 100644 --- a/config/reserved-names.js +++ b/config/reserved-names.js @@ -10,6 +10,7 @@ module.exports = [ 'about', 'admin', 'auth', + 'chat', 'digitaltelepresence', 'dist', 'dtp', diff --git a/lib/client/js/dtp-socket.js b/lib/client/js/dtp-socket.js index 82412d8..95a4a88 100644 --- a/lib/client/js/dtp-socket.js +++ b/lib/client/js/dtp-socket.js @@ -64,6 +64,10 @@ export default class DtpWebSocket { async onSocketConnect ( ) { this.log.info('onSocketConnect', 'WebSocket connected'); this.isConnected = true; + if (this.disconnectDialog) { + this.disconnectDialog.hide(); + delete this.disconnectDialog; + } } async onSocketDisconnect (reason) { @@ -85,7 +89,13 @@ export default class DtpWebSocket { }; this.log.warn('onSocketDisconnect', 'WebSocket disconnected', { reason }); - UIkit.modal.alert(`Disconnected: ${REASONS[reason]}`); + const modal = UIkit.modal.alert(`Disconnected: ${REASONS[reason]}`); + this.disconnectDialog = modal.dialog; + + UIkit.util.on(modal.dialog.$el, 'hidden', ( ) => { + this.log.info('onSocketDisconnect', 'disconnect dialog closed'); + delete this.disconnectDialog; + }); this.retryConnect(); } @@ -115,25 +125,18 @@ export default class DtpWebSocket { this.socket.emit('join', { channelId, channelType }); } - async isChannelJoined (channelId) { - return !!this.joinedChannels[channelId]; - } - async onJoinResult (message) { this.log.info('onJoinResult', 'channel joined', { message }); - this.joinedChannels[message.channelId] = message; + document.dispatchEvent(new Event('socketChannelJoined', { channelId: message.channelId })); } async leaveChannel (channelId) { this.log.info('leaveChannel', 'leaving channel', { channelId }); this.socket.emit('leave', { channelId }); - if (this.joinedChannels[channelId]) { - delete this.joinedChannels[channelId]; - } } - async sendUserChat (channelId, content) { - this.log.info('sendUserChat', 'sending message to channel', { channelId, content }); - this.socket.emit('user-chat', { channelId, content }); + async emit (messageName, payload) { + this.log.info('emit', 'sending message', { messageName, payload }); + this.socket.emit(messageName, payload); } } \ No newline at end of file diff --git a/lib/site-ioserver.js b/lib/site-ioserver.js index 5552f94..5a1f80f 100644 --- a/lib/site-ioserver.js +++ b/lib/site-ioserver.js @@ -11,7 +11,6 @@ const Redis = require('ioredis'); const mongoose = require('mongoose'); const ConnectToken = mongoose.model('ConnectToken'); -const ChatMessage = mongoose.model('ChatMessage'); const striptags = require('striptags'); const marked = require('marked'); @@ -31,6 +30,8 @@ class SiteIoServer extends Events { const { Server } = require('socket.io'); const { createAdapter } = require('@socket.io/redis-adapter'); + this.createRateLimiters(); + this.markedRenderer = new marked.Renderer(); this.markedRenderer.link = (href, title, text) => { return text; }; this.markedRenderer.image = (href, title, text) => { return text; }; @@ -53,8 +54,6 @@ class SiteIoServer extends Events { xhtml: false, }; - const transports = ['websocket'/*, 'polling'*/]; - const pubClient = new Redis({ host: process.env.REDIS_HOST, port: process.env.REDIS_PORT, @@ -66,12 +65,44 @@ class SiteIoServer extends Events { const subClient = pubClient.duplicate(); subClient.on('error', this.onRedisError.bind(this)); + const transports = ['websocket'/*, 'polling'*/]; const adapter = createAdapter(pubClient, subClient); this.io = new Server(httpServer, { adapter, transports }); this.io.on('connection', this.onSocketConnect.bind(this)); } + createRateLimiters ( ) { + 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, + key: process.env.REDIS_KEY_PREFIX || 'dtp', + enableOfflineQueue: false, + lazyConnect: 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', + }); + } + async onRedisError (error) { this.log.error('Redis error', { error }); } @@ -114,6 +145,7 @@ class SiteIoServer extends Events { const session = { user: { _id: token.user._id, + type: token.userType, created: token.user.created, username: token.user.username, displayName: token.user.displayName, @@ -125,11 +157,13 @@ class SiteIoServer extends Events { session.onJoinChannel = this.onJoinChannel.bind(this, session); session.onLeaveChannel = this.onLeaveChannel.bind(this, session); session.onUserChat = this.onUserChat.bind(this, session); + session.onUserReact = this.onUserReact.bind(this, session); socket.on('disconnect', session.onSocketDisconnect); socket.on('join', session.onJoinChannel); socket.on('leave', session.onLeaveChannel); socket.on('user-chat', session.onUserChat); + socket.on('user-react', session.onUserReact); socket.emit('authenticated', { message: 'token verified', @@ -157,47 +191,92 @@ class SiteIoServer extends Events { session.socket.leave(channelId); } - async onUserChat (session, message) { - const { channel: channelService } = this.dtp.services; - const { channelId } = message; + async onUserChat (session, messageDefinition) { + const { chat: chatService, user: userService } = this.dtp.services; + const channelId = messageDefinition.channel; - if (!message.content || (message.content.length === 0)) { + if (!messageDefinition.content || (messageDefinition.content.length === 0)) { + this.log.info('dropping empty chat message'); return; } - const channel = await channelService.getChannelById(channelId); - if (!channel) { + /* + * First, implement the rate limiter check. If rate-limited, abort all + * further processing. Store nothing in the database. Send nothing to the + * chat room. + */ + try { + const userKey = session.user._id.toString(); + await this.chatMessageLimiter.consume(userKey, 1); + } catch (rateLimiter) { + const NOW = new Date(); + if (!session.notifySpamMuzzle) { + this.log.alert('preventing chat spam', { userId: session.user._id, rateLimiter }); + session.socket.to(channelId).emit('system-message', { + created: NOW, + content: `${session.user.displayName || session.user.username} has been muted for a while.`, + }); + session.notifySpamMuzzle = true; + } + session.socket.emit('system-message', { + created: NOW, + content: `You are rate limited for ${numeral(rateLimiter.msBeforeNext / 1000.0).format('0,0.0')} seconds.`, + rateLimiter, + }); return; } - const stickers = this.findStickers(message.content); - stickers.forEach((sticker) => { - const re = new RegExp(`:${sticker}:`, 'gi'); - message.content = message.content.replace(re, '').trim(); - }); - - message.content = striptags(message.content); + /* + * Pull the author's current User record from the db and verify that they + * have permission to chat. This read must happen with every chat message + * until permission update notifications are implemented on Redis pub/sub. + */ + try { + const userCheck = await userService.getUserAccount(session.user._id); + if (!userCheck || !userCheck.permissions || !userCheck.permissions.canChat) { + session.socket.emit('system-message', { + created: new Date(), + content: `You are not allowed to chat on ${this.dtp.config.site.name}.`, + }); + return; // permission denied + } - await ChatMessage.create({ - created: new Date(), - author: session.user._id, - content: message.content, - stickers, - }); + //TODO: Forked apps may want to implement channel-level moderation, and + // this is where to implement those checks. - const renderedContent = marked(message.content, this.markedConfig); + } catch (error) { + this.log.error('failed to implement user permissions check', { userId: session.user._id, error }); + return; // can't verify permissions? No chat for you. + } - const payload = { - user: { - _id: session.user._id, - username: session.user.username, - }, - content: renderedContent, - stickers, - }; + try { + const { message, payload } = await chatService.createMessage(session.user, messageDefinition); + if (message.analysis.similarity > 0.9) { + await chatService.sendSystemMessage( + session.socket, + "Your flow feels a little spammy, so that one didn't go through.", + { type: 'warning' }, + ); + return; + } - session.socket.to(channelId).emit('user-chat', payload); - session.socket.emit('user-chat', payload); + // use chat service emitter to deliver to channel (more efficient) + // than socket.io API + await chatService.sendMessage(message.channel, 'user-chat', payload); + + // use the socket itself to emit back to the sender + session.socket.emit('user-chat', payload); + + session.notifySpamMuzzle = false; + } catch (error) { + this.log.error('failed to process user chat message', { error }); + await chatService.sendSystemMessage( + session.socket, + `Failed to send chat: ${error.message}`, + { type: 'error' }, + ); + return; + } } findStickers (content) { @@ -220,6 +299,39 @@ class SiteIoServer extends Events { }); return stickers; } + + async onUserReact (session, message) { + const { chat: chatService, user: userService } = this.dtp.services; + try { + const userCheck = await userService.getUserAccount(session.user._id); + if (!userCheck || !userCheck.permissions || !userCheck.permissions.canChat) { + session.socket.emit('system-message', { + created: new Date(), + content: `You are not allowed to chat on ${this.dtp.config.site.name}.`, + }); + return; // permission denied + } + + try { + const userKey = session.user._id.toString(); + await this.reactionLimiter.consume(userKey, 1); + } catch (error) { + return; // rate-limited + } + + const reaction = await chatService.createEmojiReaction(session.user, message); + reaction.user = session.user; + + const payload = { reaction }; + const channelId = reaction.subject.toString(); + + await chatService.sendMessage(channelId, 'user-react', payload); + session.socket.emit('user-react', payload); + } catch (error) { + this.log.error('failed to process reaction', { message, error }); + return; + } + } } module.exports.SiteIoServer = SiteIoServer; \ No newline at end of file diff --git a/lib/site-platform.js b/lib/site-platform.js index 0a5f2a8..49bfc4e 100644 --- a/lib/site-platform.js +++ b/lib/site-platform.js @@ -211,6 +211,7 @@ module.exports.startWebServer = async (dtp) => { * Expose useful modules and information */ module.app.locals.DTP_SCRIPT_DEBUG = (process.env.NODE_ENV === 'local'); + module.app.locals.env = process.env; module.app.locals.dtp = dtp; module.app.locals.pkg = require(path.join(dtp.config.root, 'package.json')); module.app.locals.mongoose = require('mongoose'); diff --git a/package.json b/package.json index ba303b0..704edb5 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "slug": "^5.2.0", "socket.io": "^4.4.1", "socket.io-emitter": "^3.2.0", + "string-similarity": "^4.0.4", "striptags": "^3.2.0", "svg-captcha": "^1.4.0", "systeminformation": "^5.11.6", diff --git a/yarn.lock b/yarn.lock index d2cd7cf..fb0aa53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2197,9 +2197,9 @@ camelcase@^6.2.0: integrity sha512-tVI4q5jjFV5CavAU8DXfza/TJcZutVKo/5Foskmsqcm0MsL91moHvwiGNnqaa2o6PF/7yT5ikDRcVcl8Rj6LCA== caniuse-lite@^1.0.30001280: - version "1.0.30001284" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001284.tgz#d3653929ded898cd0c1f09a56fd8ca6952df4fca" - integrity sha512-t28SKa7g6kiIQi6NHeOcKrOrGMzCRrXvlasPwWC26TH2QNdglgzQIRUuJ0cR3NeQPH+5jpuveeeSFDLm2zbkEw== + version "1.0.30001373" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001373.tgz" + integrity sha512-pJYArGHrPp3TUqQzFYRmP/lwJlj8RCbVe3Gd3eJQkAV8SAC6b19XS9BjMvRdvaS8RMkaTN8ZhoHP6S1y8zzwEQ== "chalk@4.1 - 4.1.2", chalk@^4.1.0, chalk@^4.1.1: version "4.1.2" @@ -7875,6 +7875,11 @@ streamsearch@0.1.2: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo= +string-similarity@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/string-similarity/-/string-similarity-4.0.4.tgz#42d01ab0b34660ea8a018da8f56a3309bb8b2a5b" + integrity sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ== + string-width@^1.0.1, string-width@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3"