diff --git a/.env.default b/.env.default index bffa96a..8e7f5f2 100644 --- a/.env.default +++ b/.env.default @@ -9,6 +9,45 @@ DTP_SITE_DOMAIN_KEY= DTP_SITE_COMPANY=Digital Telepresence, LLC DTP_PASSWORD_SALT= +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 +DTP_ATTACHMENT_WORK_PATH=/tmp/yourapp/attachment-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 +# + +DTP_HOST_CACHE_PORT=8010 +DTP_HOST_CACHE_PATH=/tmp/dtp-webapp/host-cache +DTP_HOST_CACHE_AUTH_KEY=daf3577a-2ab7-49d5-9b5a-d7e331241cde +DTP_HOST_CACHE_CLEAN_CRON=*/30 * * * * * + +# +# Nodemailer SMTP Transport configuration +# + +DTP_EMAIL_SERVICE=disabled +DTP_EMAIL_SMTP_HOST= +DTP_EMAIL_SMTP_PORT=465 +DTP_EMAIL_SMTP_SECURE=disabled +DTP_EMAIL_SMTP_FROM= +DTP_EMAIL_SMTP_USER= +DTP_EMAIL_SMTP_PASS= +DTP_EMAIL_SMTP_POOL_ENABLED=enabled +DTP_EMAIL_SMTP_POOL_MAX_CONN=5 +DTP_EMAIL_SMTP_POOL_MAX_MSGS=100 + # # Mailgun Configuration # @@ -31,6 +70,7 @@ MONGODB_DATABASE=dtp-webapp REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= +REDIS_PREFIX= # # MinIO configuration @@ -39,10 +79,12 @@ REDIS_PASSWORD= MINIO_ENDPOINT=localhost MINIO_PORT=9000 MINIO_USE_SSL=disabled -MINIO_ACCESS_KEY=dtp-webapp +MINIO_ACCESS_KEY= MINIO_SECRET_KEY= -MINIO_IMAGE_BUCKET=webapp-images -MINIO_VIDEO_BUCKET=webapp-videos + +MINIO_IMAGE_BUCKET=yourapp-images +MINIO_VIDEO_BUCKET=yourapp-videos +MINIO_ATTACHMENT_BUCKET=yourapp-attachments # # ExpressJS/HTTP configuration @@ -60,12 +102,12 @@ DTP_LOG_CONSOLE=enabled DTP_LOG_MONGODB=enabled DTP_LOG_FILE=enabled -DTP_LOG_FILE_PATH=/tmp/dtp-webapp/logs -DTP_LOG_FILE_NAME_APP=webapp-app.log -DTP_LOG_FILE_NAME_HTTP=webapp-access.log +DTP_LOG_FILE_PATH=/tmp/dtp-yourapp/logs +DTP_LOG_FILE_NAME_APP=yourapp-app.log +DTP_LOG_FILE_NAME_HTTP=yourapp-access.log 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/README.md b/README.md index 0f46e56..e6b491e 100644 --- a/README.md +++ b/README.md @@ -117,4 +117,4 @@ Redis simply has many different documents to describe it's many different featur ## Software License -The DTP Social engine is licensed under the [Apache 2.0](https://spdx.org/licenses/Apache-2.0.html) open source software license. See [LICENSE](LICENSE) for more information. \ No newline at end of file +DTP Social and the DTP Phoenix Engine and framework are licensed under the [Apache 2.0](https://spdx.org/licenses/Apache-2.0.html) open source software license. See [LICENSE](LICENSE) for more information. \ No newline at end of file diff --git a/app/controllers/admin/announcement.js b/app/controllers/admin/announcement.js index ebdf4f1..cfcd3dc 100644 --- a/app/controllers/admin/announcement.js +++ b/app/controllers/admin/announcement.js @@ -87,7 +87,7 @@ class AnnouncementAdminController extends SiteController { try { const displayList = this.createDisplayList('delete-announcement'); await announcementService.remove(res.locals.announcement); - displayList.reloadView(); + displayList.reload(); res.status(200).json({ success: true, displayList }); } catch (error) { this.log.error('failed to delete announcement', { error }); diff --git a/app/controllers/admin/content-report.js b/app/controllers/admin/content-report.js index f202f5d..fe47479 100644 --- a/app/controllers/admin/content-report.js +++ b/app/controllers/admin/content-report.js @@ -5,9 +5,8 @@ 'use strict'; const express = require('express'); -const multer = require('multer'); -const { /*SiteError,*/ SiteController } = require('../../../lib/site-lib'); +const { SiteController } = require('../../../lib/site-lib'); class ContentReportController extends SiteController { @@ -16,7 +15,7 @@ class ContentReportController extends SiteController { } async start ( ) { - const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/admin-content-report` }); + const upload = this.createMulter(); const router = express.Router(); router.use(async (req, res, next) => { diff --git a/app/controllers/admin/core-node.js b/app/controllers/admin/core-node.js index 0d68cb5..2ae9816 100644 --- a/app/controllers/admin/core-node.js +++ b/app/controllers/admin/core-node.js @@ -16,8 +16,6 @@ class CoreNodeController extends SiteController { } async start ( ) { - // const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/upload` }); - const router = express.Router(); router.use(async (req, res, next) => { res.locals.currentView = 'admin'; diff --git a/app/controllers/admin/core-user.js b/app/controllers/admin/core-user.js index 1d72973..421c6fe 100644 --- a/app/controllers/admin/core-user.js +++ b/app/controllers/admin/core-user.js @@ -16,8 +16,6 @@ class CoreUserController extends SiteController { } async start ( ) { - // const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/upload` }); - const router = express.Router(); router.use(async (req, res, next) => { res.locals.currentView = 'admin'; 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/admin/service-node.js b/app/controllers/admin/service-node.js index c644026..26f8c06 100644 --- a/app/controllers/admin/service-node.js +++ b/app/controllers/admin/service-node.js @@ -32,6 +32,8 @@ class ServiceNodeController extends SiteController { router.get('/:clientId', this.getClientView.bind(this)); router.get('/', this.getIndex.bind(this)); + router.delete('/:clientId', this.deleteClient.bind(this)); + return router; } @@ -106,6 +108,23 @@ class ServiceNodeController extends SiteController { return next(error); } } + + async deleteClient (req, res) { + const { oauth2: oauth2Service } = this.dtp.services; + try { + await oauth2Service.removeClient(res.locals.serviceNode); + + const displayList = this.createDisplayList('delete-newsletter'); + displayList.navigateTo('/admin/service-node'); + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to delete client', { clientId: res.locals.serviceNode._id, error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } } module.exports = { diff --git a/app/controllers/announcement.js b/app/controllers/announcement.js index 269291b..ea55350 100644 --- a/app/controllers/announcement.js +++ b/app/controllers/announcement.js @@ -15,11 +15,22 @@ class AnnouncementController extends SiteController { } async start ( ) { + const { comment: commentService } = this.dtp.services; + const router = express.Router(); this.dtp.app.use('/announcement', router); + const upload = this.createMulter(); + + router.use(async (req, res, next) => { + res.locals.currentView = 'announcement'; + return next(); + }); + router.param('announcementId', this.populateAnnouncementId.bind(this)); + router.post('/:announcementId/comment', upload.none(), commentService.commentCreateHandler('Announcement', 'announcement')); + router.get('/:announcementId', this.getAnnouncementView.bind(this)); router.get('/', this.getHome.bind(this)); @@ -40,8 +51,16 @@ class AnnouncementController extends SiteController { } } - async getAnnouncementView (req, res) { - res.render('announcement/view'); + async getAnnouncementView (req, res, next) { + const { comment: commentService } = this.dtp.services; + try { + res.locals.pagination = this.getPaginationParameters(req, 10); + res.locals.comments = await commentService.getForResource(res.locals.announcement, ['published'], res.locals.pagination); + res.render('announcement/view'); + } catch (error) { + this.log.error('failed to render announcement view', { error }); + return next(error); + } } async getHome (req, res, next) { diff --git a/app/controllers/auth.js b/app/controllers/auth.js index ee47be8..15caccb 100644 --- a/app/controllers/auth.js +++ b/app/controllers/auth.js @@ -6,7 +6,6 @@ const express = require('express'); const mongoose = require('mongoose'); -const multer = require('multer'); const passport = require('passport'); const uuidv4 = require('uuid').v4; @@ -25,7 +24,8 @@ class AuthController extends SiteController { coreNode: coreNodeService, limiter: limiterService, } = this.dtp.services; - const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${this.component.slug}` }); + + const upload = this.createMulter(); const router = express.Router(); this.dtp.app.use('/auth', router); @@ -35,18 +35,18 @@ class AuthController extends SiteController { router.post( '/otp/enable', - limiterService.create(limiterService.config.auth.postOtpEnable), + limiterService.createMiddleware(limiterService.config.auth.postOtpEnable), this.postOtpEnable.bind(this), ); router.post( '/otp/auth', - limiterService.create(limiterService.config.auth.postOtpAuthenticate), + limiterService.createMiddleware(limiterService.config.auth.postOtpAuthenticate), this.postOtpAuthenticate.bind(this), ); router.post( '/login', - limiterService.create(limiterService.config.auth.postLogin), + limiterService.createMiddleware(limiterService.config.auth.postLogin), upload.none(), this.postLogin.bind(this), ); @@ -54,14 +54,14 @@ class AuthController extends SiteController { router.get( '/api-token/personal', authRequired, - limiterService.create(limiterService.config.auth.getPersonalApiToken), + limiterService.createMiddleware(limiterService.config.auth.getPersonalApiToken), this.getPersonalApiToken.bind(this), ); router.get( '/socket-token', authRequiredNoRedirect, - limiterService.create(limiterService.config.auth.getSocketToken), + limiterService.createMiddleware(limiterService.config.auth.getSocketToken), this.getSocketToken.bind(this), ); @@ -69,14 +69,14 @@ class AuthController extends SiteController { router.get( '/core', - limiterService.create(limiterService.config.auth.getCoreHome), + limiterService.createMiddleware(limiterService.config.auth.getCoreHome), this.getCoreHome.bind(this), ); router.get( '/logout', authRequired, - limiterService.create(limiterService.config.auth.getLogout), + limiterService.createMiddleware(limiterService.config.auth.getLogout), this.getLogout.bind(this), ); diff --git a/app/controllers/chat.js b/app/controllers/chat.js new file mode 100644 index 0000000..e675b4a --- /dev/null +++ b/app/controllers/chat.js @@ -0,0 +1,413 @@ +// 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 upload = this.createMulter(); + + 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.param('inviteId', this.populateInviteId.bind(this)); + + router.post( + '/room/:roomId/invite/:inviteId/action', + limiterService.createMiddleware(limiterService.config.chat.postRoomInviteAction), + upload.none(), + this.postRoomInviteAction.bind(this), + ); + + router.post( + '/room/:roomId/invite', + limiterService.createMiddleware(limiterService.config.chat.postRoomInvite), + upload.none(), + this.postRoomInvite.bind(this), + ); + + router.post( + '/room/:roomId', + limiterService.createMiddleware(limiterService.config.chat.postRoomUpdate), + this.postRoomUpdate.bind(this), + ); + + router.post( + '/room', + limiterService.createMiddleware(limiterService.config.chat.postRoomCreate), + this.postRoomCreate.bind(this), + ); + + router.get( + '/room/create', + this.getRoomEditor.bind(this), + ); + + router.get( + '/room/:roomId/form/:formName', + limiterService.createMiddleware(limiterService.config.chat.getRoomForm), + this.getRoomForm.bind(this), + ); + + router.get( + '/room/:roomId/invite/:inviteId', + limiterService.createMiddleware(limiterService.config.chat.getRoomInviteView), + this.getRoomInviteView.bind(this), + ); + + router.get( + '/room/:roomId/invite', + limiterService.createMiddleware(limiterService.config.chat.getRoomInviteView), + this.getRoomInviteHome.bind(this), + ); + + router.get( + '/room/:roomId/settings', + limiterService.createMiddleware(limiterService.config.chat.getRoomSettings), + this.getRoomSettings.bind(this), + ); + + router.get( + '/room/:roomId', + limiterService.createMiddleware(limiterService.config.chat.getRoomView), + this.getRoomView.bind(this), + ); + + router.get( + '/room', + limiterService.createMiddleware(limiterService.config.chat.getRoomHome), + this.getRoomHome.bind(this), + ); + + router.get( + '/', + limiterService.createMiddleware(limiterService.config.chat.getHome), + this.getHome.bind(this), + ); + + /* + * DELETE operations + */ + + router.delete( + '/room/:roomId/invite/:inviteId', + limiterService.createMiddleware(limiterService.config.chat.deleteInvite), + this.deleteInvite.bind(this), + ); + + router.delete( + '/room/:roomId', + limiterService.createMiddleware(limiterService.config.chat.deleteRoom), + this.deleteInvite.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 populateInviteId (req, res, next, inviteId) { + const { chat: chatService } = this.dtp.services; + try { + res.locals.invite = await chatService.getRoomInviteById(inviteId); + if (!res.locals.invite) { + throw new SiteError(404, 'Invite not found'); + } + return next(); + } catch (error) { + this.log.error('failed to populate inviteId', { inviteId, error }); + return next(error); + } + } + + async postRoomInviteAction (req, res) { + const { chat: chatService } = this.dtp.services; + try { + const { response } = req.body; + const displayList = this.createDisplayList('room-invite-action'); + this.log.debug('room invite action', { message: req.body }); + switch (response) { + case 'accept': + await chatService.acceptRoomInvite(res.locals.invite); + displayList.showNotification( + `Chat room invite accepted`, + 'success', + 'top-center', + 5000, + ); + break; + + case 'reject': + await chatService.acceptRoomInvite(res.locals.invite); + displayList.showNotification( + `Chat room invite rejected`, + 'success', + 'top-center', + 5000, + ); + break; + + default: + throw new SiteError(400, 'Must specify invite action'); + } + + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to execute room invite action', { + inviteId: res.locals.invite._id, + response: req.body.response, + error, + }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + + async postRoomInvite (req, res) { + const { chat: chatService, user: userService } = this.dtp.services; + this.log.debug('room invite received', { invite: req.body }); + if (!req.body.username || !req.body.username.length) { + return res.status(400).json({ success: false, message: 'Please provide a username' }); + } + try { + req.body.username = req.body.username.trim().toLowerCase(); + while (req.body.username[0] === '@') { + req.body.username = req.body.username.slice(1); + } + + if (!req.body.username || !req.body.username.length) { + throw new SiteError(400, 'Please provide a username'); + } + + const member = await userService.getPublicProfile(req.body.username); + if (!member) { + throw new SiteError(404, `There is no account with username @${req.body.username}`); + } + if (member._id.equals(res.locals.room.owner._id)) { + throw new SiteError(400, "You can't invite yourself."); + } + + await chatService.sendRoomInvite(res.locals.room, member, req.body); + + const displayList = this.createDisplayList('invite create'); + displayList.showNotification( + `Chat room invite sent to ${member.displayName || member.username}!`, + 'success', + 'top-left', + 5000, + ); + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to create room invitation', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + + 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/room/${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/room/${res.locals.room._id}`); + } catch (error) { + this.log.error('failed to create chat room', { error }); + return next(error); + } + } + + async getRoomEditor (req, res) { + res.render('chat/room/editor'); + } + + async getRoomForm (req, res, next) { + const validFormNames = [ + 'invite-member', + ]; + const formName = req.params.formName; + if (validFormNames.indexOf(formName) === -1) { + return next(new SiteError(404, 'Form not found')); + } + try { + res.render(`chat/room/form/${formName}`); + } catch (error) { + this.log.error('failed to render form', { formName, error }); + return next(error); + } + } + + async getRoomInviteView (req, res) { + res.render('chat/room/invite/view'); + } + + async getRoomInviteHome (req, res, next) { + const { chat: chatService } = this.dtp.services; + try { + res.locals.invites = { + new: await chatService.getRoomInvites(res.locals.room, 'new'), + accepted: await chatService.getRoomInvites(res.locals.room, 'accepted'), + rejected: await chatService.getRoomInvites(res.locals.room, 'rejected'), + }; + res.render('chat/room/invite'); + } catch (error) { + this.log.error('failed to render the room invites view', { error }); + return next(error); + } + } + + async getRoomSettings (req, res) { + res.render('chat/room/editor'); + } + + 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/room/view'); + } catch (error) { + this.log.error('failed to render chat room view', { roomId: req.params.roomId, error }); + return next(error); + } + } + + async getRoomHome (req, res, next) { + const { chat: chatService } = this.dtp.services; + try { + res.locals.pagination = this.getPaginationParameters(req, 20); + res.locals.publicRooms = await chatService.getPublicRooms(req.user, res.locals.pagination); + res.render('chat/room/index'); + } catch (error) { + this.log.error('failed to render room home', { error }); + return next(error); + } + } + + async getHome (req, res, next) { + const { chat: chatService } = this.dtp.services; + try { + res.locals.pageTitle = 'Chat Home'; + + res.locals.pagination = this.getPaginationParameters(req, 20); + + const roomIds = [ ]; + res.locals.ownedChatRooms.forEach((room) => roomIds.push(room._id)); + res.locals.joinedChatRooms.forEach((room) => roomIds.push(room._id)); + res.locals.timeline = await chatService.getMultiRoomTimeline(roomIds, res.locals.pagination); + + res.render('chat/index'); + } catch (error) { + this.log.error('failed to render chat home', { error }); + return next(error); + } + } + + async deleteInvite (req, res, next) { + const { chat: chatService } = this.dtp.services; + try { + if (res.locals.room.owner._id.equals(req.user._id)) { + throw new SiteError(403, 'This is not your invitiation'); + } + + await chatService.deleteRoomInvite(res.locals.invite); + + const displayList = this.createDisplayList('delete chat invite'); + displayList.removeElement(`li[data-invite-id="${res.locals.invite._id}"]`); + displayList.showNotification( + `Invitation to ${res.locals.invite.member.displayName || res.locals.invite.member.username} deleted successfully`, + 'success', + 'top-left', + 5000, + ); + + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to delete chat room invite', { error }); + return next(error); + } + } + + async deleteRoom (req, res, next) { + const { chat: chatService } = this.dtp.services; + try { + if (res.locals.room.owner._id.equals(req.user._id)) { + throw new SiteError(403, 'This is not your chat room'); + } + + await chatService.deleteRoom(res.locals.room); + + const displayList = this.createDisplayList('delete chat invite'); + displayList.navigateTo('/chat'); + + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to delete chat room invite', { error }); + return next(error); + } + } +} + +module.exports = { + slug: 'chat', + name: 'chat', + create: async (dtp) => { return new ChatController(dtp); }, +}; \ No newline at end of file diff --git a/app/controllers/comment.js b/app/controllers/comment.js new file mode 100644 index 0000000..7935572 --- /dev/null +++ b/app/controllers/comment.js @@ -0,0 +1,157 @@ +// comment.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const express = require('express'); +const numeral = require('numeral'); + +const { SiteController, SiteError } = require('../../lib/site-lib'); + +class CommentController extends SiteController { + + constructor (dtp) { + super(dtp, module.exports); + } + + async start ( ) { + const { dtp } = this; + const { limiter: limiterService, session: sessionService } = dtp.services; + + const authRequired = sessionService.authCheckMiddleware({ requiredLogin: true }); + + const router = express.Router(); + dtp.app.use('/comment', router); + + router.use(async (req, res, next) => { + res.locals.currentView = module.exports.slug; + return next(); + }); + + router.param('commentId', this.populateCommentId.bind(this)); + + router.post('/:commentId/vote', authRequired, this.postVote.bind(this)); + + router.get('/:commentId/replies', this.getCommentReplies.bind(this)); + + router.delete('/:commentId', + authRequired, + limiterService.createMiddleware(limiterService.config.comment.deleteComment), + this.deleteComment.bind(this), + ); + } + + async populateCommentId (req, res, next, commentId) { + const { comment: commentService } = this.dtp.services; + try { + res.locals.comment = await commentService.getById(commentId); + if (!res.locals.comment) { + return next(new SiteError(404, 'Comment not found')); + } + res.locals.post = res.locals.comment.resource; + return next(); + } catch (error) { + this.log.error('failed to populate commentId', { commentId, error }); + return next(error); + } + } + + async postVote (req, res) { + const { contentVote: contentVoteService } = this.dtp.services; + try { + const displayList = this.createDisplayList('comment-vote'); + const { message, resourceStats } = await contentVoteService.recordVote(req.user, 'Comment', res.locals.comment, req.body.vote); + displayList.setTextContent( + `button[data-comment-id="${res.locals.comment._id}"][data-vote="up"] span.dtp-item-value`, + numeral(resourceStats.upvoteCount).format(resourceStats.upvoteCount > 1000 ? '0,0.0a' : '0,0'), + ); + displayList.setTextContent( + `button[data-comment-id="${res.locals.comment._id}"][data-vote="down"] span.dtp-item-value`, + numeral(resourceStats.downvoteCount).format(resourceStats.upvoteCount > 1000 ? '0,0.0a' : '0,0'), + ); + displayList.showNotification(message, 'success', 'bottom-center', 3000); + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to process comment vote', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + + async getCommentReplies (req, res) { + const { comment: commentService } = this.dtp.services; + try { + const displayList = this.createDisplayList('get-replies'); + + if (req.query.buttonId) { + displayList.removeElement(`li.dtp-load-more[data-button-id="${req.query.buttonId}"]`); + } + + Object.assign(res.locals, req.app.locals); + + res.locals.countPerPage = parseInt(req.query.cpp || "20", 10); + if (res.locals.countPerPage < 1) { + res.locals.countPerPage = 1; + } + if (res.locals.countPerPage > 20) { + res.locals.countPerPage = 20; + } + + res.locals.pagination = this.getPaginationParameters(req, res.locals.countPerPage); + res.locals.comments = await commentService.getReplies(res.locals.comment, res.locals.pagination); + + const html = await commentService.renderTemplate('replyList', res.locals); + + const replyList = `ul.dtp-reply-list[data-comment-id="${res.locals.comment._id}"]`; + displayList.addElement(replyList, 'beforeEnd', html); + + const replyListContainer = `.dtp-reply-list-container[data-comment-id="${res.locals.comment._id}"]`; + displayList.removeAttribute(replyListContainer, 'hidden'); + + if (Array.isArray(res.locals.comments) && (res.locals.comments.length > 0)) { + displayList.removeElement(`p#empty-comments-label[data-comment-id="${res.locals.comment._id}"]`); + } + + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to display comment replies', { error }); + res.status(error.statusCode || 500).json({ success: false, message: error.message }); + } + } + + async deleteComment (req, res) { + const { comment: commentService } = this.dtp.services; + try { + const displayList = this.createDisplayList('add-recipient'); + + await commentService.remove(res.locals.comment, 'removed'); + + let selector = `article[data-comment-id="${res.locals.comment._id}"] .comment-content`; + displayList.setTextContent(selector, 'Comment removed'); + + displayList.showNotification( + 'Comment removed successfully', + 'success', + 'bottom-center', + 5000, + ); + + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to remove comment', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message + }); + } + } +} + +module.exports = { + slug: 'comment', + name: 'comment', + create: async (dtp) => { return new CommentController(dtp); }, +}; \ No newline at end of file diff --git a/app/controllers/email.js b/app/controllers/email.js index facc17b..57e64dc 100644 --- a/app/controllers/email.js +++ b/app/controllers/email.js @@ -26,13 +26,13 @@ class EmailController extends SiteController { router.get( '/verify', - limiterService.create(limiterService.config.email.getEmailVerify), + limiterService.createMiddleware(limiterService.config.email.getEmailVerify), this.getEmailVerify.bind(this), ); router.get( '/opt-out', - limiterService.create(limiterService.config.email.getEmailOptOut), + limiterService.createMiddleware(limiterService.config.email.getEmailOptOut), this.getEmailOptOut.bind(this), ); diff --git a/app/controllers/form.js b/app/controllers/form.js new file mode 100644 index 0000000..d35e713 --- /dev/null +++ b/app/controllers/form.js @@ -0,0 +1,70 @@ +// email.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); +const glob = require('glob'); + +const express = require('express'); + +const { SiteController,/*, SiteError*/ +SiteError} = require('../../lib/site-lib'); + +class FormController extends SiteController { + + constructor (dtp) { + super(dtp, module.exports); + } + + async start ( ) { + const { + chat: chatService, + limiter: limiterService, + session: sessionService, + } = this.dtp.services; + + try { + this.forms = glob.sync(path.join(this.dtp.config.root, 'app', 'views', 'form', '*pug')) || [ ]; + this.forms = this.forms.map((filename) => path.parse(filename)); + } catch (error) { + this.log.error('failed to detect requestable forms', { error }); + this.forms = [ ]; + // fall through + } + + const router = express.Router(); + this.dtp.app.use('/form', router); + + router.use( + sessionService.authCheckMiddleware({ requireLogin: true }), + chatService.middleware({ maxOwnedRooms: 25, maxJoinedRooms: 50 }), + async (req, res, next) => { + res.locals.currentView = module.exports.slug; + return next(); + }, + ); + + router.get( + '/:formSlug', + limiterService.createMiddleware(limiterService.config.form.getForm), + this.getForm.bind(this), + ); + } + + async getForm (req, res, next) { + const { formSlug } = req.params; + const form = this.forms.find((form) => form.name === formSlug); + if (!form) { + return next(new SiteError(400, 'Invalid form')); + } + res.render(`form/${form.name}`); + } +} + +module.exports = { + slug: 'form', + name: 'form', + create: async (dtp) => { return new FormController(dtp); }, +}; \ No newline at end of file diff --git a/app/controllers/home.js b/app/controllers/home.js index 83a6f6e..0193e45 100644 --- a/app/controllers/home.js +++ b/app/controllers/home.js @@ -33,7 +33,7 @@ class HomeController extends SiteController { router.get('/policy/:policyDocument', this.getPolicyDocument.bind(this)); router.get('/', - limiterService.create(limiterService.config.home.getHome), + limiterService.createMiddleware(limiterService.config.home.getHome), this.getHome.bind(this), ); } diff --git a/app/controllers/image.js b/app/controllers/image.js index 24e29a1..bc8bac5 100644 --- a/app/controllers/image.js +++ b/app/controllers/image.js @@ -8,7 +8,6 @@ const fs = require('fs'); const express = require('express'); const mongoose = require('mongoose'); -const multer = require('multer'); const { SiteController/*, SiteError*/ } = require('../../lib/site-lib'); @@ -26,8 +25,7 @@ class ImageController extends SiteController { const router = express.Router(); dtp.app.use('/image', router); - const imageUpload = multer({ - dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${this.component.slug}`, + const imageUpload = this.createMulter('uploads', { limits: { fileSize: 1024 * 1000 * 12, }, @@ -46,13 +44,13 @@ class ImageController extends SiteController { ); router.post('/', - limiterService.create(limiterService.config.image.postCreateImage), + limiterService.createMiddleware(limiterService.config.image.postCreateImage), imageUpload.single('file'), this.postCreateImage.bind(this), ); router.get('/:imageId', - limiterService.create(limiterService.config.image.getImage), + limiterService.createMiddleware(limiterService.config.image.getImage), this.getHostCacheImage.bind(this), // this.getImage.bind(this), ); diff --git a/app/controllers/manifest.js b/app/controllers/manifest.js index c0afdc3..d55eb5a 100644 --- a/app/controllers/manifest.js +++ b/app/controllers/manifest.js @@ -27,7 +27,7 @@ class ManifestController extends SiteController { }); router.get('/', - limiterService.create(limiterService.config.manifest.getManifest), + limiterService.createMiddleware(limiterService.config.manifest.getManifest), this.getManifest.bind(this), ); } diff --git a/app/controllers/newsletter.js b/app/controllers/newsletter.js index c1edfba..bfaed75 100644 --- a/app/controllers/newsletter.js +++ b/app/controllers/newsletter.js @@ -5,7 +5,6 @@ 'use strict'; const express = require('express'); -const multer = require('multer'); const { SiteController } = require('../../lib/site-lib'); @@ -19,7 +18,7 @@ class NewsletterController extends SiteController { const { dtp } = this; const { limiter: limiterService } = dtp.services; - const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${module.exports.slug}` }); + const upload = this.createMulter(); const router = express.Router(); dtp.app.use('/newsletter', router); @@ -34,12 +33,12 @@ class NewsletterController extends SiteController { router.post('/', upload.none(), this.postAddRecipient.bind(this)); router.get('/:newsletterId', - limiterService.create(limiterService.config.newsletter.getView), + limiterService.createMiddleware(limiterService.config.newsletter.getView), this.getView.bind(this), ); router.get('/', - limiterService.create(limiterService.config.newsletter.getIndex), + limiterService.createMiddleware(limiterService.config.newsletter.getIndex), this.getIndex.bind(this), ); } diff --git a/app/controllers/notification.js b/app/controllers/notification.js new file mode 100644 index 0000000..4711ed0 --- /dev/null +++ b/app/controllers/notification.js @@ -0,0 +1,78 @@ +// notification.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const express = require('express'); + +const { SiteController, SiteError } = require('../../lib/site-lib'); + +class NotificationController extends SiteController { + + constructor (dtp) { + super(dtp, module.exports); + } + + async start ( ) { + const { dtp } = this; + const { limiter: limiterService } = dtp.services; + + const router = express.Router(); + dtp.app.use('/notification', router); + + router.use(async (req, res, next) => { + res.locals.currentView = 'notification'; + return next(); + }); + + router.param('notificationId', this.populateNotificationId.bind(this)); + + router.get( + '/:notificationId', + limiterService.createMiddleware(limiterService.config.notification.getNotificationView), + this.getNotificationView.bind(this), + ); + + router.get('/', + limiterService.createMiddleware(limiterService.config.notification.getNotificationHome), + this.getNotificationHome.bind(this), + ); + } + + async populateNotificationId (req, res, next, notificationId) { + const { userNotification: userNotificationService } = this.dtp.services; + try { + res.locals.notification = await userNotificationService.getById(notificationId); + if (!res.locals.notification) { + throw new SiteError(404, 'Notification not found'); + } + return next(); + } catch (error) { + this.log.error('failed to populate notificationId', { notificationId, error }); + return next(error); + } + } + + async getNotificationView (req, res) { + res.render('notification/view'); + } + + async getNotificationHome (req, res, next) { + const { userNotification: userNotificationService } = this.dtp.services; + try { + res.locals.pagination = this.getPaginationParameters(req, 20); + res.locals.notifications = await userNotificationService.getForUser(req.user, res.locals.pagination); + res.render('notification/index'); + } catch (error) { + this.log.error('failed to render notification home view', { error }); + return next(error); + } + } +} + +module.exports = { + slug: 'notification', + name: 'notification', + create: async (dtp) => { return new NotificationController(dtp); }, +}; \ No newline at end of file diff --git a/app/controllers/user.js b/app/controllers/user.js index edcef43..c5cda71 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -6,7 +6,6 @@ const express = require('express'); const mongoose = require('mongoose'); -const multer = require('multer'); const { SiteController, SiteError } = require('../../lib/site-lib'); @@ -24,7 +23,8 @@ class UserController extends SiteController { session: sessionService, } = dtp.services; - const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${this.component.slug}` }); + const upload = this.createMulter(); + const router = express.Router(); dtp.app.use('/user', router); @@ -65,7 +65,7 @@ class UserController extends SiteController { router.post( '/core/:coreUserId/settings', - limiterService.create(limiterService.config.user.postUpdateCoreSettings), + limiterService.createMiddleware(limiterService.config.user.postUpdateCoreSettings), checkProfileOwner, upload.none(), this.postUpdateCoreSettings.bind(this), @@ -73,7 +73,7 @@ class UserController extends SiteController { router.post( '/:userId/profile-photo', - limiterService.create(limiterService.config.user.postProfilePhoto), + limiterService.createMiddleware(limiterService.config.user.postProfilePhoto), checkProfileOwner, upload.single('imageFile'), this.postProfilePhoto.bind(this), @@ -81,7 +81,7 @@ class UserController extends SiteController { router.post( '/:userId/settings', - limiterService.create(limiterService.config.user.postUpdateSettings), + limiterService.createMiddleware(limiterService.config.user.postUpdateSettings), checkProfileOwner, upload.none(), this.postUpdateSettings.bind(this), @@ -89,13 +89,13 @@ class UserController extends SiteController { router.post( '/', - limiterService.create(limiterService.config.user.postCreate), + limiterService.createMiddleware(limiterService.config.user.postCreate), this.postCreateUser.bind(this), ); router.get( '/core/:coreUserId/settings', - limiterService.create(limiterService.config.user.getSettings), + limiterService.createMiddleware(limiterService.config.user.getSettings), authRequired, otpMiddleware, checkProfileOwner, @@ -103,29 +103,28 @@ class UserController extends SiteController { ); router.get( '/core/:coreUserId', - limiterService.create(limiterService.config.user.getUserProfile), + limiterService.createMiddleware(limiterService.config.user.getUserProfile), authRequired, otpMiddleware, - checkProfileOwner, this.getUserView.bind(this), ); router.get( '/:userId/otp-setup', - limiterService.create(limiterService.config.user.getOtpSetup), + limiterService.createMiddleware(limiterService.config.user.getOtpSetup), otpSetup, this.getOtpSetup.bind(this), ); router.get( '/:userId/otp-disable', - limiterService.create(limiterService.config.user.getOtpDisable), + limiterService.createMiddleware(limiterService.config.user.getOtpDisable), authRequired, this.getOtpDisable.bind(this), ); router.get( '/:userId/settings', - limiterService.create(limiterService.config.user.getSettings), + limiterService.createMiddleware(limiterService.config.user.getSettings), authRequired, otpMiddleware, checkProfileOwner, @@ -133,16 +132,15 @@ class UserController extends SiteController { ); router.get( '/:userId', - limiterService.create(limiterService.config.user.getUserProfile), + limiterService.createMiddleware(limiterService.config.user.getUserProfile), authRequired, otpMiddleware, - checkProfileOwner, this.getUserView.bind(this), ); router.delete( '/:userId/profile-photo', - limiterService.create(limiterService.config.user.deleteProfilePhoto), + limiterService.createMiddleware(limiterService.config.user.deleteProfilePhoto), authRequired, checkProfileOwner, this.deleteProfilePhoto.bind(this), @@ -157,9 +155,6 @@ class UserController extends SiteController { return next(new SiteError(406, 'Invalid User')); } try { - if (!req.user._id.equals(userId)) { - return next(new Error('Invalid account ID')); - } res.locals.userProfile = await userService.getUserAccount(userId); return next(); } catch (error) { diff --git a/app/controllers/welcome.js b/app/controllers/welcome.js index a3cef12..8fcc5f1 100644 --- a/app/controllers/welcome.js +++ b/app/controllers/welcome.js @@ -19,7 +19,7 @@ class WelcomeController extends SiteController { async start ( ) { const { limiter: limiterService } = this.dtp.services; - const welcomeLimiter = limiterService.create(limiterService.config.welcome); + const welcomeLimiter = limiterService.createMiddleware(limiterService.config.welcome); captcha.loadFont(path.join(this.dtp.config.root, 'client', 'fonts', 'Dirty Sweb.ttf')); diff --git a/app/models/announcement.js b/app/models/announcement.js index 6481078..d7d8b51 100644 --- a/app/models/announcement.js +++ b/app/models/announcement.js @@ -4,12 +4,20 @@ 'use strict'; +const path = require('path'); + const mongoose = require('mongoose'); const Schema = mongoose.Schema; +const { + ResourceStats, + ResourceStatsDefaults, +} = require(path.join(__dirname, 'lib', 'resource-stats.js')); + + const AnnouncementSchema = new Schema({ - created: { type: Date, default: Date.now, required: true, index: -1, expires: '21d' }, + created: { type: Date, default: Date.now, required: true, index: -1 }, title: { icon: { class: { type: String, default: 'fa-bullhorn', required: true }, @@ -18,6 +26,7 @@ const AnnouncementSchema = new Schema({ content: { type: String, required: true }, }, content: { type: String, required: true }, + resourceStats: { type: ResourceStats, default: ResourceStatsDefaults, required: true }, }); module.exports = mongoose.model('Announcement', AnnouncementSchema); \ No newline at end of file diff --git a/app/models/attachment.js b/app/models/attachment.js new file mode 100644 index 0000000..71c3b89 --- /dev/null +++ b/app/models/attachment.js @@ -0,0 +1,64 @@ +// attachment.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); + +const Schema = mongoose.Schema; + +const ATTACHMENT_STATUS_LIST = [ + 'processing', // the attachment is in the processing queue + 'live', // the attachment is available for use + 'rejected', // the attachment was rejected (by proccessing queue) + 'retired', // the attachment has been retired +]; + +const AttachmentFileSchema = new Schema({ + bucket: { type: String, required: true }, + key: { type: String, required: true }, + mime: { type: String, required: true }, + size: { type: Number, required: true }, + etag: { type: String, required: true }, +}, { + _id: false, +}); + +/* + * Attachments are simply files. They can really be any kind of file, but will + * mostly be image, video, and audio files. + * + * owner is the User or CoreUser that uploaded the attachment. + * + * item is the item to which the attachment is attached. + */ +const AttachmentSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: 1 }, + status: { type: String, enum: ATTACHMENT_STATUS_LIST, default: 'processing', required: true }, + ownerType: { type: String, required: true }, + owner: { type: Schema.ObjectId, required: true, index: 1, refPath: 'ownerType' }, + itemType: { type: String, required: true }, + item: { type: Schema.ObjectId, required: true, index: 1, refPath: 'itemType' }, + original: { type: AttachmentFileSchema, required: true, select: false }, + encoded: { type: AttachmentFileSchema, required: true }, + flags: { + isSensitive: { type: Boolean, default: false, required: true }, + }, +}); + +AttachmentSchema.index({ + ownerType: 1, + owner: 1, +}, { + name: 'attachment_owner_idx', +}); + +AttachmentSchema.index({ + itemType: 1, + item: 1, +}, { + name: 'attachment_item_idx', +}); + +module.exports = mongoose.model('Attachment', AttachmentSchema); \ No newline at end of file diff --git a/app/models/chat-message.js b/app/models/chat-message.js index 0a691fd..ba963ca 100644 --- a/app/models/chat-message.js +++ b/app/models/chat-message.js @@ -8,11 +8,24 @@ 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 }, + created: { type: Date, default: Date.now, required: true, index: -1 }, + 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] }, + attachments: { type: [Schema.ObjectId], ref: 'Attachment' }, }); module.exports = mongoose.model('ChatMessage', ChatMessageSchema); \ No newline at end of file diff --git a/app/models/chat-room-invite.js b/app/models/chat-room-invite.js new file mode 100644 index 0000000..f1d8fe6 --- /dev/null +++ b/app/models/chat-room-invite.js @@ -0,0 +1,30 @@ +// 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 INVITE_STATUS_LIST = ['new', 'accepted', 'rejected', 'deleted']; + +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: INVITE_STATUS_LIST, required: true }, + message: { type: String }, +}); + +ChatRoomInviteSchema.index({ + room: 1, + member: 1, +}, { + unique: true, + name: 'chatroom_invite_unique_idx', +}); + +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..6d3e2ff --- /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, required: true, refPath: 'members.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/comment.js b/app/models/comment.js index 2ea385e..2aaa3de 100644 --- a/app/models/comment.js +++ b/app/models/comment.js @@ -16,11 +16,18 @@ const CommentHistorySchema = new Schema({ const { RESOURCE_TYPE_LIST, + ResourceStats, + ResourceStatsDefaults, CommentStats, CommentStatsDefaults, } = require(path.join(__dirname, 'lib', 'resource-stats.js')); -const COMMENT_STATUS_LIST = ['published', 'removed', 'mod-warn', 'mod-removed']; +const COMMENT_STATUS_LIST = [ + 'published', + 'removed', + 'mod-warn', + 'mod-removed', +]; const CommentSchema = new Schema({ created: { type: Date, default: Date.now, required: true, index: 1 }, @@ -35,7 +42,8 @@ const CommentSchema = new Schema({ flags: { isNSFW: { type: Boolean, default: false, required: true }, }, - stats: { type: CommentStats, default: CommentStatsDefaults, required: true }, + resourceStats: { type: ResourceStats, default: ResourceStatsDefaults, required: true }, + commentStats: { type: CommentStats, default: CommentStatsDefaults, required: true }, }); /* diff --git a/app/models/core-user.js b/app/models/core-user.js index 812e826..04e9e46 100644 --- a/app/models/core-user.js +++ b/app/models/core-user.js @@ -33,7 +33,7 @@ const CoreUserSchema = new Schema({ permissions: { type: UserPermissionsSchema, select: false }, optIn: { type: UserOptInSchema, required: true, select: false }, theme: { type: String, enum: DTP_THEME_LIST, default: 'dtp-light', required: true }, - stats: { type: ResourceStats, default: ResourceStatsDefaults, required: true }, + resourceStats: { type: ResourceStats, default: ResourceStatsDefaults, required: true }, }); CoreUserSchema.index({ 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/lib/resource-stats.js b/app/models/lib/resource-stats.js index 016644d..257bfc7 100644 --- a/app/models/lib/resource-stats.js +++ b/app/models/lib/resource-stats.js @@ -8,26 +8,29 @@ const mongoose = require('mongoose'); const Schema = mongoose.Schema; -module.exports.RESOURCE_TYPE_LIST = ['Page', 'Post']; +module.exports.RESOURCE_TYPE_LIST = [ + 'Announcement', + 'Newsletter', +]; module.exports.ResourceStats = new Schema({ uniqueVisitCount: { type: Number, default: 0, required: true, index: -1 }, totalVisitCount: { type: Number, default: 0, required: true }, + upvoteCount: { type: Number, default: 0, required: true }, + downvoteCount: { type: Number, default: 0, required: true }, }); module.exports.ResourceStatsDefaults = { uniqueVisitCount: 0, totalVisitCount: 0, + upvoteCount: 0, + downvoteCount: 0, }; module.exports.CommentStats = new Schema({ - upvoteCount: { type: Number, default: 0, required: true }, - downvoteCount: { type: Number, default: 0, required: true }, replyCount: { type: Number, default: 0, required: true }, }); module.exports.CommentStatsDefaults = { - upvoteCount: 0, - downvoteCount: 0, replyCount: 0, }; \ No newline at end of file diff --git a/app/models/resource-view.js b/app/models/resource-view.js index ec00268..d59ad0a 100644 --- a/app/models/resource-view.js +++ b/app/models/resource-view.js @@ -4,11 +4,13 @@ 'use strict'; +const path = require('path'); + const mongoose = require('mongoose'); const Schema = mongoose.Schema; -const RESOURCE_TYPE_LIST = ['Page', 'Post', 'Newsletter']; +const { RESOURCE_TYPE_LIST } = require(path.join(__dirname, 'lib', 'resource-stats.js')); const ResourceViewSchema = new Schema({ created: { type: Date, default: Date.now, required: true, index: 1, expires: '30d' }, @@ -27,4 +29,4 @@ ResourceViewSchema.index({ name: 'res_view_daily_unique', }); -module.exports = mongoose.model('ResourceView', ResourceViewSchema); +module.exports = mongoose.model('ResourceView', ResourceViewSchema); \ 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/models/user-notification.js b/app/models/user-notification.js index c10620d..544de5f 100644 --- a/app/models/user-notification.js +++ b/app/models/user-notification.js @@ -7,14 +7,20 @@ const mongoose = require('mongoose'); const Schema = mongoose.Schema; +const NOTIFICATION_STATUS_LIST = ['new', 'seen']; + /* - * A notification is really just a user-specific bookmark to an event we know - * the user is interested in. These are the "timelines" presented to members. + * A notification is a user-specific bookmark to an event we know the user is + * interested in. These are the "timelines" presented to members. + * + * Notifications are only created for LOCAL users (not Core users). A + * notification sent to a CoreUser will be delivered to their Core. When it + * arrives, it becomes a UserNotification for that local user on that Core. */ const UserNotificationSchema = new Schema({ created: { type: Date, default: Date.now, required: true, index: -1, expires: '7d' }, user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, - status: { type: String, enum: ['new', 'seen'], default: 'new', required: true }, + status: { type: String, enum: NOTIFICATION_STATUS_LIST, default: 'new', required: true }, event: { type: Schema.ObjectId, required: true, ref: 'KaleidoscopeEvent' }, }); diff --git a/app/models/user.js b/app/models/user.js index 9628a3c..7c8412c 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -38,7 +38,7 @@ const UserSchema = new Schema({ permissions: { type: UserPermissionsSchema, select: false }, optIn: { type: UserOptInSchema, required: true, select: false }, theme: { type: String, enum: DTP_THEME_LIST, default: 'dtp-light', required: true }, - stats: { type: ResourceStats, default: ResourceStatsDefaults, required: true }, + resourceStats: { type: ResourceStats, default: ResourceStatsDefaults, required: true }, lastAnnouncement: { type: Date }, }, { strictPopulate: false, diff --git a/app/services/announcement.js b/app/services/announcement.js index de78d5b..786b901 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'; @@ -108,6 +108,9 @@ class AnnouncementService extends SiteService { } async remove (announcement) { + const { comment: commentService } = this.dtp.services; + await commentService.deleteForResource(announcement); + await Announcement.deleteOne({ _id: announcement._id }); } } diff --git a/app/services/attachment.js b/app/services/attachment.js new file mode 100644 index 0000000..a06fe9b --- /dev/null +++ b/app/services/attachment.js @@ -0,0 +1,186 @@ +// cache.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); +const Attachment = mongoose.model('Attachment'); + +const { SiteService } = require('../../lib/site-lib'); + +class AttachmentService extends SiteService { + + constructor (dtp) { + super(dtp, module.exports); + } + + async start ( ) { + await super.start(); + + const { user: userService } = this.dtp.services; + this.populateAttachment = [ + { + path: 'item', + }, + { + path: 'owner', + select: userService.USER_SELECT, + }, + ]; + + this.queue = this.getJobQueue('media'); + // this.template = this.loadViewTemplate('attachment/components/attachment-standalone.pug'); + } + + async create (owner, attachmentDefinition, file) { + const { minio: minioService } = this.dtp.services; + const NOW = new Date(); + + /* + * Fill in as much of the attachment as we can prior to uploading + */ + + let attachment = new Attachment(); + attachment.created = NOW; + + attachment.ownerType = owner.type; + attachment.owner = owner._id; + + attachment.itemType = attachmentDefinition.itemType; + attachment.item = mongoose.Types.ObjectId(attachmentDefinition.item._id || attachmentDefinition.item); + + attachment.flags.isSensitive = attachmentDefinition.isSensitive === 'on'; + + /* + * Upload the original file to storage + */ + + const attachmentId = attachment._id.toString(); + + attachment.original.bucket = process.env.MINIO_ATTACHMENT_BUCKET || 'dtp-attachments'; + attachment.original.key = this.getAttachmentKey(attachment, 'original'); + attachment.original.mime = file.mimetype; + attachment.original.size = file.size; + + const response = await minioService.uploadFile({ + bucket: attachment.file.bucket, + key: attachment.file.key, + filePath: file.path, + metadata: { + 'X-DTP-Attachment-ID': attachmentId, + 'Content-Type': attachment.metadata.mime, + 'Content-Length': file.size, + }, + }); + + /* + * Complete the attachment definition, and save it. + */ + + attachment.original.etag = response.etag; + await attachment.save(); + + attachment = await this.getById(attachment._id); + + await this.queue.add('attachment-ingest', { attachmentId: attachment._id }); + + return attachment; + } + + getAttachmentKey (attachment, slug) { + const attachmentId = attachment._id.toString(); + const prefix = attachmentId.slice(-4); // last 4 for best entropy + return `/attachment/${prefix}/${attachmentId}/${attachmentId}-${slug}}`; + } + + /** + * Retrieves populated Attachment documents attached to an item. + * @param {String} itemType The type of item (ex: 'ChatMessage') + * @param {*} itemId The _id of the item (ex: message._id) + * @returns Array of attachments associated with the item. + */ + async getForItem (itemType, itemId) { + const attachments = await Attachment + .find({ itemType, item: itemId }) + .sort({ order: 1, created: 1 }) + .populate(this.populateAttachment) + .lean(); + return attachments; + } + + /** + * Retrieves populated Attachment documents created by a specific owner. + * @param {User} owner The owner for which Attachments are being fetched. + * @param {*} pagination Optional pagination of data set + * @returns Array of attachments owned by the specified owner. + */ + async getForOwner (owner, pagination) { + const attachments = await Attachment + .find({ ownerType: owner.type, owner: owner._id }) + .sort({ order: 1, created: 1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateAttachment) + .lean(); + return attachments; + } + + /** + * + * @param {mongoose.Types.ObjectId} attachmentId The ID of the attachment + * @param {Object} options `withOriginal` true|false + * @returns A populated Attachment document configured per options. + */ + async getById (attachmentId, options) { + options = Object.assign({ + withOriginal: false, + }, options || { }); + + let q = Attachment.findById(attachmentId); + if (options.withOriginal) { + q = q.select('+original'); + } + + const attachment = await q.populate(this.populateAttachment).lean(); + return attachment; + } + + /** + * Updates the status of an Attachment. + * @param {Attachment} attachment The attachment being modified. + * @param {*} status The new status of the attachment + */ + async setStatus (attachment, status) { + await Attachment.updateOne({ _id: attachment._id }, { $set: { status } }); + } + + /** + * Passes an attachment and options through a Pug template to generate HTML + * output ready to be inserted into a DOM to present the attachment in the UI. + * @param {Attachment} attachment + * @param {Object} attachmentOptions Additional options passed to the template + * @returns HTML output of the template + */ + async render (attachment, attachmentOptions) { + return this.attachmentTemplate({ attachment, attachmentOptions }); + } + + /** + * Creates a Bull Queue job to delete an Attachment including it's processed + * and original media files. + * @param {Attachment} attachment The attachment to be deleted. + * @returns Bull Queue job handle for the newly created job to delete the + * attachment. + */ + async remove (attachment) { + this.log.info('creating job to delete attachment', { attachmentId: attachment._id }); + return await this.queue.add('attachment-delete', { attachmentId: attachment._id }); + } +} + +module.exports = { + slug: 'attachment', + name: 'attachment', + create: (dtp) => { return new AttachmentService(dtp); }, +}; \ No newline at end of file diff --git a/app/services/chat.js b/app/services/chat.js index 1734766..6194919 100644 --- a/app/services/chat.js +++ b/app/services/chat.js @@ -5,36 +5,577 @@ 'use strict'; const mongoose = require('mongoose'); + +const ChatRoom = mongoose.model('ChatRoom'); +const ChatRoomInvite = mongoose.model('ChatRoomInvite'); + const ChatMessage = mongoose.model('ChatMessage'); +const EmojiReaction = mongoose.model('EmojiReaction'); const ioEmitter = require('socket.io-emitter'); +const moment = require('moment'); +const marked = require('marked'); +const hljs = require('highlight.js'); + +const striptags = require('striptags'); +const unzalgo = require('unzalgo'); +const stringSimilarity = require('string-similarity'); -const { SiteService } = require('../../lib/site-lib'); +const { SiteService, SiteError } = require('../../lib/site-lib'); class ChatService extends SiteService { constructor (dtp) { super(dtp, module.exports); - this.populateContentReport = [ + } + + async start ( ) { + const { user: userService, limiter: limiterService } = this.dtp.services; + await super.start(); + + this.populateChatMessage = [ + { + path: 'channel', + }, + { + path: 'author', + select: userService.USER_SELECT, + }, { - path: 'user', - select: '_id username username_lc displayName picture', + path: 'stickers', + }, + ]; + + this.populateChatRoom = [ + { + path: 'owner', + select: userService.USER_SELECT, }, { - path: 'resource', + path: 'members.member', + select: userService.USER_SELECT, + }, + ]; + + this.populateChatRoomInvite = [ + { + path: 'room', populate: [ { - path: 'author', - select: '_id username username_lc displayName picture', + path: 'owner', + select: userService.USER_SELECT, }, ], }, + { + path: 'member', + select: userService.USER_SELECT, + }, ]; - } - async start ( ) { + this.templates = { + chatMessage: this.loadViewTemplate('chat/components/message-standalone.pug'), + }; + + this.markedRenderer = new marked.Renderer(); + this.markedRenderer.link = (href, title, text) => { return text; }; + this.markedRenderer.image = (href, title, text) => { return text; }; + + this.markedConfig = { + renderer: this.markedRenderer, + highlight: function(code, lang) { + const language = hljs.getLanguage(lang) ? lang : 'plaintext'; + return hljs.highlight(code, { language }).value; + }, + langPrefix: 'hljs language-', // highlight.js css expects a top-level 'hljs' class. + pedantic: false, + gfm: true, + breaks: false, + sanitize: false, + smartLists: true, + smartypants: false, + xhtml: false, + }; + + this.chatMessageLimiter = limiterService.createRateLimiter({ + points: 20, + duration: 60, + blockDuration: 60 * 3, + execEvenly: false, + keyPrefix: 'rl:chatmsg', + }); + + this.reactionLimiter = limiterService.createRateLimiter({ + points: 60, + duration: 60, + blockDuration: 60 * 3, + execEvenly: false, + keyPrefix: 'rl:react', + }); + + /* + * The Redis Emitter is a Socket.io-compatible message emitter that operates + * with greater efficiency than using Socket.io itself. + */ + this.emitter = ioEmitter(this.dtp.redis); + + this.queues = { + reeeper: await this.getJobQueue('reeeper'), + }; + } + + async renderTemplate (which, viewModel) { + if (!this.templates || !this.templates[which]) { + throw new Error('Chat service template does not exist'); + } + viewModel = Object.assign(viewModel, this.dtp.app.locals); + return this.templates[which](viewModel); + } + + middleware (options) { + options = Object.assign({ + maxOwnedRooms: 10, + maxJoinedRooms: 10, + }); + return async (req, res, next) => { + try { + res.locals.ownedChatRooms = await this.getRoomsForOwner(req.user, { + skip: 0, + cpp: options.maxOwnedRooms, + }); + res.locals.joinedChatRooms = await this.getRoomsForMember(req.user, { + skip: 0, + cpp: options.maxJoinedRooms, + }); + return next(); + } catch (error) { + this.log.error('failed to execute chat middleware', { error }); + return next(error); + } + }; + } + + async createRoom (owner, roomDefinition) { + const NOW = new Date(); + + const room = new ChatRoom(); + room.created = NOW; + room.lastActivity = NOW; + + room.ownerType = owner.type; + room.owner = owner._id; + + if (!roomDefinition.name || !roomDefinition.name.length) { + throw new SiteError(400, 'Must provide room name'); + } + room.name = this.filterText(roomDefinition.name); + + if (roomDefinition.description && (roomDefinition.description.length > 0)) { + room.description = this.filterText(roomDefinition.description); + } + + if (roomDefinition.policy && (roomDefinition.policy.length > 0)) { + room.policy = this.filterText(roomDefinition.policy); + } + + room.visibility = roomDefinition.visibility; + room.membershipPolicy = roomDefinition.membershipPolicy; + + room.members = [ ]; + + await room.save(); + return room.toObject(); + } + + async updateRoom (room, roomDefinition) { + const NOW = new Date(); + const updateOp = { + $set: { + lastActivity: NOW, + }, + $unset: { }, + }; + + if (!roomDefinition.name && !roomDefinition.name.length) { + throw new SiteError(400, 'Must provide room name'); + } + updateOp.$set.name = this.filterText(roomDefinition.name); + + if (roomDefinition.description && (roomDefinition.description.length > 0)) { + updateOp.$set.description = this.filterText(roomDefinition.description); + } else { + updateOp.$unset.description = 1; + } + + if (roomDefinition.policy && (roomDefinition.policy.length > 0)) { + updateOp.$set.policy = this.filterText(roomDefinition.policy); + } else { + updateOp.$unset.policy = 1; + } + + if (!roomDefinition.visibility || !roomDefinition.visibility.length) { + throw new SiteError(400, 'Must specify room visibility'); + } + updateOp.$set.visibility = roomDefinition.visibility.trim(); + + if (!roomDefinition.membershipPolicy || !roomDefinition.membershipPolicy.length) { + throw new SiteError(400, 'Must specify room membership policy'); + } + updateOp.$set.membershipPolicy = roomDefinition.membershipPolicy.trim(); + + const response = await ChatRoom.findOneAndUpdate({ _id: room._id }, updateOp, { new: true }); + this.log.debug('chat room update', { response }); + return response; + } + + async getRoomsForOwner (owner, pagination) { + pagination = Object.assign({ + skip: 0, + cpp: 50 + }, pagination); + const rooms = await ChatRoom + .find({ owner: owner._id }) + .sort({ lastActivity: -1, created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateChatRoom) + .lean(); + return rooms; + } + + async getRoomsForMember (member, pagination) { + pagination = Object.assign({ + skip: 0, + cpp: 50 + }, pagination); + const rooms = await ChatRoom + .find({ 'members.member': member._id }) + .sort({ lastActivity: -1, created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateChatRoom) + .lean(); + return rooms; + } + + async getPublicRooms (pagination) { + pagination = Object.assign({ + skip: 0, + cpp: 50 + }, pagination); + const rooms = await ChatRoom + .find({ visibility: 'public' }) + .sort({ lastActivity: -1, created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateChatRoom) + .lean(); + return rooms; + } + + async getRoomById (roomId) { + const room = await ChatRoom + .findById(roomId) + .populate(this.populateChatRoom) + .lean(); + return room; + } + + async deleteRoom (room) { + return this.queues.reeeper.add('chat-room-delete', { roomId: room._id }); + } + + async joinRoom (room, member) { + if (room.membershipPolicy !== 'open') { + throw new SiteError(403, 'The room is not open'); + } + await ChatRoom.updateOne( + { _id: room._id }, + { + $addToSet: { + members: { + memberType: member.type, + member: member._id, + }, + }, + }, + ); + } + + async leaveRoom (room, memberId) { + await ChatRoom.updateOne( + { _id: room._id }, + { + $pull: { members: { _id: memberId } }, + }, + ); + } + + async sendRoomInvite (room, member, inviteDefinition) { + const { coreNode: coreNodeService } = this.dtp.services; + const NOW = new Date(); + + if (await this.isRoomMember(room, member)) { + throw new SiteError(400, `${member.username} is already a member of ${room.name}`); + } + + let invite = await ChatRoomInvite + .findOne({ room: room._id, member: member._id }) + .populate(this.populateChatRoomInvite) + .lean(); + if (invite) { + switch (invite.status) { + case 'new': + throw new SiteError(400, `${member.displayName || member.username} was invited to join ${moment(invite.created).fromNow()}, but has not yet responded.`); + + case 'rejected': + throw new SiteError(400, `${member.displayName || member.username} rejected your invitation to join.`); + + default: + this.log.alert('deleting damaged ChatRoomInvite document', { _id: invite._id }); + await ChatRoomInvite.deleteOne({ _id: invite._id }); + break; // create a new one by proceeding + } + } + + invite = new ChatRoomInvite(); + invite.created = NOW; + invite.room = room._id; + invite.memberType = member.type; + invite.member = member._id; + invite.status = 'new'; + + if (inviteDefinition && inviteDefinition.message) { + invite.message = this.filterText(inviteDefinition.message); + } + + await invite.save(); + invite = invite.toObject(); + + this.log.info('chat room invite created', { + roomId: room._id, + memberId: member._id, + inviteId: invite._id, + }); + + /* + * Send the invite notification using DTP Core services. It will figure out + * who needs to receive the event and how to get it to them. + */ + + room.owner.type = room.ownerType; + const event = { + action: 'room-invite-create', + emitter: room.owner, + label: 'Chat Room Invitation', + content: invite.message || `Join my chat room on ${this.dtp.config.site.name}!`, + href: coreNodeService.getLocalUrl(`/chat/room/${room._id}/invite/${invite._id}`), + }; + await coreNodeService.sendKaleidoscopeEvent(event, member); + + return invite; + } + + async getRoomInvites (room, status) { + const invites = await ChatRoomInvite + .find({ room: room._id, status }) + .sort({ created: 1 }) + .populate(this.populateChatRoomInvite) + .lean(); + return invites; + } + + async getRoomInviteById (inviteId) { + const invite = await ChatRoomInvite + .findById(inviteId) + .populate(this.populateChatRoomInvite) + .lean(); + return invite; + } + + async acceptRoomInvite (invite) { + this.log.info('accepting invite to chat room', { + roomId: invite.room._id, + memberId: invite.member._id, + }); + await ChatRoom.updateOne( + { _id: invite.room._id }, + { + $addToSet: { + members: { + memberType: invite.memberType, + member: invite.member._id, + }, + }, + }, + ); + + this.log.info('updating chat invite', { inviteId: invite._id, status: 'accepted' }); + await ChatRoomInvite.updateOne( + { _id: invite._id }, + { + $set: { status: 'accepted' }, + }, + ); + } + + async rejectRoomInvite (invite) { + this.log.info('rejecting chat room invite', { + inviteId: invite._id, + roomId: invite.room._id, + memberId: invite.member._id, + }); + await ChatRoomInvite.updateOne( + { _id: invite._id }, + { + $set: { status: 'rejected' }, + }, + ); + } + + /** + * Marks an invitation as deleted, but does not physically remove it from the + * database. They expire after 30 days and will self-delete. This serves as a + * guard against invite spam. The person asking you to change this behavior + * wants to use invite spam as a form of abuse. The answer is: No. + * + * @param {ChatRoomInvite} invite The invitation to be marked as deleted. + */ + async deleteRoomInvite (invite) { + if (invite.status !== 'new') { + throw new SiteError(400, "Can't delete selected room invite"); + } + this.log.info('deleting chat room invite', { inviteId: invite._id }); + await ChatRoomInvite.updateOne({ _id: invite._id }, { $set: { status: 'deleted' } }); + } + + async isRoomMember (room, userId) { + const member = await ChatRoom.findOne({ 'members.member': userId }).lean(); + return !!member; + } + + async createMessage (author, messageDefinition) { + const { sticker: stickerService, user: userService } = this.dtp.services; + + author = await userService.getUserAccount(author._id); + if (!author || !author.permissions || !author.permissions.canChat) { + throw new SiteError(403, `You are not permitted to chat at all on ${this.dtp.config.site.name}`); + } + + try { + const userKey = author._id.toString(); + await this.chatMessageLimiter.consume(userKey, 1); + } catch (error) { + throw new SiteError(429, 'You are sending chat messages too quickly'); + } + + const NOW = new Date(); + + /* + * Record the chat message to the database + */ + + let message = new ChatMessage(); + message.created = NOW; + + message.channelType = messageDefinition.channelType; + message.channel = mongoose.Types.ObjectId(messageDefinition.channel._id || messageDefinition.channel); + + message.authorType = author.type; + message.author = author._id; + + message.content = this.filterText(messageDefinition.content); + message.analysis = await this.analyzeContent(author, message.content); + if (message.analysis.similarity > 3.0) { + throw new SiteError(429, 'Message rejected as spam (too repetitive)'); + } + + const stickerSlugs = this.findStickers(message.content); + stickerSlugs.forEach((sticker) => { + const re = new RegExp(`:${sticker}:`, 'gi'); + message.content = message.content.replace(re, '').trim(); + }); + + const stickers = await stickerService.resolveStickerSlugs(stickerSlugs); + message.stickers = stickers.map((sticker) => sticker._id); + + await message.save(); + message = message.toObject(); + + /* + * Update room's latest message pointer + */ + + await ChatRoom.updateOne( + { _id: message.channel }, + { $set: { latestMessage: message._id } }, + ); + + /* + * Prepare a message payload that can be transmitted over sockets to clients + * and rendered for display. + */ + + const renderedContent = this.renderMessageContent(message.content); + const payload = { + _id: message._id, + created: message.created, + user: { + _id: author._id, + displayName: author.displayName, + username: author.username, + }, + content: renderedContent, + stickers, + }; + + if (author.picture) { + payload.user.picture = { }; + if (author.picture.large) { + payload.user.picture.large ={ + _id: author.picture.large._id, + }; + } + if (author.picture.small) { + payload.user.picture.small = { + _id: author.picture.small._id, + }; + } + } + + /* + * Return both things + */ + + return { message, payload }; + } + + renderMessageContent (content) { + return marked.parse(content, this.markedConfig); + } + + findStickers (content) { + const tokens = content.split(' '); + const stickers = [ ]; + tokens.forEach((token) => { + if ((token[0] !== ':') || (token[token.length -1 ] !== ':')) { + return; + } + + token = token.slice(1, token.length - 1 ).toLowerCase(); + if (token.includes('/') || token.includes(':') || token.includes(' ')) { + return; // trimmed token includes invalid characters + } + + this.log.debug('found sticker request', { token }); + if (!stickers.includes(token)) { + stickers.push(striptags(token)); + } + }); + + return stickers.slice(0, 4); } async removeMessage (message) { @@ -44,6 +585,180 @@ class ChatService extends SiteService { params: { messageId: message._id }, }); } + + async getChannelHistory (channel, pagination) { + const messages = await ChatMessage + .find({ channel: channel._id }) + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateChatMessage) + .lean(); + return messages.reverse(); + } + + async getUserHistory (user, pagination) { + const messages = await ChatMessage + .find({ author: user._id }) + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateChatMessage) + .lean(); + return messages.reverse(); + } + + async getRoomMemberships (user, options) { + options = Object.assign({ + withPopulate: true, + }, options || { }); + const search = { + $or: [ + { owner: user._id }, + { 'members.member': user._id }, + ], + }; + + let q = ChatRoom.find(search).sort({ name: 1 }); + if (options.pagination) { + q = q.skip(options.pagination.skip).limit(options.pagination.cpp); + } + if (options.withPopulate) { + q = q.populate(this.populateChatRoom); + } + + const memberships = await q.lean(); + return memberships; + } + + /** + * This service is never called using user-supplied lists of room IDs. Don't + * do that, there is no membership check. Instead, every request knows the + * member's list of rooms owned and rooms joined. When this method was + * written, those arrays are being merged to build the list of roomIds. + * @param {Array} roomIds an array of Room._id values + * @param {*} pagination pagination params for the timeline + * @returns an array of messages in chronological order from all room IDs + * specified. + */ + async getMultiRoomTimeline (roomIds, pagination) { + const messages = await ChatMessage + .find({ room: { $in: roomIds } }) + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateChatMessage) + .lean(); + return messages.reverse(); + } + + /** + * Filters an input string to remove "zalgo" text and to strip all HTML tags. + * This prevents cross-site scripting and the malicious destruction of text + * layouts. + * @param {String} content The text content to be filtered. + * @returns the filtered text + */ + filterText (content) { + return striptags(unzalgo.clean(content.trim())); + } + + /** + * Analyze an input chat message against a user's history for similarity and + * other abusive content. Returns a response object with various scores + * allowing the caller to implement various policies and make various + * decisions. + * @param {User} author The author of the chat message + * @param {*} content The text of the chat message as would be distributed. + * @returns response object with various scores indicating the results of + * analyses performed. + */ + async analyzeContent (author, content) { + const response = { similarity: 0.0 }; + + /* + * Compare versus their recent chat messages, score for similarity, and + * block based on repetition. Spammers are redundant. This stops them. + */ + + const history = await ChatMessage + .find({ author: author._id }) + .sort({ created: -1 }) + .select('content') + .limit(10) + .lean(); + + history.forEach((message) => { + const similarity = stringSimilarity.compareTwoStrings(content, message.content); + if (similarity > 0.9) { // 90% or greater match with history entry + response.similarity += similarity; + } + }); + + return response; + } + + async sendMessage (channel, messageName, payload) { + if (typeof channel !== 'string') { + channel = channel.toString(); + } + this.emitter.to(channel).emit(messageName, payload); + } + + async sendSystemMessage (content, options) { + const NOW = new Date(); + + options = Object.assign({ + type: 'info', + }, options || { }); + + const payload = { + created: NOW, + type: options.type, + content, + }; + if (options.channelId) { + this.emitter.to(options.channelId).emit('system-message', payload); + return; + } + if (options.userId) { + this.emitter.to(options.userId).emit('system-message', payload); + } + } + + async createEmojiReaction (user, reactionDefinition) { + const { user: userService } = this.dtp.services; + const NOW = new Date(); + + const userCheck = await userService.getUserAccount(user._id); + if (!userCheck || !userCheck.permissions || !userCheck.permissions.canChat) { + throw new SiteError(403, 'You are not permitted to chat'); + } + + try { + const userKey = user._id.toString(); + await this.reactionLimiter.consume(userKey, 1); + } catch (error) { + throw new SiteError(429, 'You are sending reactions too quickly'); + } + + const reaction = new EmojiReaction(); + + reaction.created = NOW; + reaction.subjectType = reactionDefinition.subjectType; + reaction.subject = mongoose.Types.ObjectId(reactionDefinition.subject); + reaction.userType = user.type; + reaction.user = user._id; + reaction.reaction = reactionDefinition.reaction; + + if (reactionDefinition.timestamp) { + reaction.timestamp = reactionDefinition.timestamp; + } + + await reaction.save(); + + return reaction.toObject(); + } } module.exports = { diff --git a/app/services/comment.js b/app/services/comment.js index bf2f12c..79bb539 100644 --- a/app/services/comment.js +++ b/app/services/comment.js @@ -49,6 +49,8 @@ class CommentService extends SiteService { } async start ( ) { + await super.start(); + this.templates = { }; this.templates.comment = pug.compileFile(path.join(this.dtp.config.root, 'app', 'views', 'comment', 'components', 'comment-standalone.pug')); @@ -73,6 +75,52 @@ class CommentService extends SiteService { } } + commentCreateHandler (resourceType, resourceKey) { + const { displayEngine: displayEngineService } = this.dtp.services; + return async (req, res, next) => { + try { + res.locals.comment = await this.create( + req.user, + resourceType, + res.locals[resourceKey], + req.body, + ); + + let viewModel = Object.assign({ }, req.app.locals); + viewModel = Object.assign(viewModel, res.locals); + const html = await this.renderTemplate('comment', viewModel); + + const displayList = displayEngineService.createDisplayList('announcement-comment'); + displayList.setInputValue('textarea#content', ''); + displayList.setTextContent('#comment-character-count', '0'); + + if (req.body.replyTo) { + const replyListSelector = `.dtp-reply-list-container[data-comment-id="${req.body.replyTo}"]`; + displayList.addElement(replyListSelector, 'afterBegin', html); + displayList.removeAttribute(replyListSelector, 'hidden'); + } else { + displayList.addElement('ul#post-comment-list', 'afterBegin', html); + } + + displayList.showNotification( + 'Comment created', + 'success', + 'bottom-center', + 4000, + ); + + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to process comment', { + resourceType, + resourceId: res.locals[resourceKey]._id, + error, + }); + return next(error); + } + }; + } + async create (author, resourceType, resource, commentDefinition) { const NOW = new Date(); let comment = new Comment(); @@ -113,7 +161,7 @@ class CommentService extends SiteService { await Comment.updateOne( { _id: replyTo }, { - $inc: { 'stats.replyCount': 1 }, + $inc: { 'commentStats.replyCount': 1 }, }, ); let parent = await Comment.findById(replyTo).select('replyTo').lean(); @@ -255,7 +303,8 @@ class CommentService extends SiteService { } /** - * Deletes all comments filed against a given resource. + * Deletes all comments filed against a given resource. Will also get their + * replies as those are also filed against a resource and will match. * @param {Resource} resource The resource for which all comments are to be * deleted (physically removed from database). */ diff --git a/app/services/content-report.js b/app/services/content-report.js index b94c06b..dddd2d2 100644 --- a/app/services/content-report.js +++ b/app/services/content-report.js @@ -37,6 +37,8 @@ class ContentReportService extends SiteService { } async start ( ) { + await super.start(); + this.templates = { }; this.templates.comment = pug.compileFile(path.join(this.dtp.config.root, 'app', 'views', 'comment', 'components', 'comment-standalone.pug')); } diff --git a/app/services/content-vote.js b/app/services/content-vote.js index df6948e..a611d98 100644 --- a/app/services/content-vote.js +++ b/app/services/content-vote.js @@ -18,6 +18,7 @@ class ContentVoteService extends SiteService { } async start ( ) { + await super.start(); this.emitter = ioEmitter(this.dtp.redis); } @@ -46,10 +47,10 @@ class ContentVoteService extends SiteService { vote }); if (vote === 'up') { - updateOp.$inc['stats.upvoteCount'] = 1; + updateOp.$inc['resourceStats.upvoteCount'] = 1; message = 'Comment upvote recorded'; } else { - updateOp.$inc['stats.downvoteCount'] = 1; + updateOp.$inc['resourceStats.downvoteCount'] = 1; message = 'Comment downvote recorded'; } } else { @@ -57,8 +58,8 @@ class ContentVoteService extends SiteService { * If vote not changed, do no further work. */ if (contentVote.vote === vote) { - const updatedResource = await ResourceModel.findById(resource._id).select('stats'); - return { message: "Comment vote unchanged", stats: updatedResource.stats }; + const updatedResource = await ResourceModel.findById(resource._id).select('resourceStats'); + return { message: "Comment vote unchanged", resourceStats: updatedResource.resourceStats }; } /* @@ -73,12 +74,12 @@ class ContentVoteService extends SiteService { * Adjust resource's stats based on the changed vote */ if (vote === 'up') { - updateOp.$inc['stats.upvoteCount'] = 1; - updateOp.$inc['stats.downvoteCount'] = -1; + updateOp.$inc['resourceStats.upvoteCount'] = 1; + updateOp.$inc['resourceStats.downvoteCount'] = -1; message = 'Comment vote changed to upvote'; } else { - updateOp.$inc['stats.upvoteCount'] = -1; - updateOp.$inc['stats.downvoteCount'] = 1; + updateOp.$inc['resourceStats.upvoteCount'] = -1; + updateOp.$inc['resourceStats.downvoteCount'] = 1; message = 'Comment vote changed to downvote'; } } @@ -86,8 +87,8 @@ class ContentVoteService extends SiteService { this.log.info('updating resource stats', { resourceType, resource, updateOp }); await ResourceModel.updateOne({ _id: resource._id }, updateOp); - const updatedResource = await ResourceModel.findById(resource._id).select('stats'); - return { message, stats: updatedResource.stats }; + const updatedResource = await ResourceModel.findById(resource._id).select('resourceStats'); + return { message, resourceStats: updatedResource.resourceStats }; } } diff --git a/app/services/core-node.js b/app/services/core-node.js index 7235d27..b8cc039 100644 --- a/app/services/core-node.js +++ b/app/services/core-node.js @@ -20,7 +20,8 @@ const OAuth2Strategy = require('passport-oauth2'); const striptags = require('striptags'); -const { SiteService, SiteError } = require('../../lib/site-lib'); +const { SiteService, SiteError, SiteAsync } = require('../../lib/site-lib'); +const { ResourceStatsDefaults } = require('../models/lib/resource-stats'); class CoreAddress { @@ -58,7 +59,10 @@ class CoreNodeService extends SiteService { } async start ( ) { + await super.start(); + const cores = await this.getConnectedCores(null, true); + this.log.info('Core Node service starting', { connectedCoreCount: cores.length }); cores.forEach((core) => this.registerPassportCoreOAuth2(core)); } @@ -109,90 +113,6 @@ class CoreNodeService extends SiteService { }); } - registerPassportCoreOAuth2 (core) { - const { coreNode: coreNodeService } = this.dtp.services; - const AUTH_SCHEME = coreNodeService.getCoreRequestScheme(); - - const coreAuthStrategyName = this.getCoreAuthStrategyName(core); - const authorizationHost = `${core.address.host}:${core.address.port}`; - const authorizationURL = `${AUTH_SCHEME}://${authorizationHost}/oauth2/authorize`; - const tokenURL = `${AUTH_SCHEME}://${authorizationHost}/oauth2/token`; - const callbackURL = `${AUTH_SCHEME}://${process.env.DTP_SITE_DOMAIN}/auth/core/${core._id}/callback`; - - const coreAuthStrategy = new OAuth2Strategy( - { - authorizationURL, - tokenURL, - clientID: core.oauth.clientId.toString(), - clientSecret: core.oauth.clientSecret, - callbackURL, - }, - async (accessToken, refreshToken, params, profile, cb) => { - const NOW = new Date(); - try { - const coreUserId = mongoose.Types.ObjectId(params.coreUserId); - let user = await CoreUser.findOneAndUpdate( - { - core: core._id, - coreUserId, - }, - { - $setOnInsert: { - created: NOW, - core: core._id, - coreUserId, - flags: { - isAdmin: false, - isModerator: false, - }, - permissions: { - canLogin: true, - canChat: true, - canComment: true, - canReport: true, - }, - optIn: { - system: true, - marketing: false, - }, - theme: 'dtp-light', - stats: { - uniqueVisitCount: 0, - totalVisitCount: 0, - }, - }, - $set: { - updated: NOW, - username: params.username, - username_lc: params.username_lc, - displayName: params.displayName, - bio: params.bio, - }, - }, - { - upsert: true, - new: true, - }, - ); - user = user.toObject(); - user.type = 'CoreUser'; - return cb(null, user); - } catch (error) { - return cb(error); - } - }, - ); - - this.log.info('registering Core auth strategy', { - name: coreAuthStrategyName, - host: core.address.host, - port: core.address.port, - clientID: core.oauth.clientId.toString(), - callbackURL, - }); - passport.use(coreAuthStrategyName, coreAuthStrategy); - } - parseCoreAddress (host) { const address = new CoreAddress(); return address.parse(host); @@ -215,6 +135,25 @@ class CoreNodeService extends SiteService { return core; } + getCoreAuthStrategyName (core) { + return `dtp:${core.meta.domainKey}`; + } + + getCoreRequestScheme ( ) { + return process.env.DTP_CORE_AUTH_SCHEME || 'https'; + } + + getCoreRequestUrl (core, requestUrl) { + const coreScheme = this.getCoreRequestScheme(); + return `${coreScheme}://${core.address.host}:${core.address.port}${requestUrl}`; + } + + getLocalUrl (url) { + const CORE_SCHEME = this.getCoreRequestScheme(); + const { site } = this.dtp.config; + return `${CORE_SCHEME}://${site.domain}${url}`; + } + /** * First ensures that a record exists in the local database for the Core node. * Then, calls the node's info services to resolve more metadata about the @@ -276,11 +215,25 @@ class CoreNodeService extends SiteService { return { connectedCount, pendingCount, potentialReach }; } - async sendKaleidoscopeEvent (event) { - const CORE_SCHEME = this.getCoreRequestScheme(); + /** + * Sends a Kaleidoscope event to an array of recipients, a single recipient, + * or no recipients (undefined, a broadcast). + * @param {Object} event The event to be sent + * @param {Array} recipients Array of CoreUser to receive the event. Leave + * undefined to broadcast the event to all connected Core nodes. + * @returns Array of results, one per recipient. + */ + async sendKaleidoscopeEvent (event, recipients) { + const { hive: hiveService, userNotification: userNotificationService } = this.dtp.services; const { pkg } = this.dtp; const { site } = this.dtp.config; + const CORE_SCHEME = this.getCoreRequestScheme(); + + if (recipients && !Array.isArray(recipients)) { + recipients = [recipients]; + } + event.source = Object.assign({ pkg: { name: pkg.name, version: pkg.version }, site, @@ -304,7 +257,48 @@ class CoreNodeService extends SiteService { body: { event }, }; - return this.broadcast(request); + if (!recipients) { + return this.broadcast(request); + } + + let localEvent; // will be created if any local recipients + const results = [ ]; + + await SiteAsync.each(recipients, async (recipient) => { + switch (recipient.type) { + case 'CoreUser': + try { + const response = await this.sendRequest(recipient.core, request); + results.push({ success: true, recipient, request, response }); + } catch (error) { + this.log.error('failed to deliver request to Core node', { + coreId: recipient.core._id, + request, error, + }); + results.push({ success: false, recipient, request, error }); + } + break; + + case 'User': + try { + if (!localEvent) { + localEvent = await hiveService.createKaleidoscopeEvent(event); + } + await userNotificationService.create(recipient, localEvent); + results.push({ success: true, recipient, localEvent }); + } catch (error) { + this.log.error('failed to deliver Kaleidoscope event to local user', { recipient, event, error }); + results.push({ success: false, error }); + } + break; + + default: + results.push({ recipient, error: new SiteError(400, 'Recipient does not have a valid type')}); + break; + } + }, 4); + + return results; } async broadcast (request) { @@ -329,25 +323,6 @@ class CoreNodeService extends SiteService { return results; } - getCoreAuthStrategyName (core) { - return `dtp:${core.meta.domainKey}`; - } - - getCoreRequestScheme ( ) { - return process.env.DTP_CORE_AUTH_SCHEME || 'https'; - } - - getCoreRequestUrl (core, requestUrl) { - const coreScheme = this.getCoreRequestScheme(); - return `${coreScheme}://${core.address.host}:${core.address.port}${requestUrl}`; - } - - getLocalUrl (url) { - const CORE_SCHEME = this.getCoreRequestScheme(); - const { site } = this.dtp.config; - return `${CORE_SCHEME}://${site.domain}${url}`; - } - async sendRequest (core, request) { try { const req = new CoreNodeRequest(); @@ -552,6 +527,87 @@ class CoreNodeService extends SiteService { ); } + registerPassportCoreOAuth2 (core) { + const { coreNode: coreNodeService } = this.dtp.services; + const AUTH_SCHEME = coreNodeService.getCoreRequestScheme(); + + const coreAuthStrategyName = this.getCoreAuthStrategyName(core); + const authorizationHost = `${core.address.host}:${core.address.port}`; + const authorizationURL = `${AUTH_SCHEME}://${authorizationHost}/oauth2/authorize`; + const tokenURL = `${AUTH_SCHEME}://${authorizationHost}/oauth2/token`; + const callbackURL = `${AUTH_SCHEME}://${process.env.DTP_SITE_DOMAIN}/auth/core/${core._id}/callback`; + + const coreAuthStrategy = new OAuth2Strategy( + { + authorizationURL, + tokenURL, + clientID: core.oauth.clientId.toString(), + clientSecret: core.oauth.clientSecret, + callbackURL, + }, + async (accessToken, refreshToken, params, profile, cb) => { + const NOW = new Date(); + try { + const coreUserId = mongoose.Types.ObjectId(params.coreUserId); + let user = await CoreUser.findOneAndUpdate( + { + core: core._id, + coreUserId, + }, + { + $setOnInsert: { + created: NOW, + core: core._id, + coreUserId, + flags: { + isAdmin: false, + isModerator: false, + }, + permissions: { + canLogin: true, + canChat: true, + canComment: true, + canReport: true, + }, + optIn: { + system: true, + marketing: false, + }, + theme: 'dtp-light', + resourceStats: ResourceStatsDefaults, + }, + $set: { + updated: NOW, + username: params.username, + username_lc: params.username_lc, + displayName: params.displayName, + bio: params.bio, + }, + }, + { + upsert: true, + new: true, + }, + ); + user = user.toObject(); + user.type = 'CoreUser'; + return cb(null, user); + } catch (error) { + return cb(error); + } + }, + ); + + this.log.info('registering Core auth strategy', { + name: coreAuthStrategyName, + host: core.address.host, + port: core.address.port, + clientID: core.oauth.clientId.toString(), + callbackURL, + }); + passport.use(coreAuthStrategyName, coreAuthStrategy); + } + async getConnectedCores (pagination, withOAuth = false) { let q = CoreNode.find({ 'flags.isConnected': true }); if (!withOAuth) { diff --git a/app/services/display-engine.js b/app/services/display-engine.js index 3dd76eb..968416d 100644 --- a/app/services/display-engine.js +++ b/app/services/display-engine.js @@ -33,6 +33,13 @@ class DisplayList { }); } + closeModal ( ) { + this.commands.push({ + action: 'closeModal', + params: { }, + }); + } + addElement (selector, where, html) { this.commands.push({ selector, action: 'addElement', diff --git a/app/services/host-cache.js b/app/services/host-cache.js index f2348ec..f4eb246 100644 --- a/app/services/host-cache.js +++ b/app/services/host-cache.js @@ -36,6 +36,7 @@ class HostCacheService extends SiteService { this.hostCache.disconnect(); delete this.hostCache; } + await super.stop(); } async getFile (bucket, key) { diff --git a/app/services/image.js b/app/services/image.js index 9d9ee03..a03b111 100644 --- a/app/services/image.js +++ b/app/services/image.js @@ -1,4 +1,4 @@ -// minio.js +// image.js // Copyright (C) 2022 DTP Technologies, LLC // License: Apache-2.0 diff --git a/app/services/limiter.js b/app/services/limiter.js index 6d51283..b7a8259 100644 --- a/app/services/limiter.js +++ b/app/services/limiter.js @@ -5,6 +5,8 @@ 'use strict'; const path = require('path'); + +const { RateLimiterRedis } = require('rate-limiter-flexible'); const expressLimiter = require('express-limiter'); const { SiteService, SiteError } = require('../../lib/site-lib'); @@ -16,14 +18,15 @@ class LimiterService extends SiteService { this.config = require(path.resolve(dtp.config.root, 'config', 'limiter.js')); this.limiter = expressLimiter(this.dtp.app, this.dtp.redis); - this.handlers = { lookup: this.limiterLookup.bind(this), whitelist: this.limiterWhitelist.bind(this), }; + + this.rateLimiters = { }; } - create (config) { + createMiddleware (config) { const options = { total: config.total, expire: config.expire, @@ -52,6 +55,17 @@ class LimiterService extends SiteService { limiterWhitelist (req) { return req.user && req.user.flags.isAdmin; } + + createRateLimiter (id, config) { + if (this.rateLimiters[id]) { + return this.rateLimiters[id]; + } + config = Object.assign({ + storeClient: this.dtp.redis, + }, config); + this.rateLimiters[id] = new RateLimiterRedis(config); + return this.rateLimiters[id]; + } } module.exports = { diff --git a/app/services/markdown.js b/app/services/markdown.js index 9e62441..e7a5462 100644 --- a/app/services/markdown.js +++ b/app/services/markdown.js @@ -17,6 +17,7 @@ class MarkdownService extends SiteService { } async start ( ) { + await super.start(); this.markedRenderer = new marked.Renderer(); } diff --git a/app/services/minio.js b/app/services/minio.js index 6d23a7e..8856982 100644 --- a/app/services/minio.js +++ b/app/services/minio.js @@ -30,6 +30,7 @@ class MinioService extends SiteService { async stop ( ) { this.log.info(`stopping ${module.exports.name} service`); + await super.stop(); } async makeBucket (name, region) { diff --git a/app/services/oauth2.js b/app/services/oauth2.js index 00f0820..2e886b8 100644 --- a/app/services/oauth2.js +++ b/app/services/oauth2.js @@ -40,6 +40,8 @@ class OAuth2Service extends SiteService { } async start ( ) { + await super.start(); + const serverOptions = { }; this.log.info('creating OAuth2 server instance', { serverOptions }); @@ -456,6 +458,15 @@ class OAuth2Service extends SiteService { */ return done(null, client); } + + /** + * Removes and fully de-authorizes an OAuth2Client from the system. + * @param {OAuth2Client} client the client to be removed + */ + async removeClient (client) { + this.log.info('removing client', { clientId: client._id, }); + await OAuth2Client.deleteOne({ _id: client._id }); + } } module.exports = { diff --git a/app/services/otp-auth.js b/app/services/otp-auth.js index 8f9a532..85fb0b4 100644 --- a/app/services/otp-auth.js +++ b/app/services/otp-auth.js @@ -21,9 +21,6 @@ class OtpAuthService extends SiteService { constructor (dtp) { super(dtp, module.exports); - } - - async start ( ) { authenticator.options = { algorithm: 'sha1', step: 30, diff --git a/app/services/session.js b/app/services/session.js index e342fb8..177878e 100644 --- a/app/services/session.js +++ b/app/services/session.js @@ -17,6 +17,7 @@ class SessionService extends SiteService { } async start ( ) { + await super.start(); this.log.info(`starting ${module.exports.name} service`); passport.serializeUser(this.serializeUser.bind(this)); passport.deserializeUser(this.deserializeUser.bind(this)); @@ -24,6 +25,7 @@ class SessionService extends SiteService { async stop ( ) { this.log.info(`stopping ${module.exports.name} service`); + await super.stop(); } middleware ( ) { diff --git a/app/services/sms.js b/app/services/sms.js index 9e1a167..10abfba 100644 --- a/app/services/sms.js +++ b/app/services/sms.js @@ -17,11 +17,13 @@ class SmsService extends SiteService { } async start ( ) { + await super.start(); this.log.info(`starting ${module.exports.name} service`); } async stop ( ) { this.log.info(`stopping ${module.exports.name} service`); + await super.stop(); } async send (message) { diff --git a/app/services/sticker.js b/app/services/sticker.js new file mode 100644 index 0000000..1a35ad3 --- /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); + } + + async start ( ) { + await super.start(); + + const { user: userService } = this.dtp.services; + this.populateSticker = [ + { + path: 'owner', + select: userService.USER_SELECT, + }, + ]; + + this.queue = this.getJobQueue('media'); + 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 'ChatRoom': + 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.queue.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 setStatus (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.queue.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/services/user-notification.js b/app/services/user-notification.js index 922a280..9c1a4db 100644 --- a/app/services/user-notification.js +++ b/app/services/user-notification.js @@ -23,16 +23,46 @@ class UserNotificationService extends SiteService { select: '_id username username_lc displayName picture', }, { - path: 'attachment', + path: 'event', + populate: [ + { + path: 'attachment', + }, + ], }, ]; } async start ( ) { + await super.start(); this.templates = { }; this.templates.notification = pug.compileFile(path.join(this.dtp.config.root, 'app', 'views', 'notification', 'components', 'notification-standalone.pug')); } + middleware (options) { + options = Object.assign({ + withNotifications: false, + }, options || { }); + return async (req, res, next) => { + res.locals.middleware = res.locals.middleware || { }; + const data = res.locals.middleware.notifications = { }; + if (!req.user) { + // requests with no user don't matter to this middleware + return next(); + } + try { + data.newCount = await this.getNewCountForUser(req.user); + if (options.withNotifications) { + data.new = await this.getForUser(req.user, { skip: 0, cpp: 10 }); + } + return next(); + } catch (error) { + this.log.error('failed to populate route with notifications data', { error }); + return next(error); + } + }; + } + async create (user, event) { const notification = new UserNotification(); notification.created = event.created; @@ -41,32 +71,29 @@ class UserNotificationService extends SiteService { notification.event = event._id; await notification.save(); + await this.dtp.redis.hincrby(`user:${user._id}:notification`, 'newCount', 1); + return notification.toObject(); } async getNewCountForUser (user) { - const result = await UserNotification.aggregate([ - { - $match: { - user: user._id, - status: 'new', - }, - }, - { - $group: { - _id: { user: 1 }, - count: { $sum: 1 }, - }, - }, - { - $project: { - _id: -1, - count: '$count', - }, - }, - ]); - this.log('getNewCountForUser', { result }); - return result[0].count; + const userKey = `user:${user._id}:notification`; + let count; + + const value = await this.dtp.redis.hget(userKey, 'newCount'); + if (!value) { + count = await UserNotification.countDocuments({ user: user._id, status: 'new' }); + await this.dtp.redis.hset(userKey, 'newCount', count); + return count; + } + + count = parseInt(value, 10); + if (count < 0) { + count = await UserNotification.countDocuments({ user: user._id, status: 'new' }); + await this.dtp.redis.hset(userKey, 'newCount', count); + } + + return count; } async getForUser (user, pagination) { @@ -77,19 +104,34 @@ class UserNotificationService extends SiteService { .limit(pagination.cpp) .populate(this.populateUserNotification) .lean(); - const newNotifications = notifications.map((notif) => notif.status === 'new'); + + const newNotifications = notifications.filter((notif) => notif.status === 'new'); if (newNotifications.length > 0) { await UserNotification.updateMany( { _id: { $in: newNotifications.map((notif) => notif._id) } }, - { $set: { stats: 'seen' } }, + { $set: { status: 'seen' } }, + ); + await this.dtp.redis.hincrby( + `user:${user._id}:notification`, + 'newCount', + -(newNotifications.length), ); } + return notifications; } + + async getById (notificationId) { + const notification = await UserNotification + .findById(notificationId) + .populate(this.populateUserNotification) + .lean(); + return notification; + } } module.exports = { - slug: 'user-notification', name: 'userNotification', + slug: 'user-notification', create: (dtp) => { return new UserNotificationService(dtp); }, }; \ No newline at end of file diff --git a/app/services/user.js b/app/services/user.js index 9b7a8af..75a9eea 100644 --- a/app/services/user.js +++ b/app/services/user.js @@ -9,6 +9,7 @@ const path = require('path'); const mongoose = require('mongoose'); const User = mongoose.model('User'); +const CoreUser = mongoose.model('CoreUser'); const UserBlock = mongoose.model('UserBlock'); const passport = require('passport'); @@ -24,6 +25,8 @@ class UserService extends SiteService { constructor (dtp) { super(dtp, module.exports); + this.USER_SELECT = '_id username username_lc displayName picture'; + this.reservedNames = require(path.join(this.dtp.config.root, 'config', 'reserved-names')); this.populateUser = [ @@ -37,6 +40,7 @@ class UserService extends SiteService { } async start ( ) { + await super.start(); this.log.info(`starting ${module.exports.name} service`); this.registerPassportLocal(); @@ -48,6 +52,7 @@ class UserService extends SiteService { async stop ( ) { this.log.info(`stopping ${module.exports.name} service`); + await super.stop(); } async create (userDefinition) { @@ -443,22 +448,46 @@ class UserService extends SiteService { } async getPublicProfile (username) { - if (!username || (typeof username !== 'string') || (username.length === 0)) { + if (!username || (typeof username !== 'string')) { throw new SiteError(406, 'Invalid username'); } + username = username.trim().toLowerCase(); - const user = await User + if (username.length === 0) { + throw new SiteError(406, 'Invalid username'); + } + + /** + * Try to resolve the user as a CoreUser + */ + let user = await CoreUser .findOne({ username_lc: username }) - .select('_id created username username_lc displayName bio picture header') + .select('_id created username username_lc displayName bio picture header core') .populate(this.populateUser) .lean(); + if (user) { + user.type = 'CoreUser'; + } else { + /* + * Try to resolve the user as a local User + */ + user = await User + .findOne({ username_lc: username }) + .select('_id created username username_lc displayName bio picture header') + .populate(this.populateUser) + .lean(); + if (user) { + user.type = 'User'; + } + } + return user; } async getRecent (maxCount = 3) { const users = User .find() - .select('_id created username username_lc displayName picture') + .select(UserService.USER_SELECT) .sort({ created: -1 }) .limit(maxCount) .lean(); @@ -578,7 +607,7 @@ class UserService extends SiteService { height: 64, format: 'jpeg', formatParameters: { - conpressionLevel: 9, + compressionLevel: 9, }, }, ]; diff --git a/app/views/admin/service-node/editor.pug b/app/views/admin/service-node/editor.pug index f309898..21a09d7 100644 --- a/app/views/admin/service-node/editor.pug +++ b/app/views/admin/service-node/editor.pug @@ -18,15 +18,30 @@ block content .uk-margin label(for="notes").uk-form-label Notes - textarea(id="notes", name="notes", rows="4", placeholder="Enter client notes").uk-textarea= serviceNode.admin.notes + textarea(id="notes", name="notes", rows="4", placeholder="Enter client notes").uk-textarea= (serviceNode.admin && serviceNode.admin.notes) ? serviceNode.admin.notes : undefined .uk-margin label(for="is-active") input(id="is-active", name="isActive", type="checkbox", checked= serviceNode.flags.isActive).uk-checkbox span.uk-margin-small-left Is Active + .uk-card-footer div(uk-grid).uk-grid-small .uk-width-expand +renderBackButton() + + .uk-width-auto + button( + type="button", + data-service-node-id= serviceNode._id, + onclick="return dtp.adminApp.deleteServiceNode(event);" + ).uk-button.uk-button-danger.uk-border-rounded + span + i.fas.fa-trash + span.uk-margin-small-left Delete + .uk-width-auto - button(type="submit").uk-button.uk-button-primary Save \ No newline at end of file + button(type="submit").uk-button.uk-button-primary.uk-border-rounded + span + i.fas.fa-save + span.uk-margin-small-left Save \ No newline at end of file diff --git a/app/views/announcement/components/announcement.pug b/app/views/announcement/components/announcement.pug index 91a5625..28ef603 100644 --- a/app/views/announcement/components/announcement.pug +++ b/app/views/announcement/components/announcement.pug @@ -6,7 +6,13 @@ mixin renderAnnouncement (announcement) i(class=`fas ${announcement.title.icon.class}`, style=`color: ${announcement.title.icon.color}`) span.uk-margin-small-left= announcement.title.content .uk-card-body!= marked.parse(announcement.content, { renderer: marked.Renderer() }) - .uk-card-footer - .uk-text-small.uk-text-muted.uk-flex.uk-flex-between - div= moment(announcement.created).format('MMM DD, YYYY') - div= moment(announcement.created).format('hh:mm a') \ No newline at end of file + .uk-card-footer.uk-text-small.uk-text-muted + div(uk-grid).uk-grid-small.uk-grid-divider + .uk-width-auto + a(href=`/announcement/${announcement._id}`)= moment(announcement.created).format('MMM DD, YYYY [at] hh:mm a') + if currentView !== 'announcement' + .uk-width-auto + a(href=`/announcement/${announcement._id}`) + span + i.fas.fa-link + span.uk-margin-small-left Open Announcement \ No newline at end of file diff --git a/app/views/announcement/view.pug b/app/views/announcement/view.pug new file mode 100644 index 0000000..1ad8a26 --- /dev/null +++ b/app/views/announcement/view.pug @@ -0,0 +1,11 @@ +extends ../layouts/main +block content + + include ../comment/components/section + include components/announcement + + section.uk-section.uk-section-default.uk-section-small + .uk-container + +renderAnnouncement(announcement) + + +renderCommentSection({ name: `announcement-${announcement._id}`, rootUrl: `/announcement/${announcement._id}/comment` }) \ 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..17f418d --- /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) + + #site-emoji-picker + div THIS IS THE EMOJI PICKER + + .uk-padding-small.uk-padding-remove-bottom + textarea( + id="chat-input-text", + name="content", + rows="2", + hidden= options.inputHidden, + ).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", + onclick= "return dtp.app.chat.toggleEmojiPicker(event);", + ).uk-button.uk-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'}, + onclick="return dtp.app.chat.openChatInput();", + ).uk-button.uk-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.createAttachment(event);", + ).uk-button.uk-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", title= "Chat Home").uk-button.uk-button-default.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);", + title= "Toggle Chat Input", + ).uk-button.uk-button-default.uk-button-small + span + i.fas.fa-edit + + .uk-width-auto + button( + id="chat-send-btn", + type="submit", + title= "Send Message", + ).uk-button.uk-button-primary.uk-button-small + span + i.far.fa-paper-plane + + div(style="margin-top: 4px;") + .uk-flex.uk-flex-between + .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") \ No newline at end of file diff --git a/app/views/chat/components/message-standalone.pug b/app/views/chat/components/message-standalone.pug new file mode 100644 index 0000000..1b579e6 --- /dev/null +++ b/app/views/chat/components/message-standalone.pug @@ -0,0 +1,4 @@ +include ../../user/components/profile-icon +include message + ++renderChatMessage(message) \ No newline at end of file diff --git a/app/views/chat/components/message.pug b/app/views/chat/components/message.pug new file mode 100644 index 0000000..ea5f39b --- /dev/null +++ b/app/views/chat/components/message.pug @@ -0,0 +1,41 @@ +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 + .uk-margin-small + div(uk-grid).uk-grid-small + .uk-width-auto + +renderProfileIcon(message.author, message.author.displayName || message.author.username, 'xsmall') + + .uk-width-expand + .chat-username.uk-text-truncate= message.author.displayName || message.author.username + .uk-text-small.uk-text-muted.uk-text-truncate= message.author.username + + if !options.hideMenu && (user && !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} + + //- we're gonna go ahead and force long lines to break, and the content was + //- filtered at ingest for zalgo and HTML/XSS + .chat-content.uk-text-break!= marked.parse(message.content) + + //- "time" is filled in by the JavaScript client using the browser's locale + //- information so that "time" is always in the user's display timezone. + .chat-timestamp(data-dtp-timestamp= 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..e742987 --- /dev/null +++ b/app/views/chat/components/reaction-button.pug @@ -0,0 +1,8 @@ +mixin renderReactionButton (title, emoji, reaction) + button( + title= title, + 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/components/room-list.pug b/app/views/chat/components/room-list.pug new file mode 100644 index 0000000..6244561 --- /dev/null +++ b/app/views/chat/components/room-list.pug @@ -0,0 +1,17 @@ +mixin renderRoomList (rooms, options) + ul#room-list.uk-nav.uk-nav-default + + li.uk-nav-header + div(uk-grid).uk-grid-small + .uk-text-bold.uk-width-expand= options.title + if !options.hideCreate + .uk-width-auto + a(href='/chat/room/create', title= "Create new chat room...").uk-link-reset + i.fas.fa-plus + + if Array.isArray(rooms) && (rooms.length > 0) + each room in rooms + li.uk-active + a(href=`/chat/room/${room._id}`)= room.name + else + li= options.emptyPrompt \ No newline at end of file diff --git a/app/views/chat/components/user-list-entry.pug b/app/views/chat/components/user-list-entry.pug new file mode 100644 index 0000000..10d4957 --- /dev/null +++ b/app/views/chat/components/user-list-entry.pug @@ -0,0 +1,12 @@ +mixin renderUserListEntry (user, label) + div(uk-grid).uk-grid-small + div(class="uk-width-1-1 uk-width-auto@m") + a(href= getUserProfileUrl(user)) + +renderProfileIcon(user, user.displayName || user.username, 'small') + + div(class="uk-width-1-1 uk-width-expand@m").no-select + .uk-margin-small + .uk-text-bold.dtp-text-tight= user.displayName || user.username + .uk-text-small.dtp-text-tight @#{user.username} + if label + .uk-label= 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..b51e8d6 --- /dev/null +++ b/app/views/chat/index.pug @@ -0,0 +1,22 @@ +extends layouts/room +block content + + include components/message + + #site-chat-container.uk-flex.uk-flex-column.uk-height-1-1 + .chat-menubar.uk-padding-small + div(uk-grid).uk-grid-small + .uk-width-auto + img(src=`/img/icon/${site.domainKey}/icon-48x48.png`, alt=`${site.name} icon`) + .uk-width-expand + h1.uk-margin-remove #{site.name} Chat Timeline + + .chat-content-wrapper + #chat-message-list-wrapper.uk-height-1-1 + #chat-message-list + each message in timeline + +renderChatMessage(message, { includeRoomInfo: true }) + .chat-message-menu + button(type="button", onclick="return dtp.app.chat.resumeChatScroll(event);").chat-scroll-return Resume scrolling + + //- pre= JSON.stringify(userTimeline, null, 2) \ 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..4da72bf --- /dev/null +++ b/app/views/chat/layouts/room.pug @@ -0,0 +1,39 @@ +extends ../../layouts/main +block page-footer +block content-container + + include ../components/user-list-entry + include ../components/room-list + + section.site-chat-section + div(uk-grid).uk-height-1-1 + div(class="uk-width-1-1 uk-width-1-5@l uk-flex-last uk-flex-first@l").uk-height-1-1.uk-overflow-auto + .site-chat-sidebar-widget.uk-border-rounded.uk-margin + +renderRoomList(ownedChatRooms, { title: "Your Rooms", emptyPrompt: "You don't own any chat rooms" }) + + .site-chat-sidebar-widget.uk-border-rounded + +renderRoomList(joinedChatRooms, { title: "Joined Rooms", emptyPrompt: "You haven't joined any chat rooms", hideCreate: true }) + + div(class="uk-width-1-1 uk-width-expand@l").uk-height-1-1 + #chat-room.uk-height-1-1 + block content + + div(class="uk-width-1-1 uk-width-1-5@l").uk-height-1-1.uk-overflow-auto + if room + .site-chat-sidebar-widget.uk-border-rounded + ul#room-member-list.uk-nav.uk-nav-default + li.uk-nav-header Room Owner + li + +renderUserListEntry(room.owner, 'owner') + + if room.moderators && (room.moderators.length > 0) + li.uk-nav-header Moderators + each membership in room.moderators + li + +renderUserListEntry(membership.member, 'moderator') + + if Array.isArray(room.members) && (room.members.length > 0) + li.uk-nav-header Members + each membership in room.members + li + +renderUserListEntry(membership.member, 'member') \ No newline at end of file diff --git a/app/views/chat/room/editor.pug b/app/views/chat/room/editor.pug new file mode 100644 index 0000000..4022ba0 --- /dev/null +++ b/app/views/chat/room/editor.pug @@ -0,0 +1,66 @@ +extends ../layouts/room +block content + + - var actionUrl = room ? `/chat/room/${room._id}` : '/chat/room'; + + .content-block.uk-height-1-1.uk-overflow-auto + form(method="POST", action= actionUrl).uk-form + .uk-card.uk-card-default.uk-card-small + .uk-card-header + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-expand + h1.uk-card-title.uk-text-truncate #{room ? room.name : 'Create Chat Room'} + if room + .uk-width-auto + button(type="button", onclick="return dtp.app.chat.deleteChatRoom(event);").uk-button.uk-button-danger.uk-border-rounded + span + i.fas.fa-trash + span.uk-margin-small-left Delete + + .uk-card-body + .uk-margin + label(for="name").uk-form-label Room name + input(id="name", name="name", type="text", placeholder="Enter room name", value= room ? room.name : undefined).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= room ? room.description : undefined + + .uk-margin + label(for="policy").uk-form-label Room policy + textarea(id="policy", name="policy", rows="2", placeholder="Enter room use policy").uk-textarea= room ? room.policy : undefined + + .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= room ? room.visibility === 'public' : true).uk-radio + | Public + .ui-width-auto + label + input(id="is-private", name="visibility", type="radio", value="private", checked= room ? room.visibility === 'private' : false).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="membershipPolicy", type="radio", value="open", checked= room ? room.membershipPolicy === 'open' : true).uk-radio + | Open + .uk-width-auto + label + input(id="membership-closed", name="membershipPolicy", type="radio", value="closed", checked= room ? room.membershipPolicy === 'closed' : false).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 #{room ? 'Update' : 'Create'} room \ No newline at end of file diff --git a/app/views/chat/room/form/invite-member.pug b/app/views/chat/room/form/invite-member.pug new file mode 100644 index 0000000..5dc4c2a --- /dev/null +++ b/app/views/chat/room/form/invite-member.pug @@ -0,0 +1,22 @@ +button(type="button", uk-close).uk-modal-close-default +form( + method="POST", + action=`/chat/room/${room._id}/invite`, + onsubmit="return dtp.app.submitForm(event, 'invite chat member');" +).uk-form + .uk-card.uk-card-default.uk-card-small + .uk-card-header + h1.uk-card-title Invite Member + + .uk-card-body + p You are inviting a new member to #{room.name} + .uk-margin + label(for="username").uk-form-label Username + input(id="username", name="username", type="text", maxlength="100", required).uk-input + .uk-margin + label(for="message").uk-form-label Message + textarea(id="message", name="message", rows="2", placeholder="Enter message for recipient").uk-textarea + + .uk-card-footer.uk-flex.uk-flex-right + .uk-width-auto + button(type="submit").uk-button.uk-button-primary.uk-border-rounded Send \ No newline at end of file diff --git a/app/views/chat/room/index.pug b/app/views/chat/room/index.pug new file mode 100644 index 0000000..0711e78 --- /dev/null +++ b/app/views/chat/room/index.pug @@ -0,0 +1,29 @@ +extends ../layouts/room +block content + + mixin renderRoomTile (room) + div(data-room-id= room._id, data-room-name= room.name).uk-tile.uk-tile-default.uk-tile-small + .uk-tile-body + div(uk-grid).uk-grid-small + .uk-width-auto + .uk-width-expand + .uk-margin-small + div(title= room.name).uk-text-bold.uk-text-truncate= room.name + .uk-text-small.uk-text-truncate= room.description + div(uk-grid).uk-grid-small.uk-text-small.uk-text-muted.no-select + .uk-width-expand + a(href= getUserProfileUrl(room.owner))= room.owner.username + .uk-width-auto + span + i.fas.fa-users + span.uk-margin-small-left= formatCount(room.members.length) + + .uk-height-1-1.uk-overflow-auto + + h1 Public Rooms + div(uk-grid) + each room in publicRooms + .uk-width-1-3 + +renderRoomTile(room) + + pre= JSON.stringify(publicRooms, null, 2) \ No newline at end of file diff --git a/app/views/chat/room/invite/components/invite-list-item.pug b/app/views/chat/room/invite/components/invite-list-item.pug new file mode 100644 index 0000000..a88878f --- /dev/null +++ b/app/views/chat/room/invite/components/invite-list-item.pug @@ -0,0 +1,25 @@ +mixin renderInviteListItem (invite) + .uk-margin-small + div(uk-grid).uk-grid-small + .uk-width-auto + +renderProfileIcon(invite.member, 'Invited member') + .uk-width-expand + .uk-text-bold.uk-text-truncate= invite.member.displayName || invite.member.username + .uk-text-small.uk-text-muted + .uk-text-truncate= invite.member.username + .uk-text-truncate= moment(invite.created).fromNow() + if invite.status === 'new' + .uk-width-auto + button( + type="button", + data-room-id= invite.room._id, + data-invite-id= invite._id, + onclick='return dtp.app.chat.deleteInvite(event);', + ).uk-button.uk-button-danger.uk-button-small.uk-border-rounded + span + i.fas.fa-trash + span.uk-margin-small-left DELETE + + if invite.message + label.uk-form-label Message to @#{invite.member.username}: + div= invite.message \ No newline at end of file diff --git a/app/views/chat/room/invite/components/invite-list.pug b/app/views/chat/room/invite/components/invite-list.pug new file mode 100644 index 0000000..c677a92 --- /dev/null +++ b/app/views/chat/room/invite/components/invite-list.pug @@ -0,0 +1,6 @@ +include invite-list-item +mixin renderInviteList (invites) + ul.uk-list + each invite in invites + li(data-invite-id= invite._id) + +renderInviteListItem(invite) \ No newline at end of file diff --git a/app/views/chat/room/invite/index.pug b/app/views/chat/room/invite/index.pug new file mode 100644 index 0000000..c9a2c88 --- /dev/null +++ b/app/views/chat/room/invite/index.pug @@ -0,0 +1,34 @@ +extends ../../layouts/room +block content + + include components/invite-list + + .uk-card.uk-card-default.uk-card-small.uk-flex.uk-flex-column.uk-height-1-1 + .uk-card-header + h1.uk-card-title.uk-margin-remove #{room.name} + div Membership invitation manager + + .uk-card-body.uk-flex-1.uk-overflow-auto + +renderSectionTitle('Sent') + .uk-margin + if (Array.isArray(invites.new) && (invites.new.length > 0)) + +renderInviteList(invites.new) + else + div No unresolved invitations. + + +renderSectionTitle('Accepted') + .uk-margin + if (Array.isArray(invites.accepted) && (invites.accepted.length > 0)) + +renderInviteList(invites.accepted) + else + div No accepted invitations. + + +renderSectionTitle('Rejected') + .uk-margin + if (Array.isArray(invites.rejected) && (invites.rejected.length > 0)) + +renderInviteList(invites.rejected) + else + div No outstanding rejected invitations. + + .uk-card-footer + +renderBackButton() \ No newline at end of file diff --git a/app/views/chat/room/invite/view.pug b/app/views/chat/room/invite/view.pug new file mode 100644 index 0000000..64d3383 --- /dev/null +++ b/app/views/chat/room/invite/view.pug @@ -0,0 +1,59 @@ +extends ../../layouts/room +block content + + include ../../../kaleidoscope/components/event + include ../../../user/components/attribution-header + + form( + method="POST", + action=`/chat/room/${invite.room._id}/invite/${invite._id}/action`, + onsubmit='return dtp.app.submitForm(event, "chat-invite-action");' + ).uk-height-1-1 + .uk-card.uk-card-default.uk-card-small.uk-flex.uk-flex-column.uk-height-1-1 + .uk-card-header + div(uk-grid).uk-grid-small + .uk-width-auto + a(href= getUserProfileUrl(invite.room.owner)) + +renderProfileIcon(invite.room.owner, invite.room.owner.displayName || invite.room.owner.username, 'small') + .uk-width-expand + h1.uk-card-title.uk-margin-remove.dtp-text-tight #{invite.room.name} + if invite.room.description && (invite.room.description.length > 0) + .uk-text-small.dtp-text-tight + div!= marked.parse(invite.room.description) + div(uk-grid).uk-text-small.uk-text-muted + .uk-width-auto + div(title= "Member count").no-select + span + i.fas.fa-users + span.uk-margin-small-left= formatCount(invite.room.members.length) + + .uk-card-body.uk-flex-1.uk-overflow-auto + .uk-margin + .uk-text-bold Status + div(class={ + 'uk-text-info': (invite.status === 'new'), + 'uk-text-success': (invite.status === 'accepted'), + 'uk-text-error': (invite.status === 'rejected'), + 'uk-text-muted': (invite.status === 'deleted'), + })= invite.status + + .uk-margin + .uk-text-bold Invite message + div!= marked.parse(invite.message) + + if invite.room.policy && (invite.room.policy.length > 0) + .uk-margin + .uk-text-bold Room policy + div!= marked.parse(invite.room.policy) + + .uk-card-footer + div(uk-grid) + .uk-width-expand + +renderBackButton() + + .uk-width-auto(hidden= invite.status !== 'new') + div(uk-grid).uk-grid-small + .uk-width-auto + button(type="submit", name="response", value="reject").uk-button.uk-button-danger.uk-border-rounded Reject + .uk-width-auto + button(type="submit", name="response", value="accept").uk-button.uk-button-primary.uk-border-rounded Accept \ No newline at end of file diff --git a/app/views/chat/room/view.pug b/app/views/chat/room/view.pug new file mode 100644 index 0000000..738f5ea --- /dev/null +++ b/app/views/chat/room/view.pug @@ -0,0 +1,93 @@ +extends ../layouts/room +block content + + include ../../user/components/profile-icon + + include ../components/input-form + include ../components/message + + #site-chat-container.uk-flex.uk-flex-column.uk-height-1-1 + .chat-menubar.uk-padding-small + div(uk-grid).uk-grid-small.uk-flex-middle + div(class="uk-width-expand").no-select + div(title= room.name).chat-room-name.uk-margin-remove.uk-text-truncate= room.name + .uk-text-small.uk-text-muted.uk-text-truncate= room.description + + div(title="Total Members", class="uk-visible@m").uk-width-auto.no-select + span + i.fas.fa-users + span.uk-margin-small-left= formatCount(room.members.length) + + if !user || !room.owner._id.equals(user._id) + + .uk-width-auto + .uk-inline + button(type="button").uk-button.uk-button-link.uk-button-small + i.fas.fa-ellipsis-h + #chat-room-menu(uk-dropdown={ pos: 'bottom-right', mode: 'click' }) + ul.uk-nav.uk-nav-default.uk-dropdown-nav + li + a(href=`/chat/room/${room._id}/widget`, target="_blank") + span.nav-item-icon + i.fas.fa-comment-dots + span Pop-Out Chat + + + if user && !room.owner._id.equals(user._id) + a( + href="" + data-room-id= room._id, + onclick="return dtp.app.chat.leaveRoom(event);", + ) + span.nav-item-icon + i.fas.fa-sign-out-alt + span Leave Room + + if user && room.owner._id.equals(user._id) + li.uk-nav-divider + + li + a( + href="", + data-room-id= room._id, + onclick=`return dtp.app.chat.showForm(event, '${room._id}', 'invite-member');` + ) + span.nav-item-icon + i.fas.fa-user-plus + span Invite New Member + + li + a( + href=`/chat/room/${room._id}/invite`, + data-room-id= room._id, + ) + span.nav-item-icon + i.fas.fa-mail-bulk + span Manage Invites + + li.uk-nav-divider + + li + a(href=`/chat/room/${room._id}/settings`) + span.nav-item-icon + i.fas.fa-cog + span Settings + + + .chat-content-wrapper + div(uk-grid).uk-grid-small.uk-height-1-1 + #chat-webcam-container(hidden).uk-width-auto + ul#chat-webcam-list.uk-list + + .uk-width-expand + #chat-message-list-wrapper.uk-height-1-1 + #chat-message-list + each message in chatMessages || [ ] + +renderChatMessage(message) + + #chat-reactions.no-select + + .chat-message-menu + button(type="button", onclick="return dtp.app.chat.resumeChatScroll(event);").chat-scroll-return Resume scrolling + + +renderChatInputForm(room) \ No newline at end of file diff --git a/app/views/comment/components/comment-list-standalone.pug b/app/views/comment/components/comment-list-standalone.pug index c6bff35..30cd9ee 100644 --- a/app/views/comment/components/comment-list-standalone.pug +++ b/app/views/comment/components/comment-list-standalone.pug @@ -1,4 +1,4 @@ include ../../components/library include comment-list include composer -+renderCommentList(comments, { rootUrl: `/post/${post.slug}/comment`, countPerPage }) \ No newline at end of file ++renderCommentList(comments, { name: appletName || 'comments', rootUrl: `/post/${post.slug}/comment`, countPerPage }) \ No newline at end of file diff --git a/app/views/comment/components/comment-list.pug b/app/views/comment/components/comment-list.pug index 585ff74..1da58ac 100644 --- a/app/views/comment/components/comment-list.pug +++ b/app/views/comment/components/comment-list.pug @@ -4,7 +4,10 @@ mixin renderCommentList (comments, options = { }) if Array.isArray(comments) && (comments.length > 0) each comment in comments li(data-comment-id= comment._id) - +renderComment(comment) + - + var commentOptions = Object.assign({ }, options); + commentOptions.name = `${options.name}-reply-${comment._id}`; + +renderComment(comment, commentOptions) if (comments.length >= options.countPerPage) - var buttonId = mongoose.Types.ObjectId(); @@ -13,7 +16,7 @@ mixin renderCommentList (comments, options = { }) type="button", data-button-id= buttonId, data-post-id= post._id, - data-next-page= pagination.p + 1, + data-next-page= options.pagination ? options.pagination.p + 1 : 2, data-root-url= options.rootUrl, - onclick= `return dtp.app.loadMoreComments(event);`, + onclick= `return dtp.app.comments['${options.name}'].loadMoreComments(event);`, ).uk-button.dtp-button-primary LOAD MORE \ No newline at end of file diff --git a/app/views/comment/components/comment-standalone.pug b/app/views/comment/components/comment-standalone.pug index 46bcb54..743fe79 100644 --- a/app/views/comment/components/comment-standalone.pug +++ b/app/views/comment/components/comment-standalone.pug @@ -1,4 +1,4 @@ include ../../components/library include comment include composer -+renderComment(comment) \ No newline at end of file ++renderComment(comment, appletOptions || { }) \ No newline at end of file diff --git a/app/views/comment/components/comment.pug b/app/views/comment/components/comment.pug index 5fc5e53..f2a6798 100644 --- a/app/views/comment/components/comment.pug +++ b/app/views/comment/components/comment.pug @@ -1,6 +1,6 @@ include composer -mixin renderComment (comment) +mixin renderComment (comment, options) - var resourceId = comment.resource._id || comment.resource; article(data-comment-id= comment._id).uk-comment.dtp-site-comment header.uk-comment-header @@ -28,7 +28,7 @@ mixin renderComment (comment) a( href="", data-comment-id= comment._id, - onclick="return dtp.app.deleteComment(event);", + onclick=`return dtp.app.comments.deleteComment(event);`, ) Delete else if user li.uk-nav-header.no-select Moderation menu @@ -38,7 +38,7 @@ mixin renderComment (comment) data-resource-type= comment.resourceType, data-resource-id= resourceId, data-comment-id= comment._id, - onclick="return dtp.app.showReportCommentForm(event);", + onclick=`return dtp.app.comments.showReportCommentForm(event);`, ) Report li a( @@ -46,7 +46,7 @@ mixin renderComment (comment) data-resource-type= comment.resourceType, data-resource-id= resourceId, data-comment-id= comment._id, - onclick="return dtp.app.blockCommentAuthor(event);", + onclick=`return dtp.app.comments.blockCommentAuthor(event);`, ) Block author .uk-comment-body @@ -82,41 +82,46 @@ mixin renderComment (comment) type="button", data-comment-id= comment._id, data-vote="up", - onclick="return dtp.app.submitCommentVote(event);", + onclick=`return dtp.app.comments.submitCommentVote(event);`, title="Upvote this comment", ).uk-button.uk-button-link - +renderLabeledIcon('fa-chevron-up', formatCount(comment.stats.upvoteCount)) + +renderLabeledIcon('fa-chevron-up', formatCount(comment.resourceStats.upvoteCount)) .uk-width-auto button( type="button", data-comment-id= comment._id, data-vote="down", - onclick="return dtp.app.submitCommentVote(event);", + onclick=`return dtp.app.comments.submitCommentVote(event);`, title="Downvote this comment", ).uk-button.uk-button-link - +renderLabeledIcon('fa-chevron-down', formatCount(comment.stats.downvoteCount)) + +renderLabeledIcon('fa-chevron-down', formatCount(comment.resourceStats.downvoteCount)) .uk-width-auto button( type="button", data-comment-id= comment._id, - onclick="return dtp.app.openReplies(event);", + onclick=`return dtp.app.comments.openReplies(event);`, title="Load replies to this comment", ).uk-button.uk-button-link - +renderLabeledIcon('fa-comment', formatCount(comment.stats.replyCount)) + +renderLabeledIcon('fa-comment', formatCount(comment.commentStats.replyCount)) .uk-width-auto button( type="button", data-comment-id= comment._id, - onclick="return dtp.app.openReplyComposer(event);", + onclick=`return dtp.app.comments.openReplyComposer(event);`, title="Write a reply to this comment", ).uk-button.uk-button-link +renderLabeledIcon('fa-reply', 'reply') //- Comment replies and reply composer - div(data-comment-id= comment._id, hidden).dtp-reply-composer.uk-margin + div( + data-comment-id= comment._id, + data-root-url= options.rootUrl, + dtp-comments= options.name, + hidden, + ).dtp-reply-composer.uk-margin if user && user.permissions.canComment .uk-margin - +renderCommentComposer(`/post/${comment.resource._id}/comment`, { showCancel: true, replyTo: comment._id }) + +renderCommentComposer(`composer-reply-${comment._id}`, Object.assign({ showCancel: true, replyTo: comment._id }, options)) div(data-comment-id= comment._id, hidden).dtp-reply-list-container.uk-margin ul(data-comment-id= comment._id).dtp-reply-list.uk-list.uk-margin-medium-left \ No newline at end of file diff --git a/app/views/comment/components/composer.pug b/app/views/comment/components/composer.pug index 2513657..d55e0b3 100644 --- a/app/views/comment/components/composer.pug +++ b/app/views/comment/components/composer.pug @@ -1,36 +1,44 @@ -mixin renderCommentComposer (actionUrl, options = { }) - form(method="POST", action= actionUrl, onsubmit="return dtp.app.submitForm(event, 'create-comment');").uk-form +mixin renderCommentComposer (formId, options = { }) + form( + id= formId, + method="POST", + action= options.rootUrl, + onsubmit="return dtp.app.submitForm(event, 'create-comment');", + ).uk-form if options.replyTo input(type="hidden", name="replyTo", value= options.replyTo) .uk-card.uk-card-secondary.uk-card-small .uk-card-body - textarea( - id="content", + textarea#comment-content( name="content", rows="4", maxlength="3000", placeholder="Enter comment", - oninput="return dtp.app.onCommentInput(event);", + data-form-id= formId, + oninput=`return dtp.app.comments.onCommentInput(event);`, ).uk-textarea.uk-resize-vertical .uk-text-small div(uk-grid).uk-flex-between .uk-width-auto You are commenting as: #{user.username} - .uk-width-auto #[span#comment-character-count 0] of 3,000 + .uk-width-auto #[span.comment-character-count 0] of 3,000 + .uk-card-footer div(uk-grid).uk-flex-between.uk-grid-small .uk-width-expand ul.uk-subnav - li + li.comment-emoji-picker button( type="button", - data-target-element="content", - title="Add an emoji", - onclick="return dtp.app.showEmojiPicker(event);", + uk-tooltip="Add an emoji", ).uk-button.dtp-button-default span i.far.fa-smile + .comment-emoji-picker-drop(data-form-id= formId, uk-drop={ mode: 'click' }) + .comment-emoji-picker-ui + div THIS IS THE EMOJI PICKER + li(title="Not Safe For Work will hide your comment text by default") label input(id="is-nsfw", name="isNSFW", type="checkbox").uk-checkbox @@ -39,5 +47,6 @@ mixin renderCommentComposer (actionUrl, options = { }) if options.showCancel .uk-width-auto button(type="submit").uk-button.dtp-button-secondary Cancel + .uk-width-auto button(type="submit").uk-button.dtp-button-primary Post \ No newline at end of file diff --git a/app/views/comment/components/reply-list-standalone.pug b/app/views/comment/components/reply-list-standalone.pug index a5e3fd8..b6eee33 100644 --- a/app/views/comment/components/reply-list-standalone.pug +++ b/app/views/comment/components/reply-list-standalone.pug @@ -1,4 +1,4 @@ include ../../components/library include comment-list include composer -+renderCommentList(comments, { rootUrl: `/comment/${comment._id}/replies`, countPerPage }) \ No newline at end of file ++renderCommentList(comments, { name: appletName || 'comments', rootUrl: `/comment/${comment._id}/replies`, countPerPage }) \ No newline at end of file diff --git a/app/views/comment/components/section.pug b/app/views/comment/components/section.pug new file mode 100644 index 0000000..b60e6db --- /dev/null +++ b/app/views/comment/components/section.pug @@ -0,0 +1,38 @@ +include composer +include comment-list + +mixin renderCommentSection (options = { }) + section + .uk-container + if user && user.permissions.canComment + - + const composerOptions = Object.assign({ }, options); + composerOptions.name = `${options.name}-composer`; + .content-block(dtp-comments= composerOptions.name, data-root-url= options.rootUrl) + .uk-margin + +renderSectionTitle('Add a comment') + .uk-margin-small + +renderCommentComposer(composerOptions.name, composerOptions) + + if featuredComment + .content-block(dtp-comments= `${options.name}-feature`, data-root-url= options.rootUrl) + #featured-comment.uk-margin-large + .uk-margin + +renderSectionTitle('Linked Comment') + - + const featureOptions = Object.assign({ }, options); + featureOptions.name = `${options.name}-feature`; + +renderComment(featuredComment, featureOptions) + + .content-block(dtp-comments= options.name, data-root-url= options.rootUrl) + +renderSectionTitle('Comments') + + if Array.isArray(comments) && (comments.length > 0) + ul#post-comment-list.uk-list.uk-list-divider.uk-list-large + +renderCommentList(comments, Object.assign({ + countPerPage: countPerPage || 10, + rootUrl: options.rootUrl, + }, options)) + else + ul#post-comment-list.uk-list.uk-list-divider.uk-list-large + div There are no comments at this time. Please check back later. \ No newline at end of file diff --git a/app/views/components/button-icon.pug b/app/views/components/button-icon.pug index 1bb88bf..3dac976 100644 --- a/app/views/components/button-icon.pug +++ b/app/views/components/button-icon.pug @@ -1,4 +1,5 @@ mixin renderButtonIcon (buttonClass, buttonLabel) span i(class=`fas ${buttonClass}`) - span(class="uk-visible@m").uk-margin-small-left= buttonLabel \ No newline at end of file + if buttonLabel + span(class="uk-visible@m").uk-margin-small-left= buttonLabel \ No newline at end of file diff --git a/app/views/components/library.pug b/app/views/components/library.pug index db9a59b..d6ca080 100644 --- a/app/views/components/library.pug +++ b/app/views/components/library.pug @@ -14,6 +14,13 @@ include section-title return numeral(value).format(value > 1000 ? '0,0.0a' : '0,0'); } + function getUserProfileUrl (user) { + if (user.core) { + return `/user/core/${user._id}`; + } + return `/user/${user._id}`; + } + mixin renderCell (label, value, className) div(title=`${label}: ${numeral(value).format('0,0')}`).uk-tile.uk-tile-default.uk-padding-remove.no-select div(class=className)!= value diff --git a/app/views/components/navbar.pug b/app/views/components/navbar.pug index aa3255e..594e1fc 100644 --- a/app/views/components/navbar.pug +++ b/app/views/components/navbar.pug @@ -27,12 +27,17 @@ nav(uk-navbar).uk-navbar-container.uk-position-fixed.uk-position-top .uk-navbar-right if user ul.uk-navbar-nav - + li(class={ 'uk-active': currentView === 'notification' }) + a(href="/notification", title="Notifications") + .uk-position-relative + i.fas.fa-bell.uk-text-large + if middleware.notifications.newCount > 0 + span(style="top: -11px; right: -15px; padding: 0 3px; border-radius: 4px; background-color: #ff0013; color: #e8e8e8;").uk-position-absolute= formatCount(middleware.notifications.newCount) .uk-navbar-item if user div.no-select - +renderProfileIcon(user, "Member Menu") + +renderProfileIcon(user, `${user.displayName || user.username}'s Menu`, 'navbar') div(uk-dropdown={ mode: 'click' }).uk-navbar-dropdown ul.uk-nav.uk-navbar-dropdown-nav diff --git a/app/views/components/off-canvas.pug b/app/views/components/off-canvas.pug index 5070e0a..9a949ad 100644 --- a/app/views/components/off-canvas.pug +++ b/app/views/components/off-canvas.pug @@ -20,9 +20,21 @@ mixin renderMenuItem (iconClass, label) a(href='/').uk-display-block +renderMenuItem('fa-home', 'Home') + li(class={ "uk-active": (currentView === 'announcement') }) + a(href='/announcement').uk-display-block + +renderMenuItem('fa-bullhorn', 'Announcements') + 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/kaleidoscope/components/event.pug b/app/views/kaleidoscope/components/event.pug new file mode 100644 index 0000000..88ee8b3 --- /dev/null +++ b/app/views/kaleidoscope/components/event.pug @@ -0,0 +1,43 @@ +mixin renderKaleidoscopeEvent (event) + div( + data-event-id= event._id, + data-event-source= event.source.pkg.name, + data-event-action= event.action, + ).kaleidoscope-event + if event.thumbnail + img(src= event.thumbnail).event-feature-img + + header.event-header + if event.label + h4.uk-comment-title.uk-margin-small= event.label + + div(uk-grid).uk-grid-small.uk-flex-middle + if event.source.emitter + .uk-width-auto + a(href= event.source.emitter.href, uk-title= `Visit ${event.source.emitter.displayName || event.source.emitter.username } at ${event.source.site.name}`) + img(src=`//${event.source.site.domain}/hive/user/${event.source.emitter.emitterId}/picture`).site-profile-picture.sb-xsmall + .uk-width-expand + if event.source.emitter + .uk-text-bold= event.source.emitter.displayName + .uk-text-small + a( + href= event.source.emitter.href, + title= `Visit ${event.source.emitter.displayName || event.source.emitter.username } at ${event.source.site.name}`, + ) #{event.source.emitter.username}@#{event.source.site.domainKey} + + .event-content!= marked.parse(event.content) + + .event-footer + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-auto + .uk-text-small.uk-text-muted + a(href= event.href, title= "Open destination")= moment(event.created).fromNow() + .uk-width-auto + .uk-text-small.uk-text-muted #[span= event.source.pkg.name] + .uk-width-expand + .uk-text-small.uk-text-muted= event.action + .uk-width-auto + a(href=`//${event.source.site.domain}`, title= event.source.site.name) + img( + src=`//${event.source.site.domain}/img/icon/${event.source.site.domainKey}/icon-16x16.png`, + ).site-favicon diff --git a/app/views/layouts/main.pug b/app/views/layouts/main.pug index 2d93af1..c22e0d1 100644 --- a/app/views/layouts/main.pug +++ b/app/views/layouts/main.pug @@ -13,12 +13,13 @@ html(lang='en') meta(name="robots", content= "index,follow") meta(name="googlebot", content= "index,follow") - meta(content="#4a4a4a" name="theme-color") - meta(content="black-translucent" name="apple-mobile-web-app-status-bar-style") + meta(name="theme-color", content="#4a4a4a") + meta(name="apple-mobile-web-app-status-bar-style", content="black-translucent") block css link(rel='stylesheet', href=`/fontawesome/css/all.min.css?v=${pkg.version}`) + link(rel='stylesheet', href=`/pretty-checkbox/pretty-checkbox.min.css?v=${pkg.version}`) block vendorcss @@ -95,12 +96,13 @@ 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 + if room script. - dtp.channel = !{JSON.stringify(channel || null)}; + dtp.room = !{JSON.stringify(room || null)}; if DTP_SCRIPT_DEBUG script(src=`/dist/js/dtpweb-app.js?v=${pkg.version}`, type="module") diff --git a/app/views/notification/index.pug b/app/views/notification/index.pug new file mode 100644 index 0000000..b8c1ae6 --- /dev/null +++ b/app/views/notification/index.pug @@ -0,0 +1,14 @@ +extends ../layouts/main-sidebar +block content + + include ../kaleidoscope/components/event + + +renderSectionTitle('Notifications') + + if Array.isArray(notifications) && (notifications.length > 0) + ul.uk-list + each notification in notifications + li + +renderKaleidoscopeEvent(notification.event) + else + div No notifications \ No newline at end of file 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/views/user/components/attribution-header.pug b/app/views/user/components/attribution-header.pug new file mode 100644 index 0000000..b15f990 --- /dev/null +++ b/app/views/user/components/attribution-header.pug @@ -0,0 +1,8 @@ +mixin renderUserAttributionHeader (user) + div(uk-grid).uk-grid-small + .uk-width-auto + +renderProfileIcon(user) + .uk-width-expand + .uk-text-bold(style="line-height: 1;")= user.displayName || user.username + .uk-text-small.uk-text-muted + a(href= getUserProfileUrl(user))= user.username \ No newline at end of file diff --git a/app/views/user/components/profile-icon.pug b/app/views/user/components/profile-icon.pug index 95c6628..43099b9 100644 --- a/app/views/user/components/profile-icon.pug +++ b/app/views/user/components/profile-icon.pug @@ -1,17 +1,35 @@ -mixin renderProfileIcon (user, title, size) +- + var sizeMap = { + "xxsmall": "small", + "xsmall": "small", + "list-item": "small", + "navbar": "small", + "small": "small", + "medium": "large", + "large": "large", + "full": "full", + }; + +mixin renderProfileIcon (user, title, size = "small") if user.coreUserId img( - src=`http://${user.core.meta.domain}/core/user/${user.coreUserId}/picture?s=${size || 'small'}`, + src=`http://${user.core.meta.domain}/core/user/${user.coreUserId}/picture?s=${sizeMap[size]}`, + class= "site-profile-picture", + class= `sb-${size}`, title= title, - ).site-profile-picture.sb-navbar + ) else if user.picture && user.picture.small img( - src= `/image/${user.picture.small._id}`, + src= `/image/${user.picture[sizeMap[size]]._id}`, + class= "site-profile-picture", + class= `sb-${size}`, title= title, - ).site-profile-picture.sb-navbar + ) else img( src= "/img/default-member.png", + class= "site-profile-picture", + class= `sb-${size}`, title= title, - ).site-profile-picture.sb-navbar + ) diff --git a/app/workers/chat.js b/app/workers/chat.js new file mode 100644 index 0000000..0b5a2e7 --- /dev/null +++ b/app/workers/chat.js @@ -0,0 +1,66 @@ +// chat.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); +require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); + +const mongoose = require('mongoose'); + +const { SiteLog, SiteWorker, SiteAsync } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); + +module.rootPath = path.resolve(__dirname, '..', '..'); +module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); + +module.config = { + environment: process.env.NODE_ENV, + root: module.rootPath, + component: { name: 'chatWorker', slug: 'chat-worker' }, +}; + +module.config.site = require(path.join(module.rootPath, 'config', 'site')); + +class ChatWorker extends SiteWorker { + + constructor (dtp) { + super(dtp, dtp.config.component); + } + + async start ( ) { + await super.start(); + + await this.loadProcessor(path.join(__dirname, 'chat', 'job', 'chat-room-clear.js')); + await this.loadProcessor(path.join(__dirname, 'chat', 'job', 'chat-room-delete.js')); + + await this.startProcessors(); + } + + async stop ( ) { + await super.stop(); + } + + async deleteChatMessage (message) { + const { attachment: attachmentService } = this.dtp.services; + const ChatMessage = mongoose.model('ChatMessage'); + + await SiteAsync.each(message.attachments, attachmentService.remove.bind(attachmentService), 2); + await ChatMessage.deleteOne({ _id: message._id }); + } +} + +(async ( ) => { + try { + module.log = new SiteLog(module, module.config.component); + + module.worker = new ChatWorker(module); + await module.worker.start(); + + 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/app/workers/chat/job/chat-room-clear.js b/app/workers/chat/job/chat-room-clear.js new file mode 100644 index 0000000..1598c4e --- /dev/null +++ b/app/workers/chat/job/chat-room-clear.js @@ -0,0 +1,51 @@ +// chat/job/chat-room-clear.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); + +const mongoose = require('mongoose'); + +const ChatMessage = mongoose.model('ChatMessage'); + +const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); + +class ChatRoomClearJob extends SiteWorkerProcess { + + static get COMPONENT ( ) { + return { + name: 'charRoomClearJob', + slug: 'chat-room-clear-job', + }; + } + + constructor (worker) { + super(worker, ChatRoomClearJob.COMPONENT); + } + + async start ( ) { + await super.start(); + + this.queue = await this.getJobQueue('chat'); + + this.log.info('registering job processor', { queue: this.queue.name, name: 'chat-room-clear' }); + this.queue.process('chat-room-clear', this.processChatRoomClear.bind(this)); + } + + async stop ( ) { + await super.stop(); + } + + async processChatRoomClear (job) { + const { roomId } = job.data; + this.log.info('received chat room clear job', { id: job.id, roomId }); + await ChatMessage + .find({ room: roomId }) + .cursor() + .eachAsync(this.worker.deleteChatMessage.bind(this), 4); + } +} + +module.exports = ChatRoomClearJob; \ No newline at end of file diff --git a/app/workers/chat/job/chat-room-delete.js b/app/workers/chat/job/chat-room-delete.js new file mode 100644 index 0000000..7c06a00 --- /dev/null +++ b/app/workers/chat/job/chat-room-delete.js @@ -0,0 +1,102 @@ +// chat/job/chat-room-delete.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); + +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 { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); + +/** + * DTP Core Chat sticker processor can receive requests to ingest and delete + * stickers to be executed as background jobs in a queue. This processor + * attaches to the `media` queue and registers processors for `sticker-ingest` + * and `sticker-delete`. + */ +class ChatRoomDeleteJob extends SiteWorkerProcess { + + static get COMPONENT ( ) { + return { + name: 'chatRoomProcessor', + slug: 'chat-room-processor', + }; + } + + constructor (worker) { + super(worker, ChatRoomDeleteJob.COMPONENT); + } + + async start ( ) { + await super.start(); + + this.queue = await this.getJobQueue('chat'); + + this.log.info('registering job processor', { queue: this.queue.name, name: 'chat-room-delete' }); + this.queue.process('chat-room-delete', this.processChatRoomDelete.bind(this)); + } + + async stop ( ) { + await super.stop(); + } + + async processChatRoomDelete (job) { + const { roomId } = job.data; + this.log.info('received chat room delete job', { id: job.id, roomId }); + + await EmojiReaction + .find({ subject: roomId }) + .cursor() + .eachAsync(this.deleteEmojiReaction.bind(this)); + + await ChatMessage + .find({ room: roomId }) + .cursor() + .eachAsync(this.worker.deleteChatMessage.bind(this), 4); + + await ChatRoomInvite + .find({ room: roomId }) + .cursor() + .eachAsync(this.deleteChatRoomInvite.bind(this), 4); + + await ChatRoom.deleteOne({ _id: roomId }); + } + + async deleteEmojiReaction (reaction) { + if (!reaction || !reaction._id) { + this.log.error('skipping invalid emoji reaction for delete'); + return; + } + + const EmojiReaction = mongoose.model('EmojiReaction'); + try { + await EmojiReaction.deleteOne({ _id: reaction._id }); + } catch (error) { + this.log.error('failed to delete chat message', { reactionId: reaction._id, error }); + } + } + + async deleteChatRoomInvite (invite) { + if (!invite || !invite._id) { + this.log.error('skipping invalid invite for delete'); + return; + } + + const ChatRoomInvite = mongoose.model('ChatRoomInvite'); + try { + await ChatRoomInvite.deleteOne({ _id: invite._id }); + } catch (error) { + this.log.error('failed to delete chat room invite', { inviteId: invite._id, error }); + } + } + +} + +module.exports = ChatRoomDeleteJob; \ 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/media.js b/app/workers/media.js new file mode 100644 index 0000000..a00e5d8 --- /dev/null +++ b/app/workers/media.js @@ -0,0 +1,85 @@ +// media.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); +require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); + +const mongoose = require('mongoose'); + +const { SitePlatform, SiteLog, SiteWorker } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); + +module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); +module.config = { + environment: process.env.NODE_ENV, + root: path.resolve(__dirname, '..', '..'), + component: { name: 'mediaWorker', slug: 'media-worker' }, +}; + +/** + * Provides background media processing for the DTP ecosystem. + * + * Background media processing is simply a way of life for scalable Web + * architectures. You don't want to force your site member to sit there and + * watch a process run. You want to accept their file, toss it to storage, and + * create a job to have whatever work needs done performed. + * + * This obviously induces a variable amount of time from when the site member + * uploads the file until it's ready for online distribution. The system + * therefore facilitates ways to query the status of the job and to receive a + * notification when the work is complete. + * + * This worker serves as a starting point or demonstration of how to do + * background media processing at scale and in production. This is the exact + * code we use to run the Digital Telepresence Platform every day. + */ +class MediaWorker extends SiteWorker { + + constructor (dtp) { + super(dtp, dtp.config.component); + } + + async start ( ) { + await super.start(); + + if (process.argv[2]) { + const stickerId = mongoose.Types.ObjectId(process.argv[2]); + this.log.info('creating sticker processing job', { stickerId }); + + const queue = this.getJobQueue('media'); + await queue.add('sticker-ingest', { stickerId }); + } + + await this.loadProcessor(path.join(__dirname, 'media', 'job', 'sticker-ingest.js')); + await this.loadProcessor(path.join(__dirname, 'media', 'job', 'sticker-delete.js')); + + await this.loadProcessor(path.join(__dirname, 'media', 'job', 'attachment-ingest.js')); + await this.loadProcessor(path.join(__dirname, 'media', 'job', 'attachment-delete.js')); + + await this.startProcessors(); + } + + async stop ( ) { + await super.stop(); + } +} + +(async ( ) => { + try { + module.log = new SiteLog(module, module.config.component); + await SitePlatform.startPlatform(module, module.config.component); + + module.worker = new MediaWorker(module); + await module.worker.start(); + + 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/app/workers/media/job/attachment-delete.js b/app/workers/media/job/attachment-delete.js new file mode 100644 index 0000000..8791002 --- /dev/null +++ b/app/workers/media/job/attachment-delete.js @@ -0,0 +1,72 @@ +// media/job/attachment-delete.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); + +const mongoose = require('mongoose'); +const Attachment = mongoose.model('Attachment'); + +const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); + +class AttachmentDeleteJob extends SiteWorkerProcess { + + static get COMPONENT ( ) { + return { + name: 'attachmentDeleteJob', + slug: 'attachment-delete-job', + }; + } + + constructor (worker) { + super(worker, AttachmentDeleteJob.COMPONENT); + } + + async start ( ) { + await super.start(); + + this.queue = await this.getJobQueue('media'); + + this.log.info('registering job processor', { queue: this.queue.name, name: 'attachment-delete' }); + this.queue.process('attachment-delete', 1, this.processAttachmentDelete.bind(this)); + } + + async stop ( ) { + await super.stop(); + } + + async processAttachmentDelete (job) { + try { + const { attachment: attachmentService } = this.dtp.services; + const attachment = job.data.attachment = await attachmentService.getById( + job.data.attachmentId, + { withOriginal: true }, + ); + + await this.deleteAttachmentFile(attachment, 'processed'); + await this.deleteAttachmentFile(attachment, 'original'); + + this.log.info('deleting attachment', { _id: attachment._id }); + await Attachment.deleteOne({ _id: attachment._id }); + } catch (error) { + this.log.error('failed to delete attachment', { attachmentId: job.data.attachmentId, error }); + throw error; + } + } + + async deleteAttachmentFile (attachment, which) { + this.log.info('removing attachment file', { _id: attachment._id, which }); + + const file = attachment[which]; + if (!file || !file.bucket || !file.key) { + return; + } + + const { minio: minioService } = this.dtp.services; + await minioService.removeObject(file.bucket, file.key); + } +} + +module.exports = AttachmentDeleteJob; \ No newline at end of file diff --git a/app/workers/media/job/attachment-ingest.js b/app/workers/media/job/attachment-ingest.js new file mode 100644 index 0000000..4b619c9 --- /dev/null +++ b/app/workers/media/job/attachment-ingest.js @@ -0,0 +1,269 @@ +// media/job/attachment-ingest.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const sharp = require('sharp'); + +const ATTACHMENT_IMAGE_HEIGHT = 540; + +const mongoose = require('mongoose'); +const Attachment = mongoose.model('Attachment'); + +const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); + +class AttachmentIngestJob extends SiteWorkerProcess { + + static get COMPONENT ( ) { + return { + name: 'attachmentIngestJob', + slug: 'attachment-ingest-job', + }; + } + + constructor (worker) { + super(worker, AttachmentIngestJob.COMPONENT); + this.processors = { + processAttachmentSharp: this.processAttachmentSharp.bind(this), + processAttachmentFFMPEG: this.processAttachmentFFMPEG.bind(this), + }; + } + + async start ( ) { + await super.start(); + + this.queue = await this.getJobQueue('media'); + + this.log.info('registering job processor', { queue: this.queue.name, name: 'attachment-ingest' }); + this.queue.process('attachment-ingest', 1, this.processAttachmentIngest.bind(this)); + } + + async stop ( ) { + await super.stop(); + } + + async processAttachmentIngest (job) { + const { attachment: attachmentService } = this.dtp.services; + + const { attachmentId } = job.data; + this.log.info('received attachment-ingest job', { id: job.id, attachmentId }); + + try { + job.data.attachment = await attachmentService.getById(attachmentId, { withOriginal: true }); + + await this.resetAttachment(job); + await this.fetchAttachmentFile(job); + 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 attachment + + } catch (error) { + this.log.error('failed to process attachment for ingest', { attachmentId: job.data.attachmentId, error }); + throw error; + } finally { + if (job.data.workPath) { + this.log.info('removing attachment work path'); + await fs.promises.rmdir(job.data.workPath, { recursive: true, force: true }); + delete job.data.workPath; + } + } + } + + async fetchAttachmentFile (job) { + const { minio: minioService } = this.dtp.services; + try { + const { attachment } = job.data; + + job.data.workPath = path.join( + process.env.DTP_ATTACHMENT_WORK_PATH, + AttachmentIngestJob.COMPONENT.slug, + attachment._id.toString(), + ); + + this.jobLog(job, 'creating work directory', { worthPath: job.data.workPath }); + await fs.promises.mkdir(job.data.workPath, { recursive: true }); + + switch (attachment.original.mime) { + case 'image/jpeg': + job.data.origFilePath = path.join(job.data.workPath, `${attachment._id}.jpg`); + job.data.procFilePath = path.join(job.data.workPath, `${attachment._id}.proc.jpg`); + job.data.processor = 'processAttachmentSharp'; + job.data.sharpFormat = 'jpeg'; + job.data.sharpFormatParameters = { quality: 85 }; + break; + + case 'image/png': + job.data.origFilePath = path.join(job.data.workPath, `${attachment._id}.png`); + job.data.procFilePath = path.join(job.data.workPath, `${attachment._id}.proc.png`); + job.data.processor = 'processAttachmentSharp'; + job.data.sharpFormat = 'png'; + job.data.sharpFormatParameters = { compression: 9 }; + break; + + case 'image/gif': + job.data.origFilePath = path.join(job.data.workPath, `${attachment._id}.gif`); + job.data.procFilePath = path.join(job.data.workPath, `${attachment._id}.proc.mp4`); + job.data.processor = 'processAttachmentFFMPEG'; + break; + + case 'image/webp': // process as PNG + job.data.origFilePath = path.join(job.data.workPath, `${attachment._id}.webp`); + job.data.procFilePath = path.join(job.data.workPath, `${attachment._id}.proc.png`); + job.data.processor = 'processAttachmentSharp'; + job.data.sharpFormat = 'png'; + job.data.sharpFormatParameters = { compression: 9 }; + break; + + default: + throw new Error(`unsupported attachment type: ${attachment.original.mime}`); + } + + this.jobLog(job, 'fetching attachment original file', { + attachmentId: attachment._id, + mime: attachment.original.mime, + size: attachment.original.size, + worthPath: job.data.origFilePath, + }); + await minioService.downloadFile({ + bucket: attachment.original.bucket, + key: attachment.original.key, + filePath: job.data.origFilePath, + }); + } catch (error) { + this.log.error('failed to fetch attachment file', { attachmentId: job.data.attachmentId, error }); + throw error; + } + } + + async resetAttachment (job) { + const { minio: minioService } = this.dtp.services; + const { attachment } = job.data; + + const updateOp = { $set: { status: 'processing' } }; + + if (attachment.encoded) { + this.log.info('removing existing encoded attachment file', { file: attachment.encoded }); + await minioService.removeObject(attachment.encoded.bucket, attachment.encoded.key); + delete attachment.encoded; + updateOp.$unset = { encoded: '' }; + } + + await Attachment.updateOne({ _id: attachment._id }, updateOp); + } + + async processAttachmentSharp (job) { + const { attachment: attachmentService, minio: minioService } = this.dtp.services; + const { attachment } = job.data; + const attachmentId = attachment._id; + + const sharpImage = sharp(job.data.origFilePath); + const metadata = await sharpImage.metadata(); + this.log.info('attachment metadata from Sharp', { attachmentId, metadata }); + + let chain = sharpImage + .clone() + .toColorspace('srgb') + .resize({ height: ATTACHMENT_IMAGE_HEIGHT }); + chain = chain[job.data.sharpFormat](job.data.sharpFormatParameters); + await chain.toFile(job.data.procFilePath); + + job.data.outFileStat = await fs.promises.stat(job.data.procFilePath); + + const bucket = process.env.MINIO_ATTACHMENT_BUCKET; + const key = attachmentService.getAttachmentKey(attachment, 'processed'); + + const response = await minioService.uploadFile({ + bucket, + key, + filePath: job.data.procFilePath, + metadata: { + 'Content-Type': `image/${job.data.sharpFormat}`, + 'Content-Length': job.data.outFileStat.size, + }, + }); + + await Attachment.updateOne( + { _id: job.data.attachment._id }, + { + $set: { + status: 'live', + encoded: { + bucket, + key, + mime: `image/${job.data.sharpFormat}`, + size: job.data.outFileStat.size, + etag: response.etag, + }, + }, + }, + ); + } + + async processAttachmentFFMPEG (job) { + const { + attachment: attachmentService, + media: mediaService, + minio: minioService, + } = this.dtp.services; + + const { attachment } = job.data; + const codecVideo = (process.env.DTP_GPU_ACCELERATION === 'enabled') ? 'h264_nvenc' : 'libx264'; + + // generate the encoded attachment + // 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 ffmpegArgs = [ + '-y', '-i', job.data.origFilePath, + '-vf', `scale=-1:${ATTACHMENT_IMAGE_HEIGHT},pad=ceil(iw/2)*2:ceil(ih/2)*2`, + '-pix_fmt', 'yuv420p', + '-c:v', codecVideo, + '-b:v', '128k', + '-movflags', '+faststart', + '-an', + job.data.procFilePath, + ]; + + this.log.debug('transcoding attachment', { ffmpegArgs }); + await mediaService.ffmpeg(ffmpegArgs); + + job.data.outFileStat = await fs.promises.stat(job.data.procFilePath); + + const bucket = process.env.MINIO_VIDEO_BUCKET; + const key = attachmentService.getAttachmentKey(attachment, 'processed'); + + this.jobLog(job, 'uploading processed media file'); + const response = await minioService.uploadFile({ + bucket, key, + filePath: job.data.procFilePath, + metadata: { + 'Content-Type': 'video/mp4', + 'Content-Length': job.data.outFileStat.size, + }, + }); + + await Attachment.updateOne( + { _id: attachment._id }, + { + $set: { + status: 'live', + encoded: { + bucket, + key, + mime: 'video/mp4', + size: job.data.outFileStat.size, + etag: response.etag, + }, + }, + }, + ); + } +} + +module.exports = AttachmentIngestJob; \ No newline at end of file diff --git a/app/workers/media/job/sticker-delete.js b/app/workers/media/job/sticker-delete.js new file mode 100644 index 0000000..85fff12 --- /dev/null +++ b/app/workers/media/job/sticker-delete.js @@ -0,0 +1,62 @@ +// media/job/sticker-delete.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); + +const mongoose = require('mongoose'); +const Sticker = mongoose.model('Sticker'); + +const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); + +class StickerDeleteJob extends SiteWorkerProcess { + + static get COMPONENT ( ) { + return { + name: 'stickerDeleteJob', + slug: 'sticker-delete-job', + }; + } + + constructor (worker) { + super(worker, StickerDeleteJob.COMPONENT); + } + + async start ( ) { + await super.start(); + + this.queue = await this.getJobQueue('media'); + + this.log.info('registering job processor', { queue: this.queue.name, name: 'sticker-ingest' }); + this.queue.process('sticker-delete', 1, this.processStickerDelete.bind(this)); + } + + async stop ( ) { + await super.stop(); + } + + async processStickerDelete (job) { + const { minio: minioService, sticker: stickerService } = this.dtp.services; + 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 + } + } +} + +module.exports = StickerDeleteJob; \ No newline at end of file diff --git a/app/workers/media/job/sticker-ingest.js b/app/workers/media/job/sticker-ingest.js new file mode 100644 index 0000000..42981d6 --- /dev/null +++ b/app/workers/media/job/sticker-ingest.js @@ -0,0 +1,267 @@ +// media/job/sticker-ingest.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'); + +const mongoose = require('mongoose'); +const Sticker = mongoose.model('Sticker'); + +const sharp = require('sharp'); + +const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); + +class StickerIngestJob extends SiteWorkerProcess { + + static get COMPONENT ( ) { + return { + name: 'stickerIngestJob', + slug: 'sticker-ingest-job', + }; + } + + constructor (worker) { + super(worker, StickerIngestJob.COMPONENT); + this.processors = { + processStickerSharp: this.processStickerSharp.bind(this), + processStickerFFMPEG: this.processStickerFFMPEG.bind(this), + }; + } + + async start ( ) { + await super.start(); + + this.queue = await this.getJobQueue('media'); + + this.log.info('registering job processor', { queue: this.queue.name, name: 'sticker-ingest' }); + this.queue.process('sticker-ingest', 1, this.processStickerIngest.bind(this)); + } + + async stop ( ) { + await super.stop(); + } + + async processStickerIngest (job) { + try { + this.log.info('received sticker ingest job', { id: job.id, data: job.data }); + await this.fetchSticker(job); + 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.procFilePath = 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.procFilePath = 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.procFilePath = 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.procFilePath = 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.procFilePath = 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.procFilePath = 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; + + const updateOp = { $set: { status: 'processing' } }; + + if (sticker.encoded) { + this.log.info('removing existing encoded sticker media', { media: sticker.encoded }); + await minioService.removeObject(sticker.encoded.bucket, sticker.encoded.key); + delete sticker.encoded; + updateOp.$unset = { encoded: '' }; + } + + await Sticker.updateOne({ _id: sticker._id }, updateOp); + } + + 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.procFilePath); + + job.data.outFileStat = await fs.promises.stat(job.data.procFilePath); + + 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.procFilePath, + metadata: { + 'Content-Type': `image/${job.data.sharpFormat}`, + 'Content-Length': job.data.outFileStat.size, + }, + }); + + 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.procFilePath, + ]; + + 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.procFilePath); + + 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.procFilePath, + metadata: { + 'Content-Type': 'video/mp4', + 'Content-Length': job.data.outFileStat.size, + }, + }); + + this.jobLog(job, 'updating Sticker to live status'); + + await Sticker.updateOne( + { _id: job.data.sticker._id }, + { + $set: { + status: 'live', + encoded: { + bucket, + key, + type: 'video/mp4', + size: job.data.outFileStat.size, + }, + }, + }, + ); + } +} + +module.exports = StickerIngestJob; \ No newline at end of file diff --git a/app/workers/newsletter.js b/app/workers/newsletter.js index eb24421..25b201a 100644 --- a/app/workers/newsletter.js +++ b/app/workers/newsletter.js @@ -4,18 +4,14 @@ 'use strict'; -const DTP_COMPONENT = { name: 'newsletter', slug: 'newsletter' }; - const path = require('path'); require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); -const mongoose = require('mongoose'); - const { SiteWorker, SiteLog } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); module.config = { - component: DTP_COMPONENT, + component: { name: 'newsletterWorker', slug: 'newsletter-worker' }, root: path.resolve(__dirname, '..', '..'), }; @@ -23,18 +19,16 @@ class NewsletterWorker extends SiteWorker { constructor (dtp) { super(dtp, dtp.config.component); - this.newsletters = this.newsletters || { }; + this.newsletters = { }; } async start ( ) { await super.start(); - const { jobQueue: jobQueueService } = this.dtp.services; - this.jobQueue = await jobQueueService.getJobQueue('newsletter', { - attempts: 3, - }); - this.jobQueue.process('transmit', this.transmitNewsletter.bind(this)); - this.jobQueue.process('email-send', this.sendNewsletterEmail.bind(this)); + await this.loadProcessor(path.join(__dirname, 'newsletter', 'job', 'transmit.js')); + await this.loadProcessor(path.join(__dirname, 'newsletter', 'job', 'email-send.js')); + + await this.startProcessors(); } async stop ( ) { @@ -55,89 +49,6 @@ class NewsletterWorker extends SiteWorker { } return newsletter; } - - async transmitNewsletter (job) { - const User = mongoose.model('User'); - const NewsletterRecipient = mongoose.model('NewsletterRecipient'); - this.log.info('newsletter email job received', { data: job.data }); - try { - /* - * Transmit first to all local user accounts with verified email who've - * opted in for receiving marketing email. - */ - await User - .find({ - 'flags.isEmailVerified': true, - 'optIn.marketing': true, - }) - .select('email displayName username username_lc') - .lean() - .cursor() - .eachAsync(async (user) => { - try { - const jobData = { - newsletterId: job.data.newsletterId, - recipient: user.email, - recipientName: user.displayName || user.username, - }; - const jobOptions = { attempts: 3 }; - await this.jobQueue.add('email-send', jobData, jobOptions); - } catch (error) { - this.log.error('failed to create newsletter email job', { error }); - } - }, { parallel: 4 }); - - /* - * Transmit to all newsletter recipients on file who've joined through the - * widget on the site w/o signing up for an account. - */ - await NewsletterRecipient - .find({ 'flags.isVerified': true, 'flags.isOptIn': true, 'flags.isRejected': false }) - .lean() - .cursor() - .eachAsync(async (recipient) => { - try { - const jobData = { - newsletterId: job.data.newsletterId, - recipient: recipient.address, - }; - const jobOptions = { attempts: 3 }; - await this.jobQueue.add('email-send', jobData, jobOptions); - } catch (error) { - this.log.error('failed to create newsletter email job', { error }); - } - }, { parallel: 4 }); - } catch (error) { - this.log.error('failed to send newsletter', { newsletterId: job.data.newsletterId, error }); - throw error; - } - } - - async sendNewsletterEmail (job) { - const { email: emailService } = this.dtp.services; - const { newsletterId, recipient } = job.data; - - try { - let newsletter = await this.loadNewsletter(newsletterId); - if (!newsletter) { - throw new Error('newsletter not found'); - } - - const result = await emailService.send({ - from: process.env.DTP_EMAIL_SMTP_FROM || `noreply@${this.dtp.config.site.domainKey}`, - to: recipient, - subject: newsletter.title, - html: newsletter.content.html, - text: newsletter.content.text, - }); - - job.log(`newsletter email sent: ${result}`); - this.log.info('newsletter email sent', { recipient, result }); - } catch (error) { - this.log.error('failed to send newsletter email', { newsletterId, recipient, error }); - throw error; // throw error to Bull so it can report in job reports - } - } } (async ( ) => { @@ -147,7 +58,7 @@ class NewsletterWorker extends SiteWorker { module.worker = new NewsletterWorker(module); await module.worker.start(); - module.log.info(`${module.pkg.name} v${module.pkg.version} Newsletter worker started`); + module.log.info(`${module.pkg.name} v${module.pkg.version} ${module.config.component.name} started`); } catch (error) { module.log.error('failed to start Newsletter worker', { error }); process.exit(-1); diff --git a/app/workers/newsletter/job/email-send.js b/app/workers/newsletter/job/email-send.js new file mode 100644 index 0000000..e88343a --- /dev/null +++ b/app/workers/newsletter/job/email-send.js @@ -0,0 +1,63 @@ +// newsletter/job/email-send.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); + +const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); + +class NewsletterEmailSendJob extends SiteWorkerProcess { + + static get COMPONENT ( ) { + return { + name: 'newsletterEmailSendJob', + slug: 'newsletter-email-send-job', + }; + } + + constructor (worker) { + super(worker, NewsletterEmailSendJob.COMPONENT); + } + + async start ( ) { + await super.start(); + + this.queue = await this.getJobQueue('newsletter'); + + this.log.info('registering job processor', { queue: this.queue.name, name: 'email-send' }); + this.queue.process('email-send', this.processEmailSend.bind(this)); + } + + async stop ( ) { + await super.stop(); + } + + async processEmailSend (job) { + const { email: emailService } = this.dtp.services; + const { newsletterId, recipient } = job.data; + + try { + let newsletter = await this.worker.loadNewsletter(newsletterId); + if (!newsletter) { + throw new Error('newsletter not found'); + } + + const result = await emailService.send({ + from: process.env.DTP_EMAIL_SMTP_FROM || `noreply@${this.dtp.config.site.domainKey}`, + to: recipient, + subject: newsletter.title, + html: newsletter.content.html, + text: newsletter.content.text, + }); + + this.jobLog(job, 'newsletter email sent', { result }); + } catch (error) { + this.log.error('failed to send newsletter email', { newsletterId, recipient, error }); + throw error; // throw error to Bull so it can report in job reports + } + } +} + +module.exports = NewsletterEmailSendJob; \ No newline at end of file diff --git a/app/workers/newsletter/job/transmit.js b/app/workers/newsletter/job/transmit.js new file mode 100644 index 0000000..3b171d7 --- /dev/null +++ b/app/workers/newsletter/job/transmit.js @@ -0,0 +1,100 @@ +// newsletter/job/transmit.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); + +const mongoose = require('mongoose'); + +const User = mongoose.model('User'); +const NewsletterRecipient = mongoose.model('NewsletterRecipient'); + +const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); + +class NewsletterTransmitJob extends SiteWorkerProcess { + + static get COMPONENT ( ) { + return { + name: 'newsletterTransmitJob', + slug: 'newsletter-transmit-job', + }; + } + + constructor (worker) { + super(worker, NewsletterTransmitJob.COMPONENT); + } + + async start ( ) { + await super.start(); + + this.queue = await this.getJobQueue('newsletter'); + + this.log.info('registering job processor', { queue: this.queue.name, name: 'transmit' }); + this.queue.process('transmit', this.processTransmit.bind(this)); + } + + async stop ( ) { + await super.stop(); + } + + async processTransmit (job) { + const { newsletterId } = job.data; + this.log.info('newsletter email job received', { id: job.id, newsletterId }); + + try { + /* + * Transmit first to all local user accounts with verified email who've + * opted in for receiving marketing email. + */ + await User + .find({ + 'flags.isEmailVerified': true, + 'optIn.marketing': true, + }) + .select('email displayName username username_lc') + .lean() + .cursor() + .eachAsync(async (user) => { + try { + const jobData = { + newsletterId: newsletterId, + recipient: user.email, + recipientName: user.displayName || user.username, + }; + const jobOptions = { attempts: 3 }; + await this.queue.add('email-send', jobData, jobOptions); + } catch (error) { + this.log.error('failed to create newsletter email job', { error }); + } + }, { parallel: 4 }); + + /* + * Transmit to all newsletter recipients on file who've joined through the + * widget on the site w/o signing up for an account. + */ + await NewsletterRecipient + .find({ 'flags.isVerified': true, 'flags.isOptIn': true, 'flags.isRejected': false }) + .lean() + .cursor() + .eachAsync(async (recipient) => { + try { + const jobData = { + newsletterId: newsletterId, + recipient: recipient.address, + }; + const jobOptions = { attempts: 3 }; + await this.queue.add('email-send', jobData, jobOptions); + } catch (error) { + this.log.error('failed to create newsletter email job', { error }); + } + }, { parallel: 4 }); + } catch (error) { + this.log.error('failed to send newsletter', { newsletterId, error }); + throw error; + } + } +} + +module.exports = NewsletterTransmitJob; \ No newline at end of file diff --git a/app/workers/reeeper.js b/app/workers/reeeper.js index 91f1890..6a0da5c 100644 --- a/app/workers/reeeper.js +++ b/app/workers/reeeper.js @@ -8,24 +8,18 @@ const path = require('path'); require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); -const mongoose = require('mongoose'); - const { SiteLog, SiteWorker, } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); -const { CronJob } = require('cron'); - -const CRON_TIMEZONE = 'America/New_York'; - module.rootPath = path.resolve(__dirname, '..', '..'); 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')); @@ -39,36 +33,15 @@ class ReeeperWorker extends SiteWorker { async start ( ) { await super.start(); - await this.expireCrashedHosts(); // first-run the expirations - this.expireJob = new CronJob('*/5 * * * * *', this.expireCrashedHosts.bind(this), null, true, CRON_TIMEZONE); + + await this.loadProcessor(path.join(__dirname, 'reeeper', 'cron', 'expire-crashed-hosts.js')); + await this.loadProcessor(path.join(__dirname, 'reeeper', 'cron', 'expire-announcements.js')); + await this.startProcessors(); } async stop ( ) { - if (this.expireJob) { - this.log.info('stopping host expire job'); - this.expireJob.stop(); - delete this.expireJob; - } - await super.stop(); } - - async expireCrashedHosts ( ) { - const NetHost = mongoose.model('NetHost'); - try { - await NetHost - .find({ status: 'crashed' }) - .select('_id hostname') - .lean() - .cursor() - .eachAsync(async (host) => { - this.log.info('deactivating crashed host', { hostname: host.hostname }); - await NetHost.updateOne({ _id: host._id }, { $set: { status: 'inactive' } }); - }); - } catch (error) { - this.log.error('failed to expire crashed hosts', { error }); - } - } } (async ( ) => { diff --git a/app/workers/reeeper/cron/expire-announcements.js b/app/workers/reeeper/cron/expire-announcements.js new file mode 100644 index 0000000..648dec2 --- /dev/null +++ b/app/workers/reeeper/cron/expire-announcements.js @@ -0,0 +1,94 @@ +// reeeper/cron/expire-announcements.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); +const moment = require('moment'); + +const mongoose = require('mongoose'); +const Announcement = mongoose.model('Announcement'); + +const { CronJob } = require('cron'); + +const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); + +/** + * Announcements used to auto-expire from the MongoDB database after 21 days, + * but then I added commenting. Now, an auto-expiring Announcement would orphan + * all those comments. That's bad. + * + * The solution, therefore, is to have a cron that wakes up daily and expires + * all Announcements older than 21 days. Same policy, it just also cleans up + * the comments and whatever else gets bolted onto an Announcement over time. + * + * This is how you do that. + */ +class ExpiredAnnouncementsCron extends SiteWorkerProcess { + + static get COMPONENT ( ) { + return { + name: 'expiredAnnouncementsCron', + slug: 'expired-announcements-cron', + }; + } + + constructor (worker) { + super(worker, ExpiredAnnouncementsCron.COMPONENT); + } + + async start ( ) { + await super.start(); + + this.log.info('performing startup expiration of announcements'); + await this.expireAnnouncements(); + + this.log.info('starting daily cron to expire announcements'); + this.job = new CronJob( + '0 0 0 * * *', // at midnight every day + this.expireAnnouncements.bind(this), + null, + true, + process.env.DTP_CRON_TIMEZONE || 'America/New_York', + ); + } + + async stop ( ) { + if (this.job) { + this.log.info('stopping announcement expire job'); + this.job.stop(); + delete this.job; + } + await super.stop(); + } + + async expireAnnouncements ( ) { + const { announcement: announcementService } = this.dtp.services; + + const NOW = new Date(); + const OLDEST_DATE = moment(NOW).subtract(21, 'days').toDate(); + + try { + await Announcement + .find({ created: { $lt: OLDEST_DATE } }) + .lean() + .cursor() + .eachAsync(async (announcement) => { + try { + await announcementService.remove(announcement); + } catch (error) { + this.log.error('failed to remove expired Announcement', { + announcementId: announcement._id, + error, + }); + // fall through, we'll get it in a future run + } + }); + } catch (error) { + this.log.error('failed to expire crashed hosts', { error }); + } + } +} + +module.exports = ExpiredAnnouncementsCron; \ No newline at end of file diff --git a/app/workers/reeeper/cron/expire-crashed-hosts.js b/app/workers/reeeper/cron/expire-crashed-hosts.js new file mode 100644 index 0000000..2662fb2 --- /dev/null +++ b/app/workers/reeeper/cron/expire-crashed-hosts.js @@ -0,0 +1,80 @@ +// reeeper/cron/expire-crashed-hosts.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); + +const mongoose = require('mongoose'); +const NetHost = mongoose.model('NetHost'); + +const { CronJob } = require('cron'); + +const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); + +/** + * Hosts on the DTP network register themselves and periodically report various + * metrics. They clean up after themselves when exiting gracefully. But, hosts + * lose power or get un-plugged or get caught in a sharknado or whatever. + * + * When that happens, the Reeeper ensures those hosts don't become Night of the + * Living Dead. + * + * That is the formal technical explanation of what's going on in here. We're + * preventing DTP host processes from becoming The Night of the Living Dead. + */ +class CrashedHostsCron extends SiteWorkerProcess { + + static get COMPONENT ( ) { + return { + name: 'crashedHostsCron', + slug: 'crashed-hosts-cron', + }; + } + + constructor (worker) { + super(worker, CrashedHostsCron.COMPONENT); + } + + async start ( ) { + await super.start(); + + await this.expireCrashedHosts(); // first-run the expirations + + this.job = new CronJob( + '*/5 * * * * *', + this.expireCrashedHosts.bind(this), + null, + true, + process.env.DTP_CRON_TIMEZONE || 'America/New_York', + ); + } + + async stop ( ) { + if (this.job) { + this.log.info('stopping host expire job'); + this.job.stop(); + delete this.job; + } + await super.stop(); + } + + async expireCrashedHosts ( ) { + try { + await NetHost + .find({ status: 'crashed' }) + .select('_id hostname') + .lean() + .cursor() + .eachAsync(async (host) => { + this.log.info('deactivating crashed host', { hostname: host.hostname }); + await NetHost.updateOne({ _id: host._id }, { $set: { status: 'inactive' } }); + }); + } catch (error) { + this.log.error('failed to expire crashed hosts', { error }); + } + } +} + +module.exports = CrashedHostsCron; \ No newline at end of file diff --git a/app/workers/sample-worker.js b/app/workers/sample-worker.js deleted file mode 100644 index b53b894..0000000 --- a/app/workers/sample-worker.js +++ /dev/null @@ -1,90 +0,0 @@ -// sample-worker.js -// Copyright (C) 2022 DTP Technologies, LLC -// License: Apache-2.0 - -'use strict'; - -const DTP_COMPONENT = { name: 'Sample Worker', slug: 'sample-worker' }; - -const path = require('path'); -require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); - -const { - SiteLog, - SiteWorker, -} = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); - -const { CronJob } = require('cron'); - -module.rootPath = path.resolve(__dirname, '..', '..'); -module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); - -module.config = { - environment: process.env.NODE_ENV, - root: module.rootPath, - component: DTP_COMPONENT, -}; - -module.config.site = require(path.join(module.rootPath, 'config', 'site')); -module.config.http = require(path.join(module.rootPath, 'config', 'http')); - -class SampleWorker extends SiteWorker { - - constructor (dtp) { - super(dtp, { }); - } - - async start ( ) { - const CRON_TIMEZONE = 'America/New_York'; - - await super.start(); - - this.log.info('starting worker job'); - this.job = new CronJob( - '*/5 * * * * *', - this.runJob.bind(this), - null, true, CRON_TIMEZONE, - ); - - const { jobQueue: jobQueueService } = this.dtp.services; - this.sampleJobQueue = jobQueueService.getJobQueue('dtp-sample', this.dtp.config.jobQueues['dtp-sample']); - this.sampleJobQueue.process('dtp-sample', 1, this.processDtpSample.bind(this)); - } - - async stop ( ) { - this.log.info('stopping sample worker job'); - this.job.stop(); - delete this.job; - - await super.stop(); - } - - async runJob ( ) { - this.log.alert('sample job starting'); - - /* - * Your worker will do interesting things here - */ - - this.log.alert('sample job ending'); - } - - async processDtpSample (job) { - this.log.info('received sample job', { id: job.id, data: job.data }); - } -} - -(async ( ) => { - try { - module.log = new SiteLog(module, module.config.component); - module.worker = new SampleWorker(module); - await module.worker.start(); - } 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/index.js b/client/js/index.js index 31619cc..0be660f 100644 --- a/client/js/index.js +++ b/client/js/index.js @@ -12,6 +12,15 @@ import DtpSiteApp from './site-app.js'; import DtpWebLog from 'dtp/dtp-log.js'; import UIkit from 'uikit'; +/** + * Monkeypatch to count characters instead of .length's code point count. + * @returns character count of string + */ +String.prototype.charCount = function () { + const splat = [...this]; + return splat.length; +}; + window.addEventListener('load', async ( ) => { // application console log dtp.log = new DtpWebLog(DTP_COMPONENT); @@ -37,10 +46,10 @@ window.addEventListener('load', async ( ) => { }); document.addEventListener('socketConnected', async ( ) => { - if (dtp.channel) { - dtp.app.socket.joinChannel(dtp.channel._id); - if (dtp.user && (dtp.user._id === dtp.channel.owner._id)) { - dtp.app.socket.joinChannel(`broadcast:${dtp.channel._id}`); + if (dtp.room) { + dtp.app.socket.joinChannel(dtp.room._id); + if (dtp.user && (dtp.user._id === dtp.room.owner._id)) { + dtp.app.socket.joinChannel(`broadcast:${dtp.room._id}`); } } }); \ No newline at end of file diff --git a/client/js/site-admin-app.js b/client/js/site-admin-app.js index 833f100..57a1eb7 100644 --- a/client/js/site-admin-app.js +++ b/client/js/site-admin-app.js @@ -286,27 +286,23 @@ export default class DtpSiteAdminHostStatsApp extends DtpApp { } } - async deletePost (event) { - const postId = event.currentTarget.getAttribute('data-post-id'); - const postTitle = event.currentTarget.getAttribute('data-post-title'); - console.log(postId, postTitle); + async deleteAnnouncement (event) { + const target = event.currentTarget || event.target; + const announcementId = target.getAttribute('data-announcement-id'); try { - await UIkit.modal.confirm(`Are you sure you want to delete "${postTitle}"`); + await UIkit.modal.confirm('Are you sure you want to delete the announcement?'); } catch (error) { - this.log.info('deletePost', 'aborted'); return; } try { - const response = await fetch(`/admin/post/${postId}`, { - method: 'DELETE', - }); + const actionUrl = `/admin/announcement/${announcementId}`; + const response = await fetch(actionUrl, { method: 'DELETE' }); if (!response.ok) { - throw new Error('Failed to delete post'); + throw new Error('Server error'); } - await this.processResponse(response); + await this.processResponse(response); } catch (error) { - this.log.error('deletePost', 'failed to delete post', { postId, postTitle, error }); - UIkit.modal.alert(`Failed to delete post: ${error.message}`); + UIkit.modal.alert(`Failed to delete announcement: ${error.message}`); } } @@ -328,6 +324,26 @@ export default class DtpSiteAdminHostStatsApp extends DtpApp { UIkit.modal.alert(`Failed to send Core Connect response: ${error.message}`); } } + + async deleteServiceNode (event) { + const target = event.currentTarget || event.target; + const serviceNodeId = target.getAttribute('data-service-node-id'); + try { + await UIkit.modal.confirm('Are you sure you want to delete the Service Node?'); + } catch (error) { + return; // user cancel + } + try { + const actionUrl = `/admin/service-node/${serviceNodeId}`; + const response = await fetch(actionUrl, { method: 'DELETE' }); + if (!response.ok) { + throw new Error('Server error'); + } + await this.processResponse(response); + } catch (error) { + UIkit.modal.alert(`Failed to delete Service Node: ${error.message}`); + } + } } dtp.DtpSiteAdminHostStatsApp = DtpSiteAdminHostStatsApp; \ No newline at end of file diff --git a/client/js/site-app.js b/client/js/site-app.js index ef739cc..506c688 100644 --- a/client/js/site-app.js +++ b/client/js/site-app.js @@ -13,7 +13,8 @@ import UIkit from 'uikit'; import QRCode from 'qrcode'; import Cropper from 'cropperjs'; -import { EmojiButton } from '@joeattardi/emoji-button'; +import SiteChat from './site-chat'; +import SiteComments from './site-comments'; const GRID_COLOR = 'rgb(64, 64, 64)'; const GRID_TICK_COLOR = 'rgb(192,192,192)'; @@ -27,28 +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, - }; - - 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)); + 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.charts = {/* will hold rendered charts */}; + this.log.debug('constructor', 'app instance created', { + env: dtp.env, + isIOS: this.isIOS, + }); + + this.chat = new SiteChat(this); + this.comments = new SiteComments(this); + + this.charts = { /* will hold rendered charts */ }; this.scrollToHash(); } @@ -70,102 +67,12 @@ export default class DtpSiteApp extends DtpApp { await DtpApp.prototype.connect.call(this, { withRetry: true, withError: false }); if (this.user) { const { socket } = this.socket; - socket.on('user-chat', this.onUserChat.bind(this)); - } - } - - async onChatInputKeyDown (event) { - this.log.info('onChatInputKeyDown', 'chat input received', { event }); - if (event.key === 'Enter' && !event.shiftKey) { - return this.sendUserChat(event); + socket.on('system-message', this.chat.appendSystemMessage.bind(this.chat)); + socket.on('user-chat', this.chat.appendUserChat.bind(this.chat)); + socket.on('user-react', this.chat.createEmojiReact.bind(this.chat)); } } - 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); - } - } - - 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 goBack ( ) { if (document.referrer && (document.referrer.indexOf(`://${window.dtp.domain}`) >= 0)) { window.history.back(); @@ -175,38 +82,6 @@ export default class DtpSiteApp extends DtpApp { return false; } - async submitForm (event, userAction) { - event.preventDefault(); - event.stopPropagation(); - - try { - const formElement = event.currentTarget || event.target; - const form = new FormData(formElement); - - this.log.info('submitForm', userAction, { event, action: formElement.action }); - const response = await fetch(formElement.action, { - method: formElement.method, - body: form, - }); - - if (!response.ok) { - let json; - try { - json = await response.json(); - } catch (error) { - throw new Error('Server error'); - } - throw new Error(json.message || 'Server error'); - } - - await this.processResponse(response); - } catch (error) { - UIkit.modal.alert(`Failed to ${userAction}: ${error.message}`); - } - - return; - } - async submitImageForm (event) { event.preventDefault(); event.stopPropagation(); @@ -539,136 +414,11 @@ export default class DtpSiteApp extends DtpApp { UIkit.modal.alert(`Failed to remove image: ${error.message}`); } } - async onCommentInput (event) { - const label = document.getElementById('comment-character-count'); - label.textContent = numeral(event.target.value.length).format('0,0'); - } - - async showEmojiPicker (event) { - const targetElementName = (event.currentTarget || event.target).getAttribute('data-target-element'); - this.emojiTargetElement = document.getElementById(targetElementName); - - this.emojiPicker.togglePicker(this.emojiTargetElement); - } - - async onEmojiSelected (selection) { - this.emojiTargetElement.value += selection.emoji; - } - - async showReportCommentForm (event) { - event.preventDefault(); - event.stopPropagation(); - - const resourceType = event.currentTarget.getAttribute('data-resource-type'); - const resourceId = event.currentTarget.getAttribute('data-resource-id'); - const commentId = event.currentTarget.getAttribute('data-comment-id'); - - this.closeCommentDropdownMenu(commentId); - - try { - const response = await fetch('/content-report/comment/form', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - resourceType, resourceId, commentId - }), - }); - if (!response.ok) { - throw new Error('failed to load report form'); - } - const html = await response.text(); - this.currentDialog = UIkit.modal.dialog(html); - } catch (error) { - this.log.error('reportComment', 'failed to process comment request', { resourceType, resourceId, commentId, error }); - UIkit.modal.alert(`Failed to report comment: ${error.message}`); - } - - return true; - } - - async deleteComment (event) { - event.preventDefault(); - event.stopPropagation(); - const commentId = (event.currentTarget || event.target).getAttribute('data-comment-id'); - try { - const response = fetch(`/comment/${commentId}`, { method: 'DELETE' }); - if (!response.ok) { - throw new Error('Server error'); - } - this.processResponse(response); - } catch (error) { - UIkit.modal.alert(`Failed to delete comment: ${error.message}`); - } - } - async submitDialogForm (event, userAction) { await this.submitForm(event, userAction); await this.closeCurrentDialog(); } - async blockCommentAuthor (event) { - event.preventDefault(); - event.stopPropagation(); - - const resourceType = event.currentTarget.getAttribute('data-resource-type'); - const resourceId = event.currentTarget.getAttribute('data-resource-id'); - const commentId = event.currentTarget.getAttribute('data-comment-id'); - const actionUrl = this.getCommentActionUrl(resourceType, resourceId, commentId, 'block-author'); - - this.closeCommentDropdownMenu(commentId); - - try { - this.log.info('blockCommentAuthor', 'blocking comment author', { resourceType, resourceId, commentId }); - const response = await fetch(actionUrl, { method: 'POST'}); - await this.processResponse(response); - - } catch (error) { - this.log.error('reportComment', 'failed to process comment request', { resourceType, resourceId, commentId, error }); - UIkit.modal.alert(`Failed to block comment author: ${error.message}`); - } - - return true; - } - - closeCommentDropdownMenu (commentId) { - const dropdown = document.querySelector(`[data-comment-id="${commentId}"][uk-dropdown]`); - UIkit.dropdown(dropdown).hide(false); - } - - getCommentActionUrl (resourceType, resourceId, commentId, action) { - switch (resourceType) { - case 'Newsletter': - return `/newsletter/${resourceId}/comment/${commentId}/${action}`; - case 'Page': - return `/page/${resourceId}/comment/${commentId}/${action}`; - case 'Post': - return `/post/${resourceId}/comment/${commentId}/${action}`; - default: - break; - } - throw new Error('Invalid resource type for comment operation'); - } - - async submitCommentVote (event) { - const target = (event.currentTarget || event.target); - const commentId = target.getAttribute('data-comment-id'); - const vote = target.getAttribute('data-vote'); - try { - const response = await fetch(`/comment/${commentId}/vote`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ vote }), - }); - await this.processResponse(response); - } catch (error) { - UIkit.modal.alert(`Failed to submit vote: ${error.message}`); - } - } - async renderStatsGraph (selector, title, data) { try { const canvas = document.querySelector(selector); @@ -741,65 +491,6 @@ export default class DtpSiteApp extends DtpApp { UIkit.modal.alert(`Failed to render chart: ${error.message}`); } } - - async openReplies (event) { - event.preventDefault(); - event.stopPropagation(); - - const target = event.currentTarget || event.target; - const commentId = target.getAttribute('data-comment-id'); - - const container = document.querySelector(`.dtp-reply-list-container[data-comment-id="${commentId}"]`); - const replyList = document.querySelector(`ul.dtp-reply-list[data-comment-id="${commentId}"]`); - - const isOpen = !container.hasAttribute('hidden'); - if (isOpen) { - container.setAttribute('hidden', ''); - while (replyList.firstChild) { - replyList.removeChild(replyList.firstChild); - } - return; - } - - try { - const response = await fetch(`/comment/${commentId}/replies`); - this.processResponse(response); - } catch (error) { - UIkit.modal.alert(`Failed to load replies: ${error.message}`); - } - - return true; - } - - async openReplyComposer (event) { - event.preventDefault(); - event.stopPropagation(); - - const target = event.currentTarget || event.target; - const commentId = target.getAttribute('data-comment-id'); - const composer = document.querySelector(`.dtp-reply-composer[data-comment-id="${commentId}"]`); - composer.toggleAttribute('hidden'); - - return true; - } - - async loadMoreComments (event) { - event.preventDefault(); - event.stopPropagation(); - - const target = event.currentTarget || event.target; - - const buttonId = target.getAttribute('data-button-id'); - const rootUrl = target.getAttribute('data-root-url'); - const nextPage = target.getAttribute('data-next-page'); - - try { - const response = await fetch(`${rootUrl}?p=${nextPage}&buttonId=${buttonId}`); - await this.processResponse(response); - } catch (error) { - UIkit.modal.alert(`Failed to load more comments: ${error.message}`); - } - } } dtp.DtpSiteApp = DtpSiteApp; \ No newline at end of file diff --git a/client/js/site-chat.js b/client/js/site-chat.js new file mode 100644 index 0000000..56c7a8f --- /dev/null +++ b/client/js/site-chat.js @@ -0,0 +1,351 @@ +// 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 UIkit from 'uikit'; +import SiteReactions from './site-reactions.js'; +import * as picmo from 'picmo'; + +export default class SiteChat { + + constructor (app) { + this.app = app; + this.log = new DtpLog(DTP_COMPONENT); + + this.ui = { + menu: document.querySelector('#chat-room-menu'), + 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'), + emojiPicker: document.querySelector('#site-emoji-picker'), + 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)); + } + + if (this.ui.emojiPicker) { + this.ui.picmo = picmo.createPicker({ + rootElement: this.ui.emojiPicker, + theme: picmo.darkTheme, + }); + this.ui.picmo.addEventListener('emoji:select', this.onEmojiSelected.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; + 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 fragment = document.createDocumentFragment(); + fragment.innerHTML = message.html; + + this.ui.isModifying = true; + this.ui.messageList.insertAdjacentHTML('beforeend', message.html); + this.trimMessages(); + this.updateTimestamps(); + + 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); + } + } + + async appendSystemMessage (message) { + this.log.debug('appendSystemMessage', 'received system message', { message }); + + const systemMessage = document.createElement('div'); + systemMessage.setAttribute('data-message-type', message.type); + systemMessage.classList.add('uk-margin-small'); + systemMessage.classList.add('chat-message'); + systemMessage.classList.add('system-message'); + + const chatContent = document.createElement('div'); + chatContent.classList.add('chat-content'); + chatContent.classList.add('uk-text-break'); + chatContent.innerHTML = message.content; + systemMessage.appendChild(chatContent); + + const chatTimestamp = document.createElement('div'); + chatTimestamp.classList.add('chat-timestamp'); + chatTimestamp.classList.add('uk-text-small'); + chatTimestamp.innerHTML = moment(message.created).format('hh:mm:ss a'); + systemMessage.appendChild(chatTimestamp); + + this.ui.messageList.appendChild(systemMessage); + this.trimMessages(); + this.updateTimestamps(); + + if (this.ui.isAtBottom) { + this.ui.messageList.scrollTo(0, this.ui.messageList.scrollHeight); + } + } + + trimMessages ( ) { + while (this.ui.messageList.childNodes.length > 50) { + this.ui.messageList.removeChild(this.ui.messageList.childNodes.item(0)); + } + } + + updateTimestamps ( ) { + const timestamps = document.querySelectorAll('[data-dtp-timestamp]'); + timestamps.forEach((timestamp) => { + const created = timestamp.getAttribute('data-dtp-timestamp'); + const format = timestamp.getAttribute('data-dtp-time-format'); + timestamp.textContent = moment(created).format(format || 'MMM DD, YYYY, [at] 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; + } + + async showForm (event, roomId, formName) { + try { + UIkit.dropdown(this.ui.menu).hide(false); + await this.app.showForm(event, `/chat/room/${roomId}/form/${formName}`); + } catch (error) { + UIkit.modal.alert(`Failed to display form: ${error.message}`); + } + } + + async toggleEmojiPicker (/* event */) { + this.ui.emojiPicker.classList.toggle('picker-open'); + } + + async onEmojiSelected (event) { + this.ui.emojiPicker.classList.remove('picker-open'); + return this.insertContentAtCursor(event.emoji); + } + + async insertContentAtCursor (content) { + this.ui.input.focus(); + + if (document.selection) { + let sel = document.selection.createRange(); + sel.text = content; + } else if (this.ui.input.selectionStart || (this.ui.input.selectionStart === 0)) { + let startPos = this.ui.input.selectionStart; + let endPos = this.ui.input.selectionEnd; + + let oldLength = this.ui.input.value.length; + this.ui.input.value = + this.ui.input.value.substring(0, startPos) + + content + + this.ui.input.value.substring(endPos, this.ui.input.value.length); + + this.ui.input.selectionStart = startPos + (this.ui.input.value.length - oldLength); + this.ui.input.selectionEnd = this.ui.input.selectionStart; + } else { + this.ui.input.value += content; + } + } + + async deleteInvite (event) { + const target = event.currentTarget || event.target; + const roomId = target.getAttribute('data-room-id'); + const inviteId = target.getAttribute('data-invite-id'); + try { + const response = await fetch(`/chat/room/${roomId}/invite/${inviteId}`, { method: 'DELETE' }); + await this.app.processResponse(response); + } catch (error) { + console.log('delete canceled', error); + return; + } + } + + async deleteChatRoom (event) { + const target = event.currentTarget || event.target; + const roomId = target.getAttribute('data-room-id'); + try { + const response = await fetch(`/chat/room/${roomId}`, { method: 'DELETE' }); + await this.app.processResponse(response); + } catch (error) { + console.log('delete canceled', error); + return; + } + } +} \ No newline at end of file diff --git a/client/js/site-comments.js b/client/js/site-comments.js new file mode 100644 index 0000000..fe12dc3 --- /dev/null +++ b/client/js/site-comments.js @@ -0,0 +1,265 @@ +// site-comments.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +import DtpLog from 'dtp/dtp-log.js'; + +import UIkit from 'uikit'; + +import * as picmo from 'picmo'; + +export default class SiteComments { + + constructor (app) { + this.app = app; + this.log = new DtpLog({ name: 'Site Comments', slug: 'comments' }); + this.createEmojiPickers(); + } + + createEmojiPickers ( ) { + const pickerContainers = document.querySelectorAll('li.comment-emoji-picker:not([data-initialized])'); + for (const container of pickerContainers) { + const picker = { }; + + picker.drop = container.querySelector('.comment-emoji-picker-drop'); + picker.ui = picker.drop.querySelector('.comment-emoji-picker-ui'); + + const formId = picker.drop.getAttribute('data-form-id'); + picker.form = document.querySelector(`form#${formId}`); + picker.input = picker.form.querySelector(`textarea[data-form-id=${formId}]`); + picker.characterCount = picker.form.querySelector('span.comment-character-count'); + + picker.picmo = picmo.createPicker({ + emojisPerRow: 7, + rootElement: picker.ui, + theme: picmo.darkTheme, + }); + + picker.picmo.addEventListener('emoji:select', this.onEmojiSelected.bind(this, picker)); + + picker.emojiPickerDrop = UIkit.drop(picker.drop); + UIkit.util.on(picker.drop, 'show', ( ) => { + this.log.info('SiteComments', 'showing emoji picker'); + picker.picmo.reset(); + }); + + container.setAttribute('data-initialized', true); + } + } + + async onCommentInput (event) { + const target = event.currentTarget || event.target; + + const formId = target.getAttribute('data-form-id'); + if (!formId) { return; } + + const form = document.getElementById(formId); + if (!form) { return; } + + const label = form.querySelector('span.comment-character-count'); + if (!label) { return; } + + label.textContent = numeral(event.target.value.charCount()).format('0,0'); + } + + async showReportCommentForm (event) { + event.preventDefault(); + event.stopPropagation(); + + const resourceType = event.currentTarget.getAttribute('data-resource-type'); + const resourceId = event.currentTarget.getAttribute('data-resource-id'); + const commentId = event.currentTarget.getAttribute('data-comment-id'); + + this.closeCommentDropdownMenu(commentId); + + try { + const response = await fetch('/content-report/comment/form', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + resourceType, resourceId, commentId + }), + }); + if (!response.ok) { + throw new Error('failed to load report form'); + } + const html = await response.text(); + this.currentDialog = UIkit.modal.dialog(html); + } catch (error) { + this.log.error('reportComment', 'failed to process comment request', { resourceType, resourceId, commentId, error }); + UIkit.modal.alert(`Failed to report comment: ${error.message}`); + } + + return true; + } + + async deleteComment (event) { + event.preventDefault(); + event.stopPropagation(); + const commentId = (event.currentTarget || event.target).getAttribute('data-comment-id'); + try { + const response = fetch(`/comment/${commentId}`, { method: 'DELETE' }); + if (!response.ok) { + throw new Error('Server error'); + } + this.app.processResponse(response); + } catch (error) { + UIkit.modal.alert(`Failed to delete comment: ${error.message}`); + } + } + + async blockCommentAuthor (event) { + event.preventDefault(); + event.stopPropagation(); + + const resourceType = event.currentTarget.getAttribute('data-resource-type'); + const resourceId = event.currentTarget.getAttribute('data-resource-id'); + const commentId = event.currentTarget.getAttribute('data-comment-id'); + const actionUrl = this.getCommentActionUrl(resourceType, resourceId, commentId, 'block-author'); + + this.closeCommentDropdownMenu(commentId); + + try { + this.log.info('blockCommentAuthor', 'blocking comment author', { resourceType, resourceId, commentId }); + const response = await fetch(actionUrl, { method: 'POST'}); + await this.app.processResponse(response); + + } catch (error) { + this.log.error('reportComment', 'failed to process comment request', { resourceType, resourceId, commentId, error }); + UIkit.modal.alert(`Failed to block comment author: ${error.message}`); + } + + return true; + } + + closeCommentDropdownMenu (commentId) { + const dropdown = document.querySelector(`[data-comment-id="${commentId}"][uk-dropdown]`); + UIkit.dropdown(dropdown).hide(false); + } + + getCommentActionUrl (resourceType, resourceId, commentId, action) { + switch (resourceType) { + case 'Newsletter': + return `/newsletter/${resourceId}/comment/${commentId}/${action}`; + case 'Page': + return `/page/${resourceId}/comment/${commentId}/${action}`; + case 'Post': + return `/post/${resourceId}/comment/${commentId}/${action}`; + default: + break; + } + throw new Error('Invalid resource type for comment operation'); + } + + async submitCommentVote (event) { + const target = (event.currentTarget || event.target); + const commentId = target.getAttribute('data-comment-id'); + const vote = target.getAttribute('data-vote'); + try { + const response = await fetch(`/comment/${commentId}/vote`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ vote }), + }); + await this.app.processResponse(response); + } catch (error) { + UIkit.modal.alert(`Failed to submit vote: ${error.message}`); + } + } + + async openReplies (event) { + event.preventDefault(); + event.stopPropagation(); + + const target = event.currentTarget || event.target; + const commentId = target.getAttribute('data-comment-id'); + + const container = document.querySelector(`.dtp-reply-list-container[data-comment-id="${commentId}"]`); + const replyList = document.querySelector(`ul.dtp-reply-list[data-comment-id="${commentId}"]`); + + const isOpen = !container.hasAttribute('hidden'); + if (isOpen) { + container.setAttribute('hidden', ''); + while (replyList.firstChild) { + replyList.removeChild(replyList.firstChild); + } + return; + } + + try { + const response = await fetch(`/comment/${commentId}/replies`); + this.app.processResponse(response); + this.createEmojiPickers(); + } catch (error) { + UIkit.modal.alert(`Failed to load replies: ${error.message}`); + } + + return true; + } + + async openReplyComposer (event) { + event.preventDefault(); + event.stopPropagation(); + + const target = event.currentTarget || event.target; + const commentId = target.getAttribute('data-comment-id'); + const composer = document.querySelector(`.dtp-reply-composer[data-comment-id="${commentId}"]`); + composer.toggleAttribute('hidden'); + + return true; + } + + async loadMoreComments (event) { + event.preventDefault(); + event.stopPropagation(); + + const target = event.currentTarget || event.target; + + const buttonId = target.getAttribute('data-button-id'); + const rootUrl = target.getAttribute('data-root-url'); + const nextPage = target.getAttribute('data-next-page'); + + try { + const response = await fetch(`${rootUrl}?p=${nextPage}&buttonId=${buttonId}`); + await this.app.processResponse(response); + this.createEmojiPickers(); + } catch (error) { + UIkit.modal.alert(`Failed to load more comments: ${error.message}`); + } + } + + async onEmojiSelected (picker, event) { + picker.emojiPickerDrop.hide(false); + await this.insertContentAtCursor(picker, event.emoji); + picker.characterCount.textContent = numeral(picker.input.value.charCount()).format('0,0'); + } + + async insertContentAtCursor (picker, content) { + picker.input.focus(); + + if (document.selection) { + let sel = document.selection.createRange(); + sel.text = content; + } else if (picker.input.selectionStart || (picker.input.selectionStart === 0)) { + let startPos = picker.input.selectionStart; + let endPos = picker.input.selectionEnd; + + let oldLength = picker.input.value.length; + picker.input.value = + picker.input.value.substring(0, startPos) + + content + + picker.input.value.substring(endPos, picker.input.value.length); + + picker.input.selectionStart = startPos + (picker.input.value.length - oldLength); + picker.input.selectionEnd = picker.input.selectionStart; + } else { + picker.input.value += content; + } + } +} \ 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..b29f555 100644 --- a/client/less/site/button.less +++ b/client/less/site/button.less @@ -101,7 +101,7 @@ button.uk-button.dtp-button-default { background: none; outline: none; border: solid 2px rgb(75, 75, 75); - color: #c8c8c8; + color: @button-label-color; transition: background-color 0.2s; @@ -150,3 +150,29 @@ button.uk-button.dtp-button-danger { } } +.dtp-button-reaction { + background-color: transparent; + border: none; + border-radius: 6px; + outline: none; + cursor: pointer; + + font-size: 14px; + line-height: 18px; + + &:active { + background-color: @emoji-react-button-active-color; + font-size: 16px; + } + + span.button-icon { + display: inline-block; + width: 32px; + font-size: inherit; + line-height: inherit; + } + + 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..b227219 100644 --- a/client/less/site/chat.less +++ b/client/less/site/chat.less @@ -1,24 +1,261 @@ -.chat-message { +@emoji-picker-width: 380px; +@emoji-picker-height: 452px; - .chat-username { - color: #c8c8c8; - margin-right: 4px; - font-weight: bold; +.site-chat-section { + height: calc(100% - @navbar-nav-item-height); +} + +.site-chat-sidebar-widget { + box-sizing: border-box; + padding: @grid-small-gutter-vertical @grid-small-gutter-horizontal; + margin: @grid-small-gutter-vertical @grid-small-gutter-horizontal; + border: solid 1px @content-border-color; + background-color: @content-background-color; +} + +#site-chat-container { + overflow: auto; + background-color: @content-container-color; + + .chat-menubar { + background-color: @page-background-color; + color: @global-color; + + border-bottom: solid 1px @content-border-color; + border-left: solid 1px @content-border-color; + border-right: solid 1px @content-border-color; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + + .uk-text-muted { + color: @global-muted-color !important; + } + + .chat-room-name { + font-size: 24px; + font-weight: bold; + line-height: 1.1em; + } } - .chat-content { - color: #a8a8a8; - em { color: inherit; } - strong { color: #c8c8c8; } + #chat-input-form { + position: relative; + background-color: @page-background-color; + border-top: solid 1px @content-border-color; + border-left: solid 1px @content-border-color; + border-right: solid 1px @content-border-color; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + + textarea.uk-textarea { + padding: 2px 6px; + resize: none; + } + + #site-emoji-picker { + position: absolute; + top: 0; + left: @grid-small-gutter-horizontal; + + width: @emoji-picker-width; + height: 0; + overflow: hidden; + + margin: 0 auto; + padding: 0; + opacity: 0; + + transition: top 0.6s, height 0.6s, opacity 0.6s; + + &.picker-open { + top: -@emoji-picker-height; + height: @emoji-picker-height; + opacity: 1; + } + } + } + + .fundraising-progress-overlay { + position: absolute; + top: 40px; right: 0; left: 0; + width: 100%; + overflow: hidden; + background: black; + + &.hidden { + display: none; + } + } + + .chat-content-wrapper { + position: relative; + flex: 1; + } - .chat-sticker { - display: inline-block; - margin-right: 8px; - - video { - width: auto; - height: 100px; + #chat-message-list-wrapper { + position: relative; + + #chat-reactions { + position: absolute; + top: 0; right: 0; bottom: 0; left: 0; + background: transparent; + overflow: hidden; + pointer-events: none; + + span.reaction-icon { + display: block; + position: absolute; + font-size: 24px; + opacity: 0.6; + transform: rotate(0deg); + } + } + + #chat-message-list { + position: absolute; + top: 0; right: 0; bottom: 0; left: 0; + width: 100%; + height: 100%; + overflow: auto; + + box-shadow: var(--dtp-chat-shadow); + scroll-behavior: auto; + + &::-webkit-scrollbar { + width: 10px; + border: none; + outline: none; + padding: 8px 0; + } + + &::-webkit-scrollbar-track { + background: transparent; + border-left: solid 1px @scrollbar-border-color; + } + + &::-webkit-scrollbar-thumb { + outline: none; + + border: none; + border-top-right-radius: 8px; + border-bottom-right-radius: 8px; + + overflow: hidden; + + background-color: @scrollbar-thumb-color; + } + + .chat-message { + padding: @grid-small-gutter-vertical @grid-small-gutter-horizontal; + margin: (@grid-small-gutter-vertical / 2) @grid-small-gutter-horizontal; + + border: solid 1px @content-border-color; + border-radius: 8px; + + background: @content-background-color; + color: inherit; + font-size: var(--dtp-chat-font-size); + + &.system-message { + background: #e8e8e8; + color: #1a1a1a; + + &[data-message-type="info"] { + background: #068be4; + color: white; + } + &[data-message-type="warning"] { + background: #e4c306; + color: white; + } + &[data-message-type="error"] { + background: #ff00131a; + color: white; + } + } + + .chat-username { + 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: 40px; + border-radius: 4px; + } + + .chat-content { + 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; + } + } + + .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; + } + } } } } \ No newline at end of file diff --git a/client/less/site/content.less b/client/less/site/content.less index f4f99ce..29d28ba 100644 --- a/client/less/site/content.less +++ b/client/less/site/content.less @@ -1,5 +1,5 @@ .content-block { - padding: @global-gutter; + padding: @grid-small-gutter-horizontal; background-color: @content-background-color; :last-child { diff --git a/client/less/site/image.less b/client/less/site/image.less index 16796b0..d5ca491 100644 --- a/client/less/site/image.less +++ b/client/less/site/image.less @@ -30,7 +30,7 @@ img.site-profile-picture { height: auto; margin-left: auto; margin-right: auto; - border-radius: 5%; + border-radius: 8px; background-color: #8a8a8a; } diff --git a/client/less/site/kaleidoscope-event.less b/client/less/site/kaleidoscope-event.less new file mode 100644 index 0000000..37cbad6 --- /dev/null +++ b/client/less/site/kaleidoscope-event.less @@ -0,0 +1,48 @@ +.kaleidoscope-event { + box-sizing: border-box; + position: relative; + border: solid 2px @content-border-color; + border-radius: 6px; + overflow: hidden; + margin-bottom: @global-margin; + background-color: @content-background-color; + + box-shadow: 0 1px 6px rgba(0,0,0, 0.3); + + &[data-event-source="dtp-social"] { + border-color: #da0012; + } + + &[data-event-source="dtp-sites"] { + border-color: #0eaa00; + } + + &[data-event-action="room-invite-create"] { + border-color: #0082aa; + } + + .event-feature-img { + display: block; + line-height: 1; + width: 100%; + height: auto; + padding: 0; + margin: 0; + } + + .event-header { + padding: 4px @global-small-gutter; + } + + .event-content { + padding: 4px @global-small-gutter; + + p:last-child { + margin-bottom: 0; + } + } + + .event-footer { + padding: 4px @global-small-gutter; + } +} \ No newline at end of file diff --git a/client/less/site/main.less b/client/less/site/main.less index 2942785..c37831c 100644 --- a/client/less/site/main.less +++ b/client/less/site/main.less @@ -6,6 +6,21 @@ html, body { body { padding-top: @site-navbar-height; + background-color: @page-background-color; + + &[data-current-view="chat"] { + position: fixed; + top: @navbar-nav-item-height; right: 0; bottom: 0; left: 0; + + display: flex; + flex-direction: column; + justify-content: top; + + padding: 0; + margin: 0; + width: 100%; + height: 100%; + } &[data-current-view="oauth2-authorize-dialog"], &[data-current-view="welcome"] { @@ -42,4 +57,8 @@ body { color:white; } } +} + +.dtp-text-tight { + line-height: 1; } \ No newline at end of file diff --git a/client/less/site/uikit-theme.dtp-dark.less b/client/less/site/uikit-theme.dtp-dark.less index 7ef0aef..1c87695 100644 --- a/client/less/site/uikit-theme.dtp-dark.less +++ b/client/less/site/uikit-theme.dtp-dark.less @@ -5,6 +5,7 @@ @page-background-color: #000000; @content-background-color: #2a2a2a; @content-border-color: #4a4a4a; +@content-container-color: #2a2a2a; @site-brand-color: #ff0013; @button-label-color: #e8e8e8; @@ -12,6 +13,11 @@ @social-link-color: #e8e8e8; @checkout-button-text-color: #e8e8e8; +@emoji-react-button-active-color: #adc7a0; + +@scrollbar-border-color: @content-border-color; +@scrollbar-thumb-color: #ff001380; + @global-background: #1a1a1a; @global-muted-background: #3a3a3a; @global-primary-background: #1e87f0; @@ -23,7 +29,7 @@ @global-color: #c8c8c8; @global-emphasis-color: #ffffff; -@global-muted-color: #4a4a4a; +@global-muted-color: #9a9a9a; @global-link-color: #e00000; @global-link-hover-color: #ff0000; @@ -59,7 +65,8 @@ @button-text-hover-color: #000000; @button-text-disabled-color: #000000; -button.uk-button.uk-button-default { +button.uk-button.uk-button-default, +a.uk-button.uk-button-default { color: @global-color; } diff --git a/client/less/site/uikit-theme.dtp-light.less b/client/less/site/uikit-theme.dtp-light.less index af69641..7711a9f 100644 --- a/client/less/site/uikit-theme.dtp-light.less +++ b/client/less/site/uikit-theme.dtp-light.less @@ -5,6 +5,7 @@ @page-background-color: #e8e8e8; @content-background-color: #ffffff; @content-border-color: #a8a8a8; +@content-container-color: #c8c8c8; @site-brand-color: #ff0013; @button-label-color: #2a2a2a; @@ -12,6 +13,11 @@ @social-link-color: #2a2a2a; @checkout-button-text-color: #2a2a2a; +@emoji-react-button-active-color: #adc7a0; + +@scrollbar-border-color: @content-border-color; +@scrollbar-thumb-color: #ff001380; + // // Component: Navbar // diff --git a/client/less/style.common.less b/client/less/style.common.less index 2dc2d9e..86c5642 100644 --- a/client/less/style.common.less +++ b/client/less/style.common.less @@ -7,6 +7,7 @@ @import "site/figure.less"; @import "site/header-section.less"; @import "site/image.less"; +@import "site/kaleidoscope-event.less"; @import "site/nav.less"; @import "site/content.less"; diff --git a/config/job-queues.js b/config/job-queues.js index f95fcd1..fbcbfa0 100644 --- a/config/job-queues.js +++ b/config/job-queues.js @@ -5,13 +5,25 @@ 'use strict'; module.exports = { - 'resolver': { + 'chat': { + attempts: 5, + removeOnComplete: true, + }, + 'media': { attempts: 3, removeOnComplete: true, - removeOnFail: true, }, 'newsletter': { attempts: 3, - removeOnComplete: false, + removeOnComplete: true, + }, + 'reeeper': { + attempts: 3, + removeOnComplete: true, + }, + 'resolver': { + attempts: 3, + removeOnComplete: true, + removeOnFail: true, }, }; \ No newline at end of file diff --git a/config/limiter.js b/config/limiter.js index ff4d850..5845a90 100644 --- a/config/limiter.js +++ b/config/limiter.js @@ -51,6 +51,72 @@ module.exports = { }, }, + /* + * ChatController + */ + chat: { + postRoomInviteAction: { + total: 20, + expire: ONE_MINUTE, + message: 'You are sending room invite actions too quickly', + }, + postRoomInvite: { + total: 25, + expire: ONE_MINUTE, + message: 'You are sending room invites too quickly', + }, + 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', + }, + getRoomForm: { + total: 30, + expire: ONE_MINUTE, + message: 'You are loading chat room forms too quickly', + }, + getRoomInviteView: { + total: 15, + expire: ONE_MINUTE, + message: 'You are loading chat room invite view too quickly', + }, + getRoomSettings: { + total: 15, + expire: ONE_MINUTE, + message: 'You are loading chat rooms too quickly', + }, + getRoomView: { + total: 15, + expire: ONE_MINUTE, + message: 'You are loading chat rooms too quickly', + }, + getRoomHome: { + total: 20, + expire: ONE_MINUTE, + message: 'You are loading chat home too quickly', + }, + getHome: { + total: 30, + expire: ONE_MINUTE, + message: 'You are loading chat home too quickly', + }, + deleteInvite: { + total: 10, + expire: ONE_MINUTE, + message: 'You are deleting chat room invites too quickly', + }, + deleteRoom: { + total: 4, + expire: ONE_MINUTE, + message: 'You are deleting chat rooms too quickly', + }, + }, + comment: { deleteComment: { total: 1, @@ -91,6 +157,14 @@ module.exports = { }, }, + form: { + getForm: { + total: 20, + expire: ONE_MINUTE, + message: "You are requesting forms too quickly.", + }, + }, + /* * PostController */ @@ -143,6 +217,22 @@ module.exports = { } }, + /* + * NotificationController + */ + notification: { + getNotificationView: { + total: 60, + expire: ONE_MINUTE, + message: 'You are fetching notifications too quickly', + }, + getNotificationHome: { + total: 30, + expire: ONE_MINUTE, + message: 'You are refreshing notifications too quickly', + }, + }, + /* * NewsletterController */ diff --git a/config/reserved-names.js b/config/reserved-names.js index 2c993c3..db1f2b0 100644 --- a/config/reserved-names.js +++ b/config/reserved-names.js @@ -10,6 +10,7 @@ module.exports = [ 'about', 'admin', 'auth', + 'chat', 'digitaltelepresence', 'dist', 'dtp', @@ -23,6 +24,7 @@ module.exports = [ 'manifest.json', 'moment', 'newsletter', + 'notification', 'numeral', 'post', 'socket.io', diff --git a/docs/samples/controller.js b/docs/samples/controller.js new file mode 100644 index 0000000..3a55f4d --- /dev/null +++ b/docs/samples/controller.js @@ -0,0 +1,97 @@ +// samples/controller.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const express = require('express'); + +const { SiteController, SiteError } = require('../../lib/site-lib'); + +class HomeController extends SiteController { + + constructor (dtp) { + super(dtp, module.exports); + } + + async start ( ) { + const { dtp } = this; + const { limiter: limiterService } = dtp.services; + + const upload = this.createMulter(); + + const router = express.Router(); + dtp.app.use('/your-route', router); + + router.use(async (req, res, next) => { + res.locals.currentView = 'your-view'; + return next(); + }); + + router.param('itemId', this.populateItemId.bind(this)); + + router.post( + '/item', + limiterService.createMiddleware(limiterService.config.home.postItemCreate), + upload.none(), + this.postItemCreate.bind(this), + ); + + router.get( + '/item/:itemId', + limiterService.createMiddleware(limiterService.config.sample.getItemView), + this.getItemView.bind(this), + ); + + router.get('/', + limiterService.createMiddleware(limiterService.config.sample.getHome), + this.getHome.bind(this), + ); + } + + async populateItemId (req, res, next, itemId) { + const { item: itemService } = this.dtp.services; + try { + res.locals.item = await itemService.getById(itemId); + if (!res.locals.item) { + throw new SiteError(404, 'Item not found'); + } + return next(); + } catch (error) { + return next(error); + } + } + + async postItemCreate (req, res, next) { + const { item: itemService } = this.dtp.services; + try { + const item = await itemService.create(req.user, req.body); + res.redirect(`/item/${item._id}`); + } catch (error) { + this.log.error('failed to create item', { error }); + return next(error); + } + } + + async getItemView (req, res) { + res.render('item/view'); + } + + async getHome (req, res, next) { + const { announcement: announcementService } = this.dtp.services; + try { + res.locals.announcements = await announcementService.getLatest(req.user); + res.render('index'); + } catch (error) { + this.log.error('failed to render home view', { error }); + return next(error); + } + } +} + +module.exports = { + slug: 'home', + name: 'home', + isHome: true, + create: async (dtp) => { return new HomeController(dtp); }, +}; \ No newline at end of file diff --git a/docs/samples/service.js b/docs/samples/service.js new file mode 100644 index 0000000..0126acd --- /dev/null +++ b/docs/samples/service.js @@ -0,0 +1,68 @@ +// samples/service.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); + +const Item = mongoose.model('Item'); + +const { SiteService } = require('../../lib/site-lib'); + +class SampleService extends SiteService { + + constructor (dtp) { + super(dtp, module.exports); + } + + async start ( ) { + await super.start(); + + this.queue = this.getJobQueue('sample'); + } + + async stop ( ) { + // do your shutdown here + + await super.stop(); + } + + async create (owner, itemDefinition) { + const NOW = new Date(); + const item = new Item(); + + item.created = NOW; + item.title = itemDefinition.title; + item.content = itemDefinition.content; + + await item.save(); + + return item.toObject(); + } + + async getItems (search, pagination) { + const items = await Item + .find(search) + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .lean(); + return items; + } + + async getById (itemId) { + const item = await Item.findById(itemId).lean(); + return item; + } + + async deleteItem (item) { + await Item.deleteOne({ _id: item._id }); + } +} + +module.exports = { + name: 'sample', + slug: 'sample', + create: (dtp) => { return new SampleService(dtp); }, +}; \ No newline at end of file diff --git a/docs/samples/worker.js b/docs/samples/worker.js new file mode 100644 index 0000000..d3c725c --- /dev/null +++ b/docs/samples/worker.js @@ -0,0 +1,56 @@ +// samples/worker.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); +require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); + +const { SiteLog, SiteWorker } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); + +class SampleWorker extends SiteWorker { + + constructor (dtp) { + super(dtp, dtp.config.component); + } + + async start ( ) { + await super.start(); + + await this.loadProcessor(path.join(__dirname, 'your-worker', 'cron', 'expire-things.js')); + await this.loadProcessor(path.join(__dirname, 'your-worker', 'job', 'process-things.js')); + + await this.startProcessors(); + } + + async stop ( ) { + await super.stop(); + } +} + +(async ( ) => { + try { + module.rootPath = path.resolve(__dirname, '..', '..'); + module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); + module.component = { name: 'theWorkerName', slug: 'the-worker-name' }; + + module.config = { + environment: process.env.NODE_ENV, + root: module.rootPath, + component: module.component, + }; + + module.config.site = require(path.join(module.rootPath, 'config', 'site')); + module.log = new SiteLog(module, module.component); + + module.worker = new SampleWorker(module); + await module.worker.start(); + + module.log.info(`${module.pkg.name} v${module.pkg.version} ${module.component.name} started`); + } catch (error) { + module.log.error('failed to start worker', { component: module.component.name, error }); + process.exit(-1); + } + +})(); \ No newline at end of file diff --git a/dtp-webapp-cli.js b/dtp-webapp-cli.js index 24be353..c7dbf25 100644 --- a/dtp-webapp-cli.js +++ b/dtp-webapp-cli.js @@ -1,5 +1,5 @@ // dtp-webapp-cli.js -// Copryright (C) DTP Technologies, LLC +// Copryright (C) 2022 DTP Technologies, LLC // License: Apache-2.0 'use strict'; diff --git a/dtp-webapp.js b/dtp-webapp.js index fca0d9d..057fae8 100644 --- a/dtp-webapp.js +++ b/dtp-webapp.js @@ -1,5 +1,5 @@ // dtp-webapp.js -// Copryright (C) DTP Technologies, LLC +// Copryright (C) 2022 DTP Technologies, LLC // Licence: Apache-2.0 'use strict'; diff --git a/lib/client/js/dtp-app.js b/lib/client/js/dtp-app.js index 71c6a20..9e625ed 100644 --- a/lib/client/js/dtp-app.js +++ b/lib/client/js/dtp-app.js @@ -33,6 +33,61 @@ export default class DtpApp { } } + async showForm (event, formUrl) { + event.preventDefault(); + event.stopPropagation(); + try { + const response = await fetch(formUrl, { method: 'GET' }); + const html = await response.text(); + this.currentModal = UIkit.modal.dialog(html); + } catch (error) { + UIkit.modal.alert(`Failed to display form: ${error.message}`); + } + return true; + } + + async submitForm (event, userAction) { + event.preventDefault(); + event.stopPropagation(); + + try { + const formElement = event.currentTarget || event.target; + const form = new FormData(formElement); + + // include the submitter if we have one and it presents all required data + if (event.submitter && event.submitter.name && event.submitter.value) { + form.append(event.submitter.name, event.submitter.value); + } + + this.log.info('submitForm', userAction, { event, action: formElement.action }); + const response = await fetch(formElement.action, { + method: formElement.method, + body: form, + }); + + if (!response.ok) { + let json; + try { + json = await response.json(); + } catch (error) { + throw new Error('Server error'); + } + throw new Error(json.message || 'Server error'); + } + + await this.processResponse(response); + } catch (error) { + UIkit.modal.alert(`Failed to ${userAction}: ${error.message}`); + } finally { + if (this.currentModal) { + this.currentModal.hide(); + delete this.currentModal; + } + } + + return; + } + async processResponse (response) { const json = await response.json(); if (!json.success) { diff --git a/lib/client/js/dtp-display-engine.js b/lib/client/js/dtp-display-engine.js index 364e51a..0005dcc 100644 --- a/lib/client/js/dtp-display-engine.js +++ b/lib/client/js/dtp-display-engine.js @@ -6,11 +6,13 @@ const DTP_COMPONENT = { name: 'Display Engine', slug: 'display-engine' }; +import UIkit from 'uikit'; import DtpLog from './dtp-log.js'; export default class DtpDisplayEngine { - constructor ( ) { + constructor (app) { + this.app = app; this.processors = { }; this.log = new DtpLog(DTP_COMPONENT); } @@ -210,7 +212,15 @@ export default class DtpDisplayEngine { } async showModal (displayList, command) { - UIkit.modal.dialog(command.params.html); + this.app.currentModal = UIkit.modal.dialog(command.params.html); + } + + async closeModal ( ) { + if (!this.app.currentModal) { + return; + } + this.app.currentModal.hide(); + delete this.app.currentModal; } async navigateTo (displayList, command) { 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-common.js b/lib/site-common.js index 10cc8c6..8c00f31 100644 --- a/lib/site-common.js +++ b/lib/site-common.js @@ -7,7 +7,10 @@ const path = require('path'); const pug = require('pug'); +const striptags = require('striptags'); + const { SiteLog } = require(path.join(__dirname, 'site-log')); +const { SiteAsync } = require(path.join(__dirname, 'site-async')); const Events = require('events'); class SiteCommon extends Events { @@ -20,6 +23,39 @@ class SiteCommon extends Events { this.log = new SiteLog(dtp, component); this.appTemplateRoot = path.join(this.dtp.config.root, 'app', 'templates'); + + this.jobQueues = { }; + } + + async start ( ) {/* I will need this, do not delete. */} + + async stop ( ) { + let slugs = Object.keys(this.jobQueues); + await SiteAsync.each(slugs, async (slug) => { + const queue = this.jobQueues[slug]; + try { + this.log.info('closing job queue'); + await queue.close(); + delete this.jobQueues[slug]; + } catch (error) { + this.log.error('failed to close job queue', { name: slug, error }); + } + }, 1); + } + + async getJobQueue (name) { + if (this.jobQueues[name]) { + return this.jobQueues[name]; + } + + const { jobQueue: jobQueueService } = this.dtp.services; + const config = this.dtp.config.jobQueues[name]; + + this.log.info('connecting to job queue', { name, config }); + const queue = jobQueueService.getJobQueue(name, config); + this.jobQueues[name] = queue; + + return queue; } regenerateSession (req) { @@ -56,6 +92,37 @@ class SiteCommon extends Events { const scriptFile = path.join(this.dtp.config.root, 'app', 'views', filename); return pug.compileFile(scriptFile); } + + compileViewTemplate (filename, options) { + const scriptFile = path.join(this.dtp.config.root, 'app', 'views', filename); + const pathObj = path.parse(scriptFile); + options = Object.assign({ + filename: scriptFile, + basedir: path.join(this.dtp.config.root, 'app', 'views'), + doctype: 'html', + name: pathObj.name, + }, options); + return pug.compileFileClient(scriptFile, options); + } + + parseTagList (tagList, options) { + options = Object.assign({ + lowercase: true, + filter: [ ], + }, options); + return tagList + .split(',') + .map((metric) => { + metric = striptags(metric.trim()); + return options.lowercase ? metric.toLowerCase() : metric; + }) + .filter((metric) => { + return (typeof metric === 'string') && + (metric.length > 0) && + !options.filter.includes(metric.toLowerCase()) + ; + }); + } } module.exports.SiteCommon = SiteCommon; \ No newline at end of file diff --git a/lib/site-controller.js b/lib/site-controller.js index 37e487e..872fab4 100644 --- a/lib/site-controller.js +++ b/lib/site-controller.js @@ -5,6 +5,7 @@ 'use strict'; const path = require('path'); +const multer = require('multer'); const { SiteCommon } = require(path.join(__dirname, 'site-common')); @@ -35,6 +36,16 @@ class SiteController extends SiteCommon { return pagination; } + createMulter (slug, options) { + slug = slug || 'uploads'; + + options = Object.assign({ + dest: `/tmp/${this.dtp.config.site.domainKey}/${slug}/${this.component.slug}` + }, options || { }); + + return multer(options); + } + createDisplayList (name) { const { displayEngine: displayEngineService } = this.dtp.services; return displayEngineService.createDisplayList(name); diff --git a/lib/site-ioserver.js b/lib/site-ioserver.js index 5552f94..eea3471 100644 --- a/lib/site-ioserver.js +++ b/lib/site-ioserver.js @@ -11,9 +11,7 @@ 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'); const { SiteLog } = require(path.join(__dirname, 'site-log')); @@ -53,8 +51,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,6 +62,7 @@ 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 }); @@ -114,9 +111,11 @@ 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, + picture: token.user.picture, }, socket, }; @@ -125,11 +124,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,68 +158,46 @@ 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 } = this.dtp.services; - if (!message.content || (message.content.length === 0)) { + if (!messageDefinition.content || (messageDefinition.content.length === 0)) { return; } - const channel = await channelService.getChannelById(channelId); - if (!channel) { + try { + const { message, payload } = await chatService.createMessage(session.user, messageDefinition); + message.author = session.user; + payload.html = await chatService.renderTemplate('chatMessage', { user: session.user, message }); + await chatService.sendMessage(message.channel, 'user-chat', payload); + } catch (error) { + this.log.error('failed to process user chat message', { error }); + await chatService.sendSystemMessage(`Failed to send chat: ${error.message}`, { + type: 'error', + userId: session.user._id.toString(), + }); 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); - - await ChatMessage.create({ - created: new Date(), - author: session.user._id, - content: message.content, - stickers, - }); - - const renderedContent = marked(message.content, this.markedConfig); - - const payload = { - user: { - _id: session.user._id, - username: session.user.username, - }, - content: renderedContent, - stickers, - }; - - session.socket.to(channelId).emit('user-chat', payload); - session.socket.emit('user-chat', payload); } - 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; + async onUserReact (session, message) { + const { chat: chatService } = this.dtp.services; + try { + 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); + } catch (error) { + this.log.error('failed to process reaction', { message, error }); + session.socket.emit('system-message', { + created: new Date(), + content: `You are not allowed to chat on ${this.dtp.config.site.name}.`, + }); + return; + } } } diff --git a/lib/site-lib.js b/lib/site-lib.js index 14cf415..f28bdf7 100644 --- a/lib/site-lib.js +++ b/lib/site-lib.js @@ -15,4 +15,5 @@ module.exports = { SiteController: require(path.join(__dirname, 'site-controller')).SiteController, SiteService: require(path.join(__dirname, 'site-service')).SiteService, SiteWorker: require(path.join(__dirname, 'site-worker')).SiteWorker, + SiteWorkerProcess: require(path.join(__dirname, 'site-worker-process')).SiteWorkerProcess, }; \ No newline at end of file diff --git a/lib/site-platform.js b/lib/site-platform.js index 589a5db..071390d 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'); @@ -261,17 +262,20 @@ module.exports.startWebServer = async (dtp) => { module.app.use('/uikit/images', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'uikit', 'src', 'images'))); module.app.use('/uikit', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'uikit', 'dist'))); - module.app.use('/chart.js', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'chart.js', 'dist'))); module.app.use('/chartjs-adapter-moment', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'chartjs-adapter-moment', 'dist'))); - module.app.use('/cropperjs', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'cropperjs', 'dist'))); + module.app.use('/pretty-checkbox', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'pretty-checkbox', 'dist'))); + module.app.use('/fontawesome', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', '@fortawesome', 'fontawesome-free'))); module.app.use('/moment', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'moment', 'min'))); - module.app.use('/mpegts', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'mpegts.js', 'dist'))); module.app.use('/numeral', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'numeral', 'min'))); + + module.app.use('/cropperjs', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'cropperjs', 'dist'))); module.app.use('/tinymce', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'tinymce'))); module.app.use('/highlight.js', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'highlight.js'))); + module.app.use('/mpegts', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'mpegts.js', 'dist'))); + /* * ExpressJS middleware */ @@ -319,6 +323,7 @@ module.exports.startWebServer = async (dtp) => { module.services.oauth2.registerPassport(); module.app.use(module.services.session.middleware()); + module.app.use(module.services.userNotification.middleware({ withNotifications: false })); /* * Application logic middleware diff --git a/lib/site-worker-process.js b/lib/site-worker-process.js new file mode 100644 index 0000000..5d5ac46 --- /dev/null +++ b/lib/site-worker-process.js @@ -0,0 +1,47 @@ +// site-worker-process.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); + +const { SiteCommon } = require(path.join(__dirname, 'site-common')); + +/** + * Your actual worker processor will extend SiteWorkerProcess and implement the + * expected interface including the. + * + * Your derived class must implement a static getter for COMPONENT as follows: + * + * ``` + * static get COMPONENT ( ) { return { name: '', slug: '' }; } + * ``` + * + * It must pass that object to this constructor (super) along with the worker + * reference you are given at your constructor. + * + * Your worker logic script can then be a fully-managed process within the DTP + * ecosystem. + */ +class SiteWorkerProcess extends SiteCommon { + + constructor (worker, component) { + super(worker.dtp, component); + this.worker = worker; + } + + /** + * Utility/convenience method that logs a message to both a Bull Queue job log + * and also DTP's logging infrastructure for the worker process. + * @param {Job} job Bull queue job for which a log is written + * @param {*} message The message to be written + * @param {*} data An object containing any data to be logged + */ + async jobLog (job, message, data = { }) { + job.log(message); + this.log.info(message, { jobId: job.id, ...data }); + } +} + +module.exports.SiteWorkerProcess = SiteWorkerProcess; \ No newline at end of file diff --git a/lib/site-worker.js b/lib/site-worker.js index 2b9bd00..3ad0cce 100644 --- a/lib/site-worker.js +++ b/lib/site-worker.js @@ -1,4 +1,4 @@ -// site-service.js +// site-worker.js // Copyright (C) 2022 DTP Technologies, LLC // License: Apache-2.0 @@ -7,22 +7,26 @@ const path = require('path'); const SitePlatform = require(path.join(__dirname, 'site-platform')); + +const { SiteAsync } = require(path.join(__dirname, 'site-async')); const { SiteCommon } = require(path.join(__dirname, 'site-common')); class SiteWorker extends SiteCommon { constructor (dtp, component) { super(dtp, component); + this.processors = { }; } async start ( ) { try { - process.on('unhandledRejection', (error, p) => { + process.on('unhandledRejection', async (error, p) => { this.log.error('Unhandled rejection', { - error: error, promise: p, + message: error.message, stack: error.stack }); + process.exit(-2); }); process.on('warning', (error) => { @@ -54,8 +58,66 @@ class SiteWorker extends SiteCommon { } } - async stop ( ) { + /** + * Load a script as a Worker processor. It must be derived from + * SiteWorkerProcess and implement the expected interface. + * @param {String} scriptFile the filename of the script to load as a Worker + * processor. + * @returns the new processor instance + */ + async loadProcessor (scriptFile) { + const ProcessorClass = require(scriptFile); + const processor = new ProcessorClass(this); + const { COMPONENT } = ProcessorClass; + + this.log.info('loading worker processor', { component: COMPONENT.name }); + this.processors[COMPONENT.name] = processor; + + return processor; + } + + /** + * Start all loaded processors. The assumption here is if you load any + * additional processors *after* calling this method, you will start them + * yourself in some way. + */ + async startProcessors ( ) { + const slugs = Object.keys(this.processors); + await SiteAsync.each(slugs, async (slug) => { + const processor = this.processors[slug]; + try { + this.log.info('starting worker processor', { + component: processor.component.name, + }); + await processor.start(); + } catch (error) { + this.log.error('failed to start processor', { + component: processor.component.name, + error, + }); + } + }, 1); + } + /** + * Stops any running child processors and terminates the worker. + */ + async stop ( ) { + const slugs = Object.keys(this.processors); + await SiteAsync.each(slugs, async (slug) => { + const processor = this.processors[slug]; + try { + this.log.info('stopping worker processor', { + component: processor.component.name, + }); + await processor.stop(); + } catch (error) { + this.log.error('failed to stop processor', { + component: processor.component.name, + error, + }); + } + }, 1); } } diff --git a/package.json b/package.json index e5cd7b8..6870f3d 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ }, "dependencies": { "@fortawesome/fontawesome-free": "^5.15.4", - "@joeattardi/emoji-button": "^4.6.2", "@socket.io/redis-adapter": "^7.1.0", "anchorme": "^2.1.2", "ansicolor": "^1.1.100", @@ -28,7 +27,7 @@ "diskusage-ng": "^1.0.2", "disposable-email-provider-domains": "^1.0.9", "dotenv": "^16.0.0", - "dtp-jshint-reporter": "^1.0.2", + "dtp-jshint-reporter": "ssh://git@git.digitaltelepresence.com:digital-telepresence/dtp-jshint-reporter.git#master", "ein-validator": "^1.0.1", "email-domain-check": "^1.1.4", "email-validator": "^2.0.4", @@ -41,7 +40,7 @@ "glob": "^7.2.0", "highlight.js": "^11.4.0", "html-filter": "^4.3.2", - "ioredis": "^4.28.5", + "ioredis": "^5.2.2", "jsdom": "^19.0.0", "libphonenumber-js": "^1.9.49", "marked": "^4.0.12", @@ -64,6 +63,8 @@ "passport-oauth2": "^1.6.1", "passport-oauth2-client-password": "^0.1.2", "password-generator": "^2.3.2", + "picmo": "^5.4.0", + "pretty-checkbox": "^3.0.3", "pug": "^3.0.2", "qrcode": "^1.5.0", "rate-limiter-flexible": "^2.3.6", @@ -73,12 +74,14 @@ "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", "tinymce": "^6.1.0", "uikit": "^3.11.1", "uniqid": "^5.4.0", + "unzalgo": "^3.0.0", "url-validation": "^2.1.0", "uuid": "^8.3.2", "zxcvbn": "^4.4.2" diff --git a/start-local b/start-local index fd1d007..f17eeeb 100755 --- a/start-local +++ b/start-local @@ -7,7 +7,10 @@ export MINIO_ROOT_USER MINIO_ROOT_PASSWORD MINIO_CI_CD forever start --killSignal=SIGINT app/workers/host-services.js forever start --killSignal=SIGINT app/workers/reeeper.js + forever start --killSignal=SIGINT app/workers/newsletter.js +forever start --killSignal=SIGINT app/workers/media.js +forever start --killSignal=SIGINT app/workers/chat.js forever start --killSignal=SIGINT app/workers/hive-resolver.js @@ -15,6 +18,9 @@ minio server ./data/minio --address ":9030" --console-address ":9031" forever stop app/workers/hive-resolver.js +forever stop app/workers/chat.js +forever stop app/workers/media.js forever stop app/workers/newsletter.js + forever stop app/workers/reeeper.js forever stop app/workers/host-services.js diff --git a/yarn.lock b/yarn.lock index 3f98458..17a32b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -885,53 +885,15 @@ "@babel/helper-validator-identifier" "^7.15.7" to-fast-properties "^2.0.0" -"@fortawesome/fontawesome-common-types@^0.2.36": - version "0.2.36" - resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz#b44e52db3b6b20523e0c57ef8c42d315532cb903" - integrity sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg== - "@fortawesome/fontawesome-free@^5.15.4": version "5.15.4" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz#ecda5712b61ac852c760d8b3c79c96adca5554e5" integrity sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg== -"@fortawesome/fontawesome-svg-core@^1.2.28": - version "1.2.36" - resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.36.tgz#4f2ea6f778298e0c47c6524ce2e7fd58eb6930e3" - integrity sha512-YUcsLQKYb6DmaJjIHdDWpBIGCcyE/W+p/LMGvjQem55Mm2XWVAP5kWTMKWLv9lwpCVjpLxPyOMOyUocP1GxrtA== - dependencies: - "@fortawesome/fontawesome-common-types" "^0.2.36" - -"@fortawesome/free-regular-svg-icons@^5.13.0": - version "5.15.4" - resolved "https://registry.yarnpkg.com/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.15.4.tgz#b97edab436954333bbeac09cfc40c6a951081a02" - integrity sha512-9VNNnU3CXHy9XednJ3wzQp6SwNwT3XaM26oS4Rp391GsxVYA+0oDR2J194YCIWf7jNRCYKjUCOduxdceLrx+xw== - dependencies: - "@fortawesome/fontawesome-common-types" "^0.2.36" - -"@fortawesome/free-solid-svg-icons@^5.13.0": - version "5.15.4" - resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.4.tgz#2a68f3fc3ddda12e52645654142b9e4e8fbb6cc5" - integrity sha512-JLmQfz6tdtwxoihXLg6lT78BorrFyCf59SAwBM6qV/0zXyVeDygJVb3fk+j5Qat+Yvcxp1buLTY5iDh1ZSAQ8w== - dependencies: - "@fortawesome/fontawesome-common-types" "^0.2.36" - -"@joeattardi/emoji-button@^4.6.2": - version "4.6.2" - resolved "https://registry.yarnpkg.com/@joeattardi/emoji-button/-/emoji-button-4.6.2.tgz#75baf4ce27324e4d6fb90292f8b248235f638ad0" - integrity sha512-FhuzTmW3nVHLVp2BJfNX17CYV77fqtKZlx328D4h6Dw3cPTT1gJRNXN0jV7BvHgsl6Q/tN8DIQQxTUIO4jW3gQ== - dependencies: - "@fortawesome/fontawesome-svg-core" "^1.2.28" - "@fortawesome/free-regular-svg-icons" "^5.13.0" - "@fortawesome/free-solid-svg-icons" "^5.13.0" - "@popperjs/core" "^2.4.0" - "@types/twemoji" "^12.1.1" - escape-html "^1.0.3" - focus-trap "^5.1.0" - fuzzysort "^1.1.4" - tiny-emitter "^2.1.0" - tslib "^2.0.0" - twemoji "^13.0.0" +"@ioredis/commands@^1.1.1": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" + integrity sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg== "@otplib/core@^12.0.1": version "12.0.1" @@ -971,11 +933,6 @@ "@otplib/plugin-crypto" "^12.0.1" "@otplib/plugin-thirty-two" "^12.0.1" -"@popperjs/core@^2.4.0": - version "2.11.0" - resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.0.tgz#6734f8ebc106a0860dff7f92bf90df193f0935d7" - integrity sha512-zrsUxjLOKAzdewIDRWy9nsV1GQsKBCWaGwsZQlCgr6/q+vjyZhFgqedLfFBuI9anTPEUT4APq9Mu0SZBTzIcGQ== - "@rollup/plugin-babel@^5.2.0": version "5.3.0" resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.0.tgz#9cb1c5146ddd6a4968ad96f209c50c62f92f9879" @@ -1118,11 +1075,6 @@ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756" integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg== -"@types/twemoji@^12.1.1": - version "12.1.2" - resolved "https://registry.yarnpkg.com/@types/twemoji/-/twemoji-12.1.2.tgz#52578fd22665311e6a78d04f800275449d51c97e" - integrity sha512-3eMyKenMi0R1CeKzBYtk/Z2JIHsTMQrIrTah0q54o45pHTpWVNofU2oHx0jS8tqsDRhis2TbB6238WP9oh2l2w== - "@types/webidl-conversions@*": version "6.1.1" resolved "https://registry.yarnpkg.com/@types/webidl-conversions/-/webidl-conversions-6.1.1.tgz#e33bc8ea812a01f63f90481c666334844b12a09e" @@ -2215,9 +2167,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" @@ -2870,6 +2822,13 @@ debug@^3.2.6, debug@^3.2.7: dependencies: ms "^2.1.1" +debug@^4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + debug@~4.1.0: version "4.1.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" @@ -3163,10 +3122,9 @@ double-ended-queue@^2.1.0-0: resolved "https://registry.yarnpkg.com/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz#103d3527fd31528f40188130c841efdd78264e5c" integrity sha1-ED01J/0xUo9AGIEwyEHv3XgmTlw= -dtp-jshint-reporter@^1.0.2: +"dtp-jshint-reporter@ssh://git@git.digitaltelepresence.com:digital-telepresence/dtp-jshint-reporter.git#master": version "1.0.2" - resolved "https://registry.yarnpkg.com/dtp-jshint-reporter/-/dtp-jshint-reporter-1.0.2.tgz#34d11f78eac98027d13dd2a8641e0ec664ddd9ab" - integrity sha512-+tT86GZ5JH+GvxiSmYdVUTC8yZLktlI1e/YCmti35kLNz4KKO2qwU9Z/50LCwryJNkA8+LMPK2y/mOrX2uU/Fg== + resolved "ssh://git@git.digitaltelepresence.com:digital-telepresence/dtp-jshint-reporter.git#68b078b75cd6d048a9bf9bdc9b30ccc2a2145c4f" dependencies: chalk "^4.1.1" @@ -3260,6 +3218,11 @@ emoji-regex@^8.0.0: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +emojibase@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/emojibase/-/emojibase-6.1.0.tgz#c3bc281e998a0e06398416090c23bac8c5ed3ee8" + integrity sha512-1GkKJPXP6tVkYJHOBSJHoGOr/6uaDxZ9xJ6H7m6PfdGXTmQgbALHLWaVRY4Gi/qf5x/gT/NUXLPuSHYLqtLtrQ== + encode-utf8@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda" @@ -3468,7 +3431,7 @@ escape-goat@^2.0.0: resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q== -escape-html@^1.0.3, escape-html@~1.0.3: +escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= @@ -3867,14 +3830,6 @@ flush-write-stream@^1.0.2: inherits "^2.0.3" readable-stream "^2.3.6" -focus-trap@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-5.1.0.tgz#64a0bfabd95c382103397dbc96bfef3a3cf8e5ad" - integrity sha512-CkB/nrO55069QAUjWFBpX6oc+9V90Qhgpe6fBWApzruMq5gnlh90Oo7iSSDK7pKiV5ugG6OY2AXM5mxcmL3lwQ== - dependencies: - tabbable "^4.0.0" - xtend "^4.0.1" - follow-redirects@^1.0.0, follow-redirects@^1.14.0: version "1.14.5" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.5.tgz#f09a5848981d3c772b5392309778523f8d85c381" @@ -3946,15 +3901,6 @@ fs-extra@3.0.1: jsonfile "^3.0.0" universalify "^0.1.0" -fs-extra@^8.0.1: - version "8.1.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" - integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== - dependencies: - graceful-fs "^4.2.0" - jsonfile "^4.0.0" - universalify "^0.1.0" - fs-extra@^9.0.1: version "9.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" @@ -3996,11 +3942,6 @@ function-bind@^1.1.1: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== -fuzzysort@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/fuzzysort/-/fuzzysort-1.1.4.tgz#a0510206ed44532cbb52cf797bf5a3cb12acd4ba" - integrity sha512-JzK/lHjVZ6joAg3OnCjylwYXYVjRiwTY6Yb25LvfpJHK8bjisfnZJ5bY8aVWwTwCXgxPNgLAtmHL+Hs5q1ddLQ== - gauge@~2.7.3: version "2.7.4" resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" @@ -4679,6 +4620,21 @@ ioredis@^4.28.5: redis-parser "^3.0.0" standard-as-callback "^2.1.0" +ioredis@^5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-5.2.2.tgz#212467e04f6779b4e0e800cece7bb7d3d7b546d2" + integrity sha512-wryKc1ur8PcCmNwfcGkw5evouzpbDXxxkMkzPK8wl4xQfQf7lHe11Jotell5ikMVAtikXJEu/OJVaoV51BggRQ== + dependencies: + "@ioredis/commands" "^1.1.1" + cluster-key-slot "^1.1.0" + debug "^4.3.4" + denque "^2.0.1" + lodash.defaults "^4.2.0" + lodash.isarguments "^3.1.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + standard-as-callback "^2.1.0" + "ip-address@5.8.9 - 5.9.4": version "5.9.4" resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-5.9.4.tgz#4660ac261ad61bd397a860a007f7e98e4eaee386" @@ -5118,6 +5074,11 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= +isnumber@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isnumber/-/isnumber-1.0.0.tgz#0e3f9759b581d99dd85086f0ec2a74909cfadd01" + integrity sha512-JLiSz/zsZcGFXPrB4I/AGBvtStkt+8QmksyZBZnVXnnK9XdTEyz0tX8CRYljtwYDuIuZzih6DpHQdi+3Q6zHPw== + isobject@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" @@ -5287,22 +5248,6 @@ jsonfile@^3.0.0: optionalDependencies: graceful-fs "^4.1.6" -jsonfile@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" - integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss= - optionalDependencies: - graceful-fs "^4.1.6" - -jsonfile@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-5.0.0.tgz#e6b718f73da420d612823996fdf14a03f6ff6922" - integrity sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w== - dependencies: - universalify "^0.1.2" - optionalDependencies: - graceful-fs "^4.1.6" - jsonfile@^6.0.1: version "6.1.0" resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" @@ -6586,6 +6531,13 @@ pend@~1.2.0: resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA= +picmo@^5.4.0: + version "5.4.0" + resolved "https://registry.yarnpkg.com/picmo/-/picmo-5.4.0.tgz#d51c9258031b351217e2d165ed3781f4a192c938" + integrity sha512-Rq9R7JOuT/Dzc0kVvYisO0MwMG8m22i+fE5nuVELXmn2aXDIjr5c29b4BFQuhbtzSx+l77uwp5/V4L2/KE477w== + dependencies: + emojibase "^6.1.0" + picocolors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" @@ -6691,6 +6643,11 @@ pretty-bytes@^5.3.0, pretty-bytes@^5.4.1: resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== +pretty-checkbox@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/pretty-checkbox/-/pretty-checkbox-3.0.3.tgz#d49c8013a8fc08ee0c2d6ebde453464bfdbc428e" + integrity sha512-kCLsENsJ6h5Bcq106Q3YMSxuz2q3jtIXP7fgDB/+jZjUsZjRjAoL9Lr1TVwAEcugufVBhr5Mfd9L7P6d+SR+Yw== + pretty-hrtime@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" @@ -7932,6 +7889,13 @@ static-extend@^0.1.1: define-property "^0.2.5" object-copy "^0.1.0" +stats-lite@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/stats-lite/-/stats-lite-2.2.0.tgz#278a5571fa1d2e8b1691295dccc0235282393bbf" + integrity sha512-/Kz55rgUIv2KP2MKphwYT/NCuSfAlbbMRv2ZWw7wyXayu230zdtzhxxuXXcvsc6EmmhS8bSJl3uS1wmMHFumbA== + dependencies: + isnumber "~1.0.0" + "statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@~1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" @@ -7970,6 +7934,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" @@ -8153,11 +8122,6 @@ systeminformation@^5.11.6: resolved "https://registry.yarnpkg.com/systeminformation/-/systeminformation-5.11.6.tgz#8624cbb2e95e6fa98a4ebb0d10759427c0e88144" integrity sha512-7KBXgdnIDxABQ93w+GrPSrK/pup73+fM09VGka4A/+FhgzdlRY0JNGGDFmV8BHnFuzP9zwlI3n64yDbp7emasQ== -tabbable@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-4.0.0.tgz#5bff1d1135df1482cf0f0206434f15eadbeb9261" - integrity sha512-H1XoH1URcBOa/rZZWxLxHCtOdVUEev+9vo5YdYhC9tCY4wnybX+VQrCYuy9ubkg69fCBxCONJOSLGfw0DWMffQ== - tapable@^2.1.1, tapable@^2.2.0: version "2.2.1" resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" @@ -8284,11 +8248,6 @@ time-stamp@^1.0.0: resolved "https://registry.yarnpkg.com/time-stamp/-/time-stamp-1.1.0.tgz#764a5a11af50561921b133f3b44e618687e0f5c3" integrity sha1-dkpaEa9QVhkhsTPztE5hhofg9cM= -tiny-emitter@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" - integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== - tiny-inflate@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4" @@ -8406,7 +8365,7 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o= -tslib@^2.0.0, tslib@^2.3.0: +tslib@^2.3.0: version "2.3.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== @@ -8418,21 +8377,6 @@ tunnel-agent@^0.6.0: dependencies: safe-buffer "^5.0.1" -twemoji-parser@13.1.0: - version "13.1.0" - resolved "https://registry.yarnpkg.com/twemoji-parser/-/twemoji-parser-13.1.0.tgz#65e7e449c59258791b22ac0b37077349127e3ea4" - integrity sha512-AQOzLJpYlpWMy8n+0ATyKKZzWlZBJN+G0C+5lhX7Ftc2PeEVdUU/7ns2Pn2vVje26AIZ/OHwFoUbdv6YYD/wGg== - -twemoji@^13.0.0: - version "13.1.0" - resolved "https://registry.yarnpkg.com/twemoji/-/twemoji-13.1.0.tgz#65bb71e966dae56f0d42c30176f04cbdae109913" - integrity sha512-e3fZRl2S9UQQdBFLYXtTBT6o4vidJMnpWUAhJA+yLGR+kaUTZAt3PixC0cGvvxWSuq2MSz/o0rJraOXrWw/4Ew== - dependencies: - fs-extra "^8.0.1" - jsonfile "^5.0.0" - twemoji-parser "13.1.0" - universalify "^0.1.2" - type-check@~0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" @@ -8629,6 +8573,13 @@ unset-value@^1.0.0: has-value "^0.3.1" isobject "^3.0.0" +unzalgo@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/unzalgo/-/unzalgo-3.0.0.tgz#e9c9dbbf3dfa52c11075d93d57b92c50421bcf99" + integrity sha512-yRSDlFaYpFJK2VO0iI4I2E3l1CF8puFNL00nh7beZ/q4XSxd9XPNIlsTvfOz/fF2P6tMBLWNVLWpLBvJ9/11ZQ== + dependencies: + stats-lite "^2.2.0" + upath@^1.1.1, upath@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894" @@ -9289,7 +9240,7 @@ xmlhttprequest-ssl@~1.6.2: resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.6.3.tgz#03b713873b01659dfa2c1c5d056065b27ddc2de6" integrity sha512-3XfeQE/wNkvrIktn2Kf0869fC0BN6UpydVasGIeSm2B1Llihf7/0UfZM+eCkOw3P7bP4+qPgqhm7ZoxuJtFU0Q== -xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.0, xtend@~4.0.1: +xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==