diff --git a/.env.default b/.env.default
index eacaa4d..8e7f5f2 100644
--- a/.env.default
+++ b/.env.default
@@ -16,6 +16,7 @@ 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
@@ -83,6 +84,7 @@ MINIO_SECRET_KEY=
MINIO_IMAGE_BUCKET=yourapp-images
MINIO_VIDEO_BUCKET=yourapp-videos
+MINIO_ATTACHMENT_BUCKET=yourapp-attachments
#
# ExpressJS/HTTP configuration
diff --git a/app/controllers/auth.js b/app/controllers/auth.js
index ee47be8..b78b458 100644
--- a/app/controllers/auth.js
+++ b/app/controllers/auth.js
@@ -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
index ae22834..fdf4ecb 100644
--- a/app/controllers/chat.js
+++ b/app/controllers/chat.js
@@ -5,6 +5,7 @@
'use strict';
const express = require('express');
+const multer = require('multer');
const { SiteController,/*, SiteError*/
SiteError} = require('../../lib/site-lib');
@@ -22,6 +23,8 @@ class ChatController extends SiteController {
session: sessionService,
} = this.dtp.services;
+ const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${this.component.slug}` });
+
const router = express.Router();
this.dtp.app.use('/chat', router);
@@ -35,16 +38,31 @@ class ChatController extends SiteController {
);
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.create(limiterService.config.chat.postRoomUpdate),
+ limiterService.createMiddleware(limiterService.config.chat.postRoomUpdate),
this.postRoomUpdate.bind(this),
);
router.post(
'/room',
- limiterService.create(limiterService.config.chat.postRoomCreate),
+ limiterService.createMiddleware(limiterService.config.chat.postRoomCreate),
this.postRoomCreate.bind(this),
);
@@ -53,24 +71,64 @@ class ChatController extends SiteController {
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.create(limiterService.config.chat.getRoomView),
+ limiterService.createMiddleware(limiterService.config.chat.getRoomView),
this.getRoomView.bind(this),
);
router.get(
'/room',
- limiterService.create(limiterService.config.chat.getRoomView),
+ limiterService.createMiddleware(limiterService.config.chat.getRoomHome),
this.getRoomHome.bind(this),
);
router.get(
'/',
- limiterService.create(limiterService.config.chat.getHome),
+ 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;
}
@@ -88,13 +146,118 @@ class ChatController extends SiteController {
}
}
+ 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/${res.locals.room._id}`);
+ res.redirect(`/chat/room/${res.locals.room._id}`);
} catch (error) {
- this.log.error('failed to update chat room', { roomId: res.locals.room._id, error });
+ this.log.error('failed to update chat room', {
+ // roomId: res.locals.room._id,
+ error,
+ });
return next(error);
}
}
@@ -103,7 +266,7 @@ class ChatController extends SiteController {
const { chat: chatService } = this.dtp.services;
try {
res.locals.room = await chatService.createRoom(req.user, req.body);
- res.redirect(`/chat/${res.locals.room._id}`);
+ res.redirect(`/chat/room/${res.locals.room._id}`);
} catch (error) {
this.log.error('failed to create chat room', { error });
return next(error);
@@ -114,6 +277,45 @@ class ChatController extends SiteController {
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 {
@@ -133,7 +335,7 @@ class ChatController extends SiteController {
const { chat: chatService } = this.dtp.services;
try {
res.locals.pagination = this.getPaginationParameters(req, 20);
- res.locals.rooms = await chatService.getPublicRooms(req.user, res.locals.pagination);
+ 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 });
@@ -141,9 +343,67 @@ class ChatController extends SiteController {
}
}
- async getHome (req, res) {
- res.locals.pageTitle = 'Chat Home';
- res.render('chat/index');
+ 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);
+ }
}
}
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 fa355c6..b3cd781 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..6c79620 100644
--- a/app/controllers/image.js
+++ b/app/controllers/image.js
@@ -46,13 +46,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..2294e0e 100644
--- a/app/controllers/newsletter.js
+++ b/app/controllers/newsletter.js
@@ -34,12 +34,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..9e2b8f9 100644
--- a/app/controllers/user.js
+++ b/app/controllers/user.js
@@ -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/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 78235e2..ba963ca 100644
--- a/app/models/chat-message.js
+++ b/app/models/chat-message.js
@@ -15,7 +15,7 @@ const Schema = mongoose.Schema;
*/
const ChatMessageSchema = new Schema({
- created: { type: Date, default: Date.now, required: true, index: -1, expires: '10d' },
+ 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 },
@@ -25,6 +25,7 @@ const ChatMessageSchema = new Schema({
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
index 4d9408c..f1d8fe6 100644
--- a/app/models/chat-room-invite.js
+++ b/app/models/chat-room-invite.js
@@ -8,12 +8,23 @@ 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: ['new', 'accepted', 'rejected'], required: true },
+ 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
index 8eda8eb..6d3e2ff 100644
--- a/app/models/chat-room.js
+++ b/app/models/chat-room.js
@@ -10,7 +10,7 @@ const Schema = mongoose.Schema;
const RoomMemberSchema = new Schema({
memberType: { type: String, required: true },
- member: { type: Schema.ObjectId, refPath: 'memberType' },
+ member: { type: Schema.ObjectId, required: true, refPath: 'members.memberType' },
});
const ROOM_VISIBILITY_LIST = ['public', 'private'];
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/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 e8c32ad..6194919 100644
--- a/app/services/chat.js
+++ b/app/services/chat.js
@@ -4,8 +4,6 @@
'use strict';
-const Redis = require('ioredis');
-
const mongoose = require('mongoose');
const ChatRoom = mongoose.model('ChatRoom');
@@ -16,6 +14,7 @@ const EmojiReaction = mongoose.model('EmojiReaction');
const ioEmitter = require('socket.io-emitter');
+const moment = require('moment');
const marked = require('marked');
const hljs = require('highlight.js');
@@ -29,15 +28,19 @@ class ChatService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
+ }
+
+ async start ( ) {
+ const { user: userService, limiter: limiterService } = this.dtp.services;
+ await super.start();
- const USER_SELECT = '_id username username_lc displayName picture';
this.populateChatMessage = [
{
path: 'channel',
},
{
path: 'author',
- select: USER_SELECT,
+ select: userService.USER_SELECT,
},
{
path: 'stickers',
@@ -47,11 +50,11 @@ class ChatService extends SiteService {
this.populateChatRoom = [
{
path: 'owner',
- select: USER_SELECT,
+ select: userService.USER_SELECT,
},
{
path: 'members.member',
- select: USER_SELECT,
+ select: userService.USER_SELECT,
},
];
@@ -61,18 +64,20 @@ class ChatService extends SiteService {
populate: [
{
path: 'owner',
- select: USER_SELECT,
- },
- {
- path: 'members.member',
- select: USER_SELECT,
+ 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; };
@@ -93,24 +98,7 @@ class ChatService extends SiteService {
xhtml: false,
};
- /*
- * The chat message rate limiter uses Redis to provide accurate atomic
- * accounting regardless of which host is currently hosting a user's chat
- * connection and session.
- */
-
- const { RateLimiterRedis } = require('rate-limiter-flexible');
- const rateLimiterRedisClient = new Redis({
- host: process.env.REDIS_HOST,
- port: process.env.REDIS_PORT,
- password: process.env.REDIS_PASSWORD,
- keyPrefix: process.env.REDIS_KEY_PREFIX || 'dtp',
- lazyConnect: false,
- enableOfflineQueue: false,
- });
-
- this.chatMessageLimiter = new RateLimiterRedis({
- storeClient: rateLimiterRedisClient,
+ this.chatMessageLimiter = limiterService.createRateLimiter({
points: 20,
duration: 60,
blockDuration: 60 * 3,
@@ -118,8 +106,7 @@ class ChatService extends SiteService {
keyPrefix: 'rl:chatmsg',
});
- this.reactionLimiter = new RateLimiterRedis({
- storeClient: rateLimiterRedisClient,
+ this.reactionLimiter = limiterService.createRateLimiter({
points: 60,
duration: 60,
blockDuration: 60 * 3,
@@ -133,6 +120,18 @@ class ChatService extends SiteService {
*/
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) {
@@ -168,13 +167,16 @@ class ChatService extends SiteService {
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) {
+ if (roomDefinition.description && (roomDefinition.description.length > 0)) {
room.description = this.filterText(roomDefinition.description);
}
- if (roomDefinition.policy) {
+ if (roomDefinition.policy && (roomDefinition.policy.length > 0)) {
room.policy = this.filterText(roomDefinition.policy);
}
@@ -196,15 +198,36 @@ class ChatService extends SiteService {
$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) {
+ if (roomDefinition.description && (roomDefinition.description.length > 0)) {
updateOp.$set.description = this.filterText(roomDefinition.description);
} else {
updateOp.$unset.description = 1;
}
- await ChatRoom.updateOne({ _id: room._id }, updateOp);
+ 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) {
@@ -243,7 +266,7 @@ class ChatService extends SiteService {
cpp: 50
}, pagination);
const rooms = await ChatRoom
- .find({ 'flags.isPublic': true })
+ .find({ visibility: 'public' })
.sort({ lastActivity: -1, created: -1 })
.skip(pagination.skip)
.limit(pagination.cpp)
@@ -260,8 +283,12 @@ class ChatService extends SiteService {
return room;
}
+ async deleteRoom (room) {
+ return this.queues.reeeper.add('chat-room-delete', { roomId: room._id });
+ }
+
async joinRoom (room, member) {
- if (!room.flags.isOpen) {
+ if (room.membershipPolicy !== 'open') {
throw new SiteError(403, 'The room is not open');
}
await ChatRoom.updateOne(
@@ -286,24 +313,32 @@ class ChatService extends SiteService {
);
}
- async sendRoomInvite (room, member) {
+ async sendRoomInvite (room, member, inviteDefinition) {
+ const { coreNode: coreNodeService } = this.dtp.services;
const NOW = new Date();
- /*
- * See if there's already an outstanding invite, and return it.
- */
+ 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) {
- return 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.`);
- /*
- * Create new invite
- */
+ 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;
@@ -311,7 +346,13 @@ class ChatService extends SiteService {
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,
@@ -319,7 +360,39 @@ class ChatService extends SiteService {
inviteId: invite._id,
});
- return invite.toObject();
+ /*
+ * 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) {
@@ -343,7 +416,7 @@ class ChatService extends SiteService {
await ChatRoomInvite.updateOne(
{ _id: invite._id },
{
- $set: { stats: 'accepted' },
+ $set: { status: 'accepted' },
},
);
}
@@ -356,13 +429,31 @@ class ChatService extends SiteService {
});
await ChatRoomInvite.updateOne(
{ _id: invite._id },
- { $set: { status: 'rejected' } },
+ {
+ $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.deleteOne({ _id: 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) {
@@ -373,6 +464,13 @@ class ChatService extends SiteService {
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();
/*
@@ -390,6 +488,9 @@ class ChatService extends SiteService {
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) => {
@@ -420,23 +521,30 @@ class ChatService extends SiteService {
const renderedContent = this.renderMessageContent(message.content);
const payload = {
_id: message._id,
+ created: message.created,
user: {
_id: author._id,
displayName: author.displayName,
username: author.username,
- picture: {
- large: {
- _id: author.picture.large._id,
- },
- small: {
- _id: author.picture.small._id,
- },
- },
},
content: renderedContent,
stickers,
};
+ 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
*/
@@ -500,6 +608,50 @@ class ChatService extends SiteService {
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
@@ -547,10 +699,13 @@ class ChatService extends SiteService {
}
async sendMessage (channel, messageName, payload) {
+ if (typeof channel !== 'string') {
+ channel = channel.toString();
+ }
this.emitter.to(channel).emit(messageName, payload);
}
- async sendSystemMessage (socket, content, options) {
+ async sendSystemMessage (content, options) {
const NOW = new Date();
options = Object.assign({
@@ -562,17 +717,31 @@ class ChatService extends SiteService {
type: options.type,
content,
};
-
if (options.channelId) {
- socket.to(options.channelId).emit('system-message', payload);
+ this.emitter.to(options.channelId).emit('system-message', payload);
return;
}
-
- socket.emit('system-message', payload);
+ 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;
diff --git a/app/services/comment.js b/app/services/comment.js
index bf2f12c..191483f 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'));
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..66cef90 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);
}
diff --git a/app/services/core-node.js b/app/services/core-node.js
index 7235d27..a26990b 100644
--- a/app/services/core-node.js
+++ b/app/services/core-node.js
@@ -20,7 +20,7 @@ const OAuth2Strategy = require('passport-oauth2');
const striptags = require('striptags');
-const { SiteService, SiteError } = require('../../lib/site-lib');
+const { SiteService, SiteError, SiteAsync } = require('../../lib/site-lib');
class CoreAddress {
@@ -58,7 +58,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 +112,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 +134,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 +214,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 +256,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 +322,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 +526,90 @@ 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);
+ }
+
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 a7a99dd..2ad83d5 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/job-queue.js b/app/services/job-queue.js
index 472a304..f59510b 100644
--- a/app/services/job-queue.js
+++ b/app/services/job-queue.js
@@ -15,10 +15,6 @@ class JobQueueService extends SiteService {
this.queues = { };
}
- async start ( ) { }
-
- async stop ( ) { }
-
getJobQueue (name, defaultJobOptions) {
/*
* If we have a named queue, return it.
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..ec8532f 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 });
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
index 2f153a4..1a35ad3 100644
--- a/app/services/sticker.js
+++ b/app/services/sticker.js
@@ -20,20 +20,20 @@ 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: '-email -passwordSalt -password',
+ select: userService.USER_SELECT,
},
];
- }
-
- async start ( ) {
- const { jobQueue: jobQueueService } = this.dtp.services;
- this.jobQueue = jobQueueService.getJobQueue('sticker-ingest', {
- attempts: 3,
- });
+ this.queue = this.getJobQueue('media');
this.stickerTemplate = this.loadViewTemplate('sticker/components/sticker-standalone.pug');
}
@@ -47,7 +47,7 @@ class StickerService extends SiteService {
const currentStickers = await Sticker.find({ owner: owner._id }).select('_id').lean();
switch (ownerType) {
- case 'Channel':
+ 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.`);
}
@@ -87,7 +87,7 @@ class StickerService extends SiteService {
await sticker.save();
- await this.jobQueue.add('sticker-ingest', { stickerId: sticker._id });
+ await this.queue.add('sticker-ingest', { stickerId: sticker._id });
return sticker.toObject();
}
@@ -140,7 +140,7 @@ class StickerService extends SiteService {
return sticker;
}
- async setStickerStatus (sticker, status) {
+ async setStatus (sticker, status) {
await Sticker.updateOne({ _id: sticker._id }, { $set: { status } });
}
@@ -159,7 +159,7 @@ class StickerService extends SiteService {
async removeSticker (sticker) {
const stickerId = sticker._id;
this.log.info('creating sticker delete job', { stickerId });
- await this.jobQueue.add('sticker-delete', { stickerId });
+ await this.queue.add('sticker-delete', { stickerId });
}
async render (sticker, stickerOptions) {
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 e1446a9..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();
diff --git a/app/views/chat/components/input-form.pug b/app/views/chat/components/input-form.pug
index b058114..17f418d 100644
--- a/app/views/chat/components/input-form.pug
+++ b/app/views/chat/components/input-form.pug
@@ -9,12 +9,15 @@ mixin renderChatInputForm (room, options = { })
input(type="hidden", name="roomType", value= "ChatRoom")
input(type="hidden", name="room", value= room._id)
- .uk-card.uk-card-secondary.uk-card-body.uk-padding-small(style="border-top: solid 1px #3a3a3a;")
+ #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,
+ hidden= options.inputHidden,
).uk-textarea.uk-margin-small
div(uk-grid).uk-grid-small.uk-flex-middle
@@ -23,9 +26,8 @@ mixin renderChatInputForm (room, options = { })
type= "button",
title= "Insert emoji",
data-target-element= "chat-input-text",
- uk-tooltip={ title: 'Add Emoji', delay: 500 },
- onclick= "return dtp.app.showEmojiPicker(event);",
- ).uk-button.dtp-button-default.uk-button-small
+ onclick= "return dtp.app.chat.toggleEmojiPicker(event);",
+ ).uk-button.uk-button-default.uk-button-small
span
i.far.fa-laugh-beam
@@ -34,9 +36,8 @@ mixin renderChatInputForm (room, options = { })
type= "button",
title= "Sticker Picker",
uk-toggle={ target: '#sticker-picker'},
- uk-tooltip={ title: 'Add Sticker', delay: 500 },
onclick="return dtp.app.chat.openChatInput();",
- ).uk-button.dtp-button-default.uk-button-small
+ ).uk-button.uk-button-default.uk-button-small
span
i.far.fa-image
#sticker-picker(uk-modal)
@@ -99,19 +100,19 @@ mixin renderChatInputForm (room, options = { })
else
.uk-text-center You haven't saved any Favorite stickers
- //- .uk-width-auto
- //- button(
- //- type= "button",
- //- title= "Attach image",
- //- onclick="return dtp.app.chat.attachChatImage(event);",
- //- ).uk-button.dtp-button-default.uk-button-small
- //- span
- //- i.fas.fa-file-image
+ .uk-width-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", uk-tooltip={ title: "Chat Home", delay: 500 }).uk-button.dtp-button-secondary.uk-button-small
+ a(href="/chat", title= "Chat Home").uk-button.uk-button-default.uk-button-small
span
i.fas.fa-home
@@ -120,8 +121,8 @@ mixin renderChatInputForm (room, options = { })
id="chat-input-btn",
type="button",
onclick="return dtp.app.chat.toggleChatInput(event);",
- uk-tooltip={ title: "Toggle Chat Input", delay: 500 },
- ).uk-button.dtp-button-secondary.uk-button-small
+ title= "Toggle Chat Input",
+ ).uk-button.uk-button-default.uk-button-small
span
i.fas.fa-edit
@@ -129,23 +130,22 @@ mixin renderChatInputForm (room, options = { })
button(
id="chat-send-btn",
type="submit",
- uk-tooltip={ title: "Send Message", delay: 500 },
- ).uk-button.dtp-button-primary.uk-button-small
+ title= "Send Message",
+ ).uk-button.uk-button-primary.uk-button-small
span
i.far.fa-paper-plane
- div(uk-grid).uk-flex-between.uk-grid-small
- .uk-width-auto
- +renderReactionButton('Applaud/clap', '👏', 'clap')
- .uk-width-auto
- +renderReactionButton("On Fire!", '🔥', 'fire')
- .uk-width-auto
- +renderReactionButton("Happy", "🤗", "happy")
- .uk-width-auto
- +renderReactionButton("Laugh", "🤣", "laugh")
- .uk-width-auto
- +renderReactionButton("Angry", "🤬", "angry")
- .uk-width-auto
- +renderReactionButton("Honk", "🤡", "honk")
-
- //- .chat-menubar
\ No newline at end of file
+ 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/menubar.pug b/app/views/chat/components/menubar.pug
deleted file mode 100644
index e69de29..0000000
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
index 255d583..ea5f39b 100644
--- a/app/views/chat/components/message.pug
+++ b/app/views/chat/components/message.pug
@@ -1,32 +1,40 @@
include ../../sticker/components/sticker
mixin renderChatMessage (message, options = { })
- var authorName = message.author.displayName || message.author.username;
- div(data-message-id= message._id, data-author-id= message.author._id).chat-message
- div(uk-grid).uk-grid-small.uk-flex-bottom
- .uk-width-expand
- .uk-text-small.chat-username.uk-text-truncate= authorName
-
- if message.author.picture && message.author.picture.small
+ 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
- img(src=`/image/${message.author.picture.small._id}`, alt= `${authorName}'s profile picture`).chat-author-image
+ +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 && !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}
+ 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)
- .chat-timestamp(data-created= message.created).uk-text-small
+
+ //- "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
diff --git a/app/views/chat/components/reaction-button.pug b/app/views/chat/components/reaction-button.pug
index 1b577d4..e742987 100644
--- a/app/views/chat/components/reaction-button.pug
+++ b/app/views/chat/components/reaction-button.pug
@@ -1,6 +1,6 @@
mixin renderReactionButton (title, emoji, reaction)
button(
- uk-tooltip={ title, delay: 500 },
+ title= title,
data-reaction= reaction,
onclick="return dtp.app.chat.sendReaction(event);",
).dtp-button-reaction
diff --git a/app/views/chat/components/room-list.pug b/app/views/chat/components/room-list.pug
new file mode 100644
index 0000000..0c07821
--- /dev/null
+++ b/app/views/chat/components/room-list.pug
@@ -0,0 +1,4 @@
+mixin renderRoomList (rooms)
+ each room in rooms
+ li.uk-active
+ a(href=`/chat/room/${room._id}`)= room.name
\ 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
index 990552f..b51e8d6 100644
--- a/app/views/chat/index.pug
+++ b/app/views/chat/index.pug
@@ -1,12 +1,22 @@
extends layouts/room
block content
- .content-block.uk-height-1-1.uk-overflow-auto
+ include components/message
- h1 #{site.name} Chat
-
- p You can #[a(href='/chat/room/create') create] public and private chat rooms. A public room is listed in the public room directory, shown below. Private rooms are not listed in the directory and are unable to be found in search.
-
- p Rooms can be open, which means anyone can join them if they have them link. Rooms can also be closed, which means the room owner must invite people to join (and they have to accept).
+ #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
- h2 Public Rooms
\ No newline at end of file
+ .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
index a91c437..283389a 100644
--- a/app/views/chat/layouts/room.pug
+++ b/app/views/chat/layouts/room.pug
@@ -2,32 +2,30 @@ extends ../../layouts/main
block page-footer
block content-container
- mixin renderRoomList (rooms)
- each room in ownedChatRooms
- li.uk-active
- a(href=`/chat/room/${room._id}`)= room.name
+ 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
- .content-block.uk-border-rounded.uk-margin
+ .site-chat-sidebar-widget.uk-border-rounded.uk-margin
if Array.isArray(ownedChatRooms) && (ownedChatRooms.length > 0)
ul#room-list.uk-nav.uk-nav-default
li.uk-nav-header
div(uk-grid).uk-grid-small
- .uk-width-expand Your Rooms
+ .uk-text-bold.uk-width-expand Your Rooms
.uk-width-auto
- a(href='/chat/room/create', uk-tooltip="Create new chat room...").uk-link-reset
+ a(href='/chat/room/create', title= "Create new chat room...").uk-link-reset
i.fas.fa-plus
+renderRoomList(ownedChatRooms)
else
div You don't own any chat rooms.
- .content-block.uk-border-rounded
+ .site-chat-sidebar-widget.uk-border-rounded
if Array.isArray(joinedChatRooms) && (joinedChatRooms.length > 0)
ul#room-list.uk-nav.uk-nav-default
li.uk-nav-header Joined Rooms
- +renderRoomList(ownedChatRooms)
+ +renderRoomList(joinedChatRooms)
else
div You haven't joined any chat rooms.
@@ -36,19 +34,21 @@ block content-container
block content
div(class="uk-width-1-1 uk-width-1-5@l").uk-height-1-1.uk-overflow-auto
- .content-block.uk-border-rounded
- if chatRoom
- if Array.isArray(chatRoom.members) && (chatRoom.members.length > 0)
- ul#room-member-list.uk-nav.uk-nav-default
- li.uk-nav-header Room Members
- each member in chatRoom.members
+ 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
- a(href="")= member.displayName || member.username
- else
- div The room has no members
- else
- div Not in a room
+ +renderUserListEntry(membership.member, 'moderator')
-block viewjs
- script.
- window.dtp.room = !{JSON.stringify(room)};
\ No newline at end of file
+ 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
index 7c56b46..4022ba0 100644
--- a/app/views/chat/room/editor.pug
+++ b/app/views/chat/room/editor.pug
@@ -1,23 +1,34 @@
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="/chat").uk-form
+ form(method="POST", action= actionUrl).uk-form
.uk-card.uk-card-default.uk-card-small
.uk-card-header
- h1.uk-card-title Create Chat Room
+ 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").uk-input
+ 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
+ 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
+ textarea(id="policy", name="policy", rows="2", placeholder="Enter room use policy").uk-textarea= room ? room.policy : undefined
.uk-margin
div(uk-grid)
@@ -27,11 +38,11 @@ block content
div(uk-grid).uk-grid-small.uk-flex-middle
.uk-width-auto
label
- input(id="is-public", name="visibility", type="radio", value="public", checked).uk-radio
+ 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").uk-radio
+ input(id="is-private", name="visibility", type="radio", value="private", checked= room ? room.visibility === 'private' : false).uk-radio
| Private
.uk-width-auto
@@ -40,11 +51,11 @@ block content
div(uk-grid).uk-grid-small.uk-flex-middle
.uk-width-auto
label
- input(id="membership-open", name="policy", type="radio", value="open", checked).uk-radio
+ 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="policy", type="radio", value="closed").uk-radio
+ input(id="membership-closed", name="membershipPolicy", type="radio", value="closed", checked= room ? room.membershipPolicy === 'closed' : false).uk-radio
| Closed
.uk-card-footer
@@ -52,4 +63,4 @@ block content
.uk-width-expand
+renderBackButton()
.uk-width-auto
- button(type="submit").uk-button.uk-button-primary.uk-border-rounded Create room
\ No newline at end of file
+ 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
index 35121d0..738f5ea 100644
--- a/app/views/chat/room/view.pug
+++ b/app/views/chat/room/view.pug
@@ -1,55 +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
- div(uk-grid).uk-flex-middle.chat-menubar
- div(uk-tooltip="Room details").uk-width-expand
- h1.uk-card-title.uk-margin-remove= room.name
- div= room.description
-
- div(uk-tooltip="Active Members").uk-width-auto.no-select
- span
- i.fas.fa-user
- span(data-room-id= room._id).uk-margin-small-left.active-member-count= numeral(room.members.length).format('0,0')
-
- div(uk-tooltip="Total Members", class="uk-hidden@m").uk-width-auto.no-select
- span
- i.fas.fa-user
- span.uk-margin-small-left= formatCount(room.members.length)
-
-
- .uk-width-auto
- button(
- type="button",
- data-room-id= room._id,
- onclick="return dtp.app.chat.leaveRoom(event);",
- ).uk-button.dtp-button-default.uk-button-small.uk-border-pill.uk-text-bold
+ .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-sign-out-alt
- span.uk-margin-small-left Leave Room
-
- .uk-width-auto
- .uk-inline
- button(type="button").uk-button.uk-button-link.uk-button-small
- i.fas.fa-ellipsis-h
- div(uk-dropdown={ pos: 'bottom-right', mode: 'click' })
- ul.uk-nav.uk-dropdown-nav
- li.uk-nav-heading= room.name
- li.uk-nav-divider
- li
- a(href=`/chat/${room._id}/pop-out`, target="_blank") Pop-Out Chat
-
- #chat-message-list-wrapper
- #chat-reactions
- #chat-message-list
- each message in chatMessages || [ ]
- +renderChatMessage(message)
-
- .chat-message-menu
- button(type="button", onclick="return dtp.app.chat.resumeChatScroll(event);").chat-scroll-return Resume scrolling
-
- div
- +renderChatInputForm(room)
\ No newline at end of file
+ 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/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/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 954403a..fc94731 100644
--- a/app/views/layouts/main.pug
+++ b/app/views/layouts/main.pug
@@ -99,9 +99,9 @@ html(lang='en')
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/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/job/chat-room-clear.js b/app/workers/chat/job/chat-room-clear.js
new file mode 100644
index 0000000..d51b2a7
--- /dev/null
+++ b/app/workers/chat/job/chat-room-clear.js
@@ -0,0 +1,61 @@
+// 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 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 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();
+
+ const queue = this.getJobQueue('chat');
+
+ this.log.info('registering job processor', { queue: this.queue.name, name: 'chat-room-clear' });
+ 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..586496e
--- /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();
+
+ const queue = this.getJobQueue('chat');
+
+ this.log.info('registering job processor', { queue: this.queue.name, name: 'chat-room-delete' });
+ 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/media.js b/app/workers/media.js
new file mode 100644
index 0000000..76706a7
--- /dev/null
+++ b/app/workers/media.js
@@ -0,0 +1,86 @@
+// media.js
+// Copyright (C) 2022 DTP Technologies, LLC
+// License: Apache-2.0
+
+'use strict';
+
+const path = require('path');
+const fs = require('fs');
+require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') });
+
+const mongoose = require('mongoose');
+
+const { SitePlatform, SiteLog, SiteWorker } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib'));
+
+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..d5b9a18
--- /dev/null
+++ b/app/workers/media/job/attachment-ingest.js
@@ -0,0 +1,270 @@
+// 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 { response } = require('express');
+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/stickers.js b/app/workers/media/job/sticker-ingest.js
similarity index 58%
rename from app/workers/stickers.js
rename to app/workers/media/job/sticker-ingest.js
index f774f2f..42981d6 100644
--- a/app/workers/stickers.js
+++ b/app/workers/media/job/sticker-ingest.js
@@ -1,4 +1,4 @@
-// stickers.js
+// media/job/sticker-ingest.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
@@ -8,26 +8,25 @@ const DTP_STICKER_HEIGHT = 100;
const path = require('path');
const fs = require('fs');
-require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') });
const mongoose = require('mongoose');
-
-const { SitePlatform, SiteLog, SiteWorker } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib'));
+const Sticker = mongoose.model('Sticker');
const sharp = require('sharp');
-module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json'));
-module.config = {
- environment: process.env.NODE_ENV,
- root: path.resolve(__dirname, '..', '..'),
- component: { name: 'stickersWorker', slug: 'stickers-worker' },
-};
+const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib'));
-class StickerWorker extends SiteWorker {
+class StickerIngestJob extends SiteWorkerProcess {
- constructor (dtp) {
- super(dtp, dtp.config.component);
+ 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),
@@ -36,38 +35,21 @@ class StickerWorker extends SiteWorker {
async start ( ) {
await super.start();
- const { jobQueue: jobQueueService } = this.dtp.services;
- this.log.info('registering sticker-ingest job processor', {
- config: this.dtp.config.jobQueues['sticker-ingest'],
- });
- this.stickerProcessingQueue = jobQueueService.getJobQueue(
- 'sticker-ingest',
- this.dtp.config.jobQueues['sticker-ingest'],
- );
+ this.queue = await this.getJobQueue('media');
- this.stickerProcessingQueue.process('sticker-ingest', 1, this.processStickerIngest.bind(this));
- this.stickerProcessingQueue.process('sticker-delete', 1, this.processStickerDelete.bind(this));
+ 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 ( ) {
- if (this.stickerProcessingQueue) {
- try {
- this.log.info('closing sticker-ingest job queue');
- await this.stickerProcessingQueue.close();
- delete this.stickerProcessingQueue;
- } catch (error) {
- this.log.error('failed to close sticker ingest job queue', { error });
- // fall through
- }
- }
await super.stop();
}
async processStickerIngest (job) {
try {
this.log.info('received sticker ingest job', { id: job.id, data: job.data });
- await this.fetchSticker(job); // defines jobs.data.processor
+ await this.fetchSticker(job);
await this.resetSticker(job);
// call the chosen file processor to render the sticker for distribution
@@ -102,7 +84,7 @@ class StickerWorker extends SiteWorker {
switch (job.data.sticker.original.type) {
case 'image/jpeg':
job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.jpg`);
- job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.jpg`);
+ job.data.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 };
@@ -110,7 +92,7 @@ class StickerWorker extends SiteWorker {
case 'image/png':
job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.png`);
- job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.png`);
+ job.data.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 };
@@ -118,13 +100,13 @@ class StickerWorker extends SiteWorker {
case 'image/gif':
job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.gif`);
- job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.mp4`);
+ job.data.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.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.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 };
@@ -132,13 +114,13 @@ class StickerWorker extends SiteWorker {
case 'image/webm': // process as MP4
job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.webm`);
- job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.mp4`);
+ job.data.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.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.mp4`);
+ job.data.procFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.mp4`);
job.data.processor = 'processStickerFFMPEG';
break;
@@ -162,29 +144,17 @@ class StickerWorker extends SiteWorker {
async resetSticker (job) {
const { minio: minioService } = this.dtp.services;
const { sticker } = job.data;
-
- if (!sticker.encoded) {
- return;
+
+ 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: '' };
}
- this.log.info('removing existing encoded sticker media', { media: sticker.encoded });
- await minioService.removeObject(sticker.encoded.bucket, sticker.encoded.key);
-
- // switch sticker back to 'processing' status to prevent use in the app
- const Sticker = mongoose.model('Sticker');
- await Sticker.updateOne(
- { _id: job.data.sticker._id },
- {
- $set: {
- status: 'processing',
- },
- $unset: {
- encoded: '',
- },
- },
- );
-
- delete sticker.encoded;
+ await Sticker.updateOne({ _id: sticker._id }, updateOp);
}
async processStickerSharp (job) {
@@ -201,9 +171,9 @@ class StickerWorker extends SiteWorker {
chain = chain[job.data.sharpFormat](job.data.sharpFormatParameters);
- await chain.toFile(job.data.outFilePath);
+ await chain.toFile(job.data.procFilePath);
- job.data.outFileStat = await fs.promises.stat(job.data.outFilePath);
+ 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}`;
@@ -211,14 +181,13 @@ class StickerWorker extends SiteWorker {
await minioService.uploadFile({
bucket,
key,
- filePath: job.data.outFilePath,
+ filePath: job.data.procFilePath,
metadata: {
'Content-Type': `image/${job.data.sharpFormat}`,
'Content-Length': job.data.outFileStat.size,
},
});
- const Sticker = mongoose.model('Sticker');
await Sticker.updateOne(
{ _id: job.data.sticker._id },
{
@@ -254,14 +223,14 @@ class StickerWorker extends SiteWorker {
'-b:v', '128k',
'-movflags', '+faststart',
'-an',
- job.data.outFilePath,
+ 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.outFilePath);
+ 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`;
@@ -269,7 +238,7 @@ class StickerWorker extends SiteWorker {
this.jobLog(job, 'uploading encoded media file');
await minioService.uploadFile({
bucket, key,
- filePath: job.data.outFilePath,
+ filePath: job.data.procFilePath,
metadata: {
'Content-Type': 'video/mp4',
'Content-Length': job.data.outFileStat.size,
@@ -278,7 +247,6 @@ class StickerWorker extends SiteWorker {
this.jobLog(job, 'updating Sticker to live status');
- const Sticker = mongoose.model('Sticker');
await Sticker.updateOne(
{ _id: job.data.sticker._id },
{
@@ -294,63 +262,6 @@ class StickerWorker extends SiteWorker {
},
);
}
-
- async processStickerDelete (job) {
- const { minio: minioService, sticker: stickerService } = this.dtp.services;
- const Sticker = mongoose.model('Sticker');
- try {
- const sticker = await stickerService.getById(job.data.stickerId, true);
-
- this.log.info('removing original media', { stickerId: sticker._id, slug: sticker.slug });
- await minioService.removeObject(sticker.original.bucket, sticker.original.key);
-
- if (sticker.encoded) {
- this.log.info('removing encoded media', { stickerId: sticker._id, slug: sticker.slug });
- await minioService.removeObject(sticker.encoded.bucket, sticker.encoded.key);
- }
-
- this.log.info('removing sticker', { stickerId: sticker._id, slug: sticker.slug });
- await Sticker.deleteOne({ _id: sticker._id });
- } catch (error) {
- this.log.error('failed to delete sticker', { stickerId: job.data.stickerId, error });
- throw error; // for job report
- }
- }
-
- async jobLog (job, message, data = { }) {
- job.log(message);
- this.log.info(message, { jobId: job.id, ...data });
- }
}
-(async ( ) => {
- try {
- module.log = new SiteLog(module, module.config.component);
-
- /*
- * Platform startup
- */
- await SitePlatform.startPlatform(module, module.config.component);
-
- module.worker = new StickerWorker(module);
- await module.worker.start();
-
- /*
- * Worker startup
- */
-
- if (process.argv[2]) {
- const stickerId = mongoose.Types.ObjectId(process.argv[2]);
- this.log.info('creating sticker processing job', { stickerId });
- await module.worker.stickerProcessingQueue.add('sticker-ingest', { stickerId });
- }
-
- module.log.info(`${module.pkg.name} v${module.pkg.version} ${module.config.component.name} started`);
- } catch (error) {
- module.log.error('failed to start worker', {
- component: module.config.component,
- error,
- });
- process.exit(-1);
- }
-})();
\ No newline at end of file
+module.exports = StickerIngestJob;
\ No newline at end of file
diff --git a/app/workers/reeeper.js b/app/workers/reeeper.js
index 2103a9c..57d263d 100644
--- a/app/workers/reeeper.js
+++ b/app/workers/reeeper.js
@@ -8,15 +8,11 @@ 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');
-
module.rootPath = path.resolve(__dirname, '..', '..');
module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json'));
@@ -36,39 +32,15 @@ class ReeeperWorker extends SiteWorker {
}
async start ( ) {
- const CRON_TIMEZONE = 'America/New_York';
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.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-crashed-hosts.js b/app/workers/reeeper/cron/expire-crashed-hosts.js
new file mode 100644
index 0000000..e85940f
--- /dev/null
+++ b/app/workers/reeeper/cron/expire-crashed-hosts.js
@@ -0,0 +1,75 @@
+// 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'));
+
+/**
+ * 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 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/client/js/index.js b/client/js/index.js
index 31619cc..fc6399b 100644
--- a/client/js/index.js
+++ b/client/js/index.js
@@ -37,10 +37,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-app.js b/client/js/site-app.js
index 7ccf1c6..e8a51d9 100644
--- a/client/js/site-app.js
+++ b/client/js/site-app.js
@@ -13,7 +13,6 @@ import UIkit from 'uikit';
import QRCode from 'qrcode';
import Cropper from 'cropperjs';
-import { EmojiButton } from '@joeattardi/emoji-button';
import SiteChat from './site-chat';
const GRID_COLOR = 'rgb(64, 64, 64)';
@@ -42,12 +41,8 @@ export default class DtpSiteApp extends DtpApp {
isIOS: this.isIOS,
});
- this.emojiPicker = new EmojiButton({ theme: 'dark' });
- this.emojiPicker.on('emoji', this.onEmojiSelected.bind(this));
-
this.chat = new SiteChat(this);
-
- this.charts = {/* will hold rendered charts */};
+ this.charts = { /* will hold rendered charts */ };
this.scrollToHash();
}
@@ -69,19 +64,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));
- socket.on('user-react', this.onUserReact.bind(this));
+ 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 onUserChat (message) {
- await this.chat.appendUserChat(message);
- }
-
- async onUserReact (message) {
- await this.chat.createEmojiReact(message);
- }
-
async goBack ( ) {
if (document.referrer && (document.referrer.indexOf(`://${window.dtp.domain}`) >= 0)) {
window.history.back();
@@ -91,38 +79,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();
@@ -460,19 +416,6 @@ export default class DtpSiteApp extends DtpApp {
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);
- if (!this.emojiTargetElement) {
- return UIkit.modal.alert('Emoji picker target element does not exist');
- }
- this.emojiPicker.togglePicker(this.emojiTargetElement);
- }
-
- async onEmojiSelected (selection) {
- this.emojiTargetElement.value += selection.emoji;
- }
-
async showReportCommentForm (event) {
event.preventDefault();
event.stopPropagation();
diff --git a/client/js/site-chat.js b/client/js/site-chat.js
index 0a345e0..22e38ce 100644
--- a/client/js/site-chat.js
+++ b/client/js/site-chat.js
@@ -11,7 +11,9 @@ 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 {
@@ -20,11 +22,13 @@ export default class SiteChat {
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,
};
@@ -39,10 +43,19 @@ export default class SiteChat {
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) {
@@ -155,8 +168,6 @@ export default class SiteChat {
async appendUserChat (message) {
const isAtBottom = this.ui.isAtBottom;
-
- this.log.info('appendUserChat', 'message received', { user: message.user, content: message.content });
if (this.mutedUsers.find((block) => block.userId === message.user._id)) {
this.log.info('appendUserChat', 'message is from blocked user', {
_id: message.user._id,
@@ -165,123 +176,13 @@ export default class SiteChat {
return; // sender is blocked by local user on this device
}
- const chatMessage = document.createElement('div');
- chatMessage.setAttribute('data-message-id', message._id);
- chatMessage.setAttribute('data-author-id', message.user._id);
- chatMessage.classList.add('uk-margin-small');
- chatMessage.classList.add('chat-message');
-
- const userGrid = document.createElement('div');
- userGrid.setAttribute('uk-grid', '');
- userGrid.classList.add('uk-grid-small');
- userGrid.classList.add('uk-flex-middle');
- chatMessage.appendChild(userGrid);
-
- const usernameColumn = document.createElement('div');
- usernameColumn.classList.add('uk-width-expand');
- userGrid.appendChild(usernameColumn);
-
- const chatUser = document.createElement('div');
- const authorName = message.user.displayName || message.user.username;
- chatUser.classList.add('uk-text-small');
- chatUser.classList.add('chat-username');
- chatUser.textContent = authorName;
- usernameColumn.appendChild(chatUser);
-
- if (message.user.picture && message.user.picture.small) {
- const chatUserPictureColumn = document.createElement('div');
- chatUserPictureColumn.classList.add('uk-width-auto');
- userGrid.appendChild(chatUserPictureColumn);
-
- const chatUserPicture = document.createElement('img');
- chatUserPicture.classList.add('chat-author-image');
- chatUserPicture.setAttribute('src', `/image/${message.user.picture.small._id}`);
- chatUserPicture.setAttribute('alt', `${authorName}'s profile picture`);
- chatUserPictureColumn.appendChild(chatUserPicture);
- }
-
- if (dtp.user && (dtp.user._id !== message.user._id)) {
- const menuColumn = document.createElement('div');
- menuColumn.classList.add('uk-width-auto');
- menuColumn.classList.add('chat-user-menu');
- userGrid.appendChild(menuColumn);
-
- const menuButton = document.createElement('button');
- menuButton.setAttribute('type', 'button');
- menuButton.classList.add('uk-button');
- menuButton.classList.add('uk-button-link');
- menuButton.classList.add('uk-button-small');
- menuColumn.appendChild(menuButton);
-
- const menuIcon = document.createElement('i');
- menuIcon.classList.add('fas');
- menuIcon.classList.add('fa-ellipsis-h');
- menuButton.appendChild(menuIcon);
-
- const menuDropdown = document.createElement('div');
- menuDropdown.setAttribute('data-message-id', message._id);
- menuDropdown.setAttribute('uk-dropdown', 'mode: click');
- menuColumn.appendChild(menuDropdown);
-
- const dropdownList = document.createElement('ul');
- dropdownList.classList.add('uk-nav');
- dropdownList.classList.add('uk-dropdown-nav');
- menuDropdown.appendChild(dropdownList);
-
- let dropdownListItem = document.createElement('li');
- dropdownList.appendChild(dropdownListItem);
-
- let link = document.createElement('a');
- link.setAttribute('href', '');
- link.setAttribute('data-message-id', message._id);
- link.setAttribute('data-user-id', message.user._id);
- link.setAttribute('data-username', message.user.username);
- link.setAttribute('onclick', "return dtp.app.muteChatUser(event);");
- link.textContent = `Mute ${message.user.displayName || message.user.username}`;
- dropdownListItem.appendChild(link);
- }
-
- const chatContent = document.createElement('div');
- chatContent.classList.add('chat-content');
- chatContent.classList.add('uk-text-break');
- chatContent.innerHTML = message.content;
- chatMessage.appendChild(chatContent);
-
- const chatTimestamp = document.createElement('div');
- chatTimestamp.classList.add('chat-timestamp');
- chatTimestamp.classList.add('uk-text-small');
- chatTimestamp.textContent = moment(message.created).format('hh:mm:ss a');
- chatMessage.appendChild(chatTimestamp);
-
- if (Array.isArray(message.stickers) && message.stickers.length) {
- message.stickers.forEach((sticker) => {
- const chatContent = document.createElement('div');
- chatContent.classList.add('chat-sticker');
- chatContent.setAttribute('title', `:${sticker.slug}:`);
- chatContent.setAttribute('data-sticker-id', sticker._id);
- switch (sticker.encoded.type) {
- case 'video/mp4':
- chatContent.innerHTML = ``;
- break;
- case 'image/png':
- chatContent.innerHTML = `
`;
- break;
- case 'image/jpeg':
- chatContent.innerHTML = `
`;
- break;
- }
- chatMessage.appendChild(chatContent);
- });
- }
+ const fragment = document.createDocumentFragment();
+ fragment.innerHTML = message.html;
this.ui.isModifying = true;
- this.ui.messageList.appendChild(chatMessage);
- this.ui.messages.push(chatMessage);
-
- while (this.ui.messages.length > 50) {
- const message = this.ui.messages.shift();
- this.ui.messageList.removeChild(message);
- }
+ this.ui.messageList.insertAdjacentHTML('beforeend', message.html);
+ this.trimMessages();
+ this.updateTimestamps();
if (isAtBottom) {
/*
@@ -299,16 +200,54 @@ export default class SiteChat {
}
}
+ 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('div.chat-timestamp[data-created]');
+ const timestamps = document.querySelectorAll('[data-dtp-timestamp]');
timestamps.forEach((timestamp) => {
- const created = timestamp.getAttribute('data-created');
- timestamp.textContent = moment(created).format('hh:mm:ss a');
+ 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);
+ this.triggerEmojiExplosion();
}
triggerEmojiExplosion ( ) {
@@ -344,4 +283,70 @@ export default class SiteChat {
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/less/site/button.less b/client/less/site/button.less
index 803bc65..b29f555 100644
--- a/client/less/site/button.less
+++ b/client/less/site/button.less
@@ -151,13 +151,25 @@ button.uk-button.dtp-button-danger {
}
.dtp-button-reaction {
- background: none;
+ 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 {
- font-size: 18px;
+ display: inline-block;
+ width: 32px;
+ font-size: inherit;
+ line-height: inherit;
}
span.count-label {
diff --git a/client/less/site/chat.less b/client/less/site/chat.less
index 1928942..b227219 100644
--- a/client/less/site/chat.less
+++ b/client/less/site/chat.less
@@ -1,21 +1,78 @@
+@emoji-picker-width: 380px;
+@emoji-picker-height: 452px;
+
.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 {
- align-self: stretch;
- overflow: scroll;
+ overflow: auto;
+ background-color: @content-container-color;
.chat-menubar {
- padding: 10px @grid-small-gutter-horizontal;
+ 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-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 {
@@ -30,15 +87,21 @@
}
}
- #chat-message-list-wrapper {
+ .chat-content-wrapper {
position: relative;
flex: 1;
+ }
+
+ #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;
@@ -60,20 +123,56 @@
scroll-behavior: auto;
&::-webkit-scrollbar {
- display: none; /* Chrome */
+ width: 10px;
+ border: none;
+ outline: none;
+ padding: 8px 0;
+ }
+
+ &::-webkit-scrollbar-track {
+ background: transparent;
+ border-left: solid 1px @scrollbar-border-color;
}
- scrollbar-width: none; /* Firefox */
+ &::-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 {
- color: var(--dtp-chat-color);
- background: var(--dtp-chat-background);
+ 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);
- margin: 5px @grid-small-gutter-horizontal;
&.system-message {
- background: rgba(255,255,255, 0.1);
+ 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 {
@@ -85,7 +184,7 @@
img.chat-author-image {
width: auto;
- height: 2em;
+ height: 40px;
border-radius: 4px;
}
@@ -159,78 +258,4 @@
}
}
}
-}
-
-/*
- * OBS Chat Widget specializations
- */
-
-body[data-obs-widget="chat"] {
-
-.site-player-view {
-
- #site-chat-container {
-
- #chat-message-list-wrapper {
- background-color: transparent;
-
- #chat-reactions {
- background-color: transparent;
- }
- }
- }
-}
-}
-
-/*
- * Mobile view layout
- */
-@media screen and (max-width: 959px) {
-
- body[data-current-view="channel-broadcast"],
- body[data-current-view="dvr-player"] {
- position: fixed;
- top: 0; right: 0; bottom: 0; left: 0;
- padding: 0;
- margin: 0;
- width: 100%;
- height: 100%;
-
- .site-player-view {
- position: absolute;
- top: 64px; right: 0; bottom: 0; left: 0;
- overflow: hidden;
-
- display: flex;
- flex-direction: column;
- flex-wrap: nowrap;
-
- #site-video-container {
- flex: 0;
- }
-
- #site-chat-container {
- position: relative;
- flex: 1;
-
- #chat-message-list {
- flex: 1;
- }
-
- #chat-input-form {
- flex: 0;
- }
- }
- }
- }
-}
-
-body[data-obs-widget="chat"] {
-
- .chat-message {
-
- .chat-user-menu {
- display: none;
- }
- }
}
\ 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 f2a7ad4..c37831c 100644
--- a/client/less/site/main.less
+++ b/client/less/site/main.less
@@ -6,6 +6,7 @@ html, body {
body {
padding-top: @site-navbar-height;
+ background-color: @page-background-color;
&[data-current-view="chat"] {
position: fixed;
@@ -56,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 67f8f85..1fb3b31 100644
--- a/config/job-queues.js
+++ b/config/job-queues.js
@@ -5,8 +5,16 @@
'use strict';
module.exports = {
+ 'media': {
+ attempts: 3,
+ removeOnComplete: true,
+ },
'newsletter': {
attempts: 3,
- removeOnComplete: false,
+ removeOnComplete: true,
+ },
+ 'reeeper': {
+ attempts: 3,
+ removeOnComplete: true,
},
};
\ No newline at end of file
diff --git a/config/limiter.js b/config/limiter.js
index 1fb8146..e9676fd 100644
--- a/config/limiter.js
+++ b/config/limiter.js
@@ -55,6 +55,16 @@ 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,
@@ -65,16 +75,46 @@ module.exports = {
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: {
@@ -117,6 +157,14 @@ module.exports = {
},
},
+ form: {
+ getForm: {
+ total: 20,
+ expire: ONE_MINUTE,
+ message: "You are requesting forms too quickly.",
+ },
+ },
+
/*
* HomeController
*/
@@ -158,6 +206,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 ae3f5d5..880055c 100644
--- a/config/reserved-names.js
+++ b/config/reserved-names.js
@@ -24,6 +24,7 @@ module.exports = [
'manifest.json',
'moment',
'newsletter',
+ 'notification',
'numeral',
'socket.io',
'uikit',
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/site-common.js b/lib/site-common.js
index 10cc8c6..fd363ff 100644
--- a/lib/site-common.js
+++ b/lib/site-common.js
@@ -8,6 +8,7 @@ const path = require('path');
const pug = require('pug');
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 +21,35 @@ 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) {
+ 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 +86,18 @@ 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);
+ }
}
module.exports.SiteCommon = SiteCommon;
\ No newline at end of file
diff --git a/lib/site-ioserver.js b/lib/site-ioserver.js
index 5a1f80f..49bfdec 100644
--- a/lib/site-ioserver.js
+++ b/lib/site-ioserver.js
@@ -12,7 +12,6 @@ const Redis = require('ioredis');
const mongoose = require('mongoose');
const ConnectToken = mongoose.model('ConnectToken');
-const striptags = require('striptags');
const marked = require('marked');
const { SiteLog } = require(path.join(__dirname, 'site-log'));
@@ -30,8 +29,6 @@ class SiteIoServer extends Events {
const { Server } = require('socket.io');
const { createAdapter } = require('@socket.io/redis-adapter');
- this.createRateLimiters();
-
this.markedRenderer = new marked.Renderer();
this.markedRenderer.link = (href, title, text) => { return text; };
this.markedRenderer.image = (href, title, text) => { return text; };
@@ -72,37 +69,6 @@ class SiteIoServer extends Events {
this.io.on('connection', this.onSocketConnect.bind(this));
}
- createRateLimiters ( ) {
- const { RateLimiterRedis } = require('rate-limiter-flexible');
-
- const rateLimiterRedisClient = new Redis({
- host: process.env.REDIS_HOST,
- port: process.env.REDIS_PORT,
- password: process.env.REDIS_PASSWORD,
- key: process.env.REDIS_KEY_PREFIX || 'dtp',
- enableOfflineQueue: false,
- lazyConnect: false,
- });
-
- this.chatMessageLimiter = new RateLimiterRedis({
- storeClient: rateLimiterRedisClient,
- points: 20,
- duration: 60,
- blockDuration: 60 * 3,
- execEvenly: false,
- keyPrefix: 'rl:chatmsg',
- });
-
- this.reactionLimiter = new RateLimiterRedis({
- storeClient: rateLimiterRedisClient,
- points: 60,
- duration: 60,
- blockDuration: 60 * 3,
- execEvenly: false,
- keyPrefix: 'rl:react',
- });
- }
-
async onRedisError (error) {
this.log.error('Redis error', { error });
}
@@ -149,6 +115,7 @@ class SiteIoServer extends Events {
created: token.user.created,
username: token.user.username,
displayName: token.user.displayName,
+ picture: token.user.picture,
},
socket,
};
@@ -192,133 +159,30 @@ class SiteIoServer extends Events {
}
async onUserChat (session, messageDefinition) {
- const { chat: chatService, user: userService } = this.dtp.services;
- const channelId = messageDefinition.channel;
+ const { chat: chatService } = this.dtp.services;
if (!messageDefinition.content || (messageDefinition.content.length === 0)) {
- this.log.info('dropping empty chat message');
- return;
- }
-
- /*
- * First, implement the rate limiter check. If rate-limited, abort all
- * further processing. Store nothing in the database. Send nothing to the
- * chat room.
- */
- try {
- const userKey = session.user._id.toString();
- await this.chatMessageLimiter.consume(userKey, 1);
- } catch (rateLimiter) {
- const NOW = new Date();
- if (!session.notifySpamMuzzle) {
- this.log.alert('preventing chat spam', { userId: session.user._id, rateLimiter });
- session.socket.to(channelId).emit('system-message', {
- created: NOW,
- content: `${session.user.displayName || session.user.username} has been muted for a while.`,
- });
- session.notifySpamMuzzle = true;
- }
- session.socket.emit('system-message', {
- created: NOW,
- content: `You are rate limited for ${numeral(rateLimiter.msBeforeNext / 1000.0).format('0,0.0')} seconds.`,
- rateLimiter,
- });
return;
}
- /*
- * Pull the author's current User record from the db and verify that they
- * have permission to chat. This read must happen with every chat message
- * until permission update notifications are implemented on Redis pub/sub.
- */
- try {
- const userCheck = await userService.getUserAccount(session.user._id);
- if (!userCheck || !userCheck.permissions || !userCheck.permissions.canChat) {
- session.socket.emit('system-message', {
- created: new Date(),
- content: `You are not allowed to chat on ${this.dtp.config.site.name}.`,
- });
- return; // permission denied
- }
-
- //TODO: Forked apps may want to implement channel-level moderation, and
- // this is where to implement those checks.
-
- } catch (error) {
- this.log.error('failed to implement user permissions check', { userId: session.user._id, error });
- return; // can't verify permissions? No chat for you.
- }
-
try {
const { message, payload } = await chatService.createMessage(session.user, messageDefinition);
- if (message.analysis.similarity > 0.9) {
- await chatService.sendSystemMessage(
- session.socket,
- "Your flow feels a little spammy, so that one didn't go through.",
- { type: 'warning' },
- );
- return;
- }
-
- // use chat service emitter to deliver to channel (more efficient)
- // than socket.io API
+ message.author = session.user;
+ payload.html = await chatService.renderTemplate('chatMessage', { user: session.user, message });
await chatService.sendMessage(message.channel, 'user-chat', payload);
-
- // use the socket itself to emit back to the sender
- session.socket.emit('user-chat', payload);
-
- session.notifySpamMuzzle = false;
} catch (error) {
this.log.error('failed to process user chat message', { error });
- await chatService.sendSystemMessage(
- session.socket,
- `Failed to send chat: ${error.message}`,
- { type: 'error' },
- );
+ await chatService.sendSystemMessage(`Failed to send chat: ${error.message}`, {
+ type: 'error',
+ userId: session.user._id.toString(),
+ });
return;
}
}
- 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, user: userService } = this.dtp.services;
+ const { chat: chatService } = this.dtp.services;
try {
- const userCheck = await userService.getUserAccount(session.user._id);
- if (!userCheck || !userCheck.permissions || !userCheck.permissions.canChat) {
- session.socket.emit('system-message', {
- created: new Date(),
- content: `You are not allowed to chat on ${this.dtp.config.site.name}.`,
- });
- return; // permission denied
- }
-
- try {
- const userKey = session.user._id.toString();
- await this.reactionLimiter.consume(userKey, 1);
- } catch (error) {
- return; // rate-limited
- }
-
const reaction = await chatService.createEmojiReaction(session.user, message);
reaction.user = session.user;
@@ -329,6 +193,10 @@ class SiteIoServer extends Events {
session.socket.emit('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 49bfc4e..ed5b587 100644
--- a/lib/site-platform.js
+++ b/lib/site-platform.js
@@ -319,6 +319,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..9b79951 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,46 @@ 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('registering worker processor', { component: COMPONENT });
+ 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) => {
+ this.log.info('starting worker processor', { slug });
+ await this.processors[slug].start();
+ }, 1);
+ }
+ /**
+ * Stops any running child processors and terminates the worker.
+ */
+ async stop ( ) {
+ const slugs = Object.keys(this.processors);
+ await SiteAsync.each(slugs, async (slug) => {
+ this.log.info('stopping worker processor', { slug });
+ await this.processors[slug].stop();
+ }, 1);
}
}
diff --git a/package.json b/package.json
index 704edb5..c63e1f4 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",
@@ -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",
@@ -63,6 +62,7 @@
"passport-oauth2": "^1.6.1",
"passport-oauth2-client-password": "^0.1.2",
"password-generator": "^2.3.2",
+ "picmo": "^5.4.0",
"pug": "^3.0.2",
"qrcode": "^1.5.0",
"rate-limiter-flexible": "^2.3.6",
diff --git a/yarn.lock b/yarn.lock
index fb0aa53..91ce3f5 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"
@@ -2835,6 +2787,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"
@@ -3198,6 +3157,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"
@@ -3401,7 +3365,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=
@@ -3800,14 +3764,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"
@@ -3870,15 +3826,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"
@@ -3920,11 +3867,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"
@@ -4593,6 +4535,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"
@@ -5206,22 +5163,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"
@@ -6492,6 +6433,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"
@@ -8063,11 +8011,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"
@@ -8194,11 +8137,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"
@@ -8316,7 +8254,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==
@@ -8328,21 +8266,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"
@@ -9201,7 +9124,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==