Browse Source

this description of this changelist is incomplete and this is fine

- Integrated all chat updates from Soapbox/Shing with heavy mods to
lib/site-ioserver.js and the creation of the chat service, worker, and
jobs
- Added ability to create ChatRoom instances, invite people to them,
join them and delete them
- Refactored the shit out of SiteWorker
- Created SiteWorkerProcess
- Created the chat worker and the chat-room-clear and chat-room-delete
job processors
- Created the media worker
- Refactored Stickers from Soapbox/Shing into the media system
- Created the Attachment model, service, and media worker jobs
- Upgraded the emoji picker from emoji-button to picmo because the
author depreacted emoji-button and released picmo.
- Made a custom presentation for the emoji picker
- Created the SiteChat client-side object for managing Core Chat within
the browser
- Brought the Kaleidoscope Event UI down from DTP Social so anything can
render a timeline of them
- Added configurations for the media and reeeper job queues
- Added the basics of a Notifications view
- Added the concept of Forms
- upgraded ioredis to 5.2.2
develop^2
Rob Colbert 2 years ago
parent
commit
e3142c1271
  1. 2
      .env.default
  2. 14
      app/controllers/auth.js
  3. 284
      app/controllers/chat.js
  4. 4
      app/controllers/email.js
  5. 70
      app/controllers/form.js
  6. 2
      app/controllers/home.js
  7. 4
      app/controllers/image.js
  8. 2
      app/controllers/manifest.js
  9. 4
      app/controllers/newsletter.js
  10. 78
      app/controllers/notification.js
  11. 27
      app/controllers/user.js
  12. 2
      app/controllers/welcome.js
  13. 64
      app/models/attachment.js
  14. 3
      app/models/chat-message.js
  15. 13
      app/models/chat-room-invite.js
  16. 2
      app/models/chat-room.js
  17. 12
      app/models/user-notification.js
  18. 186
      app/services/attachment.js
  19. 299
      app/services/chat.js
  20. 2
      app/services/comment.js
  21. 2
      app/services/content-report.js
  22. 1
      app/services/content-vote.js
  23. 272
      app/services/core-node.js
  24. 7
      app/services/display-engine.js
  25. 1
      app/services/host-cache.js
  26. 2
      app/services/image.js
  27. 4
      app/services/job-queue.js
  28. 18
      app/services/limiter.js
  29. 1
      app/services/markdown.js
  30. 1
      app/services/minio.js
  31. 2
      app/services/oauth2.js
  32. 3
      app/services/otp-auth.js
  33. 2
      app/services/session.js
  34. 2
      app/services/sms.js
  35. 24
      app/services/sticker.js
  36. 94
      app/services/user-notification.js
  37. 37
      app/services/user.js
  38. 70
      app/views/chat/components/input-form.pug
  39. 0
      app/views/chat/components/menubar.pug
  40. 4
      app/views/chat/components/message-standalone.pug
  41. 52
      app/views/chat/components/message.pug
  42. 2
      app/views/chat/components/reaction-button.pug
  43. 4
      app/views/chat/components/room-list.pug
  44. 12
      app/views/chat/components/user-list-entry.pug
  45. 24
      app/views/chat/index.pug
  46. 46
      app/views/chat/layouts/room.pug
  47. 31
      app/views/chat/room/editor.pug
  48. 22
      app/views/chat/room/form/invite-member.pug
  49. 29
      app/views/chat/room/index.pug
  50. 25
      app/views/chat/room/invite/components/invite-list-item.pug
  51. 6
      app/views/chat/room/invite/components/invite-list.pug
  52. 34
      app/views/chat/room/invite/index.pug
  53. 59
      app/views/chat/room/invite/view.pug
  54. 132
      app/views/chat/room/view.pug
  55. 3
      app/views/components/button-icon.pug
  56. 7
      app/views/components/library.pug
  57. 9
      app/views/components/navbar.pug
  58. 43
      app/views/kaleidoscope/components/event.pug
  59. 4
      app/views/layouts/main.pug
  60. 14
      app/views/notification/index.pug
  61. 8
      app/views/user/components/attribution-header.pug
  62. 30
      app/views/user/components/profile-icon.pug
  63. 61
      app/workers/chat/job/chat-room-clear.js
  64. 102
      app/workers/chat/job/chat-room-delete.js
  65. 86
      app/workers/media.js
  66. 72
      app/workers/media/job/attachment-delete.js
  67. 270
      app/workers/media/job/attachment-ingest.js
  68. 62
      app/workers/media/job/sticker-delete.js
  69. 165
      app/workers/media/job/sticker-ingest.js
  70. 32
      app/workers/reeeper.js
  71. 75
      app/workers/reeeper/cron/expire-crashed-hosts.js
  72. 8
      client/js/index.js
  73. 65
      client/js/site-app.js
  74. 245
      client/js/site-chat.js
  75. 16
      client/less/site/button.less
  76. 195
      client/less/site/chat.less
  77. 2
      client/less/site/content.less
  78. 2
      client/less/site/image.less
  79. 48
      client/less/site/kaleidoscope-event.less
  80. 5
      client/less/site/main.less
  81. 11
      client/less/site/uikit-theme.dtp-dark.less
  82. 6
      client/less/site/uikit-theme.dtp-light.less
  83. 1
      client/less/style.common.less
  84. 10
      config/job-queues.js
  85. 64
      config/limiter.js
  86. 1
      config/reserved-names.js
  87. 55
      lib/client/js/dtp-app.js
  88. 14
      lib/client/js/dtp-display-engine.js
  89. 42
      lib/site-common.js
  90. 158
      lib/site-ioserver.js
  91. 1
      lib/site-lib.js
  92. 1
      lib/site-platform.js
  93. 47
      lib/site-worker-process.js
  94. 50
      lib/site-worker.js
  95. 4
      package.json
  96. 159
      yarn.lock

2
.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

14
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),
);

284
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);
}
}
}

4
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),
);

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

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

4
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),
);

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

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

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

27
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) {

2
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'));

64
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);

3
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);

13
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);

2
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'];

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

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

299
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;

2
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'));

2
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'));
}

1
app/services/content-vote.js

@ -18,6 +18,7 @@ class ContentVoteService extends SiteService {
}
async start ( ) {
await super.start();
this.emitter = ioEmitter(this.dtp.redis);
}

272
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) {

7
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',

1
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) {

2
app/services/image.js

@ -1,4 +1,4 @@
// minio.js
// image.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0

4
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.

18
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 = {

1
app/services/markdown.js

@ -17,6 +17,7 @@ class MarkdownService extends SiteService {
}
async start ( ) {
await super.start();
this.markedRenderer = new marked.Renderer();
}

1
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) {

2
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 });

3
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,

2
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 ( ) {

2
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) {

24
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) {

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

37
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();

70
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
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")

0
app/views/chat/components/menubar.pug

4
app/views/chat/components/message-standalone.pug

@ -0,0 +1,4 @@
include ../../user/components/profile-icon
include message
+renderChatMessage(message)

52
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

2
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

4
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

12
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

24
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
.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)

46
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)};
if Array.isArray(room.members) && (room.members.length > 0)
li.uk-nav-header Members
each membership in room.members
li
+renderUserListEntry(membership.member, 'member')

31
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
button(type="submit").uk-button.uk-button-primary.uk-border-rounded #{room ? 'Update' : 'Create'} room

22
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

29
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)

25
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

6
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)

34
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()

59
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

132
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)
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)

3
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
if buttonLabel
span(class="uk-visible@m").uk-margin-small-left= buttonLabel

7
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

9
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

43
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

4
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")

14
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

8
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

30
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
)

61
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;

102
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;

86
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);
}
})();

72
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;

270
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;

62
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;

165
app/workers/stickers.js → 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);
}
})();
module.exports = StickerIngestJob;

32
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 ( ) => {

75
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;

8
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}`);
}
}
});

65
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();

245
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 = `<video playsinline autoplay muted loop preload="auto"><source src="/sticker/${sticker._id}/media"></source></video>`;
break;
case 'image/png':
chatContent.innerHTML = `<img src="/sticker/${sticker._id}/media" height="100" />`;
break;
case 'image/jpeg':
chatContent.innerHTML = `<img src="/sticker/${sticker._id}/media" height="100" />`;
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;
}
}
}

16
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 {

195
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;
}
}
}

2
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 {

2
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;
}

48
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;
}
}

5
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;
}

11
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;
}

6
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
//

1
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";

10
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,
},
};

64
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
*/

1
config/reserved-names.js

@ -24,6 +24,7 @@ module.exports = [
'manifest.json',
'moment',
'newsletter',
'notification',
'numeral',
'socket.io',
'uikit',

55
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) {

14
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) {

42
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;

158
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;
}
}

1
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,
};

1
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

47
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;

50
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);
}
}

4
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",

159
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 @@ [email protected]:
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"
"[email protected] - 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"
[email protected]:
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==

Loading…
Cancel
Save