Browse Source

Merge branch 'develop' of git.digitaltelepresence.com:digital-telepresence/dtp-base into develop

develop
Rob Colbert 2 years ago
parent
commit
108428a198
  1. 56
      .env.default
  2. 2
      README.md
  3. 2
      app/controllers/admin/announcement.js
  4. 5
      app/controllers/admin/content-report.js
  5. 2
      app/controllers/admin/core-node.js
  6. 2
      app/controllers/admin/core-user.js
  7. 2
      app/controllers/admin/job-queue.js
  8. 19
      app/controllers/admin/service-node.js
  9. 23
      app/controllers/announcement.js
  10. 18
      app/controllers/auth.js
  11. 413
      app/controllers/chat.js
  12. 157
      app/controllers/comment.js
  13. 4
      app/controllers/email.js
  14. 70
      app/controllers/form.js
  15. 2
      app/controllers/home.js
  16. 8
      app/controllers/image.js
  17. 2
      app/controllers/manifest.js
  18. 7
      app/controllers/newsletter.js
  19. 78
      app/controllers/notification.js
  20. 31
      app/controllers/user.js
  21. 2
      app/controllers/welcome.js
  22. 11
      app/models/announcement.js
  23. 64
      app/models/attachment.js
  24. 19
      app/models/chat-message.js
  25. 30
      app/models/chat-room-invite.js
  26. 44
      app/models/chat-room.js
  27. 12
      app/models/comment.js
  28. 2
      app/models/core-user.js
  29. 39
      app/models/emoji-reaction.js
  30. 13
      app/models/lib/resource-stats.js
  31. 6
      app/models/resource-view.js
  32. 46
      app/models/sticker.js
  33. 12
      app/models/user-notification.js
  34. 2
      app/models/user.js
  35. 5
      app/services/announcement.js
  36. 186
      app/services/attachment.js
  37. 733
      app/services/chat.js
  38. 53
      app/services/comment.js
  39. 2
      app/services/content-report.js
  40. 21
      app/services/content-vote.js
  41. 270
      app/services/core-node.js
  42. 7
      app/services/display-engine.js
  43. 1
      app/services/host-cache.js
  44. 2
      app/services/image.js
  45. 18
      app/services/limiter.js
  46. 1
      app/services/markdown.js
  47. 1
      app/services/minio.js
  48. 11
      app/services/oauth2.js
  49. 3
      app/services/otp-auth.js
  50. 2
      app/services/session.js
  51. 2
      app/services/sms.js
  52. 204
      app/services/sticker.js
  53. 94
      app/services/user-notification.js
  54. 39
      app/services/user.js
  55. 19
      app/views/admin/service-node/editor.pug
  56. 14
      app/views/announcement/components/announcement.pug
  57. 11
      app/views/announcement/view.pug
  58. 151
      app/views/chat/components/input-form.pug
  59. 4
      app/views/chat/components/message-standalone.pug
  60. 41
      app/views/chat/components/message.pug
  61. 8
      app/views/chat/components/reaction-button.pug
  62. 17
      app/views/chat/components/room-list.pug
  63. 12
      app/views/chat/components/user-list-entry.pug
  64. 22
      app/views/chat/index.pug
  65. 39
      app/views/chat/layouts/room.pug
  66. 66
      app/views/chat/room/editor.pug
  67. 22
      app/views/chat/room/form/invite-member.pug
  68. 29
      app/views/chat/room/index.pug
  69. 25
      app/views/chat/room/invite/components/invite-list-item.pug
  70. 6
      app/views/chat/room/invite/components/invite-list.pug
  71. 34
      app/views/chat/room/invite/index.pug
  72. 59
      app/views/chat/room/invite/view.pug
  73. 93
      app/views/chat/room/view.pug
  74. 2
      app/views/comment/components/comment-list-standalone.pug
  75. 9
      app/views/comment/components/comment-list.pug
  76. 2
      app/views/comment/components/comment-standalone.pug
  77. 31
      app/views/comment/components/comment.pug
  78. 29
      app/views/comment/components/composer.pug
  79. 2
      app/views/comment/components/reply-list-standalone.pug
  80. 38
      app/views/comment/components/section.pug
  81. 3
      app/views/components/button-icon.pug
  82. 7
      app/views/components/library.pug
  83. 9
      app/views/components/navbar.pug
  84. 12
      app/views/components/off-canvas.pug
  85. 43
      app/views/kaleidoscope/components/event.pug
  86. 14
      app/views/layouts/main.pug
  87. 14
      app/views/notification/index.pug
  88. 2
      app/views/sticker/components/sticker-standalone.pug
  89. 19
      app/views/sticker/components/sticker.pug
  90. 69
      app/views/sticker/index.pug
  91. 2
      app/views/sticker/menu.pug
  92. 26
      app/views/sticker/view.pug
  93. 8
      app/views/user/components/attribution-header.pug
  94. 30
      app/views/user/components/profile-icon.pug
  95. 66
      app/workers/chat.js
  96. 51
      app/workers/chat/job/chat-room-clear.js
  97. 102
      app/workers/chat/job/chat-room-delete.js
  98. 2
      app/workers/host-services.js
  99. 85
      app/workers/media.js
  100. 72
      app/workers/media/job/attachment-delete.js

56
.env.default

@ -9,6 +9,45 @@ DTP_SITE_DOMAIN_KEY=
DTP_SITE_COMPANY=Digital Telepresence, LLC
DTP_PASSWORD_SALT=
DTP_CORE_AUTH_SCHEME=http
DTP_CORE_AUTH_HOST=localhost:3000
DTP_CORE_AUTH_PASSWORD_LEN=64
DTP_IMAGE_WORK_PATH=/tmp/yourapp/image-work
DTP_VIDEO_WORK_PATH=/tmp/yourapp/video-work
DTP_STICKER_WORK_PATH=/tmp/yourapp/sticker-work
DTP_ATTACHMENT_WORK_PATH=/tmp/yourapp/attachment-work
#
# Set this to "enabled" to use NVIDIA GPU acceleration. Setting this to enabled
# without a properly-configured NVIDIA GPU will cause processing jobs to fail.
#
DTP_GPU_ACCELERATION=disabled
#
# Host Cache configuration
#
DTP_HOST_CACHE_PORT=8010
DTP_HOST_CACHE_PATH=/tmp/dtp-webapp/host-cache
DTP_HOST_CACHE_AUTH_KEY=daf3577a-2ab7-49d5-9b5a-d7e331241cde
DTP_HOST_CACHE_CLEAN_CRON=*/30 * * * * *
#
# Nodemailer SMTP Transport configuration
#
DTP_EMAIL_SERVICE=disabled
DTP_EMAIL_SMTP_HOST=
DTP_EMAIL_SMTP_PORT=465
DTP_EMAIL_SMTP_SECURE=disabled
DTP_EMAIL_SMTP_FROM=
DTP_EMAIL_SMTP_USER=
DTP_EMAIL_SMTP_PASS=
DTP_EMAIL_SMTP_POOL_ENABLED=enabled
DTP_EMAIL_SMTP_POOL_MAX_CONN=5
DTP_EMAIL_SMTP_POOL_MAX_MSGS=100
#
# Mailgun Configuration
#
@ -31,6 +70,7 @@ MONGODB_DATABASE=dtp-webapp
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_PREFIX=
#
# MinIO configuration
@ -39,10 +79,12 @@ REDIS_PASSWORD=
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=disabled
MINIO_ACCESS_KEY=dtp-webapp
MINIO_ACCESS_KEY=
MINIO_SECRET_KEY=
MINIO_IMAGE_BUCKET=webapp-images
MINIO_VIDEO_BUCKET=webapp-videos
MINIO_IMAGE_BUCKET=yourapp-images
MINIO_VIDEO_BUCKET=yourapp-videos
MINIO_ATTACHMENT_BUCKET=yourapp-attachments
#
# ExpressJS/HTTP configuration
@ -60,12 +102,12 @@ DTP_LOG_CONSOLE=enabled
DTP_LOG_MONGODB=enabled
DTP_LOG_FILE=enabled
DTP_LOG_FILE_PATH=/tmp/dtp-webapp/logs
DTP_LOG_FILE_NAME_APP=webapp-app.log
DTP_LOG_FILE_NAME_HTTP=webapp-access.log
DTP_LOG_FILE_PATH=/tmp/dtp-yourapp/logs
DTP_LOG_FILE_NAME_APP=yourapp-app.log
DTP_LOG_FILE_NAME_HTTP=yourapp-access.log
DTP_LOG_DEBUG=enabled
DTP_LOG_INFO=enabled
DTP_LOG_WARN=enabled
DTP_LOG_HTTP_FORMAT=combined
DTP_LOG_HTTP_FORMAT=combined

2
README.md

@ -117,4 +117,4 @@ Redis simply has many different documents to describe it's many different featur
## Software License
The DTP Social engine is licensed under the [Apache 2.0](https://spdx.org/licenses/Apache-2.0.html) open source software license. See [LICENSE](LICENSE) for more information.
DTP Social and the DTP Phoenix Engine and framework are licensed under the [Apache 2.0](https://spdx.org/licenses/Apache-2.0.html) open source software license. See [LICENSE](LICENSE) for more information.

2
app/controllers/admin/announcement.js

@ -87,7 +87,7 @@ class AnnouncementAdminController extends SiteController {
try {
const displayList = this.createDisplayList('delete-announcement');
await announcementService.remove(res.locals.announcement);
displayList.reloadView();
displayList.reload();
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to delete announcement', { error });

5
app/controllers/admin/content-report.js

@ -5,9 +5,8 @@
'use strict';
const express = require('express');
const multer = require('multer');
const { /*SiteError,*/ SiteController } = require('../../../lib/site-lib');
const { SiteController } = require('../../../lib/site-lib');
class ContentReportController extends SiteController {
@ -16,7 +15,7 @@ class ContentReportController extends SiteController {
}
async start ( ) {
const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/admin-content-report` });
const upload = this.createMulter();
const router = express.Router();
router.use(async (req, res, next) => {

2
app/controllers/admin/core-node.js

@ -16,8 +16,6 @@ class CoreNodeController extends SiteController {
}
async start ( ) {
// const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/upload` });
const router = express.Router();
router.use(async (req, res, next) => {
res.locals.currentView = 'admin';

2
app/controllers/admin/core-user.js

@ -16,8 +16,6 @@ class CoreUserController extends SiteController {
}
async start ( ) {
// const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/upload` });
const router = express.Router();
router.use(async (req, res, next) => {
res.locals.currentView = 'admin';

2
app/controllers/admin/job-queue.js

@ -1,6 +1,6 @@
// admin/job-queue.js
// Copyright (C) 2022 DTP Technologies, LLC
// All Rights Reserved
// License: Apache-2.0
'use strict';

19
app/controllers/admin/service-node.js

@ -32,6 +32,8 @@ class ServiceNodeController extends SiteController {
router.get('/:clientId', this.getClientView.bind(this));
router.get('/', this.getIndex.bind(this));
router.delete('/:clientId', this.deleteClient.bind(this));
return router;
}
@ -106,6 +108,23 @@ class ServiceNodeController extends SiteController {
return next(error);
}
}
async deleteClient (req, res) {
const { oauth2: oauth2Service } = this.dtp.services;
try {
await oauth2Service.removeClient(res.locals.serviceNode);
const displayList = this.createDisplayList('delete-newsletter');
displayList.navigateTo('/admin/service-node');
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to delete client', { clientId: res.locals.serviceNode._id, error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
}
module.exports = {

23
app/controllers/announcement.js

@ -15,11 +15,22 @@ class AnnouncementController extends SiteController {
}
async start ( ) {
const { comment: commentService } = this.dtp.services;
const router = express.Router();
this.dtp.app.use('/announcement', router);
const upload = this.createMulter();
router.use(async (req, res, next) => {
res.locals.currentView = 'announcement';
return next();
});
router.param('announcementId', this.populateAnnouncementId.bind(this));
router.post('/:announcementId/comment', upload.none(), commentService.commentCreateHandler('Announcement', 'announcement'));
router.get('/:announcementId', this.getAnnouncementView.bind(this));
router.get('/', this.getHome.bind(this));
@ -40,8 +51,16 @@ class AnnouncementController extends SiteController {
}
}
async getAnnouncementView (req, res) {
res.render('announcement/view');
async getAnnouncementView (req, res, next) {
const { comment: commentService } = this.dtp.services;
try {
res.locals.pagination = this.getPaginationParameters(req, 10);
res.locals.comments = await commentService.getForResource(res.locals.announcement, ['published'], res.locals.pagination);
res.render('announcement/view');
} catch (error) {
this.log.error('failed to render announcement view', { error });
return next(error);
}
}
async getHome (req, res, next) {

18
app/controllers/auth.js

@ -6,7 +6,6 @@
const express = require('express');
const mongoose = require('mongoose');
const multer = require('multer');
const passport = require('passport');
const uuidv4 = require('uuid').v4;
@ -25,7 +24,8 @@ class AuthController extends SiteController {
coreNode: coreNodeService,
limiter: limiterService,
} = this.dtp.services;
const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${this.component.slug}` });
const upload = this.createMulter();
const router = express.Router();
this.dtp.app.use('/auth', router);
@ -35,18 +35,18 @@ class AuthController extends SiteController {
router.post(
'/otp/enable',
limiterService.create(limiterService.config.auth.postOtpEnable),
limiterService.createMiddleware(limiterService.config.auth.postOtpEnable),
this.postOtpEnable.bind(this),
);
router.post(
'/otp/auth',
limiterService.create(limiterService.config.auth.postOtpAuthenticate),
limiterService.createMiddleware(limiterService.config.auth.postOtpAuthenticate),
this.postOtpAuthenticate.bind(this),
);
router.post(
'/login',
limiterService.create(limiterService.config.auth.postLogin),
limiterService.createMiddleware(limiterService.config.auth.postLogin),
upload.none(),
this.postLogin.bind(this),
);
@ -54,14 +54,14 @@ class AuthController extends SiteController {
router.get(
'/api-token/personal',
authRequired,
limiterService.create(limiterService.config.auth.getPersonalApiToken),
limiterService.createMiddleware(limiterService.config.auth.getPersonalApiToken),
this.getPersonalApiToken.bind(this),
);
router.get(
'/socket-token',
authRequiredNoRedirect,
limiterService.create(limiterService.config.auth.getSocketToken),
limiterService.createMiddleware(limiterService.config.auth.getSocketToken),
this.getSocketToken.bind(this),
);
@ -69,14 +69,14 @@ class AuthController extends SiteController {
router.get(
'/core',
limiterService.create(limiterService.config.auth.getCoreHome),
limiterService.createMiddleware(limiterService.config.auth.getCoreHome),
this.getCoreHome.bind(this),
);
router.get(
'/logout',
authRequired,
limiterService.create(limiterService.config.auth.getLogout),
limiterService.createMiddleware(limiterService.config.auth.getLogout),
this.getLogout.bind(this),
);

413
app/controllers/chat.js

@ -0,0 +1,413 @@
// email.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const express = require('express');
const { SiteController,/*, SiteError*/
SiteError} = require('../../lib/site-lib');
class ChatController extends SiteController {
constructor (dtp) {
super(dtp, module.exports);
}
async start ( ) {
const {
chat: chatService,
limiter: limiterService,
session: sessionService,
} = this.dtp.services;
const upload = this.createMulter();
const router = express.Router();
this.dtp.app.use('/chat', router);
router.use(
sessionService.authCheckMiddleware({ requireLogin: true }),
chatService.middleware({ maxOwnedRooms: 25, maxJoinedRooms: 50 }),
async (req, res, next) => {
res.locals.currentView = 'chat';
return next();
},
);
router.param('roomId', this.populateRoomId.bind(this));
router.param('inviteId', this.populateInviteId.bind(this));
router.post(
'/room/:roomId/invite/:inviteId/action',
limiterService.createMiddleware(limiterService.config.chat.postRoomInviteAction),
upload.none(),
this.postRoomInviteAction.bind(this),
);
router.post(
'/room/:roomId/invite',
limiterService.createMiddleware(limiterService.config.chat.postRoomInvite),
upload.none(),
this.postRoomInvite.bind(this),
);
router.post(
'/room/:roomId',
limiterService.createMiddleware(limiterService.config.chat.postRoomUpdate),
this.postRoomUpdate.bind(this),
);
router.post(
'/room',
limiterService.createMiddleware(limiterService.config.chat.postRoomCreate),
this.postRoomCreate.bind(this),
);
router.get(
'/room/create',
this.getRoomEditor.bind(this),
);
router.get(
'/room/:roomId/form/:formName',
limiterService.createMiddleware(limiterService.config.chat.getRoomForm),
this.getRoomForm.bind(this),
);
router.get(
'/room/:roomId/invite/:inviteId',
limiterService.createMiddleware(limiterService.config.chat.getRoomInviteView),
this.getRoomInviteView.bind(this),
);
router.get(
'/room/:roomId/invite',
limiterService.createMiddleware(limiterService.config.chat.getRoomInviteView),
this.getRoomInviteHome.bind(this),
);
router.get(
'/room/:roomId/settings',
limiterService.createMiddleware(limiterService.config.chat.getRoomSettings),
this.getRoomSettings.bind(this),
);
router.get(
'/room/:roomId',
limiterService.createMiddleware(limiterService.config.chat.getRoomView),
this.getRoomView.bind(this),
);
router.get(
'/room',
limiterService.createMiddleware(limiterService.config.chat.getRoomHome),
this.getRoomHome.bind(this),
);
router.get(
'/',
limiterService.createMiddleware(limiterService.config.chat.getHome),
this.getHome.bind(this),
);
/*
* DELETE operations
*/
router.delete(
'/room/:roomId/invite/:inviteId',
limiterService.createMiddleware(limiterService.config.chat.deleteInvite),
this.deleteInvite.bind(this),
);
router.delete(
'/room/:roomId',
limiterService.createMiddleware(limiterService.config.chat.deleteRoom),
this.deleteInvite.bind(this),
);
return router;
}
async populateRoomId (req, res, next, roomId) {
const { chat: chatService } = this.dtp.services;
try {
res.locals.room = await chatService.getRoomById(roomId);
if (!res.locals.room) {
throw new SiteError(404, 'Room not found');
}
return next();
} catch (error) {
this.log.error('failed to populate roomId', { roomId, error });
return next(error);
}
}
async populateInviteId (req, res, next, inviteId) {
const { chat: chatService } = this.dtp.services;
try {
res.locals.invite = await chatService.getRoomInviteById(inviteId);
if (!res.locals.invite) {
throw new SiteError(404, 'Invite not found');
}
return next();
} catch (error) {
this.log.error('failed to populate inviteId', { inviteId, error });
return next(error);
}
}
async postRoomInviteAction (req, res) {
const { chat: chatService } = this.dtp.services;
try {
const { response } = req.body;
const displayList = this.createDisplayList('room-invite-action');
this.log.debug('room invite action', { message: req.body });
switch (response) {
case 'accept':
await chatService.acceptRoomInvite(res.locals.invite);
displayList.showNotification(
`Chat room invite accepted`,
'success',
'top-center',
5000,
);
break;
case 'reject':
await chatService.acceptRoomInvite(res.locals.invite);
displayList.showNotification(
`Chat room invite rejected`,
'success',
'top-center',
5000,
);
break;
default:
throw new SiteError(400, 'Must specify invite action');
}
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to execute room invite action', {
inviteId: res.locals.invite._id,
response: req.body.response,
error,
});
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async postRoomInvite (req, res) {
const { chat: chatService, user: userService } = this.dtp.services;
this.log.debug('room invite received', { invite: req.body });
if (!req.body.username || !req.body.username.length) {
return res.status(400).json({ success: false, message: 'Please provide a username' });
}
try {
req.body.username = req.body.username.trim().toLowerCase();
while (req.body.username[0] === '@') {
req.body.username = req.body.username.slice(1);
}
if (!req.body.username || !req.body.username.length) {
throw new SiteError(400, 'Please provide a username');
}
const member = await userService.getPublicProfile(req.body.username);
if (!member) {
throw new SiteError(404, `There is no account with username @${req.body.username}`);
}
if (member._id.equals(res.locals.room.owner._id)) {
throw new SiteError(400, "You can't invite yourself.");
}
await chatService.sendRoomInvite(res.locals.room, member, req.body);
const displayList = this.createDisplayList('invite create');
displayList.showNotification(
`Chat room invite sent to ${member.displayName || member.username}!`,
'success',
'top-left',
5000,
);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to create room invitation', { error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async postRoomUpdate (req, res, next) {
const { chat: chatService } = this.dtp.services;
try {
res.locals.room = await chatService.updateRoom(res.locals.room, req.body);
res.redirect(`/chat/room/${res.locals.room._id}`);
} catch (error) {
this.log.error('failed to update chat room', {
// roomId: res.locals.room._id,
error,
});
return next(error);
}
}
async postRoomCreate (req, res, next) {
const { chat: chatService } = this.dtp.services;
try {
res.locals.room = await chatService.createRoom(req.user, req.body);
res.redirect(`/chat/room/${res.locals.room._id}`);
} catch (error) {
this.log.error('failed to create chat room', { error });
return next(error);
}
}
async getRoomEditor (req, res) {
res.render('chat/room/editor');
}
async getRoomForm (req, res, next) {
const validFormNames = [
'invite-member',
];
const formName = req.params.formName;
if (validFormNames.indexOf(formName) === -1) {
return next(new SiteError(404, 'Form not found'));
}
try {
res.render(`chat/room/form/${formName}`);
} catch (error) {
this.log.error('failed to render form', { formName, error });
return next(error);
}
}
async getRoomInviteView (req, res) {
res.render('chat/room/invite/view');
}
async getRoomInviteHome (req, res, next) {
const { chat: chatService } = this.dtp.services;
try {
res.locals.invites = {
new: await chatService.getRoomInvites(res.locals.room, 'new'),
accepted: await chatService.getRoomInvites(res.locals.room, 'accepted'),
rejected: await chatService.getRoomInvites(res.locals.room, 'rejected'),
};
res.render('chat/room/invite');
} catch (error) {
this.log.error('failed to render the room invites view', { error });
return next(error);
}
}
async getRoomSettings (req, res) {
res.render('chat/room/editor');
}
async getRoomView (req, res, next) {
const { chat: chatService } = this.dtp.services;
try {
res.locals.pageTitle = res.locals.room.name;
const pagination = { skip: 0, cpp: 20 };
res.locals.chatMessages = await chatService.getChannelHistory(res.locals.room, pagination);
res.render('chat/room/view');
} catch (error) {
this.log.error('failed to render chat room view', { roomId: req.params.roomId, error });
return next(error);
}
}
async getRoomHome (req, res, next) {
const { chat: chatService } = this.dtp.services;
try {
res.locals.pagination = this.getPaginationParameters(req, 20);
res.locals.publicRooms = await chatService.getPublicRooms(req.user, res.locals.pagination);
res.render('chat/room/index');
} catch (error) {
this.log.error('failed to render room home', { error });
return next(error);
}
}
async getHome (req, res, next) {
const { chat: chatService } = this.dtp.services;
try {
res.locals.pageTitle = 'Chat Home';
res.locals.pagination = this.getPaginationParameters(req, 20);
const roomIds = [ ];
res.locals.ownedChatRooms.forEach((room) => roomIds.push(room._id));
res.locals.joinedChatRooms.forEach((room) => roomIds.push(room._id));
res.locals.timeline = await chatService.getMultiRoomTimeline(roomIds, res.locals.pagination);
res.render('chat/index');
} catch (error) {
this.log.error('failed to render chat home', { error });
return next(error);
}
}
async deleteInvite (req, res, next) {
const { chat: chatService } = this.dtp.services;
try {
if (res.locals.room.owner._id.equals(req.user._id)) {
throw new SiteError(403, 'This is not your invitiation');
}
await chatService.deleteRoomInvite(res.locals.invite);
const displayList = this.createDisplayList('delete chat invite');
displayList.removeElement(`li[data-invite-id="${res.locals.invite._id}"]`);
displayList.showNotification(
`Invitation to ${res.locals.invite.member.displayName || res.locals.invite.member.username} deleted successfully`,
'success',
'top-left',
5000,
);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to delete chat room invite', { error });
return next(error);
}
}
async deleteRoom (req, res, next) {
const { chat: chatService } = this.dtp.services;
try {
if (res.locals.room.owner._id.equals(req.user._id)) {
throw new SiteError(403, 'This is not your chat room');
}
await chatService.deleteRoom(res.locals.room);
const displayList = this.createDisplayList('delete chat invite');
displayList.navigateTo('/chat');
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to delete chat room invite', { error });
return next(error);
}
}
}
module.exports = {
slug: 'chat',
name: 'chat',
create: async (dtp) => { return new ChatController(dtp); },
};

157
app/controllers/comment.js

@ -0,0 +1,157 @@
// comment.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const express = require('express');
const numeral = require('numeral');
const { SiteController, SiteError } = require('../../lib/site-lib');
class CommentController extends SiteController {
constructor (dtp) {
super(dtp, module.exports);
}
async start ( ) {
const { dtp } = this;
const { limiter: limiterService, session: sessionService } = dtp.services;
const authRequired = sessionService.authCheckMiddleware({ requiredLogin: true });
const router = express.Router();
dtp.app.use('/comment', router);
router.use(async (req, res, next) => {
res.locals.currentView = module.exports.slug;
return next();
});
router.param('commentId', this.populateCommentId.bind(this));
router.post('/:commentId/vote', authRequired, this.postVote.bind(this));
router.get('/:commentId/replies', this.getCommentReplies.bind(this));
router.delete('/:commentId',
authRequired,
limiterService.createMiddleware(limiterService.config.comment.deleteComment),
this.deleteComment.bind(this),
);
}
async populateCommentId (req, res, next, commentId) {
const { comment: commentService } = this.dtp.services;
try {
res.locals.comment = await commentService.getById(commentId);
if (!res.locals.comment) {
return next(new SiteError(404, 'Comment not found'));
}
res.locals.post = res.locals.comment.resource;
return next();
} catch (error) {
this.log.error('failed to populate commentId', { commentId, error });
return next(error);
}
}
async postVote (req, res) {
const { contentVote: contentVoteService } = this.dtp.services;
try {
const displayList = this.createDisplayList('comment-vote');
const { message, resourceStats } = await contentVoteService.recordVote(req.user, 'Comment', res.locals.comment, req.body.vote);
displayList.setTextContent(
`button[data-comment-id="${res.locals.comment._id}"][data-vote="up"] span.dtp-item-value`,
numeral(resourceStats.upvoteCount).format(resourceStats.upvoteCount > 1000 ? '0,0.0a' : '0,0'),
);
displayList.setTextContent(
`button[data-comment-id="${res.locals.comment._id}"][data-vote="down"] span.dtp-item-value`,
numeral(resourceStats.downvoteCount).format(resourceStats.upvoteCount > 1000 ? '0,0.0a' : '0,0'),
);
displayList.showNotification(message, 'success', 'bottom-center', 3000);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to process comment vote', { error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async getCommentReplies (req, res) {
const { comment: commentService } = this.dtp.services;
try {
const displayList = this.createDisplayList('get-replies');
if (req.query.buttonId) {
displayList.removeElement(`li.dtp-load-more[data-button-id="${req.query.buttonId}"]`);
}
Object.assign(res.locals, req.app.locals);
res.locals.countPerPage = parseInt(req.query.cpp || "20", 10);
if (res.locals.countPerPage < 1) {
res.locals.countPerPage = 1;
}
if (res.locals.countPerPage > 20) {
res.locals.countPerPage = 20;
}
res.locals.pagination = this.getPaginationParameters(req, res.locals.countPerPage);
res.locals.comments = await commentService.getReplies(res.locals.comment, res.locals.pagination);
const html = await commentService.renderTemplate('replyList', res.locals);
const replyList = `ul.dtp-reply-list[data-comment-id="${res.locals.comment._id}"]`;
displayList.addElement(replyList, 'beforeEnd', html);
const replyListContainer = `.dtp-reply-list-container[data-comment-id="${res.locals.comment._id}"]`;
displayList.removeAttribute(replyListContainer, 'hidden');
if (Array.isArray(res.locals.comments) && (res.locals.comments.length > 0)) {
displayList.removeElement(`p#empty-comments-label[data-comment-id="${res.locals.comment._id}"]`);
}
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to display comment replies', { error });
res.status(error.statusCode || 500).json({ success: false, message: error.message });
}
}
async deleteComment (req, res) {
const { comment: commentService } = this.dtp.services;
try {
const displayList = this.createDisplayList('add-recipient');
await commentService.remove(res.locals.comment, 'removed');
let selector = `article[data-comment-id="${res.locals.comment._id}"] .comment-content`;
displayList.setTextContent(selector, 'Comment removed');
displayList.showNotification(
'Comment removed successfully',
'success',
'bottom-center',
5000,
);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to remove comment', { error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message
});
}
}
}
module.exports = {
slug: 'comment',
name: 'comment',
create: async (dtp) => { return new CommentController(dtp); },
};

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

8
app/controllers/image.js

@ -8,7 +8,6 @@ const fs = require('fs');
const express = require('express');
const mongoose = require('mongoose');
const multer = require('multer');
const { SiteController/*, SiteError*/ } = require('../../lib/site-lib');
@ -26,8 +25,7 @@ class ImageController extends SiteController {
const router = express.Router();
dtp.app.use('/image', router);
const imageUpload = multer({
dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${this.component.slug}`,
const imageUpload = this.createMulter('uploads', {
limits: {
fileSize: 1024 * 1000 * 12,
},
@ -46,13 +44,13 @@ class ImageController extends SiteController {
);
router.post('/',
limiterService.create(limiterService.config.image.postCreateImage),
limiterService.createMiddleware(limiterService.config.image.postCreateImage),
imageUpload.single('file'),
this.postCreateImage.bind(this),
);
router.get('/:imageId',
limiterService.create(limiterService.config.image.getImage),
limiterService.createMiddleware(limiterService.config.image.getImage),
this.getHostCacheImage.bind(this),
// this.getImage.bind(this),
);

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

7
app/controllers/newsletter.js

@ -5,7 +5,6 @@
'use strict';
const express = require('express');
const multer = require('multer');
const { SiteController } = require('../../lib/site-lib');
@ -19,7 +18,7 @@ class NewsletterController extends SiteController {
const { dtp } = this;
const { limiter: limiterService } = dtp.services;
const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${module.exports.slug}` });
const upload = this.createMulter();
const router = express.Router();
dtp.app.use('/newsletter', router);
@ -34,12 +33,12 @@ class NewsletterController extends SiteController {
router.post('/', upload.none(), this.postAddRecipient.bind(this));
router.get('/:newsletterId',
limiterService.create(limiterService.config.newsletter.getView),
limiterService.createMiddleware(limiterService.config.newsletter.getView),
this.getView.bind(this),
);
router.get('/',
limiterService.create(limiterService.config.newsletter.getIndex),
limiterService.createMiddleware(limiterService.config.newsletter.getIndex),
this.getIndex.bind(this),
);
}

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

31
app/controllers/user.js

@ -6,7 +6,6 @@
const express = require('express');
const mongoose = require('mongoose');
const multer = require('multer');
const { SiteController, SiteError } = require('../../lib/site-lib');
@ -24,7 +23,8 @@ class UserController extends SiteController {
session: sessionService,
} = dtp.services;
const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${this.component.slug}` });
const upload = this.createMulter();
const router = express.Router();
dtp.app.use('/user', router);
@ -65,7 +65,7 @@ class UserController extends SiteController {
router.post(
'/core/:coreUserId/settings',
limiterService.create(limiterService.config.user.postUpdateCoreSettings),
limiterService.createMiddleware(limiterService.config.user.postUpdateCoreSettings),
checkProfileOwner,
upload.none(),
this.postUpdateCoreSettings.bind(this),
@ -73,7 +73,7 @@ class UserController extends SiteController {
router.post(
'/:userId/profile-photo',
limiterService.create(limiterService.config.user.postProfilePhoto),
limiterService.createMiddleware(limiterService.config.user.postProfilePhoto),
checkProfileOwner,
upload.single('imageFile'),
this.postProfilePhoto.bind(this),
@ -81,7 +81,7 @@ class UserController extends SiteController {
router.post(
'/:userId/settings',
limiterService.create(limiterService.config.user.postUpdateSettings),
limiterService.createMiddleware(limiterService.config.user.postUpdateSettings),
checkProfileOwner,
upload.none(),
this.postUpdateSettings.bind(this),
@ -89,13 +89,13 @@ class UserController extends SiteController {
router.post(
'/',
limiterService.create(limiterService.config.user.postCreate),
limiterService.createMiddleware(limiterService.config.user.postCreate),
this.postCreateUser.bind(this),
);
router.get(
'/core/:coreUserId/settings',
limiterService.create(limiterService.config.user.getSettings),
limiterService.createMiddleware(limiterService.config.user.getSettings),
authRequired,
otpMiddleware,
checkProfileOwner,
@ -103,29 +103,28 @@ class UserController extends SiteController {
);
router.get(
'/core/:coreUserId',
limiterService.create(limiterService.config.user.getUserProfile),
limiterService.createMiddleware(limiterService.config.user.getUserProfile),
authRequired,
otpMiddleware,
checkProfileOwner,
this.getUserView.bind(this),
);
router.get(
'/:userId/otp-setup',
limiterService.create(limiterService.config.user.getOtpSetup),
limiterService.createMiddleware(limiterService.config.user.getOtpSetup),
otpSetup,
this.getOtpSetup.bind(this),
);
router.get(
'/:userId/otp-disable',
limiterService.create(limiterService.config.user.getOtpDisable),
limiterService.createMiddleware(limiterService.config.user.getOtpDisable),
authRequired,
this.getOtpDisable.bind(this),
);
router.get(
'/:userId/settings',
limiterService.create(limiterService.config.user.getSettings),
limiterService.createMiddleware(limiterService.config.user.getSettings),
authRequired,
otpMiddleware,
checkProfileOwner,
@ -133,16 +132,15 @@ class UserController extends SiteController {
);
router.get(
'/:userId',
limiterService.create(limiterService.config.user.getUserProfile),
limiterService.createMiddleware(limiterService.config.user.getUserProfile),
authRequired,
otpMiddleware,
checkProfileOwner,
this.getUserView.bind(this),
);
router.delete(
'/:userId/profile-photo',
limiterService.create(limiterService.config.user.deleteProfilePhoto),
limiterService.createMiddleware(limiterService.config.user.deleteProfilePhoto),
authRequired,
checkProfileOwner,
this.deleteProfilePhoto.bind(this),
@ -157,9 +155,6 @@ class UserController extends SiteController {
return next(new SiteError(406, 'Invalid User'));
}
try {
if (!req.user._id.equals(userId)) {
return next(new Error('Invalid account ID'));
}
res.locals.userProfile = await userService.getUserAccount(userId);
return next();
} catch (error) {

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

11
app/models/announcement.js

@ -4,12 +4,20 @@
'use strict';
const path = require('path');
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const {
ResourceStats,
ResourceStatsDefaults,
} = require(path.join(__dirname, 'lib', 'resource-stats.js'));
const AnnouncementSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1, expires: '21d' },
created: { type: Date, default: Date.now, required: true, index: -1 },
title: {
icon: {
class: { type: String, default: 'fa-bullhorn', required: true },
@ -18,6 +26,7 @@ const AnnouncementSchema = new Schema({
content: { type: String, required: true },
},
content: { type: String, required: true },
resourceStats: { type: ResourceStats, default: ResourceStatsDefaults, required: true },
});
module.exports = mongoose.model('Announcement', AnnouncementSchema);

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

19
app/models/chat-message.js

@ -8,11 +8,24 @@ const mongoose = require('mongoose');
const Schema = mongoose.Schema;
/*
* The intent is for forked apps to give meaning to "channel" in their
* apps. Set the channelType to the name of your channel model, and set
* channel to the _id of the channel. The model will then correctly populate.
*/
const ChatMessageSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1, expires: '10d' },
author: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' },
content: { type: String },
created: { type: Date, default: Date.now, required: true, index: -1 },
channelType: { type: String },
channel: { type: Schema.ObjectId, refPath: 'channelType' },
authorType: { type: String, enum: ['User', 'CoreUser'], required: true },
author: { type: Schema.ObjectId, required: true, index: 1, refPath: 'authorType' },
content: { type: String, maxlength: 1000 },
analysis: {
similarity: { type: Number },
},
stickers: { type: [String] },
attachments: { type: [Schema.ObjectId], ref: 'Attachment' },
});
module.exports = mongoose.model('ChatMessage', ChatMessageSchema);

30
app/models/chat-room-invite.js

@ -0,0 +1,30 @@
// chat-room-invite.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const INVITE_STATUS_LIST = ['new', 'accepted', 'rejected', 'deleted'];
const ChatRoomInviteSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1, expires: '30d' },
room: { type: Schema.ObjectId, required: true, ref: 'ChatRoom' },
memberType: { type: String, required: true },
member: { type: Schema.ObjectId, required: true, index: 1, refPath: 'memberType' },
status: { type: String, enum: INVITE_STATUS_LIST, required: true },
message: { type: String },
});
ChatRoomInviteSchema.index({
room: 1,
member: 1,
}, {
unique: true,
name: 'chatroom_invite_unique_idx',
});
module.exports = mongoose.model('ChatRoomInvite', ChatRoomInviteSchema);

44
app/models/chat-room.js

@ -0,0 +1,44 @@
// chat-room.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const RoomMemberSchema = new Schema({
memberType: { type: String, required: true },
member: { type: Schema.ObjectId, required: true, refPath: 'members.memberType' },
});
const ROOM_VISIBILITY_LIST = ['public', 'private'];
const ROOM_MEMBERSHIP_POICY_LIST = ['open', 'closed'];
const ChatRoomSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1 },
lastActivity: { type: Date, default: Date.now, required: true, index: -1 },
ownerType: { type: String, required: true },
owner: { type: Schema.ObjectId, required: true, index: 1, refPath: 'ownerType' },
name: { type: String, required: true, maxlength: 100 },
description: { type: String, maxlength: 500 },
policy: { type: String },
latestMessage: { type: Schema.ObjectId, ref: 'ChatMessage' },
visibility: { type: String, enum: ROOM_VISIBILITY_LIST, default: 'public', required: true, index: 1 },
membershipPolicy: { type: String, enum: ROOM_MEMBERSHIP_POICY_LIST, default: 'open', required: true, index: 1 },
members: { type: [RoomMemberSchema], default: [ ], required: true },
});
ChatRoomSchema.index({
visibility: 1,
membershipPolicy: 1,
}, {
partialFilterExpression: {
visibility: 'public',
membershipPolicy: 'open',
},
name: 'chatroom_public_open_idx',
});
module.exports = mongoose.model('ChatRoom', ChatRoomSchema);

12
app/models/comment.js

@ -16,11 +16,18 @@ const CommentHistorySchema = new Schema({
const {
RESOURCE_TYPE_LIST,
ResourceStats,
ResourceStatsDefaults,
CommentStats,
CommentStatsDefaults,
} = require(path.join(__dirname, 'lib', 'resource-stats.js'));
const COMMENT_STATUS_LIST = ['published', 'removed', 'mod-warn', 'mod-removed'];
const COMMENT_STATUS_LIST = [
'published',
'removed',
'mod-warn',
'mod-removed',
];
const CommentSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: 1 },
@ -35,7 +42,8 @@ const CommentSchema = new Schema({
flags: {
isNSFW: { type: Boolean, default: false, required: true },
},
stats: { type: CommentStats, default: CommentStatsDefaults, required: true },
resourceStats: { type: ResourceStats, default: ResourceStatsDefaults, required: true },
commentStats: { type: CommentStats, default: CommentStatsDefaults, required: true },
});
/*

2
app/models/core-user.js

@ -33,7 +33,7 @@ const CoreUserSchema = new Schema({
permissions: { type: UserPermissionsSchema, select: false },
optIn: { type: UserOptInSchema, required: true, select: false },
theme: { type: String, enum: DTP_THEME_LIST, default: 'dtp-light', required: true },
stats: { type: ResourceStats, default: ResourceStatsDefaults, required: true },
resourceStats: { type: ResourceStats, default: ResourceStatsDefaults, required: true },
});
CoreUserSchema.index({

39
app/models/emoji-reaction.js

@ -0,0 +1,39 @@
// emoji-reaction.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const REACTION_LIST = [
'clap',
'fire',
'happy',
'laugh',
'angry',
'honk',
];
const EmojiReactionSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1, expires: '30d' },
/*
* The thing for which a reaction is being filed.
*/
subjectType: { type: String, required: true },
subject: { type: Schema.ObjectId, required: true, index: 1, refPath: 'subjectType' },
/*
* The user creating the reaction
*/
userType: { type: String, required: true },
user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' },
reaction: { type: String, enum: REACTION_LIST, required: true },
timestamp: { type: Number },
});
module.exports = mongoose.model('EmojiReaction', EmojiReactionSchema);

13
app/models/lib/resource-stats.js

@ -8,26 +8,29 @@ const mongoose = require('mongoose');
const Schema = mongoose.Schema;
module.exports.RESOURCE_TYPE_LIST = ['Page', 'Post'];
module.exports.RESOURCE_TYPE_LIST = [
'Announcement',
'Newsletter',
];
module.exports.ResourceStats = new Schema({
uniqueVisitCount: { type: Number, default: 0, required: true, index: -1 },
totalVisitCount: { type: Number, default: 0, required: true },
upvoteCount: { type: Number, default: 0, required: true },
downvoteCount: { type: Number, default: 0, required: true },
});
module.exports.ResourceStatsDefaults = {
uniqueVisitCount: 0,
totalVisitCount: 0,
upvoteCount: 0,
downvoteCount: 0,
};
module.exports.CommentStats = new Schema({
upvoteCount: { type: Number, default: 0, required: true },
downvoteCount: { type: Number, default: 0, required: true },
replyCount: { type: Number, default: 0, required: true },
});
module.exports.CommentStatsDefaults = {
upvoteCount: 0,
downvoteCount: 0,
replyCount: 0,
};

6
app/models/resource-view.js

@ -4,11 +4,13 @@
'use strict';
const path = require('path');
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const RESOURCE_TYPE_LIST = ['Page', 'Post', 'Newsletter'];
const { RESOURCE_TYPE_LIST } = require(path.join(__dirname, 'lib', 'resource-stats.js'));
const ResourceViewSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: 1, expires: '30d' },
@ -27,4 +29,4 @@ ResourceViewSchema.index({
name: 'res_view_daily_unique',
});
module.exports = mongoose.model('ResourceView', ResourceViewSchema);
module.exports = mongoose.model('ResourceView', ResourceViewSchema);

46
app/models/sticker.js

@ -0,0 +1,46 @@
// sticker.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const STICKER_STATUS_LIST = [
'processing', // the sticker is in the processing queue
'live', // the sticker is available for use
'rejected', // the sticker was rejected (by proccessing queue)
'retired', // the sticker has been retired
];
const StickerMediaSchema = new Schema(
{
bucket: { type: String, required: true },
key: { type: String, required: true },
type: { type: String, required: true },
size: { type: Number, required: true },
},
{
_id: false,
},
);
/*
* The intention is for sticker ownership to be defined by forked applications
* and implemented by things like User, CoreUser, Channel, or whatever can
* "own" a sticker in your app.
*/
const StickerSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1 },
status: { type: String, enum: STICKER_STATUS_LIST, default: 'processing', required: true, index: 1 },
rejectedReason: { type: String },
ownerType: { type: String, required: true, index: 1 },
owner: { type: Schema.ObjectId, required: true, index: 1, refPath: 'ownerType' },
slug: { type: String, required: true, maxlength: 20, unique: true, index: 1 },
original: { type: StickerMediaSchema, required: true, select: false },
encoded: { type: StickerMediaSchema },
});
module.exports = mongoose.model('Sticker', StickerSchema);

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

2
app/models/user.js

@ -38,7 +38,7 @@ const UserSchema = new Schema({
permissions: { type: UserPermissionsSchema, select: false },
optIn: { type: UserOptInSchema, required: true, select: false },
theme: { type: String, enum: DTP_THEME_LIST, default: 'dtp-light', required: true },
stats: { type: ResourceStats, default: ResourceStatsDefaults, required: true },
resourceStats: { type: ResourceStats, default: ResourceStatsDefaults, required: true },
lastAnnouncement: { type: Date },
}, {
strictPopulate: false,

5
app/services/announcement.js

@ -1,6 +1,6 @@
// announcement.js
// Copyright (C) 2022 DTP Technologies, LLC
// All Rights Reserved
// License: Apache-2.0
'use strict';
@ -108,6 +108,9 @@ class AnnouncementService extends SiteService {
}
async remove (announcement) {
const { comment: commentService } = this.dtp.services;
await commentService.deleteForResource(announcement);
await Announcement.deleteOne({ _id: announcement._id });
}
}

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

733
app/services/chat.js

@ -5,36 +5,577 @@
'use strict';
const mongoose = require('mongoose');
const ChatRoom = mongoose.model('ChatRoom');
const ChatRoomInvite = mongoose.model('ChatRoomInvite');
const ChatMessage = mongoose.model('ChatMessage');
const EmojiReaction = mongoose.model('EmojiReaction');
const ioEmitter = require('socket.io-emitter');
const moment = require('moment');
const marked = require('marked');
const hljs = require('highlight.js');
const striptags = require('striptags');
const unzalgo = require('unzalgo');
const stringSimilarity = require('string-similarity');
const { SiteService } = require('../../lib/site-lib');
const { SiteService, SiteError } = require('../../lib/site-lib');
class ChatService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
this.populateContentReport = [
}
async start ( ) {
const { user: userService, limiter: limiterService } = this.dtp.services;
await super.start();
this.populateChatMessage = [
{
path: 'channel',
},
{
path: 'author',
select: userService.USER_SELECT,
},
{
path: 'user',
select: '_id username username_lc displayName picture',
path: 'stickers',
},
];
this.populateChatRoom = [
{
path: 'owner',
select: userService.USER_SELECT,
},
{
path: 'resource',
path: 'members.member',
select: userService.USER_SELECT,
},
];
this.populateChatRoomInvite = [
{
path: 'room',
populate: [
{
path: 'author',
select: '_id username username_lc displayName picture',
path: 'owner',
select: userService.USER_SELECT,
},
],
},
{
path: 'member',
select: userService.USER_SELECT,
},
];
}
async start ( ) {
this.templates = {
chatMessage: this.loadViewTemplate('chat/components/message-standalone.pug'),
};
this.markedRenderer = new marked.Renderer();
this.markedRenderer.link = (href, title, text) => { return text; };
this.markedRenderer.image = (href, title, text) => { return text; };
this.markedConfig = {
renderer: this.markedRenderer,
highlight: function(code, lang) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
},
langPrefix: 'hljs language-', // highlight.js css expects a top-level 'hljs' class.
pedantic: false,
gfm: true,
breaks: false,
sanitize: false,
smartLists: true,
smartypants: false,
xhtml: false,
};
this.chatMessageLimiter = limiterService.createRateLimiter({
points: 20,
duration: 60,
blockDuration: 60 * 3,
execEvenly: false,
keyPrefix: 'rl:chatmsg',
});
this.reactionLimiter = limiterService.createRateLimiter({
points: 60,
duration: 60,
blockDuration: 60 * 3,
execEvenly: false,
keyPrefix: 'rl:react',
});
/*
* The Redis Emitter is a Socket.io-compatible message emitter that operates
* with greater efficiency than using Socket.io itself.
*/
this.emitter = ioEmitter(this.dtp.redis);
this.queues = {
reeeper: await this.getJobQueue('reeeper'),
};
}
async renderTemplate (which, viewModel) {
if (!this.templates || !this.templates[which]) {
throw new Error('Chat service template does not exist');
}
viewModel = Object.assign(viewModel, this.dtp.app.locals);
return this.templates[which](viewModel);
}
middleware (options) {
options = Object.assign({
maxOwnedRooms: 10,
maxJoinedRooms: 10,
});
return async (req, res, next) => {
try {
res.locals.ownedChatRooms = await this.getRoomsForOwner(req.user, {
skip: 0,
cpp: options.maxOwnedRooms,
});
res.locals.joinedChatRooms = await this.getRoomsForMember(req.user, {
skip: 0,
cpp: options.maxJoinedRooms,
});
return next();
} catch (error) {
this.log.error('failed to execute chat middleware', { error });
return next(error);
}
};
}
async createRoom (owner, roomDefinition) {
const NOW = new Date();
const room = new ChatRoom();
room.created = NOW;
room.lastActivity = NOW;
room.ownerType = owner.type;
room.owner = owner._id;
if (!roomDefinition.name || !roomDefinition.name.length) {
throw new SiteError(400, 'Must provide room name');
}
room.name = this.filterText(roomDefinition.name);
if (roomDefinition.description && (roomDefinition.description.length > 0)) {
room.description = this.filterText(roomDefinition.description);
}
if (roomDefinition.policy && (roomDefinition.policy.length > 0)) {
room.policy = this.filterText(roomDefinition.policy);
}
room.visibility = roomDefinition.visibility;
room.membershipPolicy = roomDefinition.membershipPolicy;
room.members = [ ];
await room.save();
return room.toObject();
}
async updateRoom (room, roomDefinition) {
const NOW = new Date();
const updateOp = {
$set: {
lastActivity: NOW,
},
$unset: { },
};
if (!roomDefinition.name && !roomDefinition.name.length) {
throw new SiteError(400, 'Must provide room name');
}
updateOp.$set.name = this.filterText(roomDefinition.name);
if (roomDefinition.description && (roomDefinition.description.length > 0)) {
updateOp.$set.description = this.filterText(roomDefinition.description);
} else {
updateOp.$unset.description = 1;
}
if (roomDefinition.policy && (roomDefinition.policy.length > 0)) {
updateOp.$set.policy = this.filterText(roomDefinition.policy);
} else {
updateOp.$unset.policy = 1;
}
if (!roomDefinition.visibility || !roomDefinition.visibility.length) {
throw new SiteError(400, 'Must specify room visibility');
}
updateOp.$set.visibility = roomDefinition.visibility.trim();
if (!roomDefinition.membershipPolicy || !roomDefinition.membershipPolicy.length) {
throw new SiteError(400, 'Must specify room membership policy');
}
updateOp.$set.membershipPolicy = roomDefinition.membershipPolicy.trim();
const response = await ChatRoom.findOneAndUpdate({ _id: room._id }, updateOp, { new: true });
this.log.debug('chat room update', { response });
return response;
}
async getRoomsForOwner (owner, pagination) {
pagination = Object.assign({
skip: 0,
cpp: 50
}, pagination);
const rooms = await ChatRoom
.find({ owner: owner._id })
.sort({ lastActivity: -1, created: -1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.populate(this.populateChatRoom)
.lean();
return rooms;
}
async getRoomsForMember (member, pagination) {
pagination = Object.assign({
skip: 0,
cpp: 50
}, pagination);
const rooms = await ChatRoom
.find({ 'members.member': member._id })
.sort({ lastActivity: -1, created: -1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.populate(this.populateChatRoom)
.lean();
return rooms;
}
async getPublicRooms (pagination) {
pagination = Object.assign({
skip: 0,
cpp: 50
}, pagination);
const rooms = await ChatRoom
.find({ visibility: 'public' })
.sort({ lastActivity: -1, created: -1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.populate(this.populateChatRoom)
.lean();
return rooms;
}
async getRoomById (roomId) {
const room = await ChatRoom
.findById(roomId)
.populate(this.populateChatRoom)
.lean();
return room;
}
async deleteRoom (room) {
return this.queues.reeeper.add('chat-room-delete', { roomId: room._id });
}
async joinRoom (room, member) {
if (room.membershipPolicy !== 'open') {
throw new SiteError(403, 'The room is not open');
}
await ChatRoom.updateOne(
{ _id: room._id },
{
$addToSet: {
members: {
memberType: member.type,
member: member._id,
},
},
},
);
}
async leaveRoom (room, memberId) {
await ChatRoom.updateOne(
{ _id: room._id },
{
$pull: { members: { _id: memberId } },
},
);
}
async sendRoomInvite (room, member, inviteDefinition) {
const { coreNode: coreNodeService } = this.dtp.services;
const NOW = new Date();
if (await this.isRoomMember(room, member)) {
throw new SiteError(400, `${member.username} is already a member of ${room.name}`);
}
let invite = await ChatRoomInvite
.findOne({ room: room._id, member: member._id })
.populate(this.populateChatRoomInvite)
.lean();
if (invite) {
switch (invite.status) {
case 'new':
throw new SiteError(400, `${member.displayName || member.username} was invited to join ${moment(invite.created).fromNow()}, but has not yet responded.`);
case 'rejected':
throw new SiteError(400, `${member.displayName || member.username} rejected your invitation to join.`);
default:
this.log.alert('deleting damaged ChatRoomInvite document', { _id: invite._id });
await ChatRoomInvite.deleteOne({ _id: invite._id });
break; // create a new one by proceeding
}
}
invite = new ChatRoomInvite();
invite.created = NOW;
invite.room = room._id;
invite.memberType = member.type;
invite.member = member._id;
invite.status = 'new';
if (inviteDefinition && inviteDefinition.message) {
invite.message = this.filterText(inviteDefinition.message);
}
await invite.save();
invite = invite.toObject();
this.log.info('chat room invite created', {
roomId: room._id,
memberId: member._id,
inviteId: invite._id,
});
/*
* Send the invite notification using DTP Core services. It will figure out
* who needs to receive the event and how to get it to them.
*/
room.owner.type = room.ownerType;
const event = {
action: 'room-invite-create',
emitter: room.owner,
label: 'Chat Room Invitation',
content: invite.message || `Join my chat room on ${this.dtp.config.site.name}!`,
href: coreNodeService.getLocalUrl(`/chat/room/${room._id}/invite/${invite._id}`),
};
await coreNodeService.sendKaleidoscopeEvent(event, member);
return invite;
}
async getRoomInvites (room, status) {
const invites = await ChatRoomInvite
.find({ room: room._id, status })
.sort({ created: 1 })
.populate(this.populateChatRoomInvite)
.lean();
return invites;
}
async getRoomInviteById (inviteId) {
const invite = await ChatRoomInvite
.findById(inviteId)
.populate(this.populateChatRoomInvite)
.lean();
return invite;
}
async acceptRoomInvite (invite) {
this.log.info('accepting invite to chat room', {
roomId: invite.room._id,
memberId: invite.member._id,
});
await ChatRoom.updateOne(
{ _id: invite.room._id },
{
$addToSet: {
members: {
memberType: invite.memberType,
member: invite.member._id,
},
},
},
);
this.log.info('updating chat invite', { inviteId: invite._id, status: 'accepted' });
await ChatRoomInvite.updateOne(
{ _id: invite._id },
{
$set: { status: 'accepted' },
},
);
}
async rejectRoomInvite (invite) {
this.log.info('rejecting chat room invite', {
inviteId: invite._id,
roomId: invite.room._id,
memberId: invite.member._id,
});
await ChatRoomInvite.updateOne(
{ _id: invite._id },
{
$set: { status: 'rejected' },
},
);
}
/**
* Marks an invitation as deleted, but does not physically remove it from the
* database. They expire after 30 days and will self-delete. This serves as a
* guard against invite spam. The person asking you to change this behavior
* wants to use invite spam as a form of abuse. The answer is: No.
*
* @param {ChatRoomInvite} invite The invitation to be marked as deleted.
*/
async deleteRoomInvite (invite) {
if (invite.status !== 'new') {
throw new SiteError(400, "Can't delete selected room invite");
}
this.log.info('deleting chat room invite', { inviteId: invite._id });
await ChatRoomInvite.updateOne({ _id: invite._id }, { $set: { status: 'deleted' } });
}
async isRoomMember (room, userId) {
const member = await ChatRoom.findOne({ 'members.member': userId }).lean();
return !!member;
}
async createMessage (author, messageDefinition) {
const { sticker: stickerService, user: userService } = this.dtp.services;
author = await userService.getUserAccount(author._id);
if (!author || !author.permissions || !author.permissions.canChat) {
throw new SiteError(403, `You are not permitted to chat at all on ${this.dtp.config.site.name}`);
}
try {
const userKey = author._id.toString();
await this.chatMessageLimiter.consume(userKey, 1);
} catch (error) {
throw new SiteError(429, 'You are sending chat messages too quickly');
}
const NOW = new Date();
/*
* Record the chat message to the database
*/
let message = new ChatMessage();
message.created = NOW;
message.channelType = messageDefinition.channelType;
message.channel = mongoose.Types.ObjectId(messageDefinition.channel._id || messageDefinition.channel);
message.authorType = author.type;
message.author = author._id;
message.content = this.filterText(messageDefinition.content);
message.analysis = await this.analyzeContent(author, message.content);
if (message.analysis.similarity > 3.0) {
throw new SiteError(429, 'Message rejected as spam (too repetitive)');
}
const stickerSlugs = this.findStickers(message.content);
stickerSlugs.forEach((sticker) => {
const re = new RegExp(`:${sticker}:`, 'gi');
message.content = message.content.replace(re, '').trim();
});
const stickers = await stickerService.resolveStickerSlugs(stickerSlugs);
message.stickers = stickers.map((sticker) => sticker._id);
await message.save();
message = message.toObject();
/*
* Update room's latest message pointer
*/
await ChatRoom.updateOne(
{ _id: message.channel },
{ $set: { latestMessage: message._id } },
);
/*
* Prepare a message payload that can be transmitted over sockets to clients
* and rendered for display.
*/
const renderedContent = this.renderMessageContent(message.content);
const payload = {
_id: message._id,
created: message.created,
user: {
_id: author._id,
displayName: author.displayName,
username: author.username,
},
content: renderedContent,
stickers,
};
if (author.picture) {
payload.user.picture = { };
if (author.picture.large) {
payload.user.picture.large ={
_id: author.picture.large._id,
};
}
if (author.picture.small) {
payload.user.picture.small = {
_id: author.picture.small._id,
};
}
}
/*
* Return both things
*/
return { message, payload };
}
renderMessageContent (content) {
return marked.parse(content, this.markedConfig);
}
findStickers (content) {
const tokens = content.split(' ');
const stickers = [ ];
tokens.forEach((token) => {
if ((token[0] !== ':') || (token[token.length -1 ] !== ':')) {
return;
}
token = token.slice(1, token.length - 1 ).toLowerCase();
if (token.includes('/') || token.includes(':') || token.includes(' ')) {
return; // trimmed token includes invalid characters
}
this.log.debug('found sticker request', { token });
if (!stickers.includes(token)) {
stickers.push(striptags(token));
}
});
return stickers.slice(0, 4);
}
async removeMessage (message) {
@ -44,6 +585,180 @@ class ChatService extends SiteService {
params: { messageId: message._id },
});
}
async getChannelHistory (channel, pagination) {
const messages = await ChatMessage
.find({ channel: channel._id })
.sort({ created: -1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.populate(this.populateChatMessage)
.lean();
return messages.reverse();
}
async getUserHistory (user, pagination) {
const messages = await ChatMessage
.find({ author: user._id })
.sort({ created: -1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.populate(this.populateChatMessage)
.lean();
return messages.reverse();
}
async getRoomMemberships (user, options) {
options = Object.assign({
withPopulate: true,
}, options || { });
const search = {
$or: [
{ owner: user._id },
{ 'members.member': user._id },
],
};
let q = ChatRoom.find(search).sort({ name: 1 });
if (options.pagination) {
q = q.skip(options.pagination.skip).limit(options.pagination.cpp);
}
if (options.withPopulate) {
q = q.populate(this.populateChatRoom);
}
const memberships = await q.lean();
return memberships;
}
/**
* This service is never called using user-supplied lists of room IDs. Don't
* do that, there is no membership check. Instead, every request knows the
* member's list of rooms owned and rooms joined. When this method was
* written, those arrays are being merged to build the list of roomIds.
* @param {Array} roomIds an array of Room._id values
* @param {*} pagination pagination params for the timeline
* @returns an array of messages in chronological order from all room IDs
* specified.
*/
async getMultiRoomTimeline (roomIds, pagination) {
const messages = await ChatMessage
.find({ room: { $in: roomIds } })
.sort({ created: -1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.populate(this.populateChatMessage)
.lean();
return messages.reverse();
}
/**
* Filters an input string to remove "zalgo" text and to strip all HTML tags.
* This prevents cross-site scripting and the malicious destruction of text
* layouts.
* @param {String} content The text content to be filtered.
* @returns the filtered text
*/
filterText (content) {
return striptags(unzalgo.clean(content.trim()));
}
/**
* Analyze an input chat message against a user's history for similarity and
* other abusive content. Returns a response object with various scores
* allowing the caller to implement various policies and make various
* decisions.
* @param {User} author The author of the chat message
* @param {*} content The text of the chat message as would be distributed.
* @returns response object with various scores indicating the results of
* analyses performed.
*/
async analyzeContent (author, content) {
const response = { similarity: 0.0 };
/*
* Compare versus their recent chat messages, score for similarity, and
* block based on repetition. Spammers are redundant. This stops them.
*/
const history = await ChatMessage
.find({ author: author._id })
.sort({ created: -1 })
.select('content')
.limit(10)
.lean();
history.forEach((message) => {
const similarity = stringSimilarity.compareTwoStrings(content, message.content);
if (similarity > 0.9) { // 90% or greater match with history entry
response.similarity += similarity;
}
});
return response;
}
async sendMessage (channel, messageName, payload) {
if (typeof channel !== 'string') {
channel = channel.toString();
}
this.emitter.to(channel).emit(messageName, payload);
}
async sendSystemMessage (content, options) {
const NOW = new Date();
options = Object.assign({
type: 'info',
}, options || { });
const payload = {
created: NOW,
type: options.type,
content,
};
if (options.channelId) {
this.emitter.to(options.channelId).emit('system-message', payload);
return;
}
if (options.userId) {
this.emitter.to(options.userId).emit('system-message', payload);
}
}
async createEmojiReaction (user, reactionDefinition) {
const { user: userService } = this.dtp.services;
const NOW = new Date();
const userCheck = await userService.getUserAccount(user._id);
if (!userCheck || !userCheck.permissions || !userCheck.permissions.canChat) {
throw new SiteError(403, 'You are not permitted to chat');
}
try {
const userKey = user._id.toString();
await this.reactionLimiter.consume(userKey, 1);
} catch (error) {
throw new SiteError(429, 'You are sending reactions too quickly');
}
const reaction = new EmojiReaction();
reaction.created = NOW;
reaction.subjectType = reactionDefinition.subjectType;
reaction.subject = mongoose.Types.ObjectId(reactionDefinition.subject);
reaction.userType = user.type;
reaction.user = user._id;
reaction.reaction = reactionDefinition.reaction;
if (reactionDefinition.timestamp) {
reaction.timestamp = reactionDefinition.timestamp;
}
await reaction.save();
return reaction.toObject();
}
}
module.exports = {

53
app/services/comment.js

@ -49,6 +49,8 @@ class CommentService extends SiteService {
}
async start ( ) {
await super.start();
this.templates = { };
this.templates.comment = pug.compileFile(path.join(this.dtp.config.root, 'app', 'views', 'comment', 'components', 'comment-standalone.pug'));
@ -73,6 +75,52 @@ class CommentService extends SiteService {
}
}
commentCreateHandler (resourceType, resourceKey) {
const { displayEngine: displayEngineService } = this.dtp.services;
return async (req, res, next) => {
try {
res.locals.comment = await this.create(
req.user,
resourceType,
res.locals[resourceKey],
req.body,
);
let viewModel = Object.assign({ }, req.app.locals);
viewModel = Object.assign(viewModel, res.locals);
const html = await this.renderTemplate('comment', viewModel);
const displayList = displayEngineService.createDisplayList('announcement-comment');
displayList.setInputValue('textarea#content', '');
displayList.setTextContent('#comment-character-count', '0');
if (req.body.replyTo) {
const replyListSelector = `.dtp-reply-list-container[data-comment-id="${req.body.replyTo}"]`;
displayList.addElement(replyListSelector, 'afterBegin', html);
displayList.removeAttribute(replyListSelector, 'hidden');
} else {
displayList.addElement('ul#post-comment-list', 'afterBegin', html);
}
displayList.showNotification(
'Comment created',
'success',
'bottom-center',
4000,
);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to process comment', {
resourceType,
resourceId: res.locals[resourceKey]._id,
error,
});
return next(error);
}
};
}
async create (author, resourceType, resource, commentDefinition) {
const NOW = new Date();
let comment = new Comment();
@ -113,7 +161,7 @@ class CommentService extends SiteService {
await Comment.updateOne(
{ _id: replyTo },
{
$inc: { 'stats.replyCount': 1 },
$inc: { 'commentStats.replyCount': 1 },
},
);
let parent = await Comment.findById(replyTo).select('replyTo').lean();
@ -255,7 +303,8 @@ class CommentService extends SiteService {
}
/**
* Deletes all comments filed against a given resource.
* Deletes all comments filed against a given resource. Will also get their
* replies as those are also filed against a resource and will match.
* @param {Resource} resource The resource for which all comments are to be
* deleted (physically removed from database).
*/

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

21
app/services/content-vote.js

@ -18,6 +18,7 @@ class ContentVoteService extends SiteService {
}
async start ( ) {
await super.start();
this.emitter = ioEmitter(this.dtp.redis);
}
@ -46,10 +47,10 @@ class ContentVoteService extends SiteService {
vote
});
if (vote === 'up') {
updateOp.$inc['stats.upvoteCount'] = 1;
updateOp.$inc['resourceStats.upvoteCount'] = 1;
message = 'Comment upvote recorded';
} else {
updateOp.$inc['stats.downvoteCount'] = 1;
updateOp.$inc['resourceStats.downvoteCount'] = 1;
message = 'Comment downvote recorded';
}
} else {
@ -57,8 +58,8 @@ class ContentVoteService extends SiteService {
* If vote not changed, do no further work.
*/
if (contentVote.vote === vote) {
const updatedResource = await ResourceModel.findById(resource._id).select('stats');
return { message: "Comment vote unchanged", stats: updatedResource.stats };
const updatedResource = await ResourceModel.findById(resource._id).select('resourceStats');
return { message: "Comment vote unchanged", resourceStats: updatedResource.resourceStats };
}
/*
@ -73,12 +74,12 @@ class ContentVoteService extends SiteService {
* Adjust resource's stats based on the changed vote
*/
if (vote === 'up') {
updateOp.$inc['stats.upvoteCount'] = 1;
updateOp.$inc['stats.downvoteCount'] = -1;
updateOp.$inc['resourceStats.upvoteCount'] = 1;
updateOp.$inc['resourceStats.downvoteCount'] = -1;
message = 'Comment vote changed to upvote';
} else {
updateOp.$inc['stats.upvoteCount'] = -1;
updateOp.$inc['stats.downvoteCount'] = 1;
updateOp.$inc['resourceStats.upvoteCount'] = -1;
updateOp.$inc['resourceStats.downvoteCount'] = 1;
message = 'Comment vote changed to downvote';
}
}
@ -86,8 +87,8 @@ class ContentVoteService extends SiteService {
this.log.info('updating resource stats', { resourceType, resource, updateOp });
await ResourceModel.updateOne({ _id: resource._id }, updateOp);
const updatedResource = await ResourceModel.findById(resource._id).select('stats');
return { message, stats: updatedResource.stats };
const updatedResource = await ResourceModel.findById(resource._id).select('resourceStats');
return { message, resourceStats: updatedResource.resourceStats };
}
}

270
app/services/core-node.js

@ -20,7 +20,8 @@ const OAuth2Strategy = require('passport-oauth2');
const striptags = require('striptags');
const { SiteService, SiteError } = require('../../lib/site-lib');
const { SiteService, SiteError, SiteAsync } = require('../../lib/site-lib');
const { ResourceStatsDefaults } = require('../models/lib/resource-stats');
class CoreAddress {
@ -58,7 +59,10 @@ class CoreNodeService extends SiteService {
}
async start ( ) {
await super.start();
const cores = await this.getConnectedCores(null, true);
this.log.info('Core Node service starting', { connectedCoreCount: cores.length });
cores.forEach((core) => this.registerPassportCoreOAuth2(core));
}
@ -109,90 +113,6 @@ class CoreNodeService extends SiteService {
});
}
registerPassportCoreOAuth2 (core) {
const { coreNode: coreNodeService } = this.dtp.services;
const AUTH_SCHEME = coreNodeService.getCoreRequestScheme();
const coreAuthStrategyName = this.getCoreAuthStrategyName(core);
const authorizationHost = `${core.address.host}:${core.address.port}`;
const authorizationURL = `${AUTH_SCHEME}://${authorizationHost}/oauth2/authorize`;
const tokenURL = `${AUTH_SCHEME}://${authorizationHost}/oauth2/token`;
const callbackURL = `${AUTH_SCHEME}://${process.env.DTP_SITE_DOMAIN}/auth/core/${core._id}/callback`;
const coreAuthStrategy = new OAuth2Strategy(
{
authorizationURL,
tokenURL,
clientID: core.oauth.clientId.toString(),
clientSecret: core.oauth.clientSecret,
callbackURL,
},
async (accessToken, refreshToken, params, profile, cb) => {
const NOW = new Date();
try {
const coreUserId = mongoose.Types.ObjectId(params.coreUserId);
let user = await CoreUser.findOneAndUpdate(
{
core: core._id,
coreUserId,
},
{
$setOnInsert: {
created: NOW,
core: core._id,
coreUserId,
flags: {
isAdmin: false,
isModerator: false,
},
permissions: {
canLogin: true,
canChat: true,
canComment: true,
canReport: true,
},
optIn: {
system: true,
marketing: false,
},
theme: 'dtp-light',
stats: {
uniqueVisitCount: 0,
totalVisitCount: 0,
},
},
$set: {
updated: NOW,
username: params.username,
username_lc: params.username_lc,
displayName: params.displayName,
bio: params.bio,
},
},
{
upsert: true,
new: true,
},
);
user = user.toObject();
user.type = 'CoreUser';
return cb(null, user);
} catch (error) {
return cb(error);
}
},
);
this.log.info('registering Core auth strategy', {
name: coreAuthStrategyName,
host: core.address.host,
port: core.address.port,
clientID: core.oauth.clientId.toString(),
callbackURL,
});
passport.use(coreAuthStrategyName, coreAuthStrategy);
}
parseCoreAddress (host) {
const address = new CoreAddress();
return address.parse(host);
@ -215,6 +135,25 @@ class CoreNodeService extends SiteService {
return core;
}
getCoreAuthStrategyName (core) {
return `dtp:${core.meta.domainKey}`;
}
getCoreRequestScheme ( ) {
return process.env.DTP_CORE_AUTH_SCHEME || 'https';
}
getCoreRequestUrl (core, requestUrl) {
const coreScheme = this.getCoreRequestScheme();
return `${coreScheme}://${core.address.host}:${core.address.port}${requestUrl}`;
}
getLocalUrl (url) {
const CORE_SCHEME = this.getCoreRequestScheme();
const { site } = this.dtp.config;
return `${CORE_SCHEME}://${site.domain}${url}`;
}
/**
* First ensures that a record exists in the local database for the Core node.
* Then, calls the node's info services to resolve more metadata about the
@ -276,11 +215,25 @@ class CoreNodeService extends SiteService {
return { connectedCount, pendingCount, potentialReach };
}
async sendKaleidoscopeEvent (event) {
const CORE_SCHEME = this.getCoreRequestScheme();
/**
* Sends a Kaleidoscope event to an array of recipients, a single recipient,
* or no recipients (undefined, a broadcast).
* @param {Object} event The event to be sent
* @param {Array} recipients Array of CoreUser to receive the event. Leave
* undefined to broadcast the event to all connected Core nodes.
* @returns Array of results, one per recipient.
*/
async sendKaleidoscopeEvent (event, recipients) {
const { hive: hiveService, userNotification: userNotificationService } = this.dtp.services;
const { pkg } = this.dtp;
const { site } = this.dtp.config;
const CORE_SCHEME = this.getCoreRequestScheme();
if (recipients && !Array.isArray(recipients)) {
recipients = [recipients];
}
event.source = Object.assign({
pkg: { name: pkg.name, version: pkg.version },
site,
@ -304,7 +257,48 @@ class CoreNodeService extends SiteService {
body: { event },
};
return this.broadcast(request);
if (!recipients) {
return this.broadcast(request);
}
let localEvent; // will be created if any local recipients
const results = [ ];
await SiteAsync.each(recipients, async (recipient) => {
switch (recipient.type) {
case 'CoreUser':
try {
const response = await this.sendRequest(recipient.core, request);
results.push({ success: true, recipient, request, response });
} catch (error) {
this.log.error('failed to deliver request to Core node', {
coreId: recipient.core._id,
request, error,
});
results.push({ success: false, recipient, request, error });
}
break;
case 'User':
try {
if (!localEvent) {
localEvent = await hiveService.createKaleidoscopeEvent(event);
}
await userNotificationService.create(recipient, localEvent);
results.push({ success: true, recipient, localEvent });
} catch (error) {
this.log.error('failed to deliver Kaleidoscope event to local user', { recipient, event, error });
results.push({ success: false, error });
}
break;
default:
results.push({ recipient, error: new SiteError(400, 'Recipient does not have a valid type')});
break;
}
}, 4);
return results;
}
async broadcast (request) {
@ -329,25 +323,6 @@ class CoreNodeService extends SiteService {
return results;
}
getCoreAuthStrategyName (core) {
return `dtp:${core.meta.domainKey}`;
}
getCoreRequestScheme ( ) {
return process.env.DTP_CORE_AUTH_SCHEME || 'https';
}
getCoreRequestUrl (core, requestUrl) {
const coreScheme = this.getCoreRequestScheme();
return `${coreScheme}://${core.address.host}:${core.address.port}${requestUrl}`;
}
getLocalUrl (url) {
const CORE_SCHEME = this.getCoreRequestScheme();
const { site } = this.dtp.config;
return `${CORE_SCHEME}://${site.domain}${url}`;
}
async sendRequest (core, request) {
try {
const req = new CoreNodeRequest();
@ -552,6 +527,87 @@ class CoreNodeService extends SiteService {
);
}
registerPassportCoreOAuth2 (core) {
const { coreNode: coreNodeService } = this.dtp.services;
const AUTH_SCHEME = coreNodeService.getCoreRequestScheme();
const coreAuthStrategyName = this.getCoreAuthStrategyName(core);
const authorizationHost = `${core.address.host}:${core.address.port}`;
const authorizationURL = `${AUTH_SCHEME}://${authorizationHost}/oauth2/authorize`;
const tokenURL = `${AUTH_SCHEME}://${authorizationHost}/oauth2/token`;
const callbackURL = `${AUTH_SCHEME}://${process.env.DTP_SITE_DOMAIN}/auth/core/${core._id}/callback`;
const coreAuthStrategy = new OAuth2Strategy(
{
authorizationURL,
tokenURL,
clientID: core.oauth.clientId.toString(),
clientSecret: core.oauth.clientSecret,
callbackURL,
},
async (accessToken, refreshToken, params, profile, cb) => {
const NOW = new Date();
try {
const coreUserId = mongoose.Types.ObjectId(params.coreUserId);
let user = await CoreUser.findOneAndUpdate(
{
core: core._id,
coreUserId,
},
{
$setOnInsert: {
created: NOW,
core: core._id,
coreUserId,
flags: {
isAdmin: false,
isModerator: false,
},
permissions: {
canLogin: true,
canChat: true,
canComment: true,
canReport: true,
},
optIn: {
system: true,
marketing: false,
},
theme: 'dtp-light',
resourceStats: ResourceStatsDefaults,
},
$set: {
updated: NOW,
username: params.username,
username_lc: params.username_lc,
displayName: params.displayName,
bio: params.bio,
},
},
{
upsert: true,
new: true,
},
);
user = user.toObject();
user.type = 'CoreUser';
return cb(null, user);
} catch (error) {
return cb(error);
}
},
);
this.log.info('registering Core auth strategy', {
name: coreAuthStrategyName,
host: core.address.host,
port: core.address.port,
clientID: core.oauth.clientId.toString(),
callbackURL,
});
passport.use(coreAuthStrategyName, coreAuthStrategy);
}
async getConnectedCores (pagination, withOAuth = false) {
let q = CoreNode.find({ 'flags.isConnected': true });
if (!withOAuth) {

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

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

11
app/services/oauth2.js

@ -40,6 +40,8 @@ class OAuth2Service extends SiteService {
}
async start ( ) {
await super.start();
const serverOptions = { };
this.log.info('creating OAuth2 server instance', { serverOptions });
@ -456,6 +458,15 @@ class OAuth2Service extends SiteService {
*/
return done(null, client);
}
/**
* Removes and fully de-authorizes an OAuth2Client from the system.
* @param {OAuth2Client} client the client to be removed
*/
async removeClient (client) {
this.log.info('removing client', { clientId: client._id, });
await OAuth2Client.deleteOne({ _id: client._id });
}
}
module.exports = {

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

204
app/services/sticker.js

@ -0,0 +1,204 @@
// sticker.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const slug = require('slug');
const mongoose = require('mongoose');
const Sticker = mongoose.model('Sticker');
const User = mongoose.model('User');
const { SiteService, SiteError, SiteAsync } = require('../../lib/site-lib');
const MAX_CHANNEL_STICKERS = 50;
const MAX_USER_STICKERS = 10;
class StickerService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
}
async start ( ) {
await super.start();
const { user: userService } = this.dtp.services;
this.populateSticker = [
{
path: 'owner',
select: userService.USER_SELECT,
},
];
this.queue = this.getJobQueue('media');
this.stickerTemplate = this.loadViewTemplate('sticker/components/sticker-standalone.pug');
}
async createSticker (ownerType, owner, file, stickerDefinition) {
const { minio: minioService } = this.dtp.services;
const NOW = new Date();
this.log.debug('received sticker', { file, stickerDefinition });
// this is faster than Model.countDocuments()
const currentStickers = await Sticker.find({ owner: owner._id }).select('_id').lean();
switch (ownerType) {
case 'ChatRoom':
if (currentStickers.length >= MAX_CHANNEL_STICKERS) {
throw new SiteError(508, `You have ${MAX_CHANNEL_STICKERS} stickers. Please remove a sticker before adding a new one.`);
}
break;
case 'User':
if (currentStickers.length >= MAX_USER_STICKERS) {
throw new SiteError(508, `You have ${MAX_USER_STICKERS} stickers. Please remove a sticker before adding a new one one.`);
}
break;
}
const sticker = new Sticker();
sticker.created = NOW;
sticker.status = 'processing';
sticker.ownerType = ownerType;
sticker.owner = owner._id;
sticker.slug = slug(stickerDefinition.slug.toLowerCase().trim());
const bucket = process.env.MINIO_VIDEO_BUCKET;
const key = `/stickers/${sticker._id.toString().slice(0, 3)}/${sticker._id}`;
sticker.original = {
bucket, key,
type: file.mimetype,
size: file.size,
};
await minioService.uploadFile({
bucket, key,
filePath: file.path,
metadata: {
'Content-Type': file.mimetype,
'Content-Length': file.size,
},
});
await sticker.save();
await this.queue.add('sticker-ingest', { stickerId: sticker._id });
return sticker.toObject();
}
async getForChannel (channel) {
const stickers = await Sticker
.find({ status: 'live', ownerType: 'Channel', owner: channel._id })
.sort({ created: -1 })
.populate(this.populateSticker)
.lean();
return stickers;
}
async getForUser (user) {
const stickers = await Sticker
.find({ status: 'live', ownerType: 'User', owner: user._id })
.sort({ created: -1 })
.populate(this.populateSticker)
.lean();
return stickers;
}
async getStickers (pagination) {
const stickers = await Sticker
.find()
.sort({ created: -1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.populate(this.populateSticker)
.lean();
return stickers;
}
async getBySlug (slug) {
const sticker = await Sticker
.findOne({ slug })
.populate(this.populateSticker)
.lean();
return sticker;
}
async getById (stickerId, includeOriginal = false) {
let query = Sticker.findOne({ _id: stickerId });
if (includeOriginal) {
query = query.select('+original');
}
const sticker = await query
.populate(this.populateSticker)
.lean();
return sticker;
}
async setStatus (sticker, status) {
await Sticker.updateOne({ _id: sticker._id }, { $set: { status } });
}
async resolveStickerSlugs (slugs) {
const stickers = [ ];
await SiteAsync.each(slugs, async (slug) => {
const sticker = await Sticker.findOne({ slug: slug });
if (!sticker) {
return;
}
stickers.push(sticker);
});
return stickers;
}
async removeSticker (sticker) {
const stickerId = sticker._id;
this.log.info('creating sticker delete job', { stickerId });
await this.queue.add('sticker-delete', { stickerId });
}
async render (sticker, stickerOptions) {
return this.stickerTemplate({ sticker, stickerOptions });
}
async addFavorite (user, stickerId) {
stickerId = mongoose.Types.ObjectId(stickerId);
const sticker = await Sticker.findById(stickerId);
if (!sticker) {
throw new SiteError(404, 'Sticker not found');
}
await User.updateOne(
{ _id: user._id },
{
$addToSet: { favoriteStickers: sticker._id },
},
);
}
async removeFavorite (user, stickerId) {
stickerId = mongoose.Types.ObjectId(stickerId);
await User.updateOne(
{ _id: user._id },
{
$pull: { favoriteStickers: stickerId },
},
);
}
async getFavorites (user) {
const stickers = await Sticker
.populate(user.favoriteStickers, this.populateSticker);
return stickers;
}
}
module.exports = {
slug: 'sticker',
name: 'sticker',
create: (dtp) => { return new StickerService(dtp); },
};

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

39
app/services/user.js

@ -9,6 +9,7 @@ const path = require('path');
const mongoose = require('mongoose');
const User = mongoose.model('User');
const CoreUser = mongoose.model('CoreUser');
const UserBlock = mongoose.model('UserBlock');
const passport = require('passport');
@ -24,6 +25,8 @@ class UserService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
this.USER_SELECT = '_id username username_lc displayName picture';
this.reservedNames = require(path.join(this.dtp.config.root, 'config', 'reserved-names'));
this.populateUser = [
@ -37,6 +40,7 @@ class UserService extends SiteService {
}
async start ( ) {
await super.start();
this.log.info(`starting ${module.exports.name} service`);
this.registerPassportLocal();
@ -48,6 +52,7 @@ class UserService extends SiteService {
async stop ( ) {
this.log.info(`stopping ${module.exports.name} service`);
await super.stop();
}
async create (userDefinition) {
@ -443,22 +448,46 @@ class UserService extends SiteService {
}
async getPublicProfile (username) {
if (!username || (typeof username !== 'string') || (username.length === 0)) {
if (!username || (typeof username !== 'string')) {
throw new SiteError(406, 'Invalid username');
}
username = username.trim().toLowerCase();
const user = await User
if (username.length === 0) {
throw new SiteError(406, 'Invalid username');
}
/**
* Try to resolve the user as a CoreUser
*/
let user = await CoreUser
.findOne({ username_lc: username })
.select('_id created username username_lc displayName bio picture header')
.select('_id created username username_lc displayName bio picture header core')
.populate(this.populateUser)
.lean();
if (user) {
user.type = 'CoreUser';
} else {
/*
* Try to resolve the user as a local User
*/
user = await User
.findOne({ username_lc: username })
.select('_id created username username_lc displayName bio picture header')
.populate(this.populateUser)
.lean();
if (user) {
user.type = 'User';
}
}
return user;
}
async getRecent (maxCount = 3) {
const users = User
.find()
.select('_id created username username_lc displayName picture')
.select(UserService.USER_SELECT)
.sort({ created: -1 })
.limit(maxCount)
.lean();
@ -578,7 +607,7 @@ class UserService extends SiteService {
height: 64,
format: 'jpeg',
formatParameters: {
conpressionLevel: 9,
compressionLevel: 9,
},
},
];

19
app/views/admin/service-node/editor.pug

@ -18,15 +18,30 @@ block content
.uk-margin
label(for="notes").uk-form-label Notes
textarea(id="notes", name="notes", rows="4", placeholder="Enter client notes").uk-textarea= serviceNode.admin.notes
textarea(id="notes", name="notes", rows="4", placeholder="Enter client notes").uk-textarea= (serviceNode.admin && serviceNode.admin.notes) ? serviceNode.admin.notes : undefined
.uk-margin
label(for="is-active")
input(id="is-active", name="isActive", type="checkbox", checked= serviceNode.flags.isActive).uk-checkbox
span.uk-margin-small-left Is Active
.uk-card-footer
div(uk-grid).uk-grid-small
.uk-width-expand
+renderBackButton()
.uk-width-auto
button(
type="button",
data-service-node-id= serviceNode._id,
onclick="return dtp.adminApp.deleteServiceNode(event);"
).uk-button.uk-button-danger.uk-border-rounded
span
i.fas.fa-trash
span.uk-margin-small-left Delete
.uk-width-auto
button(type="submit").uk-button.uk-button-primary Save
button(type="submit").uk-button.uk-button-primary.uk-border-rounded
span
i.fas.fa-save
span.uk-margin-small-left Save

14
app/views/announcement/components/announcement.pug

@ -6,7 +6,13 @@ mixin renderAnnouncement (announcement)
i(class=`fas ${announcement.title.icon.class}`, style=`color: ${announcement.title.icon.color}`)
span.uk-margin-small-left= announcement.title.content
.uk-card-body!= marked.parse(announcement.content, { renderer: marked.Renderer() })
.uk-card-footer
.uk-text-small.uk-text-muted.uk-flex.uk-flex-between
div= moment(announcement.created).format('MMM DD, YYYY')
div= moment(announcement.created).format('hh:mm a')
.uk-card-footer.uk-text-small.uk-text-muted
div(uk-grid).uk-grid-small.uk-grid-divider
.uk-width-auto
a(href=`/announcement/${announcement._id}`)= moment(announcement.created).format('MMM DD, YYYY [at] hh:mm a')
if currentView !== 'announcement'
.uk-width-auto
a(href=`/announcement/${announcement._id}`)
span
i.fas.fa-link
span.uk-margin-small-left Open Announcement

11
app/views/announcement/view.pug

@ -0,0 +1,11 @@
extends ../layouts/main
block content
include ../comment/components/section
include components/announcement
section.uk-section.uk-section-default.uk-section-small
.uk-container
+renderAnnouncement(announcement)
+renderCommentSection({ name: `announcement-${announcement._id}`, rootUrl: `/announcement/${announcement._id}/comment` })

151
app/views/chat/components/input-form.pug

@ -0,0 +1,151 @@
include reaction-button
mixin renderChatInputForm (room, options = { })
form(
id="chat-input-form",
data-room-id= room._id,
onsubmit="return window.dtp.app.chat.sendUserChat(event);",
).uk-form
input(type="hidden", name="roomType", value= "ChatRoom")
input(type="hidden", name="room", value= room._id)
#site-emoji-picker
div THIS IS THE EMOJI PICKER
.uk-padding-small.uk-padding-remove-bottom
textarea(
id="chat-input-text",
name="content",
rows="2",
hidden= options.inputHidden,
).uk-textarea.uk-margin-small
div(uk-grid).uk-grid-small.uk-flex-middle
.uk-width-auto
button(
type= "button",
title= "Insert emoji",
data-target-element= "chat-input-text",
onclick= "return dtp.app.chat.toggleEmojiPicker(event);",
).uk-button.uk-button-default.uk-button-small
span
i.far.fa-laugh-beam
.uk-width-auto
button(
type= "button",
title= "Sticker Picker",
uk-toggle={ target: '#sticker-picker'},
onclick="return dtp.app.chat.openChatInput();",
).uk-button.uk-button-default.uk-button-small
span
i.far.fa-image
#sticker-picker(uk-modal)
.uk-modal-dialog.uk-modal-body
button(type="button", uk-close).uk-modal-close-default
h4.uk-text-center Sticker Picker 9000&trade;
ul(uk-tab).uk-flex-center
li.uk-active
a(href="")= user.displayName || user.username
li
a(href="")= room.name
li
a(href="") Favorites
ul.uk-switcher.chat-sticker-picker
//- Personal stickers
li
if Array.isArray(userStickers) && (userStickers.length > 0)
div(uk-grid).uk-grid-small.uk-flex-center
each sticker in userStickers
.uk-width-auto
button(
type="button",
data-sticker-id= sticker._id,
data-sticker-slug= sticker.slug,
onclick="return dtp.app.chat.insertChatSticker(event);",
).uk-button.uk-button-text
+renderSticker(sticker)
else
.uk-text-center You haven't uploaded any #[a(href="/sticker") Stickers] yet
//- Channel stickers
li
if Array.isArray(roomStickers) && (roomStickers.length > 0)
div(uk-grid).uk-grid-small.uk-flex-center
each sticker in roomStickers
.uk-width-auto
button(
type="button",
data-sticker-id= sticker._id,
data-sticker-slug= sticker.slug,
onclick="return dtp.app.chat.insertChatSticker(event);",
).uk-button.uk-button-text
+renderSticker(sticker)
else
.uk-text-center This room hasn't uploaded any #[a(href="/sticker") Stickers] yet
//- Favorite/Saved stickers
li
if Array.isArray(favoriteStickers) && (favoriteStickers.length > 0)
div(uk-grid).uk-grid-small.uk-flex-center
each sticker in favoriteStickers
.uk-width-auto
button(
type="button",
data-sticker-id= sticker._id,
data-sticker-slug= sticker.slug,
onclick="return dtp.app.chat.insertChatSticker(event);",
).uk-button.uk-button-text
+renderSticker(sticker)
else
.uk-text-center You haven't saved any Favorite stickers
.uk-width-auto
button(
type= "button",
title= "Attach image",
onclick="return dtp.app.chat.createAttachment(event);",
).uk-button.uk-button-default.uk-button-small
span
i.fas.fa-file-image
.uk-width-expand
if !options.hideHomeNav
.uk-text-small.uk-text-center.uk-text-truncate
a(href="/chat", title= "Chat Home").uk-button.uk-button-default.uk-button-small
span
i.fas.fa-home
.uk-width-auto
button(
id="chat-input-btn",
type="button",
onclick="return dtp.app.chat.toggleChatInput(event);",
title= "Toggle Chat Input",
).uk-button.uk-button-default.uk-button-small
span
i.fas.fa-edit
.uk-width-auto
button(
id="chat-send-btn",
type="submit",
title= "Send Message",
).uk-button.uk-button-primary.uk-button-small
span
i.far.fa-paper-plane
div(style="margin-top: 4px;")
.uk-flex.uk-flex-between
.uk-width-auto
+renderReactionButton('Applaud/clap', '👏', 'clap')
.uk-width-auto
+renderReactionButton("On Fire!", '🔥', 'fire')
.uk-width-auto
+renderReactionButton("Happy", "🤗", "happy")
.uk-width-auto
+renderReactionButton("Laugh", "🤣", "laugh")
.uk-width-auto
+renderReactionButton("Angry", "🤬", "angry")
.uk-width-auto
+renderReactionButton("Honk", "🤡", "honk")

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

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

41
app/views/chat/components/message.pug

@ -0,0 +1,41 @@
include ../../sticker/components/sticker
mixin renderChatMessage (message, options = { })
- var authorName = message.author.displayName || message.author.username;
div(
data-message-id= message._id, data-author-id= message.author._id
).chat-message
.uk-margin-small
div(uk-grid).uk-grid-small
.uk-width-auto
+renderProfileIcon(message.author, message.author.displayName || message.author.username, 'xsmall')
.uk-width-expand
.chat-username.uk-text-truncate= message.author.displayName || message.author.username
.uk-text-small.uk-text-muted.uk-text-truncate= message.author.username
if !options.hideMenu && (user && !message.author._id.equals(user._id))
.uk-width-auto.chat-user-menu
button(type="button").uk-button.uk-button-link.chat-menu-button
i.fas.fa-ellipsis-h
div(data-message-id= message._id, uk-dropdown="mode: click").dtp-chatmsg-menu
ul.uk-nav.uk-dropdown-nav
li
a(
href="",
data-message-id= message._id,
data-user-id= message.author._id,
data-username= message.author.username,
onclick="return dtp.app.muteChatUser(event);",
) Mute #{authorName}
//- we're gonna go ahead and force long lines to break, and the content was
//- filtered at ingest for zalgo and HTML/XSS
.chat-content.uk-text-break!= marked.parse(message.content)
//- "time" is filled in by the JavaScript client using the browser's locale
//- information so that "time" is always in the user's display timezone.
.chat-timestamp(data-dtp-timestamp= message.created).uk-text-small
if Array.isArray(message.stickers) && (message.stickers.length > 0)
each sticker in message.stickers
+renderSticker(sticker, { hideSlug: true })

8
app/views/chat/components/reaction-button.pug

@ -0,0 +1,8 @@
mixin renderReactionButton (title, emoji, reaction)
button(
title= title,
data-reaction= reaction,
onclick="return dtp.app.chat.sendReaction(event);",
).dtp-button-reaction
span.button-icon= emoji
span(class="uk-visible@l").count-label

17
app/views/chat/components/room-list.pug

@ -0,0 +1,17 @@
mixin renderRoomList (rooms, options)
ul#room-list.uk-nav.uk-nav-default
li.uk-nav-header
div(uk-grid).uk-grid-small
.uk-text-bold.uk-width-expand= options.title
if !options.hideCreate
.uk-width-auto
a(href='/chat/room/create', title= "Create new chat room...").uk-link-reset
i.fas.fa-plus
if Array.isArray(rooms) && (rooms.length > 0)
each room in rooms
li.uk-active
a(href=`/chat/room/${room._id}`)= room.name
else
li= options.emptyPrompt

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

22
app/views/chat/index.pug

@ -0,0 +1,22 @@
extends layouts/room
block content
include components/message
#site-chat-container.uk-flex.uk-flex-column.uk-height-1-1
.chat-menubar.uk-padding-small
div(uk-grid).uk-grid-small
.uk-width-auto
img(src=`/img/icon/${site.domainKey}/icon-48x48.png`, alt=`${site.name} icon`)
.uk-width-expand
h1.uk-margin-remove #{site.name} Chat Timeline
.chat-content-wrapper
#chat-message-list-wrapper.uk-height-1-1
#chat-message-list
each message in timeline
+renderChatMessage(message, { includeRoomInfo: true })
.chat-message-menu
button(type="button", onclick="return dtp.app.chat.resumeChatScroll(event);").chat-scroll-return Resume scrolling
//- pre= JSON.stringify(userTimeline, null, 2)

39
app/views/chat/layouts/room.pug

@ -0,0 +1,39 @@
extends ../../layouts/main
block page-footer
block content-container
include ../components/user-list-entry
include ../components/room-list
section.site-chat-section
div(uk-grid).uk-height-1-1
div(class="uk-width-1-1 uk-width-1-5@l uk-flex-last uk-flex-first@l").uk-height-1-1.uk-overflow-auto
.site-chat-sidebar-widget.uk-border-rounded.uk-margin
+renderRoomList(ownedChatRooms, { title: "Your Rooms", emptyPrompt: "You don't own any chat rooms" })
.site-chat-sidebar-widget.uk-border-rounded
+renderRoomList(joinedChatRooms, { title: "Joined Rooms", emptyPrompt: "You haven't joined any chat rooms", hideCreate: true })
div(class="uk-width-1-1 uk-width-expand@l").uk-height-1-1
#chat-room.uk-height-1-1
block content
div(class="uk-width-1-1 uk-width-1-5@l").uk-height-1-1.uk-overflow-auto
if room
.site-chat-sidebar-widget.uk-border-rounded
ul#room-member-list.uk-nav.uk-nav-default
li.uk-nav-header Room Owner
li
+renderUserListEntry(room.owner, 'owner')
if room.moderators && (room.moderators.length > 0)
li.uk-nav-header Moderators
each membership in room.moderators
li
+renderUserListEntry(membership.member, 'moderator')
if Array.isArray(room.members) && (room.members.length > 0)
li.uk-nav-header Members
each membership in room.members
li
+renderUserListEntry(membership.member, 'member')

66
app/views/chat/room/editor.pug

@ -0,0 +1,66 @@
extends ../layouts/room
block content
- var actionUrl = room ? `/chat/room/${room._id}` : '/chat/room';
.content-block.uk-height-1-1.uk-overflow-auto
form(method="POST", action= actionUrl).uk-form
.uk-card.uk-card-default.uk-card-small
.uk-card-header
div(uk-grid).uk-grid-small.uk-flex-middle
.uk-width-expand
h1.uk-card-title.uk-text-truncate #{room ? room.name : 'Create Chat Room'}
if room
.uk-width-auto
button(type="button", onclick="return dtp.app.chat.deleteChatRoom(event);").uk-button.uk-button-danger.uk-border-rounded
span
i.fas.fa-trash
span.uk-margin-small-left Delete
.uk-card-body
.uk-margin
label(for="name").uk-form-label Room name
input(id="name", name="name", type="text", placeholder="Enter room name", value= room ? room.name : undefined).uk-input
.uk-margin
label(for="description").uk-form-label Room description
textarea(id="description", name="description", rows="2", placeholder="Enter room description").uk-textarea= room ? room.description : undefined
.uk-margin
label(for="policy").uk-form-label Room policy
textarea(id="policy", name="policy", rows="2", placeholder="Enter room use policy").uk-textarea= room ? room.policy : undefined
.uk-margin
div(uk-grid)
.uk-width-auto
fieldset
legend Room Visibility
div(uk-grid).uk-grid-small.uk-flex-middle
.uk-width-auto
label
input(id="is-public", name="visibility", type="radio", value="public", checked= room ? room.visibility === 'public' : true).uk-radio
| Public
.ui-width-auto
label
input(id="is-private", name="visibility", type="radio", value="private", checked= room ? room.visibility === 'private' : false).uk-radio
| Private
.uk-width-auto
fieldset
legend Membership Policy
div(uk-grid).uk-grid-small.uk-flex-middle
.uk-width-auto
label
input(id="membership-open", name="membershipPolicy", type="radio", value="open", checked= room ? room.membershipPolicy === 'open' : true).uk-radio
| Open
.uk-width-auto
label
input(id="membership-closed", name="membershipPolicy", type="radio", value="closed", checked= room ? room.membershipPolicy === 'closed' : false).uk-radio
| Closed
.uk-card-footer
div(uk-grid)
.uk-width-expand
+renderBackButton()
.uk-width-auto
button(type="submit").uk-button.uk-button-primary.uk-border-rounded #{room ? 'Update' : 'Create'} room

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

93
app/views/chat/room/view.pug

@ -0,0 +1,93 @@
extends ../layouts/room
block content
include ../../user/components/profile-icon
include ../components/input-form
include ../components/message
#site-chat-container.uk-flex.uk-flex-column.uk-height-1-1
.chat-menubar.uk-padding-small
div(uk-grid).uk-grid-small.uk-flex-middle
div(class="uk-width-expand").no-select
div(title= room.name).chat-room-name.uk-margin-remove.uk-text-truncate= room.name
.uk-text-small.uk-text-muted.uk-text-truncate= room.description
div(title="Total Members", class="uk-visible@m").uk-width-auto.no-select
span
i.fas.fa-users
span.uk-margin-small-left= formatCount(room.members.length)
if !user || !room.owner._id.equals(user._id)
.uk-width-auto
.uk-inline
button(type="button").uk-button.uk-button-link.uk-button-small
i.fas.fa-ellipsis-h
#chat-room-menu(uk-dropdown={ pos: 'bottom-right', mode: 'click' })
ul.uk-nav.uk-nav-default.uk-dropdown-nav
li
a(href=`/chat/room/${room._id}/widget`, target="_blank")
span.nav-item-icon
i.fas.fa-comment-dots
span Pop-Out Chat
if user && !room.owner._id.equals(user._id)
a(
href=""
data-room-id= room._id,
onclick="return dtp.app.chat.leaveRoom(event);",
)
span.nav-item-icon
i.fas.fa-sign-out-alt
span Leave Room
if user && room.owner._id.equals(user._id)
li.uk-nav-divider
li
a(
href="",
data-room-id= room._id,
onclick=`return dtp.app.chat.showForm(event, '${room._id}', 'invite-member');`
)
span.nav-item-icon
i.fas.fa-user-plus
span Invite New Member
li
a(
href=`/chat/room/${room._id}/invite`,
data-room-id= room._id,
)
span.nav-item-icon
i.fas.fa-mail-bulk
span Manage Invites
li.uk-nav-divider
li
a(href=`/chat/room/${room._id}/settings`)
span.nav-item-icon
i.fas.fa-cog
span Settings
.chat-content-wrapper
div(uk-grid).uk-grid-small.uk-height-1-1
#chat-webcam-container(hidden).uk-width-auto
ul#chat-webcam-list.uk-list
.uk-width-expand
#chat-message-list-wrapper.uk-height-1-1
#chat-message-list
each message in chatMessages || [ ]
+renderChatMessage(message)
#chat-reactions.no-select
.chat-message-menu
button(type="button", onclick="return dtp.app.chat.resumeChatScroll(event);").chat-scroll-return Resume scrolling
+renderChatInputForm(room)

2
app/views/comment/components/comment-list-standalone.pug

@ -1,4 +1,4 @@
include ../../components/library
include comment-list
include composer
+renderCommentList(comments, { rootUrl: `/post/${post.slug}/comment`, countPerPage })
+renderCommentList(comments, { name: appletName || 'comments', rootUrl: `/post/${post.slug}/comment`, countPerPage })

9
app/views/comment/components/comment-list.pug

@ -4,7 +4,10 @@ mixin renderCommentList (comments, options = { })
if Array.isArray(comments) && (comments.length > 0)
each comment in comments
li(data-comment-id= comment._id)
+renderComment(comment)
-
var commentOptions = Object.assign({ }, options);
commentOptions.name = `${options.name}-reply-${comment._id}`;
+renderComment(comment, commentOptions)
if (comments.length >= options.countPerPage)
- var buttonId = mongoose.Types.ObjectId();
@ -13,7 +16,7 @@ mixin renderCommentList (comments, options = { })
type="button",
data-button-id= buttonId,
data-post-id= post._id,
data-next-page= pagination.p + 1,
data-next-page= options.pagination ? options.pagination.p + 1 : 2,
data-root-url= options.rootUrl,
onclick= `return dtp.app.loadMoreComments(event);`,
onclick= `return dtp.app.comments['${options.name}'].loadMoreComments(event);`,
).uk-button.dtp-button-primary LOAD MORE

2
app/views/comment/components/comment-standalone.pug

@ -1,4 +1,4 @@
include ../../components/library
include comment
include composer
+renderComment(comment)
+renderComment(comment, appletOptions || { })

31
app/views/comment/components/comment.pug

@ -1,6 +1,6 @@
include composer
mixin renderComment (comment)
mixin renderComment (comment, options)
- var resourceId = comment.resource._id || comment.resource;
article(data-comment-id= comment._id).uk-comment.dtp-site-comment
header.uk-comment-header
@ -28,7 +28,7 @@ mixin renderComment (comment)
a(
href="",
data-comment-id= comment._id,
onclick="return dtp.app.deleteComment(event);",
onclick=`return dtp.app.comments.deleteComment(event);`,
) Delete
else if user
li.uk-nav-header.no-select Moderation menu
@ -38,7 +38,7 @@ mixin renderComment (comment)
data-resource-type= comment.resourceType,
data-resource-id= resourceId,
data-comment-id= comment._id,
onclick="return dtp.app.showReportCommentForm(event);",
onclick=`return dtp.app.comments.showReportCommentForm(event);`,
) Report
li
a(
@ -46,7 +46,7 @@ mixin renderComment (comment)
data-resource-type= comment.resourceType,
data-resource-id= resourceId,
data-comment-id= comment._id,
onclick="return dtp.app.blockCommentAuthor(event);",
onclick=`return dtp.app.comments.blockCommentAuthor(event);`,
) Block author
.uk-comment-body
@ -82,41 +82,46 @@ mixin renderComment (comment)
type="button",
data-comment-id= comment._id,
data-vote="up",
onclick="return dtp.app.submitCommentVote(event);",
onclick=`return dtp.app.comments.submitCommentVote(event);`,
title="Upvote this comment",
).uk-button.uk-button-link
+renderLabeledIcon('fa-chevron-up', formatCount(comment.stats.upvoteCount))
+renderLabeledIcon('fa-chevron-up', formatCount(comment.resourceStats.upvoteCount))
.uk-width-auto
button(
type="button",
data-comment-id= comment._id,
data-vote="down",
onclick="return dtp.app.submitCommentVote(event);",
onclick=`return dtp.app.comments.submitCommentVote(event);`,
title="Downvote this comment",
).uk-button.uk-button-link
+renderLabeledIcon('fa-chevron-down', formatCount(comment.stats.downvoteCount))
+renderLabeledIcon('fa-chevron-down', formatCount(comment.resourceStats.downvoteCount))
.uk-width-auto
button(
type="button",
data-comment-id= comment._id,
onclick="return dtp.app.openReplies(event);",
onclick=`return dtp.app.comments.openReplies(event);`,
title="Load replies to this comment",
).uk-button.uk-button-link
+renderLabeledIcon('fa-comment', formatCount(comment.stats.replyCount))
+renderLabeledIcon('fa-comment', formatCount(comment.commentStats.replyCount))
.uk-width-auto
button(
type="button",
data-comment-id= comment._id,
onclick="return dtp.app.openReplyComposer(event);",
onclick=`return dtp.app.comments.openReplyComposer(event);`,
title="Write a reply to this comment",
).uk-button.uk-button-link
+renderLabeledIcon('fa-reply', 'reply')
//- Comment replies and reply composer
div(data-comment-id= comment._id, hidden).dtp-reply-composer.uk-margin
div(
data-comment-id= comment._id,
data-root-url= options.rootUrl,
dtp-comments= options.name,
hidden,
).dtp-reply-composer.uk-margin
if user && user.permissions.canComment
.uk-margin
+renderCommentComposer(`/post/${comment.resource._id}/comment`, { showCancel: true, replyTo: comment._id })
+renderCommentComposer(`composer-reply-${comment._id}`, Object.assign({ showCancel: true, replyTo: comment._id }, options))
div(data-comment-id= comment._id, hidden).dtp-reply-list-container.uk-margin
ul(data-comment-id= comment._id).dtp-reply-list.uk-list.uk-margin-medium-left

29
app/views/comment/components/composer.pug

@ -1,36 +1,44 @@
mixin renderCommentComposer (actionUrl, options = { })
form(method="POST", action= actionUrl, onsubmit="return dtp.app.submitForm(event, 'create-comment');").uk-form
mixin renderCommentComposer (formId, options = { })
form(
id= formId,
method="POST",
action= options.rootUrl,
onsubmit="return dtp.app.submitForm(event, 'create-comment');",
).uk-form
if options.replyTo
input(type="hidden", name="replyTo", value= options.replyTo)
.uk-card.uk-card-secondary.uk-card-small
.uk-card-body
textarea(
id="content",
textarea#comment-content(
name="content",
rows="4",
maxlength="3000",
placeholder="Enter comment",
oninput="return dtp.app.onCommentInput(event);",
data-form-id= formId,
oninput=`return dtp.app.comments.onCommentInput(event);`,
).uk-textarea.uk-resize-vertical
.uk-text-small
div(uk-grid).uk-flex-between
.uk-width-auto You are commenting as: #{user.username}
.uk-width-auto #[span#comment-character-count 0] of 3,000
.uk-width-auto #[span.comment-character-count 0] of 3,000
.uk-card-footer
div(uk-grid).uk-flex-between.uk-grid-small
.uk-width-expand
ul.uk-subnav
li
li.comment-emoji-picker
button(
type="button",
data-target-element="content",
title="Add an emoji",
onclick="return dtp.app.showEmojiPicker(event);",
uk-tooltip="Add an emoji",
).uk-button.dtp-button-default
span
i.far.fa-smile
.comment-emoji-picker-drop(data-form-id= formId, uk-drop={ mode: 'click' })
.comment-emoji-picker-ui
div THIS IS THE EMOJI PICKER
li(title="Not Safe For Work will hide your comment text by default")
label
input(id="is-nsfw", name="isNSFW", type="checkbox").uk-checkbox
@ -39,5 +47,6 @@ mixin renderCommentComposer (actionUrl, options = { })
if options.showCancel
.uk-width-auto
button(type="submit").uk-button.dtp-button-secondary Cancel
.uk-width-auto
button(type="submit").uk-button.dtp-button-primary Post

2
app/views/comment/components/reply-list-standalone.pug

@ -1,4 +1,4 @@
include ../../components/library
include comment-list
include composer
+renderCommentList(comments, { rootUrl: `/comment/${comment._id}/replies`, countPerPage })
+renderCommentList(comments, { name: appletName || 'comments', rootUrl: `/comment/${comment._id}/replies`, countPerPage })

38
app/views/comment/components/section.pug

@ -0,0 +1,38 @@
include composer
include comment-list
mixin renderCommentSection (options = { })
section
.uk-container
if user && user.permissions.canComment
-
const composerOptions = Object.assign({ }, options);
composerOptions.name = `${options.name}-composer`;
.content-block(dtp-comments= composerOptions.name, data-root-url= options.rootUrl)
.uk-margin
+renderSectionTitle('Add a comment')
.uk-margin-small
+renderCommentComposer(composerOptions.name, composerOptions)
if featuredComment
.content-block(dtp-comments= `${options.name}-feature`, data-root-url= options.rootUrl)
#featured-comment.uk-margin-large
.uk-margin
+renderSectionTitle('Linked Comment')
-
const featureOptions = Object.assign({ }, options);
featureOptions.name = `${options.name}-feature`;
+renderComment(featuredComment, featureOptions)
.content-block(dtp-comments= options.name, data-root-url= options.rootUrl)
+renderSectionTitle('Comments')
if Array.isArray(comments) && (comments.length > 0)
ul#post-comment-list.uk-list.uk-list-divider.uk-list-large
+renderCommentList(comments, Object.assign({
countPerPage: countPerPage || 10,
rootUrl: options.rootUrl,
}, options))
else
ul#post-comment-list.uk-list.uk-list-divider.uk-list-large
div There are no comments at this time. Please check back later.

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

12
app/views/components/off-canvas.pug

@ -20,9 +20,21 @@ mixin renderMenuItem (iconClass, label)
a(href='/').uk-display-block
+renderMenuItem('fa-home', 'Home')
li(class={ "uk-active": (currentView === 'announcement') })
a(href='/announcement').uk-display-block
+renderMenuItem('fa-bullhorn', 'Announcements')
if user
li.uk-nav-header Member Menu
li(class={ "uk-active": (currentView === 'chat') })
a(href=`/chat`).uk-display-block
div(uk-grid).uk-grid-collapse
.uk-width-auto
.app-menu-icon
i.fas.fa-comment-alt
.uk-width-expand Chat
li(class={ "uk-active": (currentView === 'user-settings') })
a(href=`/user/${user._id}`).uk-display-block
div(uk-grid).uk-grid-collapse

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

14
app/views/layouts/main.pug

@ -13,12 +13,13 @@ html(lang='en')
meta(name="robots", content= "index,follow")
meta(name="googlebot", content= "index,follow")
meta(content="#4a4a4a" name="theme-color")
meta(content="black-translucent" name="apple-mobile-web-app-status-bar-style")
meta(name="theme-color", content="#4a4a4a")
meta(name="apple-mobile-web-app-status-bar-style", content="black-translucent")
block css
link(rel='stylesheet', href=`/fontawesome/css/all.min.css?v=${pkg.version}`)
link(rel='stylesheet', href=`/pretty-checkbox/pretty-checkbox.min.css?v=${pkg.version}`)
block vendorcss
@ -95,12 +96,13 @@ html(lang='en')
if user
script.
window.dtp.user = !{JSON.stringify(safeUser, null, 2)}
window.dtp.domain = !{JSON.stringify(site.domain)}
window.dtp.user = !{JSON.stringify(safeUser, null, 2)};
window.dtp.domain = !{JSON.stringify(site.domain)};
window.dtp.env = !{JSON.stringify(env.NODE_ENV)};
if channel
if room
script.
dtp.channel = !{JSON.stringify(channel || null)};
dtp.room = !{JSON.stringify(room || null)};
if DTP_SCRIPT_DEBUG
script(src=`/dist/js/dtpweb-app.js?v=${pkg.version}`, type="module")

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

2
app/views/sticker/components/sticker-standalone.pug

@ -0,0 +1,2 @@
include sticker
+renderSticker(sticker, stickerOptions || { })

19
app/views/sticker/components/sticker.pug

@ -0,0 +1,19 @@
mixin renderSticker (sticker, options = { })
if sticker && sticker.encoded
div(
title= `:${sticker.slug}:`,
data-sticker-id= sticker._id,
data-sticker-slug= sticker.slug,
onclick= 'return dtp.app.showStickerMenu(event);',
).chat-sticker.uk-text-center
case sticker.encoded.type
when 'video/mp4'
video(playsinline, autoplay, muted, loop)
source(src=`/sticker/${sticker._id}/media`)
when 'image/png'
img(src=`/sticker/${sticker._id}/media`)
when 'image/jpg'
img(src=`/sticker/${sticker._id}/media`)
if !options.hideSlug
.uk-text-small.uk-text-muted :#{sticker.slug}:

69
app/views/sticker/index.pug

@ -0,0 +1,69 @@
extends ../layouts/main
block content
include ../sticker/components/sticker
mixin renderStickerList (stickers)
div(uk-grid).uk-grid-small
each sticker in stickers
div(class="uk-width-1-1 uk-width-auto@s")
a(href=`/sticker/${sticker._id}`).uk-display-block.uk-text-center
+renderSticker(sticker)
mixin renderStickerUploadForm (actionUrl, channel)
form(method="POST", action= actionUrl, enctype="multipart/form-data").uk-form
if channel
input(id="channel-id", type="hidden", name="channel", value= channel._id)
.uk-margin
input(id="sticker-slug", name="slug", type="text", placeholder= "Enter sticker name").uk-input
div(uk-grid).uk-grid-small
.uk-width-auto
.uk-form-custom
input(id="sticker-file", name="stickerFile", type="file")
button(type="button").uk-button.dtp-button-default
span
i.far.fa-image
span.uk-margin-small-left Select File
.uk-width-auto
button(type="submit").uk-button.dtp-button-primary
span
i.fas.fa-plus
span.uk-margin-small-left Add sticker
section.uk-section.uk-section-default.uk-section-small
.uk-container
div(uk-grid)
div(class="uk-width-1-1 uk-width-2-3@l")
h2 #{user.displayName || user.username}'s stickers
.uk-margin
+renderStickerUploadForm('/sticker')
.uk-margin
if Array.isArray(userStickers) && (userStickers.length > 0)
+renderStickerList(userStickers)
else
div #{user.displayName || user.username} has no stickers.
if channel
section.uk-section.uk-section-default.uk-section-small
.uk-container
h2 #{channel.name}'s stickers
.uk-margin
+renderStickerUploadForm('/sticker', channel)
.uk-margin
if Array.isArray(channelStickers) && (channelStickers.length > 0)
+renderStickerList(channelStickers)
else
div #{channel.name} has no stickers.
div(class="uk-width-1-1 uk-width-1-3@l")
h1 Stickers
p Stickers accepts PNG, JPEG, GIF and MP4 files. Transparency/alpha is supported in the PNG format. Animations will be transcoded to MP4.
p Stickers can be up to 2MB, and animated stickers are limited to 10 seconds in duration regardless of format. Resolutions from 100x100 to 320x100 are accepted. Larger Stickers will be scaled to cover an aspect-correct reduction, if possible. Some cropping may occur.
p Stickers may not contain pornography. Animated stickers with audio will have the audio data removed as part of their conversion, all Stickers are silent loops.

2
app/views/sticker/menu.pug

@ -0,0 +1,2 @@
h1 This is the sticker menu
//- pre= JSON.stringify(user, null, 2)

26
app/views/sticker/view.pug

@ -0,0 +1,26 @@
extends ../layouts/main
block content
include ../sticker/components/sticker
section.uk-section.uk-section-default.uk-section-small
.uk-container
.uk-card.uk-card-default
.uk-card-header.uk-text-center
h1.uk-card-title :#{sticker.slug}:
.uk-card-body
.uk-margin-small
+renderSticker(sticker)
.uk-text-small.uk-text-center
div #{sticker.encoded.type}, orig: #{numeral(sticker.original.size).format('0,0.0a')}, encoded: #{numeral(sticker.encoded.size).format('0,0.0a')}
.uk-card-footer
div(uk-grid).uk-flex-center
.uk-width-auto
button(
type="button",
data-sticker-id= sticker._id,
data-sticker-slug= sticker.slug,
onclick="return dtp.app.deleteSticker(event);",
).uk-button.dtp-button-danger Remove Sticker
.uk-width-auto
include ../components/back-button

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
)

66
app/workers/chat.js

@ -0,0 +1,66 @@
// chat.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') });
const mongoose = require('mongoose');
const { SiteLog, SiteWorker, SiteAsync } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib'));
module.rootPath = path.resolve(__dirname, '..', '..');
module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json'));
module.config = {
environment: process.env.NODE_ENV,
root: module.rootPath,
component: { name: 'chatWorker', slug: 'chat-worker' },
};
module.config.site = require(path.join(module.rootPath, 'config', 'site'));
class ChatWorker extends SiteWorker {
constructor (dtp) {
super(dtp, dtp.config.component);
}
async start ( ) {
await super.start();
await this.loadProcessor(path.join(__dirname, 'chat', 'job', 'chat-room-clear.js'));
await this.loadProcessor(path.join(__dirname, 'chat', 'job', 'chat-room-delete.js'));
await this.startProcessors();
}
async stop ( ) {
await super.stop();
}
async deleteChatMessage (message) {
const { attachment: attachmentService } = this.dtp.services;
const ChatMessage = mongoose.model('ChatMessage');
await SiteAsync.each(message.attachments, attachmentService.remove.bind(attachmentService), 2);
await ChatMessage.deleteOne({ _id: message._id });
}
}
(async ( ) => {
try {
module.log = new SiteLog(module, module.config.component);
module.worker = new ChatWorker(module);
await module.worker.start();
module.log.info(`${module.pkg.name} v${module.pkg.version} ${module.config.component.name} started`);
} catch (error) {
module.log.error('failed to start worker', { component: module.config.component, error });
process.exit(-1);
}
})();

51
app/workers/chat/job/chat-room-clear.js

@ -0,0 +1,51 @@
// chat/job/chat-room-clear.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const path = require('path');
const mongoose = require('mongoose');
const ChatMessage = mongoose.model('ChatMessage');
const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib'));
class ChatRoomClearJob extends SiteWorkerProcess {
static get COMPONENT ( ) {
return {
name: 'charRoomClearJob',
slug: 'chat-room-clear-job',
};
}
constructor (worker) {
super(worker, ChatRoomClearJob.COMPONENT);
}
async start ( ) {
await super.start();
this.queue = await this.getJobQueue('chat');
this.log.info('registering job processor', { queue: this.queue.name, name: 'chat-room-clear' });
this.queue.process('chat-room-clear', this.processChatRoomClear.bind(this));
}
async stop ( ) {
await super.stop();
}
async processChatRoomClear (job) {
const { roomId } = job.data;
this.log.info('received chat room clear job', { id: job.id, roomId });
await ChatMessage
.find({ room: roomId })
.cursor()
.eachAsync(this.worker.deleteChatMessage.bind(this), 4);
}
}
module.exports = ChatRoomClearJob;

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();
this.queue = await this.getJobQueue('chat');
this.log.info('registering job processor', { queue: this.queue.name, name: 'chat-room-delete' });
this.queue.process('chat-room-delete', this.processChatRoomDelete.bind(this));
}
async stop ( ) {
await super.stop();
}
async processChatRoomDelete (job) {
const { roomId } = job.data;
this.log.info('received chat room delete job', { id: job.id, roomId });
await EmojiReaction
.find({ subject: roomId })
.cursor()
.eachAsync(this.deleteEmojiReaction.bind(this));
await ChatMessage
.find({ room: roomId })
.cursor()
.eachAsync(this.worker.deleteChatMessage.bind(this), 4);
await ChatRoomInvite
.find({ room: roomId })
.cursor()
.eachAsync(this.deleteChatRoomInvite.bind(this), 4);
await ChatRoom.deleteOne({ _id: roomId });
}
async deleteEmojiReaction (reaction) {
if (!reaction || !reaction._id) {
this.log.error('skipping invalid emoji reaction for delete');
return;
}
const EmojiReaction = mongoose.model('EmojiReaction');
try {
await EmojiReaction.deleteOne({ _id: reaction._id });
} catch (error) {
this.log.error('failed to delete chat message', { reactionId: reaction._id, error });
}
}
async deleteChatRoomInvite (invite) {
if (!invite || !invite._id) {
this.log.error('skipping invalid invite for delete');
return;
}
const ChatRoomInvite = mongoose.model('ChatRoomInvite');
try {
await ChatRoomInvite.deleteOne({ _id: invite._id });
} catch (error) {
this.log.error('failed to delete chat room invite', { inviteId: invite._id, error });
}
}
}
module.exports = ChatRoomDeleteJob;

2
app/workers/host-services.js

@ -32,7 +32,7 @@ module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json'));
module.config = {
environment: process.env.NODE_ENV,
root: module.rootPath,
component: { name: 'DTP Host Services', slug: 'host-services' },
component: { name: 'hostServicesWorker', slug: 'host-services-worker' },
site: require(path.join(module.rootPath, 'config', 'site')),
http: require(path.join(module.rootPath, 'config', 'http')),
};

85
app/workers/media.js

@ -0,0 +1,85 @@
// media.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') });
const mongoose = require('mongoose');
const { SitePlatform, SiteLog, SiteWorker } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib'));
module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json'));
module.config = {
environment: process.env.NODE_ENV,
root: path.resolve(__dirname, '..', '..'),
component: { name: 'mediaWorker', slug: 'media-worker' },
};
/**
* Provides background media processing for the DTP ecosystem.
*
* Background media processing is simply a way of life for scalable Web
* architectures. You don't want to force your site member to sit there and
* watch a process run. You want to accept their file, toss it to storage, and
* create a job to have whatever work needs done performed.
*
* This obviously induces a variable amount of time from when the site member
* uploads the file until it's ready for online distribution. The system
* therefore facilitates ways to query the status of the job and to receive a
* notification when the work is complete.
*
* This worker serves as a starting point or demonstration of how to do
* background media processing at scale and in production. This is the exact
* code we use to run the Digital Telepresence Platform every day.
*/
class MediaWorker extends SiteWorker {
constructor (dtp) {
super(dtp, dtp.config.component);
}
async start ( ) {
await super.start();
if (process.argv[2]) {
const stickerId = mongoose.Types.ObjectId(process.argv[2]);
this.log.info('creating sticker processing job', { stickerId });
const queue = this.getJobQueue('media');
await queue.add('sticker-ingest', { stickerId });
}
await this.loadProcessor(path.join(__dirname, 'media', 'job', 'sticker-ingest.js'));
await this.loadProcessor(path.join(__dirname, 'media', 'job', 'sticker-delete.js'));
await this.loadProcessor(path.join(__dirname, 'media', 'job', 'attachment-ingest.js'));
await this.loadProcessor(path.join(__dirname, 'media', 'job', 'attachment-delete.js'));
await this.startProcessors();
}
async stop ( ) {
await super.stop();
}
}
(async ( ) => {
try {
module.log = new SiteLog(module, module.config.component);
await SitePlatform.startPlatform(module, module.config.component);
module.worker = new MediaWorker(module);
await module.worker.start();
module.log.info(`${module.pkg.name} v${module.pkg.version} ${module.config.component.name} started`);
} catch (error) {
module.log.error('failed to start worker', {
component: module.config.component,
error,
});
process.exit(-1);
}
})();

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;

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save