From 02ff28eb674637bed3953c1f89a82d889a811329 Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 22 Jul 2022 17:57:46 -0400 Subject: [PATCH 01/35] switch dtp-jshint-reporter to git; copyright touch-ups --- dtp-webapp-cli.js | 2 +- dtp-webapp.js | 2 +- package.json | 2 +- yarn.lock | 5 ++--- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/dtp-webapp-cli.js b/dtp-webapp-cli.js index 24be353..c7dbf25 100644 --- a/dtp-webapp-cli.js +++ b/dtp-webapp-cli.js @@ -1,5 +1,5 @@ // dtp-webapp-cli.js -// Copryright (C) DTP Technologies, LLC +// Copryright (C) 2022 DTP Technologies, LLC // License: Apache-2.0 'use strict'; diff --git a/dtp-webapp.js b/dtp-webapp.js index fca0d9d..057fae8 100644 --- a/dtp-webapp.js +++ b/dtp-webapp.js @@ -1,5 +1,5 @@ // dtp-webapp.js -// Copryright (C) DTP Technologies, LLC +// Copryright (C) 2022 DTP Technologies, LLC // Licence: Apache-2.0 'use strict'; diff --git a/package.json b/package.json index 7391d61..f877d54 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "diskusage-ng": "^1.0.2", "disposable-email-provider-domains": "^1.0.9", "dotenv": "^16.0.0", - "dtp-jshint-reporter": "^1.0.2", + "dtp-jshint-reporter": "ssh://git@git.digitaltelepresence.com:digital-telepresence/dtp-jshint-reporter.git#master", "ein-validator": "^1.0.1", "email-domain-check": "^1.1.4", "email-validator": "^2.0.4", diff --git a/yarn.lock b/yarn.lock index 78c15a7..364dcd6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3102,10 +3102,9 @@ double-ended-queue@^2.1.0-0: resolved "https://registry.yarnpkg.com/double-ended-queue/-/double-ended-queue-2.1.0-0.tgz#103d3527fd31528f40188130c841efdd78264e5c" integrity sha1-ED01J/0xUo9AGIEwyEHv3XgmTlw= -dtp-jshint-reporter@^1.0.2: +"dtp-jshint-reporter@ssh://git@git.digitaltelepresence.com:digital-telepresence/dtp-jshint-reporter.git#master": version "1.0.2" - resolved "https://registry.yarnpkg.com/dtp-jshint-reporter/-/dtp-jshint-reporter-1.0.2.tgz#34d11f78eac98027d13dd2a8641e0ec664ddd9ab" - integrity sha512-+tT86GZ5JH+GvxiSmYdVUTC8yZLktlI1e/YCmti35kLNz4KKO2qwU9Z/50LCwryJNkA8+LMPK2y/mOrX2uU/Fg== + resolved "ssh://git@git.digitaltelepresence.com:digital-telepresence/dtp-jshint-reporter.git#68b078b75cd6d048a9bf9bdc9b30ccc2a2145c4f" dependencies: chalk "^4.1.1" From 76c931feaf1e2b5ac303478715e9b21298faa4f3 Mon Sep 17 00:00:00 2001 From: rob Date: Sun, 24 Jul 2022 13:11:38 -0400 Subject: [PATCH 02/35] add log line at worker startup --- app/workers/sample-worker.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/workers/sample-worker.js b/app/workers/sample-worker.js index b53b894..786e86a 100644 --- a/app/workers/sample-worker.js +++ b/app/workers/sample-worker.js @@ -77,8 +77,11 @@ class SampleWorker extends SiteWorker { (async ( ) => { try { module.log = new SiteLog(module, module.config.component); + module.worker = new SampleWorker(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, From e705dc65753a7512cc6e1699d225d2aa20e1a55a Mon Sep 17 00:00:00 2001 From: rob Date: Sun, 24 Jul 2022 13:13:13 -0400 Subject: [PATCH 03/35] updated startup logging and name --- app/workers/newsletter.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/workers/newsletter.js b/app/workers/newsletter.js index eb24421..697fc1d 100644 --- a/app/workers/newsletter.js +++ b/app/workers/newsletter.js @@ -4,7 +4,7 @@ 'use strict'; -const DTP_COMPONENT = { name: 'newsletter', slug: 'newsletter' }; +const DTP_COMPONENT = { name: 'Newsletter Worker', slug: 'newsletter' }; const path = require('path'); require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); @@ -147,7 +147,7 @@ class NewsletterWorker extends SiteWorker { module.worker = new NewsletterWorker(module); await module.worker.start(); - module.log.info(`${module.pkg.name} v${module.pkg.version} Newsletter worker started`); + module.log.info(`${module.pkg.name} v${module.pkg.version} ${module.config.component.name} started`); } catch (error) { module.log.error('failed to start Newsletter worker', { error }); process.exit(-1); From 92b30b3bc8a1c7e2eed91893a514e4ec6aab6232 Mon Sep 17 00:00:00 2001 From: rob Date: Sun, 24 Jul 2022 13:15:16 -0400 Subject: [PATCH 04/35] removed DTP_COMPONENT global constant --- app/workers/newsletter.js | 4 +--- app/workers/sample-worker.js | 4 +--- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/app/workers/newsletter.js b/app/workers/newsletter.js index 697fc1d..5a75f32 100644 --- a/app/workers/newsletter.js +++ b/app/workers/newsletter.js @@ -4,8 +4,6 @@ 'use strict'; -const DTP_COMPONENT = { name: 'Newsletter Worker', slug: 'newsletter' }; - const path = require('path'); require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); @@ -15,7 +13,7 @@ const { SiteWorker, SiteLog } = require(path.join(__dirname, '..', '..', 'lib', module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); module.config = { - component: DTP_COMPONENT, + component: { name: 'Newsletter Worker', slug: 'newsletter' }, root: path.resolve(__dirname, '..', '..'), }; diff --git a/app/workers/sample-worker.js b/app/workers/sample-worker.js index 786e86a..34e1cac 100644 --- a/app/workers/sample-worker.js +++ b/app/workers/sample-worker.js @@ -4,8 +4,6 @@ 'use strict'; -const DTP_COMPONENT = { name: 'Sample Worker', slug: 'sample-worker' }; - const path = require('path'); require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); @@ -22,7 +20,7 @@ module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); module.config = { environment: process.env.NODE_ENV, root: module.rootPath, - component: DTP_COMPONENT, + component: { name: 'Sample Worker', slug: 'sample-worker' }, }; module.config.site = require(path.join(module.rootPath, 'config', 'site')); From 96e40f26a705c6912b3d3a6e6cdab33ae3778530 Mon Sep 17 00:00:00 2001 From: rob Date: Sun, 24 Jul 2022 13:18:13 -0400 Subject: [PATCH 05/35] move CRON_TIMEZONE declaration into start method --- app/workers/reeeper.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/workers/reeeper.js b/app/workers/reeeper.js index 91f1890..4e0d299 100644 --- a/app/workers/reeeper.js +++ b/app/workers/reeeper.js @@ -17,8 +17,6 @@ const { const { CronJob } = require('cron'); -const CRON_TIMEZONE = 'America/New_York'; - module.rootPath = path.resolve(__dirname, '..', '..'); module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); @@ -38,7 +36,9 @@ class ReeeperWorker extends SiteWorker { } async start ( ) { + const CRON_TIMEZONE = 'America/New_York'; await super.start(); + await this.expireCrashedHosts(); // first-run the expirations this.expireJob = new CronJob('*/5 * * * * *', this.expireCrashedHosts.bind(this), null, true, CRON_TIMEZONE); } From ecf2ede294794e020b6e85e77a1b506e25c5682c Mon Sep 17 00:00:00 2001 From: CyberShell Date: Mon, 25 Jul 2022 22:42:44 +0000 Subject: [PATCH 06/35] fix spelling in UserService file --- app/services/user.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/services/user.js b/app/services/user.js index 9b7a8af..e1446a9 100644 --- a/app/services/user.js +++ b/app/services/user.js @@ -578,7 +578,7 @@ class UserService extends SiteService { height: 64, format: 'jpeg', formatParameters: { - conpressionLevel: 9, + compressionLevel: 9, }, }, ]; From a4475c45df9e5c06b0d06a4fd47faa9c0d534d95 Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 28 Jul 2022 13:16:07 -0400 Subject: [PATCH 07/35] updates & cleanup --- .env.default | 42 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/.env.default b/.env.default index bffa96a..a436018 100644 --- a/.env.default +++ b/.env.default @@ -9,6 +9,34 @@ 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 + +# +# 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 +59,7 @@ MONGODB_DATABASE=dtp-webapp REDIS_HOST=localhost REDIS_PORT=6379 REDIS_PASSWORD= +REDIS_PREFIX= # # MinIO configuration @@ -39,10 +68,11 @@ 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 # # ExpressJS/HTTP configuration @@ -60,9 +90,9 @@ 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 From 40f47c9b469a2c8cb808b195e6066379938aac87 Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 28 Jul 2022 13:16:19 -0400 Subject: [PATCH 08/35] cleanup --- app/views/layouts/main.pug | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/views/layouts/main.pug b/app/views/layouts/main.pug index 2d93af1..6bd2a53 100644 --- a/app/views/layouts/main.pug +++ b/app/views/layouts/main.pug @@ -13,8 +13,8 @@ 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 From 8aa71f7b1b2894237967f5254b08e5dc484e44e9 Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 30 Jul 2022 03:10:10 -0400 Subject: [PATCH 09/35] add unzalgo to strip Zalgo text from input --- package.json | 1 + yarn.lock | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/package.json b/package.json index f877d54..ba303b0 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "tinymce": "^6.1.0", "uikit": "^3.11.1", "uniqid": "^5.4.0", + "unzalgo": "^3.0.0", "url-validation": "^2.1.0", "uuid": "^8.3.2", "zxcvbn": "^4.4.2" diff --git a/yarn.lock b/yarn.lock index 364dcd6..d2cd7cf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5032,6 +5032,11 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= +isnumber@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isnumber/-/isnumber-1.0.0.tgz#0e3f9759b581d99dd85086f0ec2a74909cfadd01" + integrity sha512-JLiSz/zsZcGFXPrB4I/AGBvtStkt+8QmksyZBZnVXnnK9XdTEyz0tX8CRYljtwYDuIuZzih6DpHQdi+3Q6zHPw== + isobject@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/isobject/-/isobject-2.1.0.tgz#f065561096a3f1da2ef46272f815c840d87e0c89" @@ -7825,6 +7830,13 @@ static-extend@^0.1.1: define-property "^0.2.5" object-copy "^0.1.0" +stats-lite@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/stats-lite/-/stats-lite-2.2.0.tgz#278a5571fa1d2e8b1691295dccc0235282393bbf" + integrity sha512-/Kz55rgUIv2KP2MKphwYT/NCuSfAlbbMRv2ZWw7wyXayu230zdtzhxxuXXcvsc6EmmhS8bSJl3uS1wmMHFumbA== + dependencies: + isnumber "~1.0.0" + "statuses@>= 1.4.0 < 2", "statuses@>= 1.5.0 < 2", statuses@~1.5.0: version "1.5.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" @@ -8517,6 +8529,13 @@ unset-value@^1.0.0: has-value "^0.3.1" isobject "^3.0.0" +unzalgo@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/unzalgo/-/unzalgo-3.0.0.tgz#e9c9dbbf3dfa52c11075d93d57b92c50421bcf99" + integrity sha512-yRSDlFaYpFJK2VO0iI4I2E3l1CF8puFNL00nh7beZ/q4XSxd9XPNIlsTvfOz/fF2P6tMBLWNVLWpLBvJ9/11ZQ== + dependencies: + stats-lite "^2.2.0" + upath@^1.1.1, upath@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894" From d6bd4cc103dc5678d045b179bcd052dec76012c5 Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 30 Jul 2022 03:10:22 -0400 Subject: [PATCH 10/35] add filterText service method to strip HTML tags and Zalgo text --- app/services/chat.js | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/services/chat.js b/app/services/chat.js index 1734766..f8e8a25 100644 --- a/app/services/chat.js +++ b/app/services/chat.js @@ -8,7 +8,8 @@ const mongoose = require('mongoose'); const ChatMessage = mongoose.model('ChatMessage'); const ioEmitter = require('socket.io-emitter'); - +const striptags = require('striptags'); +const unzalgo = require('unzalgo'); const { SiteService } = require('../../lib/site-lib'); @@ -44,6 +45,17 @@ class ChatService extends SiteService { params: { messageId: message._id }, }); } + + /** + * 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())); + } } module.exports = { From 3283c24c32ae04222a02d900ce6e1d3fe1eb888b Mon Sep 17 00:00:00 2001 From: rob Date: Sun, 31 Jul 2022 12:32:17 -0400 Subject: [PATCH 11/35] brought full-featured chat, stickers, and emoji reacts in from Shing/Soapbox This is part of the creation of Venue and Radio. --- .env.default | 12 +- app/controllers/admin/job-queue.js | 2 +- app/controllers/chat.js | 127 ++++ app/models/chat-message.js | 16 +- app/models/chat-room-invite.js | 19 + app/models/chat-room.js | 44 ++ app/models/emoji-reaction.js | 39 ++ app/models/sticker.js | 46 ++ app/services/announcement.js | 2 +- app/services/chat.js | 548 +++++++++++++++++- app/services/sticker.js | 204 +++++++ app/views/chat/components/input-form.pug | 151 +++++ app/views/chat/components/menubar.pug | 0 app/views/chat/components/message.pug | 33 ++ app/views/chat/components/reaction-button.pug | 8 + app/views/chat/index.pug | 54 ++ app/views/chat/layouts/room.pug | 50 ++ app/views/chat/view.pug | 57 ++ app/views/components/off-canvas.pug | 8 + app/views/layouts/main.pug | 5 +- .../sticker/components/sticker-standalone.pug | 2 + app/views/sticker/components/sticker.pug | 19 + app/views/sticker/index.pug | 69 +++ app/views/sticker/menu.pug | 2 + app/views/sticker/view.pug | 26 + app/workers/host-services.js | 2 +- app/workers/newsletter.js | 2 +- app/workers/reeeper.js | 2 +- app/workers/sample-worker.js | 2 +- app/workers/stickers.js | 356 ++++++++++++ client/js/site-app.js | 126 +--- client/js/site-chat.js | 347 +++++++++++ client/js/site-reactions.js | 149 +++++ client/less/site/button.less | 14 + client/less/site/chat.less | 221 ++++++- config/limiter.js | 26 + config/reserved-names.js | 1 + lib/client/js/dtp-socket.js | 27 +- lib/site-ioserver.js | 178 ++++-- lib/site-platform.js | 1 + package.json | 1 + yarn.lock | 11 +- 42 files changed, 2834 insertions(+), 175 deletions(-) create mode 100644 app/controllers/chat.js create mode 100644 app/models/chat-room-invite.js create mode 100644 app/models/chat-room.js create mode 100644 app/models/emoji-reaction.js create mode 100644 app/models/sticker.js create mode 100644 app/services/sticker.js create mode 100644 app/views/chat/components/input-form.pug create mode 100644 app/views/chat/components/menubar.pug create mode 100644 app/views/chat/components/message.pug create mode 100644 app/views/chat/components/reaction-button.pug create mode 100644 app/views/chat/index.pug create mode 100644 app/views/chat/layouts/room.pug create mode 100644 app/views/chat/view.pug create mode 100644 app/views/sticker/components/sticker-standalone.pug create mode 100644 app/views/sticker/components/sticker.pug create mode 100644 app/views/sticker/index.pug create mode 100644 app/views/sticker/menu.pug create mode 100644 app/views/sticker/view.pug create mode 100644 app/workers/stickers.js create mode 100644 client/js/site-chat.js create mode 100644 client/js/site-reactions.js diff --git a/.env.default b/.env.default index a436018..eacaa4d 100644 --- a/.env.default +++ b/.env.default @@ -13,6 +13,16 @@ DTP_CORE_AUTH_SCHEME=http DTP_CORE_AUTH_HOST=localhost:3000 DTP_CORE_AUTH_PASSWORD_LEN=64 +DTP_IMAGE_WORK_PATH=/tmp/yourapp/image-work +DTP_VIDEO_WORK_PATH=/tmp/yourapp/video-work +DTP_STICKER_WORK_PATH=/tmp/yourapp/sticker-work + +# +# Set this to "enabled" to use NVIDIA GPU acceleration. Setting this to enabled +# without a properly-configured NVIDIA GPU will cause processing jobs to fail. +# +DTP_GPU_ACCELERATION=disabled + # # Host Cache configuration # @@ -98,4 +108,4 @@ DTP_LOG_DEBUG=enabled DTP_LOG_INFO=enabled DTP_LOG_WARN=enabled -DTP_LOG_HTTP_FORMAT=combined \ No newline at end of file +DTP_LOG_HTTP_FORMAT=combined diff --git a/app/controllers/admin/job-queue.js b/app/controllers/admin/job-queue.js index 77a769b..8136d96 100644 --- a/app/controllers/admin/job-queue.js +++ b/app/controllers/admin/job-queue.js @@ -1,6 +1,6 @@ // admin/job-queue.js // Copyright (C) 2022 DTP Technologies, LLC -// All Rights Reserved +// License: Apache-2.0 'use strict'; diff --git a/app/controllers/chat.js b/app/controllers/chat.js new file mode 100644 index 0000000..2cef875 --- /dev/null +++ b/app/controllers/chat.js @@ -0,0 +1,127 @@ +// email.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const express = require('express'); + +const { SiteController,/*, SiteError*/ +SiteError} = require('../../lib/site-lib'); + +class ChatController extends SiteController { + + constructor (dtp) { + super(dtp, module.exports); + } + + async start ( ) { + const { + chat: chatService, + limiter: limiterService, + session: sessionService, + } = this.dtp.services; + + const router = express.Router(); + this.dtp.app.use('/chat', router); + + router.use( + sessionService.authCheckMiddleware({ requireLogin: true }), + chatService.middleware({ maxOwnedRooms: 25, maxJoinedRooms: 50 }), + async (req, res, next) => { + res.locals.currentView = 'chat'; + return next(); + }, + ); + + router.param('roomId', this.populateRoomId.bind(this)); + + router.post( + '/:roomId', + limiterService.create(limiterService.config.chat.postRoomUpdate), + this.postRoomUpdate.bind(this), + ); + + router.post( + '/', + limiterService.create(limiterService.config.chat.postRoomCreate), + this.postRoomCreate.bind(this), + ); + + router.get( + '/:roomId', + limiterService.create(limiterService.config.chat.getRoomView), + this.getRoomView.bind(this), + ); + + router.get( + '/', + limiterService.create(limiterService.config.chat.getHome), + this.getHome.bind(this), + ); + + return router; + } + + async populateRoomId (req, res, next, roomId) { + const { chat: chatService } = this.dtp.services; + try { + res.locals.room = await chatService.getRoomById(roomId); + if (!res.locals.room) { + throw new SiteError(404, 'Room not found'); + } + return next(); + } catch (error) { + this.log.error('failed to populate roomId', { roomId, error }); + return next(error); + } + } + + async postRoomUpdate (req, res, next) { + const { chat: chatService } = this.dtp.services; + try { + res.locals.room = await chatService.updateRoom(res.locals.room, req.body); + res.redirect(`/chat/${res.locals.room._id}`); + } catch (error) { + this.log.error('failed to update chat room', { roomId: res.locals.room._id, error }); + return next(error); + } + } + + async postRoomCreate (req, res, next) { + const { chat: chatService } = this.dtp.services; + try { + res.locals.room = await chatService.createRoom(req.user, req.body); + res.redirect(`/chat/${res.locals.room._id}`); + } catch (error) { + this.log.error('failed to create chat room', { error }); + return next(error); + } + } + + async getRoomView (req, res, next) { + const { chat: chatService } = this.dtp.services; + try { + res.locals.pageTitle = res.locals.room.name; + + const pagination = { skip: 0, cpp: 20 }; + res.locals.chatMessages = await chatService.getChannelHistory(res.locals.room, pagination); + + res.render('chat/view'); + } catch (error) { + this.log.error('failed to render chat room view', { roomId: req.params.roomId, error }); + return next(error); + } + } + + async getHome (req, res) { + res.locals.pageTitle = 'Chat Home'; + res.render('chat/index'); + } +} + +module.exports = { + slug: 'chat', + name: 'chat', + create: async (dtp) => { return new ChatController(dtp); }, +}; \ No newline at end of file diff --git a/app/models/chat-message.js b/app/models/chat-message.js index 0a691fd..78235e2 100644 --- a/app/models/chat-message.js +++ b/app/models/chat-message.js @@ -8,10 +8,22 @@ const mongoose = require('mongoose'); const Schema = mongoose.Schema; +/* + * The intent is for forked apps to give meaning to "channel" in their + * apps. Set the channelType to the name of your channel model, and set + * channel to the _id of the channel. The model will then correctly populate. + */ + const ChatMessageSchema = new Schema({ created: { type: Date, default: Date.now, required: true, index: -1, expires: '10d' }, - author: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, - content: { type: String }, + channelType: { type: String }, + channel: { type: Schema.ObjectId, refPath: 'channelType' }, + authorType: { type: String, enum: ['User', 'CoreUser'], required: true }, + author: { type: Schema.ObjectId, required: true, index: 1, refPath: 'authorType' }, + content: { type: String, maxlength: 1000 }, + analysis: { + similarity: { type: Number }, + }, stickers: { type: [String] }, }); diff --git a/app/models/chat-room-invite.js b/app/models/chat-room-invite.js new file mode 100644 index 0000000..4d9408c --- /dev/null +++ b/app/models/chat-room-invite.js @@ -0,0 +1,19 @@ +// chat-room-invite.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); + +const Schema = mongoose.Schema; + +const ChatRoomInviteSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: -1, expires: '30d' }, + room: { type: Schema.ObjectId, required: true, ref: 'ChatRoom' }, + memberType: { type: String, required: true }, + member: { type: Schema.ObjectId, required: true, index: 1, refPath: 'memberType' }, + status: { type: String, enum: ['new', 'accepted', 'rejected'], required: true }, +}); + +module.exports = mongoose.model('ChatRoomInvite', ChatRoomInviteSchema); \ No newline at end of file diff --git a/app/models/chat-room.js b/app/models/chat-room.js new file mode 100644 index 0000000..8eda8eb --- /dev/null +++ b/app/models/chat-room.js @@ -0,0 +1,44 @@ +// chat-room.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); + +const Schema = mongoose.Schema; + +const RoomMemberSchema = new Schema({ + memberType: { type: String, required: true }, + member: { type: Schema.ObjectId, refPath: 'memberType' }, +}); + +const ROOM_VISIBILITY_LIST = ['public', 'private']; +const ROOM_MEMBERSHIP_POICY_LIST = ['open', 'closed']; + +const ChatRoomSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: -1 }, + lastActivity: { type: Date, default: Date.now, required: true, index: -1 }, + ownerType: { type: String, required: true }, + owner: { type: Schema.ObjectId, required: true, index: 1, refPath: 'ownerType' }, + name: { type: String, required: true, maxlength: 100 }, + description: { type: String, maxlength: 500 }, + policy: { type: String }, + latestMessage: { type: Schema.ObjectId, ref: 'ChatMessage' }, + visibility: { type: String, enum: ROOM_VISIBILITY_LIST, default: 'public', required: true, index: 1 }, + membershipPolicy: { type: String, enum: ROOM_MEMBERSHIP_POICY_LIST, default: 'open', required: true, index: 1 }, + members: { type: [RoomMemberSchema], default: [ ], required: true }, +}); + +ChatRoomSchema.index({ + visibility: 1, + membershipPolicy: 1, +}, { + partialFilterExpression: { + visibility: 'public', + membershipPolicy: 'open', + }, + name: 'chatroom_public_open_idx', +}); + +module.exports = mongoose.model('ChatRoom', ChatRoomSchema); \ No newline at end of file diff --git a/app/models/emoji-reaction.js b/app/models/emoji-reaction.js new file mode 100644 index 0000000..6ce84a2 --- /dev/null +++ b/app/models/emoji-reaction.js @@ -0,0 +1,39 @@ +// emoji-reaction.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); + +const Schema = mongoose.Schema; + +const REACTION_LIST = [ + 'clap', + 'fire', + 'happy', + 'laugh', + 'angry', + 'honk', +]; + +const EmojiReactionSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: -1, expires: '30d' }, + + /* + * The thing for which a reaction is being filed. + */ + subjectType: { type: String, required: true }, + subject: { type: Schema.ObjectId, required: true, index: 1, refPath: 'subjectType' }, + + /* + * The user creating the reaction + */ + userType: { type: String, required: true }, + user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, + + reaction: { type: String, enum: REACTION_LIST, required: true }, + timestamp: { type: Number }, +}); + +module.exports = mongoose.model('EmojiReaction', EmojiReactionSchema); \ No newline at end of file diff --git a/app/models/sticker.js b/app/models/sticker.js new file mode 100644 index 0000000..5e33840 --- /dev/null +++ b/app/models/sticker.js @@ -0,0 +1,46 @@ +// sticker.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); + +const Schema = mongoose.Schema; + +const STICKER_STATUS_LIST = [ + 'processing', // the sticker is in the processing queue + 'live', // the sticker is available for use + 'rejected', // the sticker was rejected (by proccessing queue) + 'retired', // the sticker has been retired +]; + +const StickerMediaSchema = new Schema( + { + bucket: { type: String, required: true }, + key: { type: String, required: true }, + type: { type: String, required: true }, + size: { type: Number, required: true }, + }, + { + _id: false, + }, +); + +/* + * The intention is for sticker ownership to be defined by forked applications + * and implemented by things like User, CoreUser, Channel, or whatever can + * "own" a sticker in your app. + */ +const StickerSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: -1 }, + status: { type: String, enum: STICKER_STATUS_LIST, default: 'processing', required: true, index: 1 }, + rejectedReason: { type: String }, + ownerType: { type: String, required: true, index: 1 }, + owner: { type: Schema.ObjectId, required: true, index: 1, refPath: 'ownerType' }, + slug: { type: String, required: true, maxlength: 20, unique: true, index: 1 }, + original: { type: StickerMediaSchema, required: true, select: false }, + encoded: { type: StickerMediaSchema }, +}); + +module.exports = mongoose.model('Sticker', StickerSchema); \ No newline at end of file diff --git a/app/services/announcement.js b/app/services/announcement.js index de78d5b..a5e812c 100644 --- a/app/services/announcement.js +++ b/app/services/announcement.js @@ -1,6 +1,6 @@ // announcement.js // Copyright (C) 2022 DTP Technologies, LLC -// All Rights Reserved +// License: Apache-2.0 'use strict'; diff --git a/app/services/chat.js b/app/services/chat.js index f8e8a25..e8c32ad 100644 --- a/app/services/chat.js +++ b/app/services/chat.js @@ -4,30 +4,68 @@ 'use strict'; +const Redis = require('ioredis'); + const mongoose = require('mongoose'); + +const ChatRoom = mongoose.model('ChatRoom'); +const ChatRoomInvite = mongoose.model('ChatRoomInvite'); + const ChatMessage = mongoose.model('ChatMessage'); +const EmojiReaction = mongoose.model('EmojiReaction'); const ioEmitter = require('socket.io-emitter'); + +const marked = require('marked'); +const hljs = require('highlight.js'); + const striptags = require('striptags'); const unzalgo = require('unzalgo'); +const stringSimilarity = require('string-similarity'); -const { SiteService } = require('../../lib/site-lib'); +const { SiteService, SiteError } = require('../../lib/site-lib'); class ChatService extends SiteService { constructor (dtp) { super(dtp, module.exports); - this.populateContentReport = [ + + const USER_SELECT = '_id username username_lc displayName picture'; + this.populateChatMessage = [ + { + path: 'channel', + }, { - path: 'user', - select: '_id username username_lc displayName picture', + path: 'author', + select: USER_SELECT, }, { - path: 'resource', + path: 'stickers', + }, + ]; + + this.populateChatRoom = [ + { + path: 'owner', + select: USER_SELECT, + }, + { + path: 'members.member', + select: USER_SELECT, + }, + ]; + + this.populateChatRoomInvite = [ + { + path: 'room', populate: [ { - path: 'author', - select: '_id username username_lc displayName picture', + path: 'owner', + select: USER_SELECT, + }, + { + path: 'members.member', + select: USER_SELECT, }, ], }, @@ -35,9 +73,403 @@ class ChatService extends SiteService { } async start ( ) { + this.markedRenderer = new marked.Renderer(); + this.markedRenderer.link = (href, title, text) => { return text; }; + this.markedRenderer.image = (href, title, text) => { return text; }; + + this.markedConfig = { + renderer: this.markedRenderer, + highlight: function(code, lang) { + const language = hljs.getLanguage(lang) ? lang : 'plaintext'; + return hljs.highlight(code, { language }).value; + }, + langPrefix: 'hljs language-', // highlight.js css expects a top-level 'hljs' class. + pedantic: false, + gfm: true, + breaks: false, + sanitize: false, + smartLists: true, + smartypants: false, + xhtml: false, + }; + + /* + * The chat message rate limiter uses Redis to provide accurate atomic + * accounting regardless of which host is currently hosting a user's chat + * connection and session. + */ + + const { RateLimiterRedis } = require('rate-limiter-flexible'); + const rateLimiterRedisClient = new Redis({ + host: process.env.REDIS_HOST, + port: process.env.REDIS_PORT, + password: process.env.REDIS_PASSWORD, + keyPrefix: process.env.REDIS_KEY_PREFIX || 'dtp', + lazyConnect: false, + enableOfflineQueue: false, + }); + + this.chatMessageLimiter = new RateLimiterRedis({ + storeClient: rateLimiterRedisClient, + points: 20, + duration: 60, + blockDuration: 60 * 3, + execEvenly: false, + keyPrefix: 'rl:chatmsg', + }); + + this.reactionLimiter = new RateLimiterRedis({ + storeClient: rateLimiterRedisClient, + points: 60, + duration: 60, + blockDuration: 60 * 3, + execEvenly: false, + keyPrefix: 'rl:react', + }); + + /* + * The Redis Emitter is a Socket.io-compatible message emitter that operates + * with greater efficiency than using Socket.io itself. + */ + this.emitter = ioEmitter(this.dtp.redis); } + middleware (options) { + options = Object.assign({ + maxOwnedRooms: 10, + maxJoinedRooms: 10, + }); + return async (req, res, next) => { + try { + res.locals.ownedChatRooms = await this.getRoomsForOwner(req.user, { + skip: 0, + cpp: options.maxOwnedRooms, + }); + res.locals.joinedChatRooms = await this.getRoomsForMember(req.user, { + skip: 0, + cpp: options.maxJoinedRooms, + }); + return next(); + } catch (error) { + this.log.error('failed to execute chat middleware', { error }); + return next(error); + } + }; + } + + async createRoom (owner, roomDefinition) { + const NOW = new Date(); + + const room = new ChatRoom(); + room.created = NOW; + room.lastActivity = NOW; + + room.ownerType = owner.type; + room.owner = owner._id; + + room.name = this.filterText(roomDefinition.name); + + if (roomDefinition.description) { + room.description = this.filterText(roomDefinition.description); + } + + if (roomDefinition.policy) { + room.policy = this.filterText(roomDefinition.policy); + } + + room.visibility = roomDefinition.visibility; + room.membershipPolicy = roomDefinition.membershipPolicy; + + room.members = [ ]; + + await room.save(); + return room.toObject(); + } + + async updateRoom (room, roomDefinition) { + const NOW = new Date(); + const updateOp = { + $set: { + lastActivity: NOW, + }, + $unset: { }, + }; + + updateOp.$set.name = this.filterText(roomDefinition.name); + + if (roomDefinition.description && roomDefinition.description.length > 0) { + updateOp.$set.description = this.filterText(roomDefinition.description); + } else { + updateOp.$unset.description = 1; + } + + await ChatRoom.updateOne({ _id: room._id }, updateOp); + } + + async getRoomsForOwner (owner, pagination) { + pagination = Object.assign({ + skip: 0, + cpp: 50 + }, pagination); + const rooms = await ChatRoom + .find({ owner: owner._id }) + .sort({ lastActivity: -1, created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateChatRoom) + .lean(); + return rooms; + } + + async getRoomsForMember (member, pagination) { + pagination = Object.assign({ + skip: 0, + cpp: 50 + }, pagination); + const rooms = await ChatRoom + .find({ 'members.member': member._id }) + .sort({ lastActivity: -1, created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateChatRoom) + .lean(); + return rooms; + } + + async getPublicRooms (pagination) { + pagination = Object.assign({ + skip: 0, + cpp: 50 + }, pagination); + const rooms = await ChatRoom + .find({ 'flags.isPublic': true }) + .sort({ lastActivity: -1, created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateChatRoom) + .lean(); + return rooms; + } + + async getRoomById (roomId) { + const room = await ChatRoom + .findById(roomId) + .populate(this.populateChatRoom) + .lean(); + return room; + } + + async joinRoom (room, member) { + if (!room.flags.isOpen) { + throw new SiteError(403, 'The room is not open'); + } + await ChatRoom.updateOne( + { _id: room._id }, + { + $addToSet: { + members: { + memberType: member.type, + member: member._id, + }, + }, + }, + ); + } + + async leaveRoom (room, memberId) { + await ChatRoom.updateOne( + { _id: room._id }, + { + $pull: { members: { _id: memberId } }, + }, + ); + } + + async sendRoomInvite (room, member) { + const NOW = new Date(); + + /* + * See if there's already an outstanding invite, and return it. + */ + + let invite = await ChatRoomInvite + .findOne({ room: room._id, member: member._id }) + .populate(this.populateChatRoomInvite) + .lean(); + if (invite) { + return invite; + } + + /* + * Create new invite + */ + + invite = new ChatRoomInvite(); + invite.created = NOW; + invite.room = room._id; + invite.memberType = member.type; + invite.member = member._id; + invite.status = 'new'; + await invite.save(); + + this.log.info('chat room invite created', { + roomId: room._id, + memberId: member._id, + inviteId: invite._id, + }); + + return invite.toObject(); + } + + async acceptRoomInvite (invite) { + this.log.info('accepting invite to chat room', { + roomId: invite.room._id, + memberId: invite.member._id, + }); + await ChatRoom.updateOne( + { _id: invite.room._id }, + { + $addToSet: { + members: { + memberType: invite.memberType, + member: invite.member._id, + }, + }, + }, + ); + + this.log.info('updating chat invite', { inviteId: invite._id, status: 'accepted' }); + await ChatRoomInvite.updateOne( + { _id: invite._id }, + { + $set: { stats: 'accepted' }, + }, + ); + } + + async rejectRoomInvite (invite) { + this.log.info('rejecting chat room invite', { + inviteId: invite._id, + roomId: invite.room._id, + memberId: invite.member._id, + }); + await ChatRoomInvite.updateOne( + { _id: invite._id }, + { $set: { status: 'rejected' } }, + ); + } + + async deleteRoomInvite (invite) { + this.log.info('deleting chat room invite', { inviteId: invite._id }); + await ChatRoomInvite.deleteOne({ _id: invite._id }); + } + + async createMessage (author, messageDefinition) { + const { sticker: stickerService, user: userService } = this.dtp.services; + + author = await userService.getUserAccount(author._id); + if (!author || !author.permissions || !author.permissions.canChat) { + throw new SiteError(403, `You are not permitted to chat at all on ${this.dtp.config.site.name}`); + } + + const NOW = new Date(); + + /* + * Record the chat message to the database + */ + + let message = new ChatMessage(); + message.created = NOW; + + message.channelType = messageDefinition.channelType; + message.channel = mongoose.Types.ObjectId(messageDefinition.channel._id || messageDefinition.channel); + + message.authorType = author.type; + message.author = author._id; + + message.content = this.filterText(messageDefinition.content); + message.analysis = await this.analyzeContent(author, message.content); + + const stickerSlugs = this.findStickers(message.content); + stickerSlugs.forEach((sticker) => { + const re = new RegExp(`:${sticker}:`, 'gi'); + message.content = message.content.replace(re, '').trim(); + }); + + const stickers = await stickerService.resolveStickerSlugs(stickerSlugs); + message.stickers = stickers.map((sticker) => sticker._id); + + await message.save(); + message = message.toObject(); + + /* + * Update room's latest message pointer + */ + + await ChatRoom.updateOne( + { _id: message.channel }, + { $set: { latestMessage: message._id } }, + ); + + /* + * Prepare a message payload that can be transmitted over sockets to clients + * and rendered for display. + */ + + const renderedContent = this.renderMessageContent(message.content); + const payload = { + _id: message._id, + user: { + _id: author._id, + displayName: author.displayName, + username: author.username, + picture: { + large: { + _id: author.picture.large._id, + }, + small: { + _id: author.picture.small._id, + }, + }, + }, + content: renderedContent, + stickers, + }; + + /* + * Return both things + */ + + return { message, payload }; + } + + renderMessageContent (content) { + return marked.parse(content, this.markedConfig); + } + + findStickers (content) { + const tokens = content.split(' '); + const stickers = [ ]; + tokens.forEach((token) => { + if ((token[0] !== ':') || (token[token.length -1 ] !== ':')) { + return; + } + + token = token.slice(1, token.length - 1 ).toLowerCase(); + if (token.includes('/') || token.includes(':') || token.includes(' ')) { + return; // trimmed token includes invalid characters + } + + this.log.debug('found sticker request', { token }); + if (!stickers.includes(token)) { + stickers.push(striptags(token)); + } + }); + + return stickers.slice(0, 4); + } + async removeMessage (message) { await ChatMessage.deleteOne({ _id: message._id }); this.emitter(`site:${this.dtp.config.site.domainKey}:chat`, { @@ -46,6 +478,28 @@ class ChatService extends SiteService { }); } + async getChannelHistory (channel, pagination) { + const messages = await ChatMessage + .find({ channel: channel._id }) + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateChatMessage) + .lean(); + return messages.reverse(); + } + + async getUserHistory (user, pagination) { + const messages = await ChatMessage + .find({ author: user._id }) + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateChatMessage) + .lean(); + return messages.reverse(); + } + /** * Filters an input string to remove "zalgo" text and to strip all HTML tags. * This prevents cross-site scripting and the malicious destruction of text @@ -56,6 +510,86 @@ class ChatService extends SiteService { filterText (content) { return striptags(unzalgo.clean(content.trim())); } + + /** + * Analyze an input chat message against a user's history for similarity and + * other abusive content. Returns a response object with various scores + * allowing the caller to implement various policies and make various + * decisions. + * @param {User} author The author of the chat message + * @param {*} content The text of the chat message as would be distributed. + * @returns response object with various scores indicating the results of + * analyses performed. + */ + async analyzeContent (author, content) { + const response = { similarity: 0.0 }; + + /* + * Compare versus their recent chat messages, score for similarity, and + * block based on repetition. Spammers are redundant. This stops them. + */ + + const history = await ChatMessage + .find({ author: author._id }) + .sort({ created: -1 }) + .select('content') + .limit(10) + .lean(); + + history.forEach((message) => { + const similarity = stringSimilarity.compareTwoStrings(content, message.content); + if (similarity > 0.9) { // 90% or greater match with history entry + response.similarity += similarity; + } + }); + + return response; + } + + async sendMessage (channel, messageName, payload) { + this.emitter.to(channel).emit(messageName, payload); + } + + async sendSystemMessage (socket, content, options) { + const NOW = new Date(); + + options = Object.assign({ + type: 'info', + }, options || { }); + + const payload = { + created: NOW, + type: options.type, + content, + }; + + if (options.channelId) { + socket.to(options.channelId).emit('system-message', payload); + return; + } + + socket.emit('system-message', payload); + } + + async createEmojiReaction (user, reactionDefinition) { + const NOW = new Date(); + const reaction = new EmojiReaction(); + + reaction.created = NOW; + reaction.subjectType = reactionDefinition.subjectType; + reaction.subject = mongoose.Types.ObjectId(reactionDefinition.subject); + reaction.userType = user.type; + reaction.user = user._id; + reaction.reaction = reactionDefinition.reaction; + + if (reactionDefinition.timestamp) { + reaction.timestamp = reactionDefinition.timestamp; + } + + await reaction.save(); + + return reaction.toObject(); + } } module.exports = { diff --git a/app/services/sticker.js b/app/services/sticker.js new file mode 100644 index 0000000..2f153a4 --- /dev/null +++ b/app/services/sticker.js @@ -0,0 +1,204 @@ +// sticker.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const slug = require('slug'); + +const mongoose = require('mongoose'); + +const Sticker = mongoose.model('Sticker'); +const User = mongoose.model('User'); + +const { SiteService, SiteError, SiteAsync } = require('../../lib/site-lib'); + +const MAX_CHANNEL_STICKERS = 50; +const MAX_USER_STICKERS = 10; + +class StickerService extends SiteService { + + constructor (dtp) { + super(dtp, module.exports); + this.populateSticker = [ + { + path: 'owner', + select: '-email -passwordSalt -password', + }, + ]; + } + + async start ( ) { + const { jobQueue: jobQueueService } = this.dtp.services; + this.jobQueue = jobQueueService.getJobQueue('sticker-ingest', { + attempts: 3, + }); + + this.stickerTemplate = this.loadViewTemplate('sticker/components/sticker-standalone.pug'); + } + + async createSticker (ownerType, owner, file, stickerDefinition) { + const { minio: minioService } = this.dtp.services; + const NOW = new Date(); + + this.log.debug('received sticker', { file, stickerDefinition }); + + // this is faster than Model.countDocuments() + const currentStickers = await Sticker.find({ owner: owner._id }).select('_id').lean(); + + switch (ownerType) { + case 'Channel': + if (currentStickers.length >= MAX_CHANNEL_STICKERS) { + throw new SiteError(508, `You have ${MAX_CHANNEL_STICKERS} stickers. Please remove a sticker before adding a new one.`); + } + break; + + case 'User': + if (currentStickers.length >= MAX_USER_STICKERS) { + throw new SiteError(508, `You have ${MAX_USER_STICKERS} stickers. Please remove a sticker before adding a new one one.`); + } + break; + } + + const sticker = new Sticker(); + sticker.created = NOW; + sticker.status = 'processing'; + sticker.ownerType = ownerType; + sticker.owner = owner._id; + sticker.slug = slug(stickerDefinition.slug.toLowerCase().trim()); + + const bucket = process.env.MINIO_VIDEO_BUCKET; + const key = `/stickers/${sticker._id.toString().slice(0, 3)}/${sticker._id}`; + + sticker.original = { + bucket, key, + type: file.mimetype, + size: file.size, + }; + + await minioService.uploadFile({ + bucket, key, + filePath: file.path, + metadata: { + 'Content-Type': file.mimetype, + 'Content-Length': file.size, + }, + }); + + await sticker.save(); + + await this.jobQueue.add('sticker-ingest', { stickerId: sticker._id }); + + return sticker.toObject(); + } + + async getForChannel (channel) { + const stickers = await Sticker + .find({ status: 'live', ownerType: 'Channel', owner: channel._id }) + .sort({ created: -1 }) + .populate(this.populateSticker) + .lean(); + return stickers; + } + + async getForUser (user) { + const stickers = await Sticker + .find({ status: 'live', ownerType: 'User', owner: user._id }) + .sort({ created: -1 }) + .populate(this.populateSticker) + .lean(); + return stickers; + } + + async getStickers (pagination) { + const stickers = await Sticker + .find() + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateSticker) + .lean(); + return stickers; + } + + async getBySlug (slug) { + const sticker = await Sticker + .findOne({ slug }) + .populate(this.populateSticker) + .lean(); + return sticker; + } + + async getById (stickerId, includeOriginal = false) { + let query = Sticker.findOne({ _id: stickerId }); + if (includeOriginal) { + query = query.select('+original'); + } + const sticker = await query + .populate(this.populateSticker) + .lean(); + return sticker; + } + + async setStickerStatus (sticker, status) { + await Sticker.updateOne({ _id: sticker._id }, { $set: { status } }); + } + + async resolveStickerSlugs (slugs) { + const stickers = [ ]; + await SiteAsync.each(slugs, async (slug) => { + const sticker = await Sticker.findOne({ slug: slug }); + if (!sticker) { + return; + } + stickers.push(sticker); + }); + return stickers; + } + + async removeSticker (sticker) { + const stickerId = sticker._id; + this.log.info('creating sticker delete job', { stickerId }); + await this.jobQueue.add('sticker-delete', { stickerId }); + } + + async render (sticker, stickerOptions) { + return this.stickerTemplate({ sticker, stickerOptions }); + } + + async addFavorite (user, stickerId) { + stickerId = mongoose.Types.ObjectId(stickerId); + const sticker = await Sticker.findById(stickerId); + if (!sticker) { + throw new SiteError(404, 'Sticker not found'); + } + await User.updateOne( + { _id: user._id }, + { + $addToSet: { favoriteStickers: sticker._id }, + }, + ); + } + + async removeFavorite (user, stickerId) { + stickerId = mongoose.Types.ObjectId(stickerId); + await User.updateOne( + { _id: user._id }, + { + $pull: { favoriteStickers: stickerId }, + }, + ); + } + + async getFavorites (user) { + const stickers = await Sticker + .populate(user.favoriteStickers, this.populateSticker); + return stickers; + } +} + +module.exports = { + slug: 'sticker', + name: 'sticker', + create: (dtp) => { return new StickerService(dtp); }, +}; \ No newline at end of file diff --git a/app/views/chat/components/input-form.pug b/app/views/chat/components/input-form.pug new file mode 100644 index 0000000..b058114 --- /dev/null +++ b/app/views/chat/components/input-form.pug @@ -0,0 +1,151 @@ +include reaction-button +mixin renderChatInputForm (room, options = { }) + form( + id="chat-input-form", + data-room-id= room._id, + onsubmit="return window.dtp.app.chat.sendUserChat(event);", + ).uk-form + + input(type="hidden", name="roomType", value= "ChatRoom") + input(type="hidden", name="room", value= room._id) + + .uk-card.uk-card-secondary.uk-card-body.uk-padding-small(style="border-top: solid 1px #3a3a3a;") + textarea( + id="chat-input-text", + name="content", + rows="2", + hidden, + ).uk-textarea.uk-margin-small + + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-auto + button( + type= "button", + title= "Insert emoji", + data-target-element= "chat-input-text", + uk-tooltip={ title: 'Add Emoji', delay: 500 }, + onclick= "return dtp.app.showEmojiPicker(event);", + ).uk-button.dtp-button-default.uk-button-small + span + i.far.fa-laugh-beam + + .uk-width-auto + button( + type= "button", + title= "Sticker Picker", + uk-toggle={ target: '#sticker-picker'}, + uk-tooltip={ title: 'Add Sticker', delay: 500 }, + onclick="return dtp.app.chat.openChatInput();", + ).uk-button.dtp-button-default.uk-button-small + span + i.far.fa-image + #sticker-picker(uk-modal) + .uk-modal-dialog.uk-modal-body + button(type="button", uk-close).uk-modal-close-default + h4.uk-text-center Sticker Picker 9000™ + ul(uk-tab).uk-flex-center + li.uk-active + a(href="")= user.displayName || user.username + li + a(href="")= room.name + li + a(href="") Favorites + ul.uk-switcher.chat-sticker-picker + //- Personal stickers + li + if Array.isArray(userStickers) && (userStickers.length > 0) + div(uk-grid).uk-grid-small.uk-flex-center + each sticker in userStickers + .uk-width-auto + button( + type="button", + data-sticker-id= sticker._id, + data-sticker-slug= sticker.slug, + onclick="return dtp.app.chat.insertChatSticker(event);", + ).uk-button.uk-button-text + +renderSticker(sticker) + else + .uk-text-center You haven't uploaded any #[a(href="/sticker") Stickers] yet + + //- Channel stickers + li + if Array.isArray(roomStickers) && (roomStickers.length > 0) + div(uk-grid).uk-grid-small.uk-flex-center + each sticker in roomStickers + .uk-width-auto + button( + type="button", + data-sticker-id= sticker._id, + data-sticker-slug= sticker.slug, + onclick="return dtp.app.chat.insertChatSticker(event);", + ).uk-button.uk-button-text + +renderSticker(sticker) + else + .uk-text-center This room hasn't uploaded any #[a(href="/sticker") Stickers] yet + + //- Favorite/Saved stickers + li + if Array.isArray(favoriteStickers) && (favoriteStickers.length > 0) + div(uk-grid).uk-grid-small.uk-flex-center + each sticker in favoriteStickers + .uk-width-auto + button( + type="button", + data-sticker-id= sticker._id, + data-sticker-slug= sticker.slug, + onclick="return dtp.app.chat.insertChatSticker(event);", + ).uk-button.uk-button-text + +renderSticker(sticker) + else + .uk-text-center You haven't saved any Favorite stickers + + //- .uk-width-auto + //- button( + //- type= "button", + //- title= "Attach image", + //- onclick="return dtp.app.chat.attachChatImage(event);", + //- ).uk-button.dtp-button-default.uk-button-small + //- span + //- i.fas.fa-file-image + + .uk-width-expand + if !options.hideHomeNav + .uk-text-small.uk-text-center.uk-text-truncate + a(href="/chat", uk-tooltip={ title: "Chat Home", delay: 500 }).uk-button.dtp-button-secondary.uk-button-small + span + i.fas.fa-home + + .uk-width-auto + button( + id="chat-input-btn", + type="button", + onclick="return dtp.app.chat.toggleChatInput(event);", + uk-tooltip={ title: "Toggle Chat Input", delay: 500 }, + ).uk-button.dtp-button-secondary.uk-button-small + span + i.fas.fa-edit + + .uk-width-auto + button( + id="chat-send-btn", + type="submit", + uk-tooltip={ title: "Send Message", delay: 500 }, + ).uk-button.dtp-button-primary.uk-button-small + span + i.far.fa-paper-plane + + div(uk-grid).uk-flex-between.uk-grid-small + .uk-width-auto + +renderReactionButton('Applaud/clap', '👏', 'clap') + .uk-width-auto + +renderReactionButton("On Fire!", '🔥', 'fire') + .uk-width-auto + +renderReactionButton("Happy", "🤗", "happy") + .uk-width-auto + +renderReactionButton("Laugh", "🤣", "laugh") + .uk-width-auto + +renderReactionButton("Angry", "🤬", "angry") + .uk-width-auto + +renderReactionButton("Honk", "🤡", "honk") + + //- .chat-menubar \ No newline at end of file diff --git a/app/views/chat/components/menubar.pug b/app/views/chat/components/menubar.pug new file mode 100644 index 0000000..e69de29 diff --git a/app/views/chat/components/message.pug b/app/views/chat/components/message.pug new file mode 100644 index 0000000..255d583 --- /dev/null +++ b/app/views/chat/components/message.pug @@ -0,0 +1,33 @@ +include ../../sticker/components/sticker +mixin renderChatMessage (message, options = { }) + - var authorName = message.author.displayName || message.author.username; + div(data-message-id= message._id, data-author-id= message.author._id).chat-message + div(uk-grid).uk-grid-small.uk-flex-bottom + .uk-width-expand + .uk-text-small.chat-username.uk-text-truncate= authorName + + if message.author.picture && message.author.picture.small + .uk-width-auto + img(src=`/image/${message.author.picture.small._id}`, alt= `${authorName}'s profile picture`).chat-author-image + + if !options.hideMenu && !message.author._id.equals(user._id) + .uk-width-auto.chat-user-menu + button(type="button").uk-button.uk-button-link.chat-menu-button + i.fas.fa-ellipsis-h + div(data-message-id= message._id, uk-dropdown="mode: click").dtp-chatmsg-menu + ul.uk-nav.uk-dropdown-nav + li + a( + href="", + data-message-id= message._id, + data-user-id= message.author._id, + data-username= message.author.username, + onclick="return dtp.app.muteChatUser(event);", + ) Mute #{authorName} + + .chat-content.uk-text-break!= marked.parse(message.content) + .chat-timestamp(data-created= message.created).uk-text-small + + if Array.isArray(message.stickers) && (message.stickers.length > 0) + each sticker in message.stickers + +renderSticker(sticker, { hideSlug: true }) \ No newline at end of file diff --git a/app/views/chat/components/reaction-button.pug b/app/views/chat/components/reaction-button.pug new file mode 100644 index 0000000..1b577d4 --- /dev/null +++ b/app/views/chat/components/reaction-button.pug @@ -0,0 +1,8 @@ +mixin renderReactionButton (title, emoji, reaction) + button( + uk-tooltip={ title, delay: 500 }, + data-reaction= reaction, + onclick="return dtp.app.chat.sendReaction(event);", + ).dtp-button-reaction + span.button-icon= emoji + span(class="uk-visible@l").count-label \ No newline at end of file diff --git a/app/views/chat/index.pug b/app/views/chat/index.pug new file mode 100644 index 0000000..92d63a6 --- /dev/null +++ b/app/views/chat/index.pug @@ -0,0 +1,54 @@ +extends layouts/room +block content + + form(method="POST", action="/chat").uk-form + .uk-card.uk-card-default.uk-card-small + .uk-card-header + h1.uk-card-title Create Chat Room + .uk-card-body + .uk-margin + label(for="name").uk-form-label Room name + input(id="name", name="name", type="text", placeholder="Enter room name").uk-input + + .uk-margin + label(for="description").uk-form-label Room description + textarea(id="description", name="description", rows="2", placeholder="Enter room description").uk-textarea + + .uk-margin + label(for="policy").uk-form-label Room policy + textarea(id="policy", name="policy", rows="2", placeholder="Enter room use policy").uk-textarea + + .uk-margin + div(uk-grid) + .uk-width-auto + fieldset + legend Room Visibility + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-auto + label + input(id="is-public", name="visibility", type="radio", value="public", checked).uk-radio + | Public + .ui-width-auto + label + input(id="is-private", name="visibility", type="radio", value="private").uk-radio + | Private + + .uk-width-auto + fieldset + legend Membership Policy + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-auto + label + input(id="membership-open", name="policy", type="radio", value="open", checked).uk-radio + | Open + .uk-width-auto + label + input(id="membership-closed", name="policy", type="radio", value="closed").uk-radio + | Closed + + .uk-card-footer + div(uk-grid) + .uk-width-expand + +renderBackButton() + .uk-width-auto + button(type="submit").uk-button.uk-button-primary.uk-border-rounded Create room \ No newline at end of file diff --git a/app/views/chat/layouts/room.pug b/app/views/chat/layouts/room.pug new file mode 100644 index 0000000..23eacc6 --- /dev/null +++ b/app/views/chat/layouts/room.pug @@ -0,0 +1,50 @@ +extends ../../layouts/main +block content-container + + mixin renderRoomList (rooms) + each room in ownedChatRooms + li.uk-active + a(href=`/chat/${room._id}`)= room.name + + + section.uk-section.uk-section-default.uk-section-small + .uk-container.uk-container-expand + div(uk-grid) + div(class="uk-width-1-1 uk-width-1-5@l uk-flex-first@l").uk-flex-last + .content-block.uk-border-rounded.uk-margin + if Array.isArray(ownedChatRooms) && (ownedChatRooms.length > 0) + ul#room-list.uk-nav.uk-nav-default + li.uk-nav-header Your Rooms + +renderRoomList(ownedChatRooms) + else + div You don't own any chat rooms. + + .content-block.uk-border-rounded + if Array.isArray(joinedChatRooms) && (joinedChatRooms.length > 0) + ul#room-list.uk-nav.uk-nav-default + li.uk-nav-header Joined Rooms + +renderRoomList(ownedChatRooms) + else + div You haven't joined any chat rooms. + + div(class="uk-width-1-1 uk-width-expand@l") + #chat-room + block content + + div(class="uk-width-1-1 uk-width-1-5@l") + .content-block.uk-border-rounded + if chatRoom + if Array.isArray(chatRoom.members) && (chatRoom.members.length > 0) + ul#room-member-list.uk-nav.uk-nav-default + li.uk-nav-header Room Members + each member in chatRoom.members + li + a(href="")= member.displayName || member.username + else + div The room has no members + else + div Not in a room + +block viewjs + script. + window.dtp.room = !{JSON.stringify(room)}; \ No newline at end of file diff --git a/app/views/chat/view.pug b/app/views/chat/view.pug new file mode 100644 index 0000000..9268f33 --- /dev/null +++ b/app/views/chat/view.pug @@ -0,0 +1,57 @@ +extends layouts/room +block content + + include components/input-form + include components/message + + .uk-card.uk-card-default.uk-card-small + .uk-card-header + div(uk-grid).uk-flex-middle.chat-menubar + div(uk-tooltip="Room details").uk-width-expand + h1.uk-card-title.uk-margin-remove= room.name + div= room.description + + div(uk-tooltip="Active Members").uk-width-auto.no-select + span + i.fas.fa-user + span(data-room-id= room._id).uk-margin-small-left.active-member-count= numeral(room.members.length).format('0,0') + + div(uk-tooltip="Total Members", class="uk-hidden@m").uk-width-auto.no-select + span + i.fas.fa-user + span.uk-margin-small-left= formatCount(room.members.length) + + + .uk-width-auto + button( + type="button", + data-room-id= room._id, + onclick="return dtp.app.chat.leaveRoom(event);", + ).uk-button.uk-button-small.uk-border-pill.uk-text-bold + span + i.fas.fa-user + span.uk-margin-small-left Leave Room + .uk-width-auto + .uk-inline + button(type="button").uk-button.uk-button-link.uk-button-small + i.fas.fa-ellipsis-h + div(uk-dropdown={ pos: 'bottom-right', mode: 'click' }) + ul.uk-nav.uk-dropdown-nav + li.uk-nav-heading= room.name + li.uk-nav-divider + li + a(href=`/chat/${room._id}/pop-out`, target="_blank") Pop-Out Chat + + .uk-card-body + #site-chat-container.uk-flex.uk-flex-column + #chat-message-list-wrapper + #chat-reactions + #chat-message-list + each message in chatMessages || [ ] + +renderChatMessage(message) + + .chat-message-menu + button(type="button", onclick="return dtp.app.chat.resumeChatScroll(event);").chat-scroll-return Resume scrolling + + .uk-card-footer + +renderChatInputForm(room) \ No newline at end of file diff --git a/app/views/components/off-canvas.pug b/app/views/components/off-canvas.pug index 4a535b1..fcc04e4 100644 --- a/app/views/components/off-canvas.pug +++ b/app/views/components/off-canvas.pug @@ -23,6 +23,14 @@ mixin renderMenuItem (iconClass, label) if user li.uk-nav-header Member Menu + li(class={ "uk-active": (currentView === 'chat') }) + a(href=`/chat`).uk-display-block + div(uk-grid).uk-grid-collapse + .uk-width-auto + .app-menu-icon + i.fas.fa-comment-alt + .uk-width-expand Chat + li(class={ "uk-active": (currentView === 'user-settings') }) a(href=`/user/${user._id}`).uk-display-block div(uk-grid).uk-grid-collapse diff --git a/app/views/layouts/main.pug b/app/views/layouts/main.pug index 6bd2a53..954403a 100644 --- a/app/views/layouts/main.pug +++ b/app/views/layouts/main.pug @@ -95,8 +95,9 @@ html(lang='en') if user script. - window.dtp.user = !{JSON.stringify(safeUser, null, 2)} - window.dtp.domain = !{JSON.stringify(site.domain)} + window.dtp.user = !{JSON.stringify(safeUser, null, 2)}; + window.dtp.domain = !{JSON.stringify(site.domain)}; + window.dtp.env = !{JSON.stringify(env.NODE_ENV)}; if channel script. diff --git a/app/views/sticker/components/sticker-standalone.pug b/app/views/sticker/components/sticker-standalone.pug new file mode 100644 index 0000000..e03daa3 --- /dev/null +++ b/app/views/sticker/components/sticker-standalone.pug @@ -0,0 +1,2 @@ +include sticker ++renderSticker(sticker, stickerOptions || { }) \ No newline at end of file diff --git a/app/views/sticker/components/sticker.pug b/app/views/sticker/components/sticker.pug new file mode 100644 index 0000000..13e4c44 --- /dev/null +++ b/app/views/sticker/components/sticker.pug @@ -0,0 +1,19 @@ +mixin renderSticker (sticker, options = { }) + if sticker && sticker.encoded + div( + title= `:${sticker.slug}:`, + data-sticker-id= sticker._id, + data-sticker-slug= sticker.slug, + onclick= 'return dtp.app.showStickerMenu(event);', + ).chat-sticker.uk-text-center + case sticker.encoded.type + when 'video/mp4' + video(playsinline, autoplay, muted, loop) + source(src=`/sticker/${sticker._id}/media`) + when 'image/png' + img(src=`/sticker/${sticker._id}/media`) + when 'image/jpg' + img(src=`/sticker/${sticker._id}/media`) + + if !options.hideSlug + .uk-text-small.uk-text-muted :#{sticker.slug}: \ No newline at end of file diff --git a/app/views/sticker/index.pug b/app/views/sticker/index.pug new file mode 100644 index 0000000..8151283 --- /dev/null +++ b/app/views/sticker/index.pug @@ -0,0 +1,69 @@ +extends ../layouts/main +block content + + include ../sticker/components/sticker + + mixin renderStickerList (stickers) + div(uk-grid).uk-grid-small + each sticker in stickers + div(class="uk-width-1-1 uk-width-auto@s") + a(href=`/sticker/${sticker._id}`).uk-display-block.uk-text-center + +renderSticker(sticker) + + mixin renderStickerUploadForm (actionUrl, channel) + form(method="POST", action= actionUrl, enctype="multipart/form-data").uk-form + if channel + input(id="channel-id", type="hidden", name="channel", value= channel._id) + + .uk-margin + input(id="sticker-slug", name="slug", type="text", placeholder= "Enter sticker name").uk-input + + div(uk-grid).uk-grid-small + .uk-width-auto + .uk-form-custom + input(id="sticker-file", name="stickerFile", type="file") + button(type="button").uk-button.dtp-button-default + span + i.far.fa-image + span.uk-margin-small-left Select File + + .uk-width-auto + button(type="submit").uk-button.dtp-button-primary + span + i.fas.fa-plus + span.uk-margin-small-left Add sticker + + section.uk-section.uk-section-default.uk-section-small + .uk-container + div(uk-grid) + div(class="uk-width-1-1 uk-width-2-3@l") + h2 #{user.displayName || user.username}'s stickers + + .uk-margin + +renderStickerUploadForm('/sticker') + + .uk-margin + if Array.isArray(userStickers) && (userStickers.length > 0) + +renderStickerList(userStickers) + else + div #{user.displayName || user.username} has no stickers. + + if channel + section.uk-section.uk-section-default.uk-section-small + .uk-container + h2 #{channel.name}'s stickers + + .uk-margin + +renderStickerUploadForm('/sticker', channel) + + .uk-margin + if Array.isArray(channelStickers) && (channelStickers.length > 0) + +renderStickerList(channelStickers) + else + div #{channel.name} has no stickers. + + div(class="uk-width-1-1 uk-width-1-3@l") + h1 Stickers + p Stickers accepts PNG, JPEG, GIF and MP4 files. Transparency/alpha is supported in the PNG format. Animations will be transcoded to MP4. + p Stickers can be up to 2MB, and animated stickers are limited to 10 seconds in duration regardless of format. Resolutions from 100x100 to 320x100 are accepted. Larger Stickers will be scaled to cover an aspect-correct reduction, if possible. Some cropping may occur. + p Stickers may not contain pornography. Animated stickers with audio will have the audio data removed as part of their conversion, all Stickers are silent loops. \ No newline at end of file diff --git a/app/views/sticker/menu.pug b/app/views/sticker/menu.pug new file mode 100644 index 0000000..6d25206 --- /dev/null +++ b/app/views/sticker/menu.pug @@ -0,0 +1,2 @@ +h1 This is the sticker menu +//- pre= JSON.stringify(user, null, 2) \ No newline at end of file diff --git a/app/views/sticker/view.pug b/app/views/sticker/view.pug new file mode 100644 index 0000000..4abcd0e --- /dev/null +++ b/app/views/sticker/view.pug @@ -0,0 +1,26 @@ +extends ../layouts/main +block content + + include ../sticker/components/sticker + + section.uk-section.uk-section-default.uk-section-small + .uk-container + .uk-card.uk-card-default + .uk-card-header.uk-text-center + h1.uk-card-title :#{sticker.slug}: + .uk-card-body + .uk-margin-small + +renderSticker(sticker) + .uk-text-small.uk-text-center + div #{sticker.encoded.type}, orig: #{numeral(sticker.original.size).format('0,0.0a')}, encoded: #{numeral(sticker.encoded.size).format('0,0.0a')} + .uk-card-footer + div(uk-grid).uk-flex-center + .uk-width-auto + button( + type="button", + data-sticker-id= sticker._id, + data-sticker-slug= sticker.slug, + onclick="return dtp.app.deleteSticker(event);", + ).uk-button.dtp-button-danger Remove Sticker + .uk-width-auto + include ../components/back-button \ No newline at end of file diff --git a/app/workers/host-services.js b/app/workers/host-services.js index ffedc5a..8b2f9ab 100644 --- a/app/workers/host-services.js +++ b/app/workers/host-services.js @@ -32,7 +32,7 @@ module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); module.config = { environment: process.env.NODE_ENV, root: module.rootPath, - component: { name: 'DTP Host Services', slug: 'host-services' }, + component: { name: 'hostServicesWorker', slug: 'host-services-worker' }, site: require(path.join(module.rootPath, 'config', 'site')), http: require(path.join(module.rootPath, 'config', 'http')), }; diff --git a/app/workers/newsletter.js b/app/workers/newsletter.js index 5a75f32..9efb0a3 100644 --- a/app/workers/newsletter.js +++ b/app/workers/newsletter.js @@ -13,7 +13,7 @@ const { SiteWorker, SiteLog } = require(path.join(__dirname, '..', '..', 'lib', module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); module.config = { - component: { name: 'Newsletter Worker', slug: 'newsletter' }, + component: { name: 'newsletterWorker', slug: 'newsletter-worker' }, root: path.resolve(__dirname, '..', '..'), }; diff --git a/app/workers/reeeper.js b/app/workers/reeeper.js index 4e0d299..2103a9c 100644 --- a/app/workers/reeeper.js +++ b/app/workers/reeeper.js @@ -23,7 +23,7 @@ module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); module.config = { environment: process.env.NODE_ENV, root: module.rootPath, - component: { name: 'DTP Reeeper', slug: 'reeeper' }, + component: { name: 'reeeper', slug: 'reeeper' }, }; module.config.site = require(path.join(module.rootPath, 'config', 'site')); diff --git a/app/workers/sample-worker.js b/app/workers/sample-worker.js index 34e1cac..c5298ec 100644 --- a/app/workers/sample-worker.js +++ b/app/workers/sample-worker.js @@ -20,7 +20,7 @@ module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); module.config = { environment: process.env.NODE_ENV, root: module.rootPath, - component: { name: 'Sample Worker', slug: 'sample-worker' }, + component: { name: 'sampleWorker', slug: 'sample-worker' }, }; module.config.site = require(path.join(module.rootPath, 'config', 'site')); diff --git a/app/workers/stickers.js b/app/workers/stickers.js new file mode 100644 index 0000000..cce69c3 --- /dev/null +++ b/app/workers/stickers.js @@ -0,0 +1,356 @@ +// stickers.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const DTP_STICKER_HEIGHT = 100; + +const path = require('path'); +const fs = require('fs'); +require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); + +const mongoose = require('mongoose'); + +const { SitePlatform, SiteLog, SiteWorker } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); + +const sharp = require('sharp'); + +module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); +module.config = { + environment: process.env.NODE_ENV, + root: path.resolve(__dirname, '..', '..'), + component: { name: 'stickersWorker', slug: 'stickers-worker' }, +}; + +class StickerWorker extends SiteWorker { + + constructor (dtp) { + super(dtp, dtp.config.component); + + this.processors = { + processStickerSharp: this.processStickerSharp.bind(this), + processStickerFFMPEG: this.processStickerFFMPEG.bind(this), + }; + } + + async start ( ) { + await super.start(); + const { jobQueue: jobQueueService } = this.dtp.services; + + this.log.info('registering sticker-ingest job processor', { + config: this.dtp.config.jobQueues['sticker-ingest'], + }); + this.stickerProcessingQueue = jobQueueService.getJobQueue( + 'sticker-ingest', + this.dtp.config.jobQueues['sticker-ingest'], + ); + + this.stickerProcessingQueue.process('sticker-ingest', 1, this.processStickerIngest.bind(this)); + this.stickerProcessingQueue.process('sticker-delete', 1, this.processStickerDelete.bind(this)); + } + + async stop ( ) { + if (this.stickerProcessingQueue) { + try { + this.log.info('closing sticker-ingest job queue'); + await this.stickerProcessingQueue.close(); + delete this.stickerProcessingQueue; + } catch (error) { + this.log.error('failed to close sticker ingest job queue', { error }); + // fall through + } + } + await super.stop(); + } + + async processStickerIngest (job) { + try { + this.log.info('received sticker ingest job', { id: job.id, data: job.data }); + await this.fetchSticker(job); // defines jobs.data.processor + await this.resetSticker(job); + + // call the chosen file processor to render the sticker for distribution + await this.processors[job.data.processor](job); + + //TODO: emit a completion event which should cause a refresh of the + // creator's view to display the processed sticker + } catch (error) { + this.log.error('failed to process sticker', { stickerId: job.data.stickerId, error }); + throw error; + } finally { + if (job.data.workPath) { + this.log.info('cleaning up sticker work path', { workPath: job.data.workPath }); + await fs.promises.rm(job.data.workPath, { recursive: true }); + } + } + } + + async fetchSticker (job) { + const { minio: minioService, sticker: stickerService } = this.dtp.services; + job.data.sticker = await stickerService.getById(job.data.stickerId, true); + + job.data.workPath = path.join( + process.env.DTP_STICKER_WORK_PATH, + this.dtp.config.component.slug, + job.data.sticker._id.toString(), + ); + + this.jobLog(job, 'creating work directory', { worthPath: job.data.workPath }); + await fs.promises.mkdir(job.data.workPath, { recursive: true }); + + switch (job.data.sticker.original.type) { + case 'image/jpeg': + job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.jpg`); + job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.jpg`); + job.data.processor = 'processStickerSharp'; + job.data.sharpFormat = 'jpeg'; + job.data.sharpFormatParameters = { quality: 85 }; + break; + + case 'image/png': + job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.png`); + job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.png`); + job.data.processor = 'processStickerSharp'; + job.data.sharpFormat = 'png'; + job.data.sharpFormatParameters = { compression: 9 }; + break; + + case 'image/gif': + job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.gif`); + job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.mp4`); + job.data.processor = 'processStickerFFMPEG'; + break; + + case 'image/webp': // process as PNG + job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.webp`); + job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.png`); + job.data.processor = 'processStickerSharp'; + job.data.sharpFormat = 'png'; + job.data.sharpFormatParameters = { compression: 9 }; + break; + + case 'image/webm': // process as MP4 + job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.webm`); + job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.mp4`); + job.data.processor = 'processStickerFFMPEG'; + break; + + case 'video/mp4': + job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.mp4`); + job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.mp4`); + job.data.processor = 'processStickerFFMPEG'; + break; + + default: + throw new Error(`unsupported sticker type: ${job.data.sticker.original.type}`); + } + + this.jobLog(job, 'fetching original media', { // blah 62096adcb69874552a0f87bc + stickerId: job.data.sticker._id, + slug: job.data.sticker.slug, + type: job.data.sticker.original.type, + worthPath: job.data.origFilePath, + }); + await minioService.downloadFile({ + bucket: job.data.sticker.original.bucket, + key: job.data.sticker.original.key, + filePath: job.data.origFilePath, + }); + } + + async resetSticker (job) { + const { minio: minioService } = this.dtp.services; + const { sticker } = job.data; + + if (!sticker.encoded) { + return; + } + + this.log.info('removing existing encoded sticker media', { media: sticker.encoded }); + await minioService.removeObject(sticker.encoded.bucket, sticker.encoded.key); + + // switch sticker back to 'processing' status to prevent use in the app + const Sticker = mongoose.model('Sticker'); + await Sticker.updateOne( + { _id: job.data.sticker._id }, + { + $set: { + status: 'processing', + }, + $unset: { + encoded: '', + }, + }, + ); + + delete sticker.encoded; + } + + async processStickerSharp (job) { + const { minio: minioService } = this.dtp.services; + + const sharpImage = sharp(job.data.origFilePath); + const metadata = await sharpImage.metadata(); + this.log.info('sticker metadata from Sharp', { stickerId: job.data.sticker._id, metadata }); + + let chain = sharpImage + .clone() + .toColorspace('srgb') + .resize({ height: DTP_STICKER_HEIGHT }); + + chain = chain[job.data.sharpFormat](job.data.sharpFormatParameters); + + await chain.toFile(job.data.outFilePath); + + job.data.outFileStat = await fs.promises.stat(job.data.outFilePath); + + const bucket = process.env.MINIO_VIDEO_BUCKET; + const key = `/stickers/${job.data.sticker._id.toString().slice(0, 3)}/${job.data.sticker._id}.encoded.${job.data.sharpFormat}`; + + await minioService.uploadFile({ + bucket, + key, + filePath: job.data.outFilePath, + metadata: { + 'Content-Type': `image/${job.data.sharpFormat}`, + 'Content-Length': job.data.outFileStat.size, + }, + }); + + const Sticker = mongoose.model('Sticker'); + await Sticker.updateOne( + { _id: job.data.sticker._id }, + { + $set: { + status: 'live', + encoded: { + bucket, + key, + type: `image/${job.data.sharpFormat}`, + size: job.data.outFileStat.size, + } + }, + }, + ); + } + + async processStickerFFMPEG (job) { + const { media: mediaService, minio: minioService } = this.dtp.services; + + const codecVideo = (process.env.DTP_GPU_ACCELERATION === 'enabled') ? 'h264_nvenc' : 'libx264'; + + // generate the encoded sticker + // Output height is 100 lines by [aspect] width with width and height being + // padded to be divisible by 2. The video stream is given a bit rate of + // 128Kbps, and the media is flagged for +faststart. Audio is stripped if + // present. + + const ffmpegStickerArgs = [ + '-y', '-i', job.data.origFilePath, + '-vf', `scale=-1:${DTP_STICKER_HEIGHT},pad=ceil(iw/2)*2:ceil(ih/2)*2`, + '-pix_fmt', 'yuv420p', + '-c:v', codecVideo, + '-b:v', '128k', + '-movflags', '+faststart', + '-an', + job.data.outFilePath, + ]; + + this.jobLog(job, `transcoding motion sticker: ${job.data.sticker.slug}`); + this.log.debug('transcoding motion sticker', { ffmpegStickerArgs }); + await mediaService.ffmpeg(ffmpegStickerArgs); + + job.data.outFileStat = await fs.promises.stat(job.data.outFilePath); + + const bucket = process.env.MINIO_VIDEO_BUCKET; + const key = `/stickers/${job.data.sticker._id.toString().slice(0, 3)}/${job.data.sticker._id}.encoded.mp4`; + + this.jobLog(job, 'uploading encoded media file'); + await minioService.uploadFile({ + bucket, key, + filePath: job.data.outFilePath, + metadata: { + 'Content-Type': 'video/mp4', + 'Content-Length': job.data.outFileStat.size, + }, + }); + + this.jobLog(job, 'updating Sticker to live status'); + + const Sticker = mongoose.model('Sticker'); + await Sticker.updateOne( + { _id: job.data.sticker._id }, + { + $set: { + status: 'live', + encoded: { + bucket, + key, + type: 'video/mp4', + size: job.data.outFileStat.size, + }, + }, + }, + ); + } + + async processStickerDelete (job) { + const { minio: minioService, sticker: stickerService } = this.dtp.services; + const Sticker = mongoose.model('Sticker'); + try { + const sticker = await stickerService.getById(job.data.stickerId, true); + + this.log.info('removing original media', { stickerId: sticker._id, slug: sticker.slug }); + await minioService.removeObject(sticker.original.bucket, sticker.original.key); + + if (sticker.encoded) { + this.log.info('removing encoded media', { stickerId: sticker._id, slug: sticker.slug }); + await minioService.removeObject(sticker.encoded.bucket, sticker.encoded.key); + } + + this.log.info('removing sticker', { stickerId: sticker._id, slug: sticker.slug }); + await Sticker.deleteOne({ _id: sticker._id }); + } catch (error) { + this.log.error('failed to delete sticker', { stickerId: job.data.stickerId, error }); + throw error; // for job report + } + } + + async jobLog (job, message, data = { }) { + job.log(message); + this.log.info(message, { jobId: job.id, ...data }); + } +} + +(async ( ) => { + try { + module.log = new SiteLog(module, module.config.componentName); + + /* + * Platform startup + */ + await SitePlatform.startPlatform(module, module.config.component); + + module.worker = new StickerWorker(module); + await module.worker.start(); + + /* + * Worker startup + */ + + if (process.argv[2]) { + const stickerId = mongoose.Types.ObjectId(process.argv[2]); + this.log.info('creating sticker processing job', { stickerId }); + await module.worker.stickerProcessingQueue.add('sticker-ingest', { stickerId }); + } + + module.log.info(`${module.pkg.name} v${module.pkg.version} ${module.config.component.name} started`); + } catch (error) { + module.log.error('failed to start worker', { + component: module.config.component, + error, + }); + process.exit(-1); + } +})(); \ No newline at end of file diff --git a/client/js/site-app.js b/client/js/site-app.js index ef739cc..7ccf1c6 100644 --- a/client/js/site-app.js +++ b/client/js/site-app.js @@ -14,6 +14,7 @@ import QRCode from 'qrcode'; import Cropper from 'cropperjs'; import { EmojiButton } from '@joeattardi/emoji-button'; +import SiteChat from './site-chat'; const GRID_COLOR = 'rgb(64, 64, 64)'; const GRID_TICK_COLOR = 'rgb(192,192,192)'; @@ -27,26 +28,24 @@ export default class DtpSiteApp extends DtpApp { constructor (user) { super(DTP_COMPONENT, user); - this.log.debug('constructor', 'app instance created'); - - this.chat = { - form: document.querySelector('#chat-input-form'), - messageList: document.querySelector('#chat-message-list'), - messages: [ ], - messageMenu: document.querySelector('.chat-message-menu'), - input: document.querySelector('#chat-input-text'), - isAtBottom: true, - }; + + if (dtp.env === 'production') { + const isSafari = !!navigator.userAgent.match(/Version\/[\d\.]+.*Safari/); + const isIOS = /iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream; + this.isIOS = isSafari || isIOS; + } else { + this.isIOS = false; + } + + this.log.debug('constructor', 'app instance created', { + env: dtp.env, + isIOS: this.isIOS, + }); this.emojiPicker = new EmojiButton({ theme: 'dark' }); this.emojiPicker.on('emoji', this.onEmojiSelected.bind(this)); - if (this.chat.messageList) { - this.chat.messageList.addEventListener('scroll', this.onChatMessageListScroll.bind(this)); - } - if (this.chat.input) { - this.chat.input.addEventListener('keydown', this.onChatInputKeyDown.bind(this)); - } + this.chat = new SiteChat(this); this.charts = {/* will hold rendered charts */}; @@ -71,99 +70,16 @@ export default class DtpSiteApp extends DtpApp { if (this.user) { const { socket } = this.socket; socket.on('user-chat', this.onUserChat.bind(this)); + socket.on('user-react', this.onUserReact.bind(this)); } } - async onChatInputKeyDown (event) { - this.log.info('onChatInputKeyDown', 'chat input received', { event }); - if (event.key === 'Enter' && !event.shiftKey) { - return this.sendUserChat(event); - } - } - - async sendUserChat (event) { - event.preventDefault(); - - if (!dtp.channel || !dtp.channel._id) { - UIkit.modal.alert('There is a problem with Chat. Please refresh the page.'); - return; - } - - const channelId = dtp.channel._id; - this.log.info('chat form', channelId); - - const content = this.chat.input.value; - this.chat.input.value = ''; - - if (content.length === 0) { - return true; - } - - this.log.info('sendUserChat', 'sending chat message', { channel: this.user._id, content }); - this.socket.sendUserChat(channelId, content); - - // set focus back to chat input - this.chat.input.focus(); - - return true; - } - async onUserChat (message) { - this.log.info('onUserChat', 'message received', { user: message.user, content: message.content }); - - const chatMessage = document.createElement('div'); - chatMessage.classList.add('uk-margin-small'); - chatMessage.classList.add('chat-message'); - - const chatUser = document.createElement('div'); - chatUser.classList.add('uk-text-small'); - chatUser.classList.add('chat-username'); - chatUser.textContent = message.user.username; - chatMessage.appendChild(chatUser); - - const chatContent = document.createElement('div'); - chatContent.classList.add('chat-content'); - chatContent.innerHTML = message.content; - chatMessage.appendChild(chatContent); - - if (Array.isArray(message.stickers) && message.stickers.length) { - message.stickers.forEach((sticker) => { - const chatContent = document.createElement('div'); - chatContent.classList.add('chat-sticker'); - chatContent.innerHTML = ``; - chatMessage.appendChild(chatContent); - }); - } - - this.chat.messageList.appendChild(chatMessage); - this.chat.messages.push(chatMessage); - - while (this.chat.messages.length > 50) { - const message = this.chat.messages.shift(); - this.chat.messageList.removeChild(message); - } - if (this.chat.isAtBottom) { - this.chat.messageList.scrollTo(0, this.chat.messageList.scrollHeight); - } + await this.chat.appendUserChat(message); } - async onChatMessageListScroll (/* event */) { - const prevBottom = this.chat.isAtBottom; - const scrollPos = this.chat.messageList.scrollTop + this.chat.messageList.clientHeight; - - this.chat.isAtBottom = scrollPos >= this.chat.messageList.scrollHeight; - if (this.chat.isAtBottom !== prevBottom) { - this.log.info('onChatMessageListScroll', 'at-bottom status change', { atBottom: this.chat.isAtBottom }); - if (this.chat.isAtBottom) { - this.chat.messageMenu.classList.remove('chat-menu-visible'); - } else { - this.chat.messageMenu.classList.add('chat-menu-visible'); - } - } - } - - async resumeChatScroll ( ) { - this.chat.messageList.scrollTop = this.chat.messageList.scrollHeight; + async onUserReact (message) { + await this.chat.createEmojiReact(message); } async goBack ( ) { @@ -547,7 +463,9 @@ export default class DtpSiteApp extends DtpApp { async showEmojiPicker (event) { const targetElementName = (event.currentTarget || event.target).getAttribute('data-target-element'); this.emojiTargetElement = document.getElementById(targetElementName); - + if (!this.emojiTargetElement) { + return UIkit.modal.alert('Emoji picker target element does not exist'); + } this.emojiPicker.togglePicker(this.emojiTargetElement); } diff --git a/client/js/site-chat.js b/client/js/site-chat.js new file mode 100644 index 0000000..0a345e0 --- /dev/null +++ b/client/js/site-chat.js @@ -0,0 +1,347 @@ +// site-chat.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const DTP_COMPONENT = { name: 'Site Chat', slug: 'site-chat' }; +const dtp = window.dtp = window.dtp || { }; // jshint ignore:line + +const EMOJI_EXPLOSION_DURATION = 8000; +const EMOJI_EXPLOSION_INTERVAL = 100; + +import DtpLog from 'dtp/dtp-log.js'; +import SiteReactions from './site-reactions.js'; + +export default class SiteChat { + + constructor (app) { + this.app = app; + this.log = new DtpLog(DTP_COMPONENT); + + this.ui = { + form: document.querySelector('#chat-input-form'), + messageList: document.querySelector('#chat-message-list'), + messages: [ ], + messageMenu: document.querySelector('.chat-message-menu'), + input: document.querySelector('#chat-input-text'), + isAtBottom: true, + isModifying: false, + }; + + if (this.ui.messageList) { + this.ui.messageList.addEventListener('scroll', this.onChatMessageListScroll.bind(this)); + this.updateTimestamps(); + setTimeout(( ) => { + this.log.info('constructor', 'scrolling chat', { top: this.ui.messageList.scrollHeight }); + this.ui.messageList.scrollTo({ top: this.ui.messageList.scrollHeight, behavior: 'instant' }); + }, 100); + this.ui.reactions = new SiteReactions(); + this.lastReaction = new Date(); + } + if (this.ui.input) { + this.ui.input.addEventListener('keydown', this.onChatInputKeyDown.bind(this)); + } + + this.lastReaction = new Date(); + + if (window.localStorage) { + this.mutedUsers = window.localStorage.mutedUsers ? JSON.parse(window.localStorage.mutedUsers) : [ ]; + this.filterChatView(); + } + } + + async filterChatView ( ) { + this.mutedUsers.forEach((block) => { + document.querySelectorAll(`.chat-message[data-author-id="${block.userId}"]`).forEach((message) => { + message.parentElement.removeChild(message); + }); + }); + } + + async toggleChatInput (event) { + event.preventDefault(); + event.stopPropagation(); + + this.ui.input.toggleAttribute('hidden'); + if (this.ui.input.getAttribute('hidden')) { + this.ui.input.focus(); + } + + return true; + } + + async openChatInput ( ) { + if (this.ui.input.hasAttribute('hidden')) { + this.ui.input.removeAttribute('hidden'); + } + return true; + } + + async onChatInputKeyDown (event) { + if (event.key === 'Enter' && !event.shiftKey) { + return this.sendUserChat(event); + } + } + + async onChatMessageListScroll (/* event */) { + const prevBottom = this.ui.isAtBottom; + const scrollPos = this.ui.messageList.scrollTop + this.ui.messageList.clientHeight; + + this.ui.isAtBottom = scrollPos >= (this.ui.messageList.scrollHeight - 8); + if (this.ui.isAtBottom !== prevBottom) { + this.log.info('onChatMessageListScroll', 'at-bottom status change', { atBottom: this.ui.isAtBottom }); + if (this.ui.isAtBottom) { + this.ui.messageMenu.classList.remove('chat-menu-visible'); + } else { + this.ui.messageMenu.classList.add('chat-menu-visible'); + } + } + } + + async resumeChatScroll ( ) { + this.ui.messageList.scrollTop = this.ui.messageList.scrollHeight; + } + + async sendUserChat (event) { + event.preventDefault(); + + if (!dtp.room || !dtp.room._id) { + UIkit.modal.alert('There is a problem with Chat. Please refresh the page.'); + return; + } + + const roomId = dtp.room._id; + const content = this.ui.input.value; + this.ui.input.value = ''; + + if (content.length === 0) { + return true; + } + + this.log.info('sendUserChat', 'sending chat message', { roomId, content }); + this.app.socket.emit('user-chat', { + channelType: 'ChatRoom', + channel: roomId, + content, + }); + + // set focus back to chat input + this.ui.input.focus(); + + return true; + } + + async sendReaction (event) { + const NOW = new Date(); + if (NOW - this.lastReaction < 1000) { + return; + } + this.lastReaction = NOW; + + const target = event.currentTarget || event.target; + if (!target) { + return; + } + + const reaction = target.getAttribute('data-reaction'); + this.log.info('sendReaction', 'sending user reaction', { reaction }); + this.app.socket.emit('user-react', { + subjectType: 'ChatRoom', + subject: dtp.room._id, + reaction, + }); + } + + async appendUserChat (message) { + const isAtBottom = this.ui.isAtBottom; + + this.log.info('appendUserChat', 'message received', { user: message.user, content: message.content }); + if (this.mutedUsers.find((block) => block.userId === message.user._id)) { + this.log.info('appendUserChat', 'message is from blocked user', { + _id: message.user._id, + username: message.user.username, + }); + return; // sender is blocked by local user on this device + } + + const chatMessage = document.createElement('div'); + chatMessage.setAttribute('data-message-id', message._id); + chatMessage.setAttribute('data-author-id', message.user._id); + chatMessage.classList.add('uk-margin-small'); + chatMessage.classList.add('chat-message'); + + const userGrid = document.createElement('div'); + userGrid.setAttribute('uk-grid', ''); + userGrid.classList.add('uk-grid-small'); + userGrid.classList.add('uk-flex-middle'); + chatMessage.appendChild(userGrid); + + const usernameColumn = document.createElement('div'); + usernameColumn.classList.add('uk-width-expand'); + userGrid.appendChild(usernameColumn); + + const chatUser = document.createElement('div'); + const authorName = message.user.displayName || message.user.username; + chatUser.classList.add('uk-text-small'); + chatUser.classList.add('chat-username'); + chatUser.textContent = authorName; + usernameColumn.appendChild(chatUser); + + if (message.user.picture && message.user.picture.small) { + const chatUserPictureColumn = document.createElement('div'); + chatUserPictureColumn.classList.add('uk-width-auto'); + userGrid.appendChild(chatUserPictureColumn); + + const chatUserPicture = document.createElement('img'); + chatUserPicture.classList.add('chat-author-image'); + chatUserPicture.setAttribute('src', `/image/${message.user.picture.small._id}`); + chatUserPicture.setAttribute('alt', `${authorName}'s profile picture`); + chatUserPictureColumn.appendChild(chatUserPicture); + } + + if (dtp.user && (dtp.user._id !== message.user._id)) { + const menuColumn = document.createElement('div'); + menuColumn.classList.add('uk-width-auto'); + menuColumn.classList.add('chat-user-menu'); + userGrid.appendChild(menuColumn); + + const menuButton = document.createElement('button'); + menuButton.setAttribute('type', 'button'); + menuButton.classList.add('uk-button'); + menuButton.classList.add('uk-button-link'); + menuButton.classList.add('uk-button-small'); + menuColumn.appendChild(menuButton); + + const menuIcon = document.createElement('i'); + menuIcon.classList.add('fas'); + menuIcon.classList.add('fa-ellipsis-h'); + menuButton.appendChild(menuIcon); + + const menuDropdown = document.createElement('div'); + menuDropdown.setAttribute('data-message-id', message._id); + menuDropdown.setAttribute('uk-dropdown', 'mode: click'); + menuColumn.appendChild(menuDropdown); + + const dropdownList = document.createElement('ul'); + dropdownList.classList.add('uk-nav'); + dropdownList.classList.add('uk-dropdown-nav'); + menuDropdown.appendChild(dropdownList); + + let dropdownListItem = document.createElement('li'); + dropdownList.appendChild(dropdownListItem); + + let link = document.createElement('a'); + link.setAttribute('href', ''); + link.setAttribute('data-message-id', message._id); + link.setAttribute('data-user-id', message.user._id); + link.setAttribute('data-username', message.user.username); + link.setAttribute('onclick', "return dtp.app.muteChatUser(event);"); + link.textContent = `Mute ${message.user.displayName || message.user.username}`; + dropdownListItem.appendChild(link); + } + + const chatContent = document.createElement('div'); + chatContent.classList.add('chat-content'); + chatContent.classList.add('uk-text-break'); + chatContent.innerHTML = message.content; + chatMessage.appendChild(chatContent); + + const chatTimestamp = document.createElement('div'); + chatTimestamp.classList.add('chat-timestamp'); + chatTimestamp.classList.add('uk-text-small'); + chatTimestamp.textContent = moment(message.created).format('hh:mm:ss a'); + chatMessage.appendChild(chatTimestamp); + + if (Array.isArray(message.stickers) && message.stickers.length) { + message.stickers.forEach((sticker) => { + const chatContent = document.createElement('div'); + chatContent.classList.add('chat-sticker'); + chatContent.setAttribute('title', `:${sticker.slug}:`); + chatContent.setAttribute('data-sticker-id', sticker._id); + switch (sticker.encoded.type) { + case 'video/mp4': + chatContent.innerHTML = ``; + break; + case 'image/png': + chatContent.innerHTML = ``; + break; + case 'image/jpeg': + chatContent.innerHTML = ``; + break; + } + chatMessage.appendChild(chatContent); + }); + } + + this.ui.isModifying = true; + this.ui.messageList.appendChild(chatMessage); + this.ui.messages.push(chatMessage); + + while (this.ui.messages.length > 50) { + const message = this.ui.messages.shift(); + this.ui.messageList.removeChild(message); + } + + if (isAtBottom) { + /* + * This is jank. I don't know why I had to add this jank, but it is jank. + * The browser started emitting a scroll event *after* I issue this scroll + * command to return to the bottom of the view. So, I have to issue the + * scroll, let it fuck up, and issue the scroll again. I don't care why. + */ + this.ui.messageList.scrollTo(0, this.ui.messageList.scrollHeight); + setTimeout(( ) => { + this.ui.isAtBottom = true; + this.ui.messageList.scrollTo(0, this.ui.messageList.scrollHeight); + this.ui.isModifying = false; + }, 25); + } + } + + updateTimestamps ( ) { + const timestamps = document.querySelectorAll('div.chat-timestamp[data-created]'); + timestamps.forEach((timestamp) => { + const created = timestamp.getAttribute('data-created'); + timestamp.textContent = moment(created).format('hh:mm:ss a'); + }); + } + + createEmojiReact (message) { + this.ui.reactions.create(message.reaction); + } + + triggerEmojiExplosion ( ) { + const reactions = ['happy', 'angry', 'honk', 'clap', 'fire', 'laugh']; + const stopHandler = this.stopEmojiExplosion.bind(this); + + if (this.emojiExplosionTimeout && this.emojiExplosionInterval) { + clearTimeout(this.emojiExplosionTimeout); + this.emojiExplosionTimeout = setTimeout(stopHandler, EMOJI_EXPLOSION_DURATION); + return; + } + + // spawn 10 emoji reacts per second until told to stop + this.emojiExplosionInterval = setInterval(( ) => { + // choose a random reaction from the list of available reactions and + // spawn it. + const reaction = reactions[Math.floor(Math.random() * reactions.length)]; + this.ui.reactions.create({ reaction }); + }, EMOJI_EXPLOSION_INTERVAL); + + // set a timeout to stop the explosion + this.emojiExplosionTimeout = setTimeout(stopHandler, EMOJI_EXPLOSION_DURATION); + } + + stopEmojiExplosion ( ) { + if (!this.emojiExplosionTimeout || !this.emojiExplosionInterval) { + return; + } + + clearTimeout(this.emojiExplosionTimeout); + delete this.emojiExplosionTimeout; + + clearInterval(this.emojiExplosionInterval); + delete this.emojiExplosionInterval; + } +} \ No newline at end of file diff --git a/client/js/site-reactions.js b/client/js/site-reactions.js new file mode 100644 index 0000000..a6116fb --- /dev/null +++ b/client/js/site-reactions.js @@ -0,0 +1,149 @@ +// site-reactions.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const DTP_COMPONENT = { name: 'Site Reactions', slug: 'site-reactions' }; +const dtp = window.dtp = window.dtp || { }; // jshint ignore:line + +import DtpLog from 'dtp/dtp-log'; + +class Reaction { + + constructor (container, reaction) { + this.container = container; + this.reaction = reaction; + + this.element = document.createElement('span'); + this.element.classList.add('reaction-icon'); + + switch (this.reaction.reaction) { + case 'clap': + this.element.textContent = '👏'; + break; + + case 'fire': + this.element.textContent = '🔥'; + break; + + case 'happy': + this.element.textContent = '🤗'; + break; + + case 'laugh': + this.element.textContent = '🤣'; + break; + + case 'angry': + this.element.textContent = '🤬'; + break; + + case 'honk': + this.element.textContent = '🤡'; + break; + } + + this.position = { + x: Math.random() * (this.container.offsetWidth - 32.0), + y: this.container.clientHeight, + }; + + this.opacity = 1.0; + this.moveSpeed = 150.0 + (Math.random() * 50.0); + + this.rotation = 0.0; + this.rotationDelta = 60.0 + (Math.random() * 15.0); + + this.container.appendChild(this.element); + } + + update (elapsed) { + const scale = elapsed / 1000.0; + this.position.y -= this.moveSpeed * scale; + this.rotation += this.rotationDelta * scale; + if (this.rotation > 30 || this.rotation < -30) { + this.rotationDelta = -this.rotationDelta; + } + + const adjustedY = this.position.y + this.element.offsetHeight; + if (adjustedY > 100) { + return; + } + if (adjustedY === 0) { + this.opacity = 0.0; + return; + } + + this.opacity = adjustedY / 100.0; + } + + render ( ) { + this.element.style.left = `${this.position.x}px`; + this.element.style.top = `${this.position.y}px`; + + if (this.opacity > 0.8) { this.opacity = 0.8; } + this.element.style.opacity = this.opacity; + + const transform = `rotate(${this.rotation}deg)`; + this.element.style.transform = transform; + } + + destroy ( ) { + this.container.removeChild(this.element); + } +} + +export default class SiteReactions { + + constructor ( ) { + this.log = new DtpLog(DTP_COMPONENT); + + this.container = document.querySelector('#chat-reactions'); + this.reactions = [ ]; + + this.updateHandler = this.onUpdate.bind(this); + } + + create (reaction) { + const react = new Reaction(this.container, reaction); + this.reactions.push(react); + + if (this.reactions.length === 1) { + this.lastUpdate = new Date(); + requestAnimationFrame(this.updateHandler); + } + } + + onUpdate ( ) { + const NOW = new Date(); + const elapsed = NOW - this.lastUpdate; + const expired = [ ]; + + for (const reaction of this.reactions) { + reaction.update(elapsed); + if (reaction.position.y <= -(reaction.element.offsetHeight)) { + expired.push(reaction); + } else { + reaction.render(); + } + } + + expired.forEach((react) => { + const idx = this.reactions.indexOf(react); + if (idx === -1) { + return; + } + react.destroy(); + this.reactions.splice(idx, 1); + }); + + if (this.reactions.length > 0) { + requestAnimationFrame(this.updateHandler); + } + + this.lastUpdate = NOW; + } +} + +dtp.SiteReactions = SiteReactions; \ No newline at end of file diff --git a/client/less/site/button.less b/client/less/site/button.less index a4a1cc1..99c6eac 100644 --- a/client/less/site/button.less +++ b/client/less/site/button.less @@ -150,3 +150,17 @@ button.uk-button.dtp-button-danger { } } +.dtp-button-reaction { + background: none; + border: none; + outline: none; + cursor: pointer; + + span.button-icon { + font-size: 18px; + } + + span.count-label { + color: #e8e8e8; + } +} \ No newline at end of file diff --git a/client/less/site/chat.less b/client/less/site/chat.less index 3b7df3e..0771e68 100644 --- a/client/less/site/chat.less +++ b/client/less/site/chat.less @@ -1,24 +1,235 @@ +#site-chat-container { + align-self: stretch; + + .chat-menubar { + padding: 4px 16px; + } + + #chat-input-form { + textarea.uk-textarea { + padding: 2px 6px; + resize: none; + } + } + + .fundraising-progress-overlay { + position: absolute; + top: 40px; right: 0; left: 0; + width: 100%; + overflow: hidden; + background: black; + + &.hidden { + display: none; + } + } + + #chat-message-list-wrapper { + position: relative; + flex: 1; + + #chat-reactions { + position: absolute; + top: 0; right: 0; bottom: 0; left: 0; + background: transparent; + overflow: hidden; + + span.reaction-icon { + display: block; + position: absolute; + font-size: 24px; + opacity: 0.6; + transform: rotate(0deg); + } + } + + #chat-message-list { + position: relative; + display: flex; + flex-direction: column; + + height: 410px; + resize: vertical; + + overflow: auto; + box-shadow: var(--dtp-chat-shadow); + scroll-behavior: auto; + + &::-webkit-scrollbar { + display: none; /* Chrome */ + } + + scrollbar-width: none; /* Firefox */ + + .chat-message { + + .chat-user-menu { + + button.chat-menu-button { + padding: 0; + margin: 0; + background: transparent; + outline: none; + border: none; + line-height: 1; + } + } + } + } + + .chat-message-menu { + position: absolute; + display: none; + right: 4px; bottom: 20px; left: 4px; + text-align: center; + + &.chat-menu-visible { + display: block; + } + + button.chat-scroll-return { + padding: 4px 8px; + background: rgba(0,0,0, 0.6); + color: #c8c8c8; + border: solid 2px @site-brand-color; + border-radius: 8px; + outline: none; + cursor: pointer; + + &:hover { + color: white; + } + &:active { + background: rgba(160, 0, 0, 0.9); + color: white; + } + } + } + } +} + +/* + * OBS Chat Widget specializations + */ + +body[data-obs-widget="chat"] { + +.site-player-view { + + #site-chat-container { + + #chat-message-list-wrapper { + background-color: transparent; + + #chat-reactions { + background-color: transparent; + } + } + } +} +} + +/* + * Mobile view layout + */ +@media screen and (max-width: 959px) { + + body[data-current-view="channel-broadcast"], + body[data-current-view="dvr-player"] { + position: fixed; + top: 0; right: 0; bottom: 0; left: 0; + padding: 0; + margin: 0; + width: 100%; + height: 100%; + + .site-player-view { + position: absolute; + top: 64px; right: 0; bottom: 0; left: 0; + overflow: hidden; + + display: flex; + flex-direction: column; + flex-wrap: nowrap; + + #site-video-container { + flex: 0; + } + + #site-chat-container { + position: relative; + flex: 1; + + #chat-message-list { + flex: 1; + } + + #chat-input-form { + flex: 0; + } + } + } + } +} + .chat-message { + color: var(--dtp-chat-color); + background: var(--dtp-chat-background); + border-radius: 8px; + font-size: var(--dtp-chat-font-size); + margin-bottom: 5px; + + &.system-message { + background: rgba(255,255,255, 0.1); + } .chat-username { - color: #c8c8c8; - margin-right: 4px; font-weight: bold; + font-size: var(--dtp-chat-font-size); + line-height: 1; + color: var(--dtp-chat-username-color); + } + + img.chat-author-image { + width: auto; + height: 2em; + border-radius: 4px; } .chat-content { - color: #a8a8a8; - em { color: inherit; } - strong { color: #c8c8c8; } + line-height: 1.2em; + font-size: var(--dtp-chat-font-size); + color: inherit; + overflow-wrap: break-word; + + p:last-child { + margin-bottom: 0; + } + } + + .chat-timestamp { + color: var(--dtp-chat-timestamp-color); } .chat-sticker { display: inline-block; + margin-top: 4px; margin-right: 8px; + color: inherit; video { width: auto; height: 100px; } } +} + +body[data-obs-widget="chat"] { + + .chat-message { + + .chat-user-menu { + display: none; + } + } } \ No newline at end of file diff --git a/config/limiter.js b/config/limiter.js index 86ddcc6..1fb8146 100644 --- a/config/limiter.js +++ b/config/limiter.js @@ -51,6 +51,32 @@ module.exports = { }, }, + /* + * ChatController + */ + chat: { + postRoomUpdate: { + total: 10, + expire: ONE_MINUTE, + message: 'You are updating chat rooms too quickly', + }, + postRoomCreate: { + total: 1, + expire: ONE_MINUTE * 5, + message: 'You are creating chat rooms too quickly', + }, + getRoomView: { + total: 15, + expire: ONE_MINUTE, + message: 'You are loading chat rooms too quickly', + }, + getHome: { + total: 30, + expire: ONE_MINUTE, + message: 'You are loading chat home too quickly', + }, + }, + comment: { deleteComment: { total: 1, diff --git a/config/reserved-names.js b/config/reserved-names.js index 3a73f70..ae3f5d5 100644 --- a/config/reserved-names.js +++ b/config/reserved-names.js @@ -10,6 +10,7 @@ module.exports = [ 'about', 'admin', 'auth', + 'chat', 'digitaltelepresence', 'dist', 'dtp', diff --git a/lib/client/js/dtp-socket.js b/lib/client/js/dtp-socket.js index 82412d8..95a4a88 100644 --- a/lib/client/js/dtp-socket.js +++ b/lib/client/js/dtp-socket.js @@ -64,6 +64,10 @@ export default class DtpWebSocket { async onSocketConnect ( ) { this.log.info('onSocketConnect', 'WebSocket connected'); this.isConnected = true; + if (this.disconnectDialog) { + this.disconnectDialog.hide(); + delete this.disconnectDialog; + } } async onSocketDisconnect (reason) { @@ -85,7 +89,13 @@ export default class DtpWebSocket { }; this.log.warn('onSocketDisconnect', 'WebSocket disconnected', { reason }); - UIkit.modal.alert(`Disconnected: ${REASONS[reason]}`); + const modal = UIkit.modal.alert(`Disconnected: ${REASONS[reason]}`); + this.disconnectDialog = modal.dialog; + + UIkit.util.on(modal.dialog.$el, 'hidden', ( ) => { + this.log.info('onSocketDisconnect', 'disconnect dialog closed'); + delete this.disconnectDialog; + }); this.retryConnect(); } @@ -115,25 +125,18 @@ export default class DtpWebSocket { this.socket.emit('join', { channelId, channelType }); } - async isChannelJoined (channelId) { - return !!this.joinedChannels[channelId]; - } - async onJoinResult (message) { this.log.info('onJoinResult', 'channel joined', { message }); - this.joinedChannels[message.channelId] = message; + document.dispatchEvent(new Event('socketChannelJoined', { channelId: message.channelId })); } async leaveChannel (channelId) { this.log.info('leaveChannel', 'leaving channel', { channelId }); this.socket.emit('leave', { channelId }); - if (this.joinedChannels[channelId]) { - delete this.joinedChannels[channelId]; - } } - async sendUserChat (channelId, content) { - this.log.info('sendUserChat', 'sending message to channel', { channelId, content }); - this.socket.emit('user-chat', { channelId, content }); + async emit (messageName, payload) { + this.log.info('emit', 'sending message', { messageName, payload }); + this.socket.emit(messageName, payload); } } \ No newline at end of file diff --git a/lib/site-ioserver.js b/lib/site-ioserver.js index 5552f94..5a1f80f 100644 --- a/lib/site-ioserver.js +++ b/lib/site-ioserver.js @@ -11,7 +11,6 @@ const Redis = require('ioredis'); const mongoose = require('mongoose'); const ConnectToken = mongoose.model('ConnectToken'); -const ChatMessage = mongoose.model('ChatMessage'); const striptags = require('striptags'); const marked = require('marked'); @@ -31,6 +30,8 @@ class SiteIoServer extends Events { const { Server } = require('socket.io'); const { createAdapter } = require('@socket.io/redis-adapter'); + this.createRateLimiters(); + this.markedRenderer = new marked.Renderer(); this.markedRenderer.link = (href, title, text) => { return text; }; this.markedRenderer.image = (href, title, text) => { return text; }; @@ -53,8 +54,6 @@ class SiteIoServer extends Events { xhtml: false, }; - const transports = ['websocket'/*, 'polling'*/]; - const pubClient = new Redis({ host: process.env.REDIS_HOST, port: process.env.REDIS_PORT, @@ -66,12 +65,44 @@ class SiteIoServer extends Events { const subClient = pubClient.duplicate(); subClient.on('error', this.onRedisError.bind(this)); + const transports = ['websocket'/*, 'polling'*/]; const adapter = createAdapter(pubClient, subClient); this.io = new Server(httpServer, { adapter, transports }); this.io.on('connection', this.onSocketConnect.bind(this)); } + createRateLimiters ( ) { + const { RateLimiterRedis } = require('rate-limiter-flexible'); + + const rateLimiterRedisClient = new Redis({ + host: process.env.REDIS_HOST, + port: process.env.REDIS_PORT, + password: process.env.REDIS_PASSWORD, + key: process.env.REDIS_KEY_PREFIX || 'dtp', + enableOfflineQueue: false, + lazyConnect: false, + }); + + this.chatMessageLimiter = new RateLimiterRedis({ + storeClient: rateLimiterRedisClient, + points: 20, + duration: 60, + blockDuration: 60 * 3, + execEvenly: false, + keyPrefix: 'rl:chatmsg', + }); + + this.reactionLimiter = new RateLimiterRedis({ + storeClient: rateLimiterRedisClient, + points: 60, + duration: 60, + blockDuration: 60 * 3, + execEvenly: false, + keyPrefix: 'rl:react', + }); + } + async onRedisError (error) { this.log.error('Redis error', { error }); } @@ -114,6 +145,7 @@ class SiteIoServer extends Events { const session = { user: { _id: token.user._id, + type: token.userType, created: token.user.created, username: token.user.username, displayName: token.user.displayName, @@ -125,11 +157,13 @@ class SiteIoServer extends Events { session.onJoinChannel = this.onJoinChannel.bind(this, session); session.onLeaveChannel = this.onLeaveChannel.bind(this, session); session.onUserChat = this.onUserChat.bind(this, session); + session.onUserReact = this.onUserReact.bind(this, session); socket.on('disconnect', session.onSocketDisconnect); socket.on('join', session.onJoinChannel); socket.on('leave', session.onLeaveChannel); socket.on('user-chat', session.onUserChat); + socket.on('user-react', session.onUserReact); socket.emit('authenticated', { message: 'token verified', @@ -157,47 +191,92 @@ class SiteIoServer extends Events { session.socket.leave(channelId); } - async onUserChat (session, message) { - const { channel: channelService } = this.dtp.services; - const { channelId } = message; + async onUserChat (session, messageDefinition) { + const { chat: chatService, user: userService } = this.dtp.services; + const channelId = messageDefinition.channel; - if (!message.content || (message.content.length === 0)) { + if (!messageDefinition.content || (messageDefinition.content.length === 0)) { + this.log.info('dropping empty chat message'); return; } - const channel = await channelService.getChannelById(channelId); - if (!channel) { + /* + * First, implement the rate limiter check. If rate-limited, abort all + * further processing. Store nothing in the database. Send nothing to the + * chat room. + */ + try { + const userKey = session.user._id.toString(); + await this.chatMessageLimiter.consume(userKey, 1); + } catch (rateLimiter) { + const NOW = new Date(); + if (!session.notifySpamMuzzle) { + this.log.alert('preventing chat spam', { userId: session.user._id, rateLimiter }); + session.socket.to(channelId).emit('system-message', { + created: NOW, + content: `${session.user.displayName || session.user.username} has been muted for a while.`, + }); + session.notifySpamMuzzle = true; + } + session.socket.emit('system-message', { + created: NOW, + content: `You are rate limited for ${numeral(rateLimiter.msBeforeNext / 1000.0).format('0,0.0')} seconds.`, + rateLimiter, + }); return; } - const stickers = this.findStickers(message.content); - stickers.forEach((sticker) => { - const re = new RegExp(`:${sticker}:`, 'gi'); - message.content = message.content.replace(re, '').trim(); - }); - - message.content = striptags(message.content); + /* + * Pull the author's current User record from the db and verify that they + * have permission to chat. This read must happen with every chat message + * until permission update notifications are implemented on Redis pub/sub. + */ + try { + const userCheck = await userService.getUserAccount(session.user._id); + if (!userCheck || !userCheck.permissions || !userCheck.permissions.canChat) { + session.socket.emit('system-message', { + created: new Date(), + content: `You are not allowed to chat on ${this.dtp.config.site.name}.`, + }); + return; // permission denied + } - await ChatMessage.create({ - created: new Date(), - author: session.user._id, - content: message.content, - stickers, - }); + //TODO: Forked apps may want to implement channel-level moderation, and + // this is where to implement those checks. - const renderedContent = marked(message.content, this.markedConfig); + } catch (error) { + this.log.error('failed to implement user permissions check', { userId: session.user._id, error }); + return; // can't verify permissions? No chat for you. + } - const payload = { - user: { - _id: session.user._id, - username: session.user.username, - }, - content: renderedContent, - stickers, - }; + try { + const { message, payload } = await chatService.createMessage(session.user, messageDefinition); + if (message.analysis.similarity > 0.9) { + await chatService.sendSystemMessage( + session.socket, + "Your flow feels a little spammy, so that one didn't go through.", + { type: 'warning' }, + ); + return; + } - session.socket.to(channelId).emit('user-chat', payload); - session.socket.emit('user-chat', payload); + // use chat service emitter to deliver to channel (more efficient) + // than socket.io API + await chatService.sendMessage(message.channel, 'user-chat', payload); + + // use the socket itself to emit back to the sender + session.socket.emit('user-chat', payload); + + session.notifySpamMuzzle = false; + } catch (error) { + this.log.error('failed to process user chat message', { error }); + await chatService.sendSystemMessage( + session.socket, + `Failed to send chat: ${error.message}`, + { type: 'error' }, + ); + return; + } } findStickers (content) { @@ -220,6 +299,39 @@ class SiteIoServer extends Events { }); return stickers; } + + async onUserReact (session, message) { + const { chat: chatService, user: userService } = this.dtp.services; + try { + const userCheck = await userService.getUserAccount(session.user._id); + if (!userCheck || !userCheck.permissions || !userCheck.permissions.canChat) { + session.socket.emit('system-message', { + created: new Date(), + content: `You are not allowed to chat on ${this.dtp.config.site.name}.`, + }); + return; // permission denied + } + + try { + const userKey = session.user._id.toString(); + await this.reactionLimiter.consume(userKey, 1); + } catch (error) { + return; // rate-limited + } + + const reaction = await chatService.createEmojiReaction(session.user, message); + reaction.user = session.user; + + const payload = { reaction }; + const channelId = reaction.subject.toString(); + + await chatService.sendMessage(channelId, 'user-react', payload); + session.socket.emit('user-react', payload); + } catch (error) { + this.log.error('failed to process reaction', { message, error }); + return; + } + } } module.exports.SiteIoServer = SiteIoServer; \ No newline at end of file diff --git a/lib/site-platform.js b/lib/site-platform.js index 0a5f2a8..49bfc4e 100644 --- a/lib/site-platform.js +++ b/lib/site-platform.js @@ -211,6 +211,7 @@ module.exports.startWebServer = async (dtp) => { * Expose useful modules and information */ module.app.locals.DTP_SCRIPT_DEBUG = (process.env.NODE_ENV === 'local'); + module.app.locals.env = process.env; module.app.locals.dtp = dtp; module.app.locals.pkg = require(path.join(dtp.config.root, 'package.json')); module.app.locals.mongoose = require('mongoose'); diff --git a/package.json b/package.json index ba303b0..704edb5 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "slug": "^5.2.0", "socket.io": "^4.4.1", "socket.io-emitter": "^3.2.0", + "string-similarity": "^4.0.4", "striptags": "^3.2.0", "svg-captcha": "^1.4.0", "systeminformation": "^5.11.6", diff --git a/yarn.lock b/yarn.lock index d2cd7cf..fb0aa53 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2197,9 +2197,9 @@ camelcase@^6.2.0: integrity sha512-tVI4q5jjFV5CavAU8DXfza/TJcZutVKo/5Foskmsqcm0MsL91moHvwiGNnqaa2o6PF/7yT5ikDRcVcl8Rj6LCA== caniuse-lite@^1.0.30001280: - version "1.0.30001284" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001284.tgz#d3653929ded898cd0c1f09a56fd8ca6952df4fca" - integrity sha512-t28SKa7g6kiIQi6NHeOcKrOrGMzCRrXvlasPwWC26TH2QNdglgzQIRUuJ0cR3NeQPH+5jpuveeeSFDLm2zbkEw== + version "1.0.30001373" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001373.tgz" + integrity sha512-pJYArGHrPp3TUqQzFYRmP/lwJlj8RCbVe3Gd3eJQkAV8SAC6b19XS9BjMvRdvaS8RMkaTN8ZhoHP6S1y8zzwEQ== "chalk@4.1 - 4.1.2", chalk@^4.1.0, chalk@^4.1.1: version "4.1.2" @@ -7875,6 +7875,11 @@ streamsearch@0.1.2: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo= +string-similarity@^4.0.4: + version "4.0.4" + resolved "https://registry.yarnpkg.com/string-similarity/-/string-similarity-4.0.4.tgz#42d01ab0b34660ea8a018da8f56a3309bb8b2a5b" + integrity sha512-/q/8Q4Bl4ZKAPjj8WerIBJWALKkaPRfrvhfF8k/B23i4nzrlRj2/go1m90In7nG/3XDSbOo0+pu6RvCTM9RGMQ== + string-width@^1.0.1, string-width@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-1.0.2.tgz#118bdf5b8cdc51a2a7e70d211e07e2b0b9b107d3" From 61c96a2cc2ad096974e5795c9c95632f5122c2de Mon Sep 17 00:00:00 2001 From: rob Date: Sun, 31 Jul 2022 14:16:09 -0400 Subject: [PATCH 12/35] Chat UI progress --- app/views/chat/index.pug | 53 +------------- app/views/chat/layouts/room.pug | 67 +++++++++--------- app/views/chat/room-editor.pug | 54 ++++++++++++++ app/views/chat/view.pug | 96 +++++++++++++------------ client/less/site/button.less | 2 +- client/less/site/chat.less | 121 ++++++++++++++++---------------- client/less/site/main.less | 14 ++++ 7 files changed, 212 insertions(+), 195 deletions(-) create mode 100644 app/views/chat/room-editor.pug diff --git a/app/views/chat/index.pug b/app/views/chat/index.pug index 92d63a6..82f7e82 100644 --- a/app/views/chat/index.pug +++ b/app/views/chat/index.pug @@ -1,54 +1,5 @@ extends layouts/room block content - form(method="POST", action="/chat").uk-form - .uk-card.uk-card-default.uk-card-small - .uk-card-header - h1.uk-card-title Create Chat Room - .uk-card-body - .uk-margin - label(for="name").uk-form-label Room name - input(id="name", name="name", type="text", placeholder="Enter room name").uk-input - - .uk-margin - label(for="description").uk-form-label Room description - textarea(id="description", name="description", rows="2", placeholder="Enter room description").uk-textarea - - .uk-margin - label(for="policy").uk-form-label Room policy - textarea(id="policy", name="policy", rows="2", placeholder="Enter room use policy").uk-textarea - - .uk-margin - div(uk-grid) - .uk-width-auto - fieldset - legend Room Visibility - div(uk-grid).uk-grid-small.uk-flex-middle - .uk-width-auto - label - input(id="is-public", name="visibility", type="radio", value="public", checked).uk-radio - | Public - .ui-width-auto - label - input(id="is-private", name="visibility", type="radio", value="private").uk-radio - | Private - - .uk-width-auto - fieldset - legend Membership Policy - div(uk-grid).uk-grid-small.uk-flex-middle - .uk-width-auto - label - input(id="membership-open", name="policy", type="radio", value="open", checked).uk-radio - | Open - .uk-width-auto - label - input(id="membership-closed", name="policy", type="radio", value="closed").uk-radio - | Closed - - .uk-card-footer - div(uk-grid) - .uk-width-expand - +renderBackButton() - .uk-width-auto - button(type="submit").uk-button.uk-button-primary.uk-border-rounded Create room \ No newline at end of file + h1 Chat Home + \ No newline at end of file diff --git a/app/views/chat/layouts/room.pug b/app/views/chat/layouts/room.pug index 23eacc6..1cb9945 100644 --- a/app/views/chat/layouts/room.pug +++ b/app/views/chat/layouts/room.pug @@ -1,4 +1,5 @@ extends ../../layouts/main +block page-footer block content-container mixin renderRoomList (rooms) @@ -6,44 +7,42 @@ block content-container li.uk-active a(href=`/chat/${room._id}`)= room.name + 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") + .content-block.uk-border-rounded.uk-margin + if Array.isArray(ownedChatRooms) && (ownedChatRooms.length > 0) + ul#room-list.uk-nav.uk-nav-default + li.uk-nav-header Your Rooms + +renderRoomList(ownedChatRooms) + else + div You don't own any chat rooms. - section.uk-section.uk-section-default.uk-section-small - .uk-container.uk-container-expand - div(uk-grid) - div(class="uk-width-1-1 uk-width-1-5@l uk-flex-first@l").uk-flex-last - .content-block.uk-border-rounded.uk-margin - if Array.isArray(ownedChatRooms) && (ownedChatRooms.length > 0) - ul#room-list.uk-nav.uk-nav-default - li.uk-nav-header Your Rooms - +renderRoomList(ownedChatRooms) - else - div You don't own any chat rooms. - - .content-block.uk-border-rounded - if Array.isArray(joinedChatRooms) && (joinedChatRooms.length > 0) - ul#room-list.uk-nav.uk-nav-default - li.uk-nav-header Joined Rooms - +renderRoomList(ownedChatRooms) - else - div You haven't joined any chat rooms. + .content-block.uk-border-rounded + if Array.isArray(joinedChatRooms) && (joinedChatRooms.length > 0) + ul#room-list.uk-nav.uk-nav-default + li.uk-nav-header Joined Rooms + +renderRoomList(ownedChatRooms) + else + div You haven't joined any chat rooms. - div(class="uk-width-1-1 uk-width-expand@l") - #chat-room - block content + div(class="uk-width-1-1 uk-width-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") - .content-block.uk-border-rounded - if chatRoom - if Array.isArray(chatRoom.members) && (chatRoom.members.length > 0) - ul#room-member-list.uk-nav.uk-nav-default - li.uk-nav-header Room Members - each member in chatRoom.members - li - a(href="")= member.displayName || member.username - else - div The room has no members + div(class="uk-width-1-1 uk-width-1-5@l") + .content-block.uk-border-rounded + if chatRoom + if Array.isArray(chatRoom.members) && (chatRoom.members.length > 0) + ul#room-member-list.uk-nav.uk-nav-default + li.uk-nav-header Room Members + each member in chatRoom.members + li + a(href="")= member.displayName || member.username else - div Not in a room + div The room has no members + else + div Not in a room block viewjs script. diff --git a/app/views/chat/room-editor.pug b/app/views/chat/room-editor.pug new file mode 100644 index 0000000..92d63a6 --- /dev/null +++ b/app/views/chat/room-editor.pug @@ -0,0 +1,54 @@ +extends layouts/room +block content + + form(method="POST", action="/chat").uk-form + .uk-card.uk-card-default.uk-card-small + .uk-card-header + h1.uk-card-title Create Chat Room + .uk-card-body + .uk-margin + label(for="name").uk-form-label Room name + input(id="name", name="name", type="text", placeholder="Enter room name").uk-input + + .uk-margin + label(for="description").uk-form-label Room description + textarea(id="description", name="description", rows="2", placeholder="Enter room description").uk-textarea + + .uk-margin + label(for="policy").uk-form-label Room policy + textarea(id="policy", name="policy", rows="2", placeholder="Enter room use policy").uk-textarea + + .uk-margin + div(uk-grid) + .uk-width-auto + fieldset + legend Room Visibility + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-auto + label + input(id="is-public", name="visibility", type="radio", value="public", checked).uk-radio + | Public + .ui-width-auto + label + input(id="is-private", name="visibility", type="radio", value="private").uk-radio + | Private + + .uk-width-auto + fieldset + legend Membership Policy + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-auto + label + input(id="membership-open", name="policy", type="radio", value="open", checked).uk-radio + | Open + .uk-width-auto + label + input(id="membership-closed", name="policy", type="radio", value="closed").uk-radio + | Closed + + .uk-card-footer + div(uk-grid) + .uk-width-expand + +renderBackButton() + .uk-width-auto + button(type="submit").uk-button.uk-button-primary.uk-border-rounded Create room \ No newline at end of file diff --git a/app/views/chat/view.pug b/app/views/chat/view.pug index 9268f33..73bbfa1 100644 --- a/app/views/chat/view.pug +++ b/app/views/chat/view.pug @@ -4,54 +4,52 @@ block content include components/input-form include components/message - .uk-card.uk-card-default.uk-card-small - .uk-card-header - div(uk-grid).uk-flex-middle.chat-menubar - div(uk-tooltip="Room details").uk-width-expand - h1.uk-card-title.uk-margin-remove= room.name - div= room.description - - div(uk-tooltip="Active Members").uk-width-auto.no-select + #site-chat-container.uk-flex.uk-flex-column.uk-height-1-1 + div(uk-grid).uk-flex-middle.chat-menubar + div(uk-tooltip="Room details").uk-width-expand + h1.uk-card-title.uk-margin-remove= room.name + div= room.description + + div(uk-tooltip="Active Members").uk-width-auto.no-select + span + i.fas.fa-user + span(data-room-id= room._id).uk-margin-small-left.active-member-count= numeral(room.members.length).format('0,0') + + div(uk-tooltip="Total Members", class="uk-hidden@m").uk-width-auto.no-select + span + i.fas.fa-user + span.uk-margin-small-left= formatCount(room.members.length) + + + .uk-width-auto + button( + type="button", + data-room-id= room._id, + onclick="return dtp.app.chat.leaveRoom(event);", + ).uk-button.dtp-button-default.uk-button-small.uk-border-pill.uk-text-bold span - i.fas.fa-user - span(data-room-id= room._id).uk-margin-small-left.active-member-count= numeral(room.members.length).format('0,0') - - div(uk-tooltip="Total Members", class="uk-hidden@m").uk-width-auto.no-select - span - i.fas.fa-user - span.uk-margin-small-left= formatCount(room.members.length) - - - .uk-width-auto - button( - type="button", - data-room-id= room._id, - onclick="return dtp.app.chat.leaveRoom(event);", - ).uk-button.uk-button-small.uk-border-pill.uk-text-bold - span - i.fas.fa-user - span.uk-margin-small-left Leave Room - .uk-width-auto - .uk-inline - button(type="button").uk-button.uk-button-link.uk-button-small - i.fas.fa-ellipsis-h - div(uk-dropdown={ pos: 'bottom-right', mode: 'click' }) - ul.uk-nav.uk-dropdown-nav - li.uk-nav-heading= room.name - li.uk-nav-divider - li - a(href=`/chat/${room._id}/pop-out`, target="_blank") Pop-Out Chat - - .uk-card-body - #site-chat-container.uk-flex.uk-flex-column - #chat-message-list-wrapper - #chat-reactions - #chat-message-list - each message in chatMessages || [ ] - +renderChatMessage(message) - - .chat-message-menu - button(type="button", onclick="return dtp.app.chat.resumeChatScroll(event);").chat-scroll-return Resume scrolling - - .uk-card-footer + i.fas.fa-sign-out-alt + span.uk-margin-small-left Leave Room + + .uk-width-auto + .uk-inline + button(type="button").uk-button.uk-button-link.uk-button-small + i.fas.fa-ellipsis-h + div(uk-dropdown={ pos: 'bottom-right', mode: 'click' }) + ul.uk-nav.uk-dropdown-nav + li.uk-nav-heading= room.name + li.uk-nav-divider + li + a(href=`/chat/${room._id}/pop-out`, target="_blank") Pop-Out Chat + + #chat-message-list-wrapper + #chat-reactions + #chat-message-list + each message in chatMessages || [ ] + +renderChatMessage(message) + + .chat-message-menu + button(type="button", onclick="return dtp.app.chat.resumeChatScroll(event);").chat-scroll-return Resume scrolling + + div +renderChatInputForm(room) \ No newline at end of file diff --git a/client/less/site/button.less b/client/less/site/button.less index 99c6eac..803bc65 100644 --- a/client/less/site/button.less +++ b/client/less/site/button.less @@ -101,7 +101,7 @@ button.uk-button.dtp-button-default { background: none; outline: none; border: solid 2px rgb(75, 75, 75); - color: #c8c8c8; + color: @button-label-color; transition: background-color 0.2s; diff --git a/client/less/site/chat.less b/client/less/site/chat.less index 0771e68..1928942 100644 --- a/client/less/site/chat.less +++ b/client/less/site/chat.less @@ -1,8 +1,14 @@ +.site-chat-section { + height: calc(100% - @navbar-nav-item-height); +} + #site-chat-container { align-self: stretch; + overflow: scroll; .chat-menubar { - padding: 4px 16px; + padding: 10px @grid-small-gutter-horizontal; + border-bottom: solid 1px @content-border-color; } #chat-input-form { @@ -44,14 +50,12 @@ } #chat-message-list { - position: relative; - display: flex; - flex-direction: column; - - height: 410px; - resize: vertical; - + position: absolute; + top: 0; right: 0; bottom: 0; left: 0; + width: 100%; + height: 100%; overflow: auto; + box-shadow: var(--dtp-chat-shadow); scroll-behavior: auto; @@ -62,6 +66,55 @@ scrollbar-width: none; /* Firefox */ .chat-message { + color: var(--dtp-chat-color); + background: var(--dtp-chat-background); + border-radius: 8px; + font-size: var(--dtp-chat-font-size); + margin: 5px @grid-small-gutter-horizontal; + + &.system-message { + background: rgba(255,255,255, 0.1); + } + + .chat-username { + font-weight: bold; + font-size: var(--dtp-chat-font-size); + line-height: 1; + color: var(--dtp-chat-username-color); + } + + img.chat-author-image { + width: auto; + height: 2em; + border-radius: 4px; + } + + .chat-content { + line-height: 1.2em; + font-size: var(--dtp-chat-font-size); + color: inherit; + overflow-wrap: break-word; + + p:last-child { + margin-bottom: 0; + } + } + + .chat-timestamp { + color: var(--dtp-chat-timestamp-color); + } + + .chat-sticker { + display: inline-block; + margin-top: 4px; + margin-right: 8px; + color: inherit; + + video { + width: auto; + height: 100px; + } + } .chat-user-menu { @@ -172,58 +225,6 @@ body[data-obs-widget="chat"] { } } -.chat-message { - color: var(--dtp-chat-color); - background: var(--dtp-chat-background); - border-radius: 8px; - font-size: var(--dtp-chat-font-size); - margin-bottom: 5px; - - &.system-message { - background: rgba(255,255,255, 0.1); - } - - .chat-username { - font-weight: bold; - font-size: var(--dtp-chat-font-size); - line-height: 1; - color: var(--dtp-chat-username-color); - } - - img.chat-author-image { - width: auto; - height: 2em; - border-radius: 4px; - } - - .chat-content { - line-height: 1.2em; - font-size: var(--dtp-chat-font-size); - color: inherit; - overflow-wrap: break-word; - - p:last-child { - margin-bottom: 0; - } - } - - .chat-timestamp { - color: var(--dtp-chat-timestamp-color); - } - - .chat-sticker { - display: inline-block; - margin-top: 4px; - margin-right: 8px; - color: inherit; - - video { - width: auto; - height: 100px; - } - } -} - body[data-obs-widget="chat"] { .chat-message { diff --git a/client/less/site/main.less b/client/less/site/main.less index 2942785..f2a7ad4 100644 --- a/client/less/site/main.less +++ b/client/less/site/main.less @@ -7,6 +7,20 @@ html, body { body { padding-top: @site-navbar-height; + &[data-current-view="chat"] { + position: fixed; + top: @navbar-nav-item-height; right: 0; bottom: 0; left: 0; + + display: flex; + flex-direction: column; + justify-content: top; + + padding: 0; + margin: 0; + width: 100%; + height: 100%; + } + &[data-current-view="oauth2-authorize-dialog"], &[data-current-view="welcome"] { position: fixed; From 6e672f826275649b395833e66eed41a71a928bb4 Mon Sep 17 00:00:00 2001 From: rob Date: Sun, 31 Jul 2022 15:25:01 -0400 Subject: [PATCH 13/35] scooted room views into chat/room --- app/controllers/chat.js | 35 ++++++++++++++++--- app/views/chat/index.pug | 11 ++++-- app/views/chat/layouts/room.pug | 13 ++++--- app/views/chat/room-editor.pug | 54 ----------------------------- app/views/chat/room/editor.pug | 55 ++++++++++++++++++++++++++++++ app/views/chat/{ => room}/view.pug | 6 ++-- 6 files changed, 107 insertions(+), 67 deletions(-) delete mode 100644 app/views/chat/room-editor.pug create mode 100644 app/views/chat/room/editor.pug rename app/views/chat/{ => room}/view.pug (93%) diff --git a/app/controllers/chat.js b/app/controllers/chat.js index 2cef875..ae22834 100644 --- a/app/controllers/chat.js +++ b/app/controllers/chat.js @@ -37,23 +37,34 @@ class ChatController extends SiteController { router.param('roomId', this.populateRoomId.bind(this)); router.post( - '/:roomId', + '/room/:roomId', limiterService.create(limiterService.config.chat.postRoomUpdate), this.postRoomUpdate.bind(this), ); router.post( - '/', + '/room', limiterService.create(limiterService.config.chat.postRoomCreate), this.postRoomCreate.bind(this), ); router.get( - '/:roomId', + '/room/create', + this.getRoomEditor.bind(this), + ); + + router.get( + '/room/:roomId', limiterService.create(limiterService.config.chat.getRoomView), this.getRoomView.bind(this), ); + router.get( + '/room', + limiterService.create(limiterService.config.chat.getRoomView), + this.getRoomHome.bind(this), + ); + router.get( '/', limiterService.create(limiterService.config.chat.getHome), @@ -99,6 +110,10 @@ class ChatController extends SiteController { } } + async getRoomEditor (req, res) { + res.render('chat/room/editor'); + } + async getRoomView (req, res, next) { const { chat: chatService } = this.dtp.services; try { @@ -107,13 +122,25 @@ class ChatController extends SiteController { const pagination = { skip: 0, cpp: 20 }; res.locals.chatMessages = await chatService.getChannelHistory(res.locals.room, pagination); - res.render('chat/view'); + 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.rooms = 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) { res.locals.pageTitle = 'Chat Home'; res.render('chat/index'); diff --git a/app/views/chat/index.pug b/app/views/chat/index.pug index 82f7e82..990552f 100644 --- a/app/views/chat/index.pug +++ b/app/views/chat/index.pug @@ -1,5 +1,12 @@ extends layouts/room block content - h1 Chat Home - \ No newline at end of file + .content-block.uk-height-1-1.uk-overflow-auto + + h1 #{site.name} Chat + + p You can #[a(href='/chat/room/create') create] public and private chat rooms. A public room is listed in the public room directory, shown below. Private rooms are not listed in the directory and are unable to be found in search. + + p Rooms can be open, which means anyone can join them if they have them link. Rooms can also be closed, which means the room owner must invite people to join (and they have to accept). + + h2 Public Rooms \ No newline at end of file diff --git a/app/views/chat/layouts/room.pug b/app/views/chat/layouts/room.pug index 1cb9945..a91c437 100644 --- a/app/views/chat/layouts/room.pug +++ b/app/views/chat/layouts/room.pug @@ -5,15 +5,20 @@ block content-container mixin renderRoomList (rooms) each room in ownedChatRooms li.uk-active - a(href=`/chat/${room._id}`)= room.name + a(href=`/chat/room/${room._id}`)= room.name 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") + div(class="uk-width-1-1 uk-width-1-5@l uk-flex-last uk-flex-first@l").uk-height-1-1.uk-overflow-auto .content-block.uk-border-rounded.uk-margin if Array.isArray(ownedChatRooms) && (ownedChatRooms.length > 0) ul#room-list.uk-nav.uk-nav-default - li.uk-nav-header Your Rooms + li.uk-nav-header + div(uk-grid).uk-grid-small + .uk-width-expand Your Rooms + .uk-width-auto + a(href='/chat/room/create', uk-tooltip="Create new chat room...").uk-link-reset + i.fas.fa-plus +renderRoomList(ownedChatRooms) else div You don't own any chat rooms. @@ -30,7 +35,7 @@ block content-container #chat-room.uk-height-1-1 block content - div(class="uk-width-1-1 uk-width-1-5@l") + div(class="uk-width-1-1 uk-width-1-5@l").uk-height-1-1.uk-overflow-auto .content-block.uk-border-rounded if chatRoom if Array.isArray(chatRoom.members) && (chatRoom.members.length > 0) diff --git a/app/views/chat/room-editor.pug b/app/views/chat/room-editor.pug deleted file mode 100644 index 92d63a6..0000000 --- a/app/views/chat/room-editor.pug +++ /dev/null @@ -1,54 +0,0 @@ -extends layouts/room -block content - - form(method="POST", action="/chat").uk-form - .uk-card.uk-card-default.uk-card-small - .uk-card-header - h1.uk-card-title Create Chat Room - .uk-card-body - .uk-margin - label(for="name").uk-form-label Room name - input(id="name", name="name", type="text", placeholder="Enter room name").uk-input - - .uk-margin - label(for="description").uk-form-label Room description - textarea(id="description", name="description", rows="2", placeholder="Enter room description").uk-textarea - - .uk-margin - label(for="policy").uk-form-label Room policy - textarea(id="policy", name="policy", rows="2", placeholder="Enter room use policy").uk-textarea - - .uk-margin - div(uk-grid) - .uk-width-auto - fieldset - legend Room Visibility - div(uk-grid).uk-grid-small.uk-flex-middle - .uk-width-auto - label - input(id="is-public", name="visibility", type="radio", value="public", checked).uk-radio - | Public - .ui-width-auto - label - input(id="is-private", name="visibility", type="radio", value="private").uk-radio - | Private - - .uk-width-auto - fieldset - legend Membership Policy - div(uk-grid).uk-grid-small.uk-flex-middle - .uk-width-auto - label - input(id="membership-open", name="policy", type="radio", value="open", checked).uk-radio - | Open - .uk-width-auto - label - input(id="membership-closed", name="policy", type="radio", value="closed").uk-radio - | Closed - - .uk-card-footer - div(uk-grid) - .uk-width-expand - +renderBackButton() - .uk-width-auto - button(type="submit").uk-button.uk-button-primary.uk-border-rounded Create room \ No newline at end of file diff --git a/app/views/chat/room/editor.pug b/app/views/chat/room/editor.pug new file mode 100644 index 0000000..7c56b46 --- /dev/null +++ b/app/views/chat/room/editor.pug @@ -0,0 +1,55 @@ +extends ../layouts/room +block content + + .content-block.uk-height-1-1.uk-overflow-auto + form(method="POST", action="/chat").uk-form + .uk-card.uk-card-default.uk-card-small + .uk-card-header + h1.uk-card-title Create Chat Room + .uk-card-body + .uk-margin + label(for="name").uk-form-label Room name + input(id="name", name="name", type="text", placeholder="Enter room name").uk-input + + .uk-margin + label(for="description").uk-form-label Room description + textarea(id="description", name="description", rows="2", placeholder="Enter room description").uk-textarea + + .uk-margin + label(for="policy").uk-form-label Room policy + textarea(id="policy", name="policy", rows="2", placeholder="Enter room use policy").uk-textarea + + .uk-margin + div(uk-grid) + .uk-width-auto + fieldset + legend Room Visibility + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-auto + label + input(id="is-public", name="visibility", type="radio", value="public", checked).uk-radio + | Public + .ui-width-auto + label + input(id="is-private", name="visibility", type="radio", value="private").uk-radio + | Private + + .uk-width-auto + fieldset + legend Membership Policy + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-auto + label + input(id="membership-open", name="policy", type="radio", value="open", checked).uk-radio + | Open + .uk-width-auto + label + input(id="membership-closed", name="policy", type="radio", value="closed").uk-radio + | Closed + + .uk-card-footer + div(uk-grid) + .uk-width-expand + +renderBackButton() + .uk-width-auto + button(type="submit").uk-button.uk-button-primary.uk-border-rounded Create room \ No newline at end of file diff --git a/app/views/chat/view.pug b/app/views/chat/room/view.pug similarity index 93% rename from app/views/chat/view.pug rename to app/views/chat/room/view.pug index 73bbfa1..35121d0 100644 --- a/app/views/chat/view.pug +++ b/app/views/chat/room/view.pug @@ -1,8 +1,8 @@ -extends layouts/room +extends ../layouts/room block content - include components/input-form - include components/message + include ../components/input-form + include ../components/message #site-chat-container.uk-flex.uk-flex-column.uk-height-1-1 div(uk-grid).uk-flex-middle.chat-menubar From 548eaac0be5feb4a3601d3f518b34bce1bea7f3f Mon Sep 17 00:00:00 2001 From: rob Date: Sun, 31 Jul 2022 15:25:13 -0400 Subject: [PATCH 14/35] fix stickers worker start-up and add to start-local --- app/workers/stickers.js | 2 +- start-local | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/app/workers/stickers.js b/app/workers/stickers.js index cce69c3..f774f2f 100644 --- a/app/workers/stickers.js +++ b/app/workers/stickers.js @@ -325,7 +325,7 @@ class StickerWorker extends SiteWorker { (async ( ) => { try { - module.log = new SiteLog(module, module.config.componentName); + module.log = new SiteLog(module, module.config.component); /* * Platform startup diff --git a/start-local b/start-local index 20a0a21..036979f 100755 --- a/start-local +++ b/start-local @@ -10,9 +10,11 @@ export MINIO_ROOT_USER MINIO_ROOT_PASSWORD MINIO_CI_CD forever start --killSignal=SIGINT app/workers/host-services.js forever start --killSignal=SIGINT app/workers/reeeper.js forever start --killSignal=SIGINT app/workers/newsletter.js +forever start --killSignal=SIGINT app/workers/stickers.js minio server ./data/minio --address ":9010" --console-address ":9011" +forever stop app/workers/stickers.js forever stop app/workers/newsletter.js forever stop app/workers/reeeper.js forever stop app/workers/host-services.js \ No newline at end of file From e3142c1271d76a8e4589f972e18e418355d2ae94 Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 4 Aug 2022 03:25:31 -0400 Subject: [PATCH 15/35] this description of this changelist is incomplete and this is fine - Integrated all chat updates from Soapbox/Shing with heavy mods to lib/site-ioserver.js and the creation of the chat service, worker, and jobs - Added ability to create ChatRoom instances, invite people to them, join them and delete them - Refactored the shit out of SiteWorker - Created SiteWorkerProcess - Created the chat worker and the chat-room-clear and chat-room-delete job processors - Created the media worker - Refactored Stickers from Soapbox/Shing into the media system - Created the Attachment model, service, and media worker jobs - Upgraded the emoji picker from emoji-button to picmo because the author depreacted emoji-button and released picmo. - Made a custom presentation for the emoji picker - Created the SiteChat client-side object for managing Core Chat within the browser - Brought the Kaleidoscope Event UI down from DTP Social so anything can render a timeline of them - Added configurations for the media and reeeper job queues - Added the basics of a Notifications view - Added the concept of Forms - upgraded ioredis to 5.2.2 --- .env.default | 2 + app/controllers/auth.js | 14 +- app/controllers/chat.js | 284 ++++++++++++++++- app/controllers/email.js | 4 +- app/controllers/form.js | 70 ++++ app/controllers/home.js | 2 +- app/controllers/image.js | 4 +- app/controllers/manifest.js | 2 +- app/controllers/newsletter.js | 4 +- app/controllers/notification.js | 78 +++++ app/controllers/user.js | 27 +- app/controllers/welcome.js | 2 +- app/models/attachment.js | 64 ++++ app/models/chat-message.js | 3 +- app/models/chat-room-invite.js | 13 +- app/models/chat-room.js | 2 +- app/models/user-notification.js | 12 +- app/services/attachment.js | 186 +++++++++++ app/services/chat.js | 299 ++++++++++++++---- app/services/comment.js | 2 + app/services/content-report.js | 2 + app/services/content-vote.js | 1 + app/services/core-node.js | 272 +++++++++------- app/services/display-engine.js | 7 + app/services/host-cache.js | 1 + app/services/image.js | 2 +- app/services/job-queue.js | 4 - app/services/limiter.js | 18 +- app/services/markdown.js | 1 + app/services/minio.js | 1 + app/services/oauth2.js | 2 + app/services/otp-auth.js | 3 - app/services/session.js | 2 + app/services/sms.js | 2 + app/services/sticker.js | 24 +- app/services/user-notification.js | 94 ++++-- app/services/user.js | 37 ++- app/views/chat/components/input-form.pug | 70 ++-- app/views/chat/components/menubar.pug | 0 .../chat/components/message-standalone.pug | 4 + app/views/chat/components/message.pug | 52 +-- app/views/chat/components/reaction-button.pug | 2 +- app/views/chat/components/room-list.pug | 4 + app/views/chat/components/user-list-entry.pug | 12 + app/views/chat/index.pug | 24 +- app/views/chat/layouts/room.pug | 46 +-- app/views/chat/room/editor.pug | 31 +- app/views/chat/room/form/invite-member.pug | 22 ++ app/views/chat/room/index.pug | 29 ++ .../invite/components/invite-list-item.pug | 25 ++ .../room/invite/components/invite-list.pug | 6 + app/views/chat/room/invite/index.pug | 34 ++ app/views/chat/room/invite/view.pug | 59 ++++ app/views/chat/room/view.pug | 132 +++++--- app/views/components/button-icon.pug | 3 +- app/views/components/library.pug | 7 + app/views/components/navbar.pug | 9 +- app/views/kaleidoscope/components/event.pug | 43 +++ app/views/layouts/main.pug | 4 +- app/views/notification/index.pug | 14 + .../user/components/attribution-header.pug | 8 + app/views/user/components/profile-icon.pug | 30 +- app/workers/chat/job/chat-room-clear.js | 61 ++++ app/workers/chat/job/chat-room-delete.js | 102 ++++++ app/workers/media.js | 86 +++++ app/workers/media/job/attachment-delete.js | 72 +++++ app/workers/media/job/attachment-ingest.js | 270 ++++++++++++++++ app/workers/media/job/sticker-delete.js | 62 ++++ .../job/sticker-ingest.js} | 165 +++------- app/workers/reeeper.js | 32 +- .../reeeper/cron/expire-crashed-hosts.js | 75 +++++ client/js/index.js | 8 +- client/js/site-app.js | 65 +--- client/js/site-chat.js | 245 +++++++------- client/less/site/button.less | 16 +- client/less/site/chat.less | 195 +++++++----- client/less/site/content.less | 2 +- client/less/site/image.less | 2 +- client/less/site/kaleidoscope-event.less | 48 +++ client/less/site/main.less | 5 + client/less/site/uikit-theme.dtp-dark.less | 11 +- client/less/site/uikit-theme.dtp-light.less | 6 + client/less/style.common.less | 1 + config/job-queues.js | 10 +- config/limiter.js | 64 ++++ config/reserved-names.js | 1 + lib/client/js/dtp-app.js | 55 ++++ lib/client/js/dtp-display-engine.js | 14 +- lib/site-common.js | 42 +++ lib/site-ioserver.js | 158 +-------- lib/site-lib.js | 1 + lib/site-platform.js | 1 + lib/site-worker-process.js | 47 +++ lib/site-worker.js | 50 ++- package.json | 4 +- yarn.lock | 159 +++------- 96 files changed, 3217 insertions(+), 1135 deletions(-) create mode 100644 app/controllers/form.js create mode 100644 app/controllers/notification.js create mode 100644 app/models/attachment.js create mode 100644 app/services/attachment.js delete mode 100644 app/views/chat/components/menubar.pug create mode 100644 app/views/chat/components/message-standalone.pug create mode 100644 app/views/chat/components/room-list.pug create mode 100644 app/views/chat/components/user-list-entry.pug create mode 100644 app/views/chat/room/form/invite-member.pug create mode 100644 app/views/chat/room/index.pug create mode 100644 app/views/chat/room/invite/components/invite-list-item.pug create mode 100644 app/views/chat/room/invite/components/invite-list.pug create mode 100644 app/views/chat/room/invite/index.pug create mode 100644 app/views/chat/room/invite/view.pug create mode 100644 app/views/kaleidoscope/components/event.pug create mode 100644 app/views/notification/index.pug create mode 100644 app/views/user/components/attribution-header.pug create mode 100644 app/workers/chat/job/chat-room-clear.js create mode 100644 app/workers/chat/job/chat-room-delete.js create mode 100644 app/workers/media.js create mode 100644 app/workers/media/job/attachment-delete.js create mode 100644 app/workers/media/job/attachment-ingest.js create mode 100644 app/workers/media/job/sticker-delete.js rename app/workers/{stickers.js => media/job/sticker-ingest.js} (58%) create mode 100644 app/workers/reeeper/cron/expire-crashed-hosts.js create mode 100644 client/less/site/kaleidoscope-event.less create mode 100644 lib/site-worker-process.js diff --git a/.env.default b/.env.default index eacaa4d..8e7f5f2 100644 --- a/.env.default +++ b/.env.default @@ -16,6 +16,7 @@ DTP_CORE_AUTH_PASSWORD_LEN=64 DTP_IMAGE_WORK_PATH=/tmp/yourapp/image-work DTP_VIDEO_WORK_PATH=/tmp/yourapp/video-work DTP_STICKER_WORK_PATH=/tmp/yourapp/sticker-work +DTP_ATTACHMENT_WORK_PATH=/tmp/yourapp/attachment-work # # Set this to "enabled" to use NVIDIA GPU acceleration. Setting this to enabled @@ -83,6 +84,7 @@ MINIO_SECRET_KEY= MINIO_IMAGE_BUCKET=yourapp-images MINIO_VIDEO_BUCKET=yourapp-videos +MINIO_ATTACHMENT_BUCKET=yourapp-attachments # # ExpressJS/HTTP configuration diff --git a/app/controllers/auth.js b/app/controllers/auth.js index ee47be8..b78b458 100644 --- a/app/controllers/auth.js +++ b/app/controllers/auth.js @@ -35,18 +35,18 @@ class AuthController extends SiteController { router.post( '/otp/enable', - limiterService.create(limiterService.config.auth.postOtpEnable), + limiterService.createMiddleware(limiterService.config.auth.postOtpEnable), this.postOtpEnable.bind(this), ); router.post( '/otp/auth', - limiterService.create(limiterService.config.auth.postOtpAuthenticate), + limiterService.createMiddleware(limiterService.config.auth.postOtpAuthenticate), this.postOtpAuthenticate.bind(this), ); router.post( '/login', - limiterService.create(limiterService.config.auth.postLogin), + limiterService.createMiddleware(limiterService.config.auth.postLogin), upload.none(), this.postLogin.bind(this), ); @@ -54,14 +54,14 @@ class AuthController extends SiteController { router.get( '/api-token/personal', authRequired, - limiterService.create(limiterService.config.auth.getPersonalApiToken), + limiterService.createMiddleware(limiterService.config.auth.getPersonalApiToken), this.getPersonalApiToken.bind(this), ); router.get( '/socket-token', authRequiredNoRedirect, - limiterService.create(limiterService.config.auth.getSocketToken), + limiterService.createMiddleware(limiterService.config.auth.getSocketToken), this.getSocketToken.bind(this), ); @@ -69,14 +69,14 @@ class AuthController extends SiteController { router.get( '/core', - limiterService.create(limiterService.config.auth.getCoreHome), + limiterService.createMiddleware(limiterService.config.auth.getCoreHome), this.getCoreHome.bind(this), ); router.get( '/logout', authRequired, - limiterService.create(limiterService.config.auth.getLogout), + limiterService.createMiddleware(limiterService.config.auth.getLogout), this.getLogout.bind(this), ); diff --git a/app/controllers/chat.js b/app/controllers/chat.js index ae22834..fdf4ecb 100644 --- a/app/controllers/chat.js +++ b/app/controllers/chat.js @@ -5,6 +5,7 @@ 'use strict'; const express = require('express'); +const multer = require('multer'); const { SiteController,/*, SiteError*/ SiteError} = require('../../lib/site-lib'); @@ -22,6 +23,8 @@ class ChatController extends SiteController { session: sessionService, } = this.dtp.services; + const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${this.component.slug}` }); + const router = express.Router(); this.dtp.app.use('/chat', router); @@ -35,16 +38,31 @@ class ChatController extends SiteController { ); router.param('roomId', this.populateRoomId.bind(this)); + router.param('inviteId', this.populateInviteId.bind(this)); + + router.post( + '/room/:roomId/invite/:inviteId/action', + limiterService.createMiddleware(limiterService.config.chat.postRoomInviteAction), + upload.none(), + this.postRoomInviteAction.bind(this), + ); + + router.post( + '/room/:roomId/invite', + limiterService.createMiddleware(limiterService.config.chat.postRoomInvite), + upload.none(), + this.postRoomInvite.bind(this), + ); router.post( '/room/:roomId', - limiterService.create(limiterService.config.chat.postRoomUpdate), + limiterService.createMiddleware(limiterService.config.chat.postRoomUpdate), this.postRoomUpdate.bind(this), ); router.post( '/room', - limiterService.create(limiterService.config.chat.postRoomCreate), + limiterService.createMiddleware(limiterService.config.chat.postRoomCreate), this.postRoomCreate.bind(this), ); @@ -53,24 +71,64 @@ class ChatController extends SiteController { this.getRoomEditor.bind(this), ); + router.get( + '/room/:roomId/form/:formName', + limiterService.createMiddleware(limiterService.config.chat.getRoomForm), + this.getRoomForm.bind(this), + ); + + router.get( + '/room/:roomId/invite/:inviteId', + limiterService.createMiddleware(limiterService.config.chat.getRoomInviteView), + this.getRoomInviteView.bind(this), + ); + + router.get( + '/room/:roomId/invite', + limiterService.createMiddleware(limiterService.config.chat.getRoomInviteView), + this.getRoomInviteHome.bind(this), + ); + + router.get( + '/room/:roomId/settings', + limiterService.createMiddleware(limiterService.config.chat.getRoomSettings), + this.getRoomSettings.bind(this), + ); + router.get( '/room/:roomId', - limiterService.create(limiterService.config.chat.getRoomView), + limiterService.createMiddleware(limiterService.config.chat.getRoomView), this.getRoomView.bind(this), ); router.get( '/room', - limiterService.create(limiterService.config.chat.getRoomView), + limiterService.createMiddleware(limiterService.config.chat.getRoomHome), this.getRoomHome.bind(this), ); router.get( '/', - limiterService.create(limiterService.config.chat.getHome), + limiterService.createMiddleware(limiterService.config.chat.getHome), this.getHome.bind(this), ); + /* + * DELETE operations + */ + + router.delete( + '/room/:roomId/invite/:inviteId', + limiterService.createMiddleware(limiterService.config.chat.deleteInvite), + this.deleteInvite.bind(this), + ); + + router.delete( + '/room/:roomId', + limiterService.createMiddleware(limiterService.config.chat.deleteRoom), + this.deleteInvite.bind(this), + ); + return router; } @@ -88,13 +146,118 @@ class ChatController extends SiteController { } } + async populateInviteId (req, res, next, inviteId) { + const { chat: chatService } = this.dtp.services; + try { + res.locals.invite = await chatService.getRoomInviteById(inviteId); + if (!res.locals.invite) { + throw new SiteError(404, 'Invite not found'); + } + return next(); + } catch (error) { + this.log.error('failed to populate inviteId', { inviteId, error }); + return next(error); + } + } + + async postRoomInviteAction (req, res) { + const { chat: chatService } = this.dtp.services; + try { + const { response } = req.body; + const displayList = this.createDisplayList('room-invite-action'); + this.log.debug('room invite action', { message: req.body }); + switch (response) { + case 'accept': + await chatService.acceptRoomInvite(res.locals.invite); + displayList.showNotification( + `Chat room invite accepted`, + 'success', + 'top-center', + 5000, + ); + break; + + case 'reject': + await chatService.acceptRoomInvite(res.locals.invite); + displayList.showNotification( + `Chat room invite rejected`, + 'success', + 'top-center', + 5000, + ); + break; + + default: + throw new SiteError(400, 'Must specify invite action'); + } + + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to execute room invite action', { + inviteId: res.locals.invite._id, + response: req.body.response, + error, + }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + + async postRoomInvite (req, res) { + const { chat: chatService, user: userService } = this.dtp.services; + this.log.debug('room invite received', { invite: req.body }); + if (!req.body.username || !req.body.username.length) { + return res.status(400).json({ success: false, message: 'Please provide a username' }); + } + try { + req.body.username = req.body.username.trim().toLowerCase(); + while (req.body.username[0] === '@') { + req.body.username = req.body.username.slice(1); + } + + if (!req.body.username || !req.body.username.length) { + throw new SiteError(400, 'Please provide a username'); + } + + const member = await userService.getPublicProfile(req.body.username); + if (!member) { + throw new SiteError(404, `There is no account with username @${req.body.username}`); + } + if (member._id.equals(res.locals.room.owner._id)) { + throw new SiteError(400, "You can't invite yourself."); + } + + await chatService.sendRoomInvite(res.locals.room, member, req.body); + + const displayList = this.createDisplayList('invite create'); + displayList.showNotification( + `Chat room invite sent to ${member.displayName || member.username}!`, + 'success', + 'top-left', + 5000, + ); + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to create room invitation', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + async postRoomUpdate (req, res, next) { const { chat: chatService } = this.dtp.services; try { res.locals.room = await chatService.updateRoom(res.locals.room, req.body); - res.redirect(`/chat/${res.locals.room._id}`); + res.redirect(`/chat/room/${res.locals.room._id}`); } catch (error) { - this.log.error('failed to update chat room', { roomId: res.locals.room._id, error }); + this.log.error('failed to update chat room', { + // roomId: res.locals.room._id, + error, + }); return next(error); } } @@ -103,7 +266,7 @@ class ChatController extends SiteController { const { chat: chatService } = this.dtp.services; try { res.locals.room = await chatService.createRoom(req.user, req.body); - res.redirect(`/chat/${res.locals.room._id}`); + res.redirect(`/chat/room/${res.locals.room._id}`); } catch (error) { this.log.error('failed to create chat room', { error }); return next(error); @@ -114,6 +277,45 @@ class ChatController extends SiteController { res.render('chat/room/editor'); } + async getRoomForm (req, res, next) { + const validFormNames = [ + 'invite-member', + ]; + const formName = req.params.formName; + if (validFormNames.indexOf(formName) === -1) { + return next(new SiteError(404, 'Form not found')); + } + try { + res.render(`chat/room/form/${formName}`); + } catch (error) { + this.log.error('failed to render form', { formName, error }); + return next(error); + } + } + + async getRoomInviteView (req, res) { + res.render('chat/room/invite/view'); + } + + async getRoomInviteHome (req, res, next) { + const { chat: chatService } = this.dtp.services; + try { + res.locals.invites = { + new: await chatService.getRoomInvites(res.locals.room, 'new'), + accepted: await chatService.getRoomInvites(res.locals.room, 'accepted'), + rejected: await chatService.getRoomInvites(res.locals.room, 'rejected'), + }; + res.render('chat/room/invite'); + } catch (error) { + this.log.error('failed to render the room invites view', { error }); + return next(error); + } + } + + async getRoomSettings (req, res) { + res.render('chat/room/editor'); + } + async getRoomView (req, res, next) { const { chat: chatService } = this.dtp.services; try { @@ -133,7 +335,7 @@ class ChatController extends SiteController { const { chat: chatService } = this.dtp.services; try { res.locals.pagination = this.getPaginationParameters(req, 20); - res.locals.rooms = await chatService.getPublicRooms(req.user, res.locals.pagination); + res.locals.publicRooms = await chatService.getPublicRooms(req.user, res.locals.pagination); res.render('chat/room/index'); } catch (error) { this.log.error('failed to render room home', { error }); @@ -141,9 +343,67 @@ class ChatController extends SiteController { } } - async getHome (req, res) { - res.locals.pageTitle = 'Chat Home'; - res.render('chat/index'); + async getHome (req, res, next) { + const { chat: chatService } = this.dtp.services; + try { + res.locals.pageTitle = 'Chat Home'; + + res.locals.pagination = this.getPaginationParameters(req, 20); + + const roomIds = [ ]; + res.locals.ownedChatRooms.forEach((room) => roomIds.push(room._id)); + res.locals.joinedChatRooms.forEach((room) => roomIds.push(room._id)); + res.locals.timeline = await chatService.getMultiRoomTimeline(roomIds, res.locals.pagination); + + res.render('chat/index'); + } catch (error) { + this.log.error('failed to render chat home', { error }); + return next(error); + } + } + + async deleteInvite (req, res, next) { + const { chat: chatService } = this.dtp.services; + try { + if (res.locals.room.owner._id.equals(req.user._id)) { + throw new SiteError(403, 'This is not your invitiation'); + } + + await chatService.deleteRoomInvite(res.locals.invite); + + const displayList = this.createDisplayList('delete chat invite'); + displayList.removeElement(`li[data-invite-id="${res.locals.invite._id}"]`); + displayList.showNotification( + `Invitation to ${res.locals.invite.member.displayName || res.locals.invite.member.username} deleted successfully`, + 'success', + 'top-left', + 5000, + ); + + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to delete chat room invite', { error }); + return next(error); + } + } + + async deleteRoom (req, res, next) { + const { chat: chatService } = this.dtp.services; + try { + if (res.locals.room.owner._id.equals(req.user._id)) { + throw new SiteError(403, 'This is not your chat room'); + } + + await chatService.deleteRoom(res.locals.room); + + const displayList = this.createDisplayList('delete chat invite'); + displayList.navigateTo('/chat'); + + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to delete chat room invite', { error }); + return next(error); + } } } diff --git a/app/controllers/email.js b/app/controllers/email.js index facc17b..57e64dc 100644 --- a/app/controllers/email.js +++ b/app/controllers/email.js @@ -26,13 +26,13 @@ class EmailController extends SiteController { router.get( '/verify', - limiterService.create(limiterService.config.email.getEmailVerify), + limiterService.createMiddleware(limiterService.config.email.getEmailVerify), this.getEmailVerify.bind(this), ); router.get( '/opt-out', - limiterService.create(limiterService.config.email.getEmailOptOut), + limiterService.createMiddleware(limiterService.config.email.getEmailOptOut), this.getEmailOptOut.bind(this), ); diff --git a/app/controllers/form.js b/app/controllers/form.js new file mode 100644 index 0000000..d35e713 --- /dev/null +++ b/app/controllers/form.js @@ -0,0 +1,70 @@ +// email.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); +const glob = require('glob'); + +const express = require('express'); + +const { SiteController,/*, SiteError*/ +SiteError} = require('../../lib/site-lib'); + +class FormController extends SiteController { + + constructor (dtp) { + super(dtp, module.exports); + } + + async start ( ) { + const { + chat: chatService, + limiter: limiterService, + session: sessionService, + } = this.dtp.services; + + try { + this.forms = glob.sync(path.join(this.dtp.config.root, 'app', 'views', 'form', '*pug')) || [ ]; + this.forms = this.forms.map((filename) => path.parse(filename)); + } catch (error) { + this.log.error('failed to detect requestable forms', { error }); + this.forms = [ ]; + // fall through + } + + const router = express.Router(); + this.dtp.app.use('/form', router); + + router.use( + sessionService.authCheckMiddleware({ requireLogin: true }), + chatService.middleware({ maxOwnedRooms: 25, maxJoinedRooms: 50 }), + async (req, res, next) => { + res.locals.currentView = module.exports.slug; + return next(); + }, + ); + + router.get( + '/:formSlug', + limiterService.createMiddleware(limiterService.config.form.getForm), + this.getForm.bind(this), + ); + } + + async getForm (req, res, next) { + const { formSlug } = req.params; + const form = this.forms.find((form) => form.name === formSlug); + if (!form) { + return next(new SiteError(400, 'Invalid form')); + } + res.render(`form/${form.name}`); + } +} + +module.exports = { + slug: 'form', + name: 'form', + create: async (dtp) => { return new FormController(dtp); }, +}; \ No newline at end of file diff --git a/app/controllers/home.js b/app/controllers/home.js index fa355c6..b3cd781 100644 --- a/app/controllers/home.js +++ b/app/controllers/home.js @@ -33,7 +33,7 @@ class HomeController extends SiteController { router.get('/policy/:policyDocument', this.getPolicyDocument.bind(this)); router.get('/', - limiterService.create(limiterService.config.home.getHome), + limiterService.createMiddleware(limiterService.config.home.getHome), this.getHome.bind(this), ); } diff --git a/app/controllers/image.js b/app/controllers/image.js index 24e29a1..6c79620 100644 --- a/app/controllers/image.js +++ b/app/controllers/image.js @@ -46,13 +46,13 @@ class ImageController extends SiteController { ); router.post('/', - limiterService.create(limiterService.config.image.postCreateImage), + limiterService.createMiddleware(limiterService.config.image.postCreateImage), imageUpload.single('file'), this.postCreateImage.bind(this), ); router.get('/:imageId', - limiterService.create(limiterService.config.image.getImage), + limiterService.createMiddleware(limiterService.config.image.getImage), this.getHostCacheImage.bind(this), // this.getImage.bind(this), ); diff --git a/app/controllers/manifest.js b/app/controllers/manifest.js index c0afdc3..d55eb5a 100644 --- a/app/controllers/manifest.js +++ b/app/controllers/manifest.js @@ -27,7 +27,7 @@ class ManifestController extends SiteController { }); router.get('/', - limiterService.create(limiterService.config.manifest.getManifest), + limiterService.createMiddleware(limiterService.config.manifest.getManifest), this.getManifest.bind(this), ); } diff --git a/app/controllers/newsletter.js b/app/controllers/newsletter.js index c1edfba..2294e0e 100644 --- a/app/controllers/newsletter.js +++ b/app/controllers/newsletter.js @@ -34,12 +34,12 @@ class NewsletterController extends SiteController { router.post('/', upload.none(), this.postAddRecipient.bind(this)); router.get('/:newsletterId', - limiterService.create(limiterService.config.newsletter.getView), + limiterService.createMiddleware(limiterService.config.newsletter.getView), this.getView.bind(this), ); router.get('/', - limiterService.create(limiterService.config.newsletter.getIndex), + limiterService.createMiddleware(limiterService.config.newsletter.getIndex), this.getIndex.bind(this), ); } diff --git a/app/controllers/notification.js b/app/controllers/notification.js new file mode 100644 index 0000000..4711ed0 --- /dev/null +++ b/app/controllers/notification.js @@ -0,0 +1,78 @@ +// notification.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const express = require('express'); + +const { SiteController, SiteError } = require('../../lib/site-lib'); + +class NotificationController extends SiteController { + + constructor (dtp) { + super(dtp, module.exports); + } + + async start ( ) { + const { dtp } = this; + const { limiter: limiterService } = dtp.services; + + const router = express.Router(); + dtp.app.use('/notification', router); + + router.use(async (req, res, next) => { + res.locals.currentView = 'notification'; + return next(); + }); + + router.param('notificationId', this.populateNotificationId.bind(this)); + + router.get( + '/:notificationId', + limiterService.createMiddleware(limiterService.config.notification.getNotificationView), + this.getNotificationView.bind(this), + ); + + router.get('/', + limiterService.createMiddleware(limiterService.config.notification.getNotificationHome), + this.getNotificationHome.bind(this), + ); + } + + async populateNotificationId (req, res, next, notificationId) { + const { userNotification: userNotificationService } = this.dtp.services; + try { + res.locals.notification = await userNotificationService.getById(notificationId); + if (!res.locals.notification) { + throw new SiteError(404, 'Notification not found'); + } + return next(); + } catch (error) { + this.log.error('failed to populate notificationId', { notificationId, error }); + return next(error); + } + } + + async getNotificationView (req, res) { + res.render('notification/view'); + } + + async getNotificationHome (req, res, next) { + const { userNotification: userNotificationService } = this.dtp.services; + try { + res.locals.pagination = this.getPaginationParameters(req, 20); + res.locals.notifications = await userNotificationService.getForUser(req.user, res.locals.pagination); + res.render('notification/index'); + } catch (error) { + this.log.error('failed to render notification home view', { error }); + return next(error); + } + } +} + +module.exports = { + slug: 'notification', + name: 'notification', + create: async (dtp) => { return new NotificationController(dtp); }, +}; \ No newline at end of file diff --git a/app/controllers/user.js b/app/controllers/user.js index edcef43..9e2b8f9 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -65,7 +65,7 @@ class UserController extends SiteController { router.post( '/core/:coreUserId/settings', - limiterService.create(limiterService.config.user.postUpdateCoreSettings), + limiterService.createMiddleware(limiterService.config.user.postUpdateCoreSettings), checkProfileOwner, upload.none(), this.postUpdateCoreSettings.bind(this), @@ -73,7 +73,7 @@ class UserController extends SiteController { router.post( '/:userId/profile-photo', - limiterService.create(limiterService.config.user.postProfilePhoto), + limiterService.createMiddleware(limiterService.config.user.postProfilePhoto), checkProfileOwner, upload.single('imageFile'), this.postProfilePhoto.bind(this), @@ -81,7 +81,7 @@ class UserController extends SiteController { router.post( '/:userId/settings', - limiterService.create(limiterService.config.user.postUpdateSettings), + limiterService.createMiddleware(limiterService.config.user.postUpdateSettings), checkProfileOwner, upload.none(), this.postUpdateSettings.bind(this), @@ -89,13 +89,13 @@ class UserController extends SiteController { router.post( '/', - limiterService.create(limiterService.config.user.postCreate), + limiterService.createMiddleware(limiterService.config.user.postCreate), this.postCreateUser.bind(this), ); router.get( '/core/:coreUserId/settings', - limiterService.create(limiterService.config.user.getSettings), + limiterService.createMiddleware(limiterService.config.user.getSettings), authRequired, otpMiddleware, checkProfileOwner, @@ -103,29 +103,28 @@ class UserController extends SiteController { ); router.get( '/core/:coreUserId', - limiterService.create(limiterService.config.user.getUserProfile), + limiterService.createMiddleware(limiterService.config.user.getUserProfile), authRequired, otpMiddleware, - checkProfileOwner, this.getUserView.bind(this), ); router.get( '/:userId/otp-setup', - limiterService.create(limiterService.config.user.getOtpSetup), + limiterService.createMiddleware(limiterService.config.user.getOtpSetup), otpSetup, this.getOtpSetup.bind(this), ); router.get( '/:userId/otp-disable', - limiterService.create(limiterService.config.user.getOtpDisable), + limiterService.createMiddleware(limiterService.config.user.getOtpDisable), authRequired, this.getOtpDisable.bind(this), ); router.get( '/:userId/settings', - limiterService.create(limiterService.config.user.getSettings), + limiterService.createMiddleware(limiterService.config.user.getSettings), authRequired, otpMiddleware, checkProfileOwner, @@ -133,16 +132,15 @@ class UserController extends SiteController { ); router.get( '/:userId', - limiterService.create(limiterService.config.user.getUserProfile), + limiterService.createMiddleware(limiterService.config.user.getUserProfile), authRequired, otpMiddleware, - checkProfileOwner, this.getUserView.bind(this), ); router.delete( '/:userId/profile-photo', - limiterService.create(limiterService.config.user.deleteProfilePhoto), + limiterService.createMiddleware(limiterService.config.user.deleteProfilePhoto), authRequired, checkProfileOwner, this.deleteProfilePhoto.bind(this), @@ -157,9 +155,6 @@ class UserController extends SiteController { return next(new SiteError(406, 'Invalid User')); } try { - if (!req.user._id.equals(userId)) { - return next(new Error('Invalid account ID')); - } res.locals.userProfile = await userService.getUserAccount(userId); return next(); } catch (error) { diff --git a/app/controllers/welcome.js b/app/controllers/welcome.js index a3cef12..8fcc5f1 100644 --- a/app/controllers/welcome.js +++ b/app/controllers/welcome.js @@ -19,7 +19,7 @@ class WelcomeController extends SiteController { async start ( ) { const { limiter: limiterService } = this.dtp.services; - const welcomeLimiter = limiterService.create(limiterService.config.welcome); + const welcomeLimiter = limiterService.createMiddleware(limiterService.config.welcome); captcha.loadFont(path.join(this.dtp.config.root, 'client', 'fonts', 'Dirty Sweb.ttf')); diff --git a/app/models/attachment.js b/app/models/attachment.js new file mode 100644 index 0000000..71c3b89 --- /dev/null +++ b/app/models/attachment.js @@ -0,0 +1,64 @@ +// attachment.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); + +const Schema = mongoose.Schema; + +const ATTACHMENT_STATUS_LIST = [ + 'processing', // the attachment is in the processing queue + 'live', // the attachment is available for use + 'rejected', // the attachment was rejected (by proccessing queue) + 'retired', // the attachment has been retired +]; + +const AttachmentFileSchema = new Schema({ + bucket: { type: String, required: true }, + key: { type: String, required: true }, + mime: { type: String, required: true }, + size: { type: Number, required: true }, + etag: { type: String, required: true }, +}, { + _id: false, +}); + +/* + * Attachments are simply files. They can really be any kind of file, but will + * mostly be image, video, and audio files. + * + * owner is the User or CoreUser that uploaded the attachment. + * + * item is the item to which the attachment is attached. + */ +const AttachmentSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: 1 }, + status: { type: String, enum: ATTACHMENT_STATUS_LIST, default: 'processing', required: true }, + ownerType: { type: String, required: true }, + owner: { type: Schema.ObjectId, required: true, index: 1, refPath: 'ownerType' }, + itemType: { type: String, required: true }, + item: { type: Schema.ObjectId, required: true, index: 1, refPath: 'itemType' }, + original: { type: AttachmentFileSchema, required: true, select: false }, + encoded: { type: AttachmentFileSchema, required: true }, + flags: { + isSensitive: { type: Boolean, default: false, required: true }, + }, +}); + +AttachmentSchema.index({ + ownerType: 1, + owner: 1, +}, { + name: 'attachment_owner_idx', +}); + +AttachmentSchema.index({ + itemType: 1, + item: 1, +}, { + name: 'attachment_item_idx', +}); + +module.exports = mongoose.model('Attachment', AttachmentSchema); \ No newline at end of file diff --git a/app/models/chat-message.js b/app/models/chat-message.js index 78235e2..ba963ca 100644 --- a/app/models/chat-message.js +++ b/app/models/chat-message.js @@ -15,7 +15,7 @@ const Schema = mongoose.Schema; */ const ChatMessageSchema = new Schema({ - created: { type: Date, default: Date.now, required: true, index: -1, expires: '10d' }, + created: { type: Date, default: Date.now, required: true, index: -1 }, channelType: { type: String }, channel: { type: Schema.ObjectId, refPath: 'channelType' }, authorType: { type: String, enum: ['User', 'CoreUser'], required: true }, @@ -25,6 +25,7 @@ const ChatMessageSchema = new Schema({ similarity: { type: Number }, }, stickers: { type: [String] }, + attachments: { type: [Schema.ObjectId], ref: 'Attachment' }, }); module.exports = mongoose.model('ChatMessage', ChatMessageSchema); \ No newline at end of file diff --git a/app/models/chat-room-invite.js b/app/models/chat-room-invite.js index 4d9408c..f1d8fe6 100644 --- a/app/models/chat-room-invite.js +++ b/app/models/chat-room-invite.js @@ -8,12 +8,23 @@ const mongoose = require('mongoose'); const Schema = mongoose.Schema; +const INVITE_STATUS_LIST = ['new', 'accepted', 'rejected', 'deleted']; + const ChatRoomInviteSchema = new Schema({ created: { type: Date, default: Date.now, required: true, index: -1, expires: '30d' }, room: { type: Schema.ObjectId, required: true, ref: 'ChatRoom' }, memberType: { type: String, required: true }, member: { type: Schema.ObjectId, required: true, index: 1, refPath: 'memberType' }, - status: { type: String, enum: ['new', 'accepted', 'rejected'], required: true }, + status: { type: String, enum: INVITE_STATUS_LIST, required: true }, + message: { type: String }, +}); + +ChatRoomInviteSchema.index({ + room: 1, + member: 1, +}, { + unique: true, + name: 'chatroom_invite_unique_idx', }); module.exports = mongoose.model('ChatRoomInvite', ChatRoomInviteSchema); \ No newline at end of file diff --git a/app/models/chat-room.js b/app/models/chat-room.js index 8eda8eb..6d3e2ff 100644 --- a/app/models/chat-room.js +++ b/app/models/chat-room.js @@ -10,7 +10,7 @@ const Schema = mongoose.Schema; const RoomMemberSchema = new Schema({ memberType: { type: String, required: true }, - member: { type: Schema.ObjectId, refPath: 'memberType' }, + member: { type: Schema.ObjectId, required: true, refPath: 'members.memberType' }, }); const ROOM_VISIBILITY_LIST = ['public', 'private']; diff --git a/app/models/user-notification.js b/app/models/user-notification.js index c10620d..544de5f 100644 --- a/app/models/user-notification.js +++ b/app/models/user-notification.js @@ -7,14 +7,20 @@ const mongoose = require('mongoose'); const Schema = mongoose.Schema; +const NOTIFICATION_STATUS_LIST = ['new', 'seen']; + /* - * A notification is really just a user-specific bookmark to an event we know - * the user is interested in. These are the "timelines" presented to members. + * A notification is a user-specific bookmark to an event we know the user is + * interested in. These are the "timelines" presented to members. + * + * Notifications are only created for LOCAL users (not Core users). A + * notification sent to a CoreUser will be delivered to their Core. When it + * arrives, it becomes a UserNotification for that local user on that Core. */ const UserNotificationSchema = new Schema({ created: { type: Date, default: Date.now, required: true, index: -1, expires: '7d' }, user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, - status: { type: String, enum: ['new', 'seen'], default: 'new', required: true }, + status: { type: String, enum: NOTIFICATION_STATUS_LIST, default: 'new', required: true }, event: { type: Schema.ObjectId, required: true, ref: 'KaleidoscopeEvent' }, }); diff --git a/app/services/attachment.js b/app/services/attachment.js new file mode 100644 index 0000000..a06fe9b --- /dev/null +++ b/app/services/attachment.js @@ -0,0 +1,186 @@ +// cache.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); +const Attachment = mongoose.model('Attachment'); + +const { SiteService } = require('../../lib/site-lib'); + +class AttachmentService extends SiteService { + + constructor (dtp) { + super(dtp, module.exports); + } + + async start ( ) { + await super.start(); + + const { user: userService } = this.dtp.services; + this.populateAttachment = [ + { + path: 'item', + }, + { + path: 'owner', + select: userService.USER_SELECT, + }, + ]; + + this.queue = this.getJobQueue('media'); + // this.template = this.loadViewTemplate('attachment/components/attachment-standalone.pug'); + } + + async create (owner, attachmentDefinition, file) { + const { minio: minioService } = this.dtp.services; + const NOW = new Date(); + + /* + * Fill in as much of the attachment as we can prior to uploading + */ + + let attachment = new Attachment(); + attachment.created = NOW; + + attachment.ownerType = owner.type; + attachment.owner = owner._id; + + attachment.itemType = attachmentDefinition.itemType; + attachment.item = mongoose.Types.ObjectId(attachmentDefinition.item._id || attachmentDefinition.item); + + attachment.flags.isSensitive = attachmentDefinition.isSensitive === 'on'; + + /* + * Upload the original file to storage + */ + + const attachmentId = attachment._id.toString(); + + attachment.original.bucket = process.env.MINIO_ATTACHMENT_BUCKET || 'dtp-attachments'; + attachment.original.key = this.getAttachmentKey(attachment, 'original'); + attachment.original.mime = file.mimetype; + attachment.original.size = file.size; + + const response = await minioService.uploadFile({ + bucket: attachment.file.bucket, + key: attachment.file.key, + filePath: file.path, + metadata: { + 'X-DTP-Attachment-ID': attachmentId, + 'Content-Type': attachment.metadata.mime, + 'Content-Length': file.size, + }, + }); + + /* + * Complete the attachment definition, and save it. + */ + + attachment.original.etag = response.etag; + await attachment.save(); + + attachment = await this.getById(attachment._id); + + await this.queue.add('attachment-ingest', { attachmentId: attachment._id }); + + return attachment; + } + + getAttachmentKey (attachment, slug) { + const attachmentId = attachment._id.toString(); + const prefix = attachmentId.slice(-4); // last 4 for best entropy + return `/attachment/${prefix}/${attachmentId}/${attachmentId}-${slug}}`; + } + + /** + * Retrieves populated Attachment documents attached to an item. + * @param {String} itemType The type of item (ex: 'ChatMessage') + * @param {*} itemId The _id of the item (ex: message._id) + * @returns Array of attachments associated with the item. + */ + async getForItem (itemType, itemId) { + const attachments = await Attachment + .find({ itemType, item: itemId }) + .sort({ order: 1, created: 1 }) + .populate(this.populateAttachment) + .lean(); + return attachments; + } + + /** + * Retrieves populated Attachment documents created by a specific owner. + * @param {User} owner The owner for which Attachments are being fetched. + * @param {*} pagination Optional pagination of data set + * @returns Array of attachments owned by the specified owner. + */ + async getForOwner (owner, pagination) { + const attachments = await Attachment + .find({ ownerType: owner.type, owner: owner._id }) + .sort({ order: 1, created: 1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateAttachment) + .lean(); + return attachments; + } + + /** + * + * @param {mongoose.Types.ObjectId} attachmentId The ID of the attachment + * @param {Object} options `withOriginal` true|false + * @returns A populated Attachment document configured per options. + */ + async getById (attachmentId, options) { + options = Object.assign({ + withOriginal: false, + }, options || { }); + + let q = Attachment.findById(attachmentId); + if (options.withOriginal) { + q = q.select('+original'); + } + + const attachment = await q.populate(this.populateAttachment).lean(); + return attachment; + } + + /** + * Updates the status of an Attachment. + * @param {Attachment} attachment The attachment being modified. + * @param {*} status The new status of the attachment + */ + async setStatus (attachment, status) { + await Attachment.updateOne({ _id: attachment._id }, { $set: { status } }); + } + + /** + * Passes an attachment and options through a Pug template to generate HTML + * output ready to be inserted into a DOM to present the attachment in the UI. + * @param {Attachment} attachment + * @param {Object} attachmentOptions Additional options passed to the template + * @returns HTML output of the template + */ + async render (attachment, attachmentOptions) { + return this.attachmentTemplate({ attachment, attachmentOptions }); + } + + /** + * Creates a Bull Queue job to delete an Attachment including it's processed + * and original media files. + * @param {Attachment} attachment The attachment to be deleted. + * @returns Bull Queue job handle for the newly created job to delete the + * attachment. + */ + async remove (attachment) { + this.log.info('creating job to delete attachment', { attachmentId: attachment._id }); + return await this.queue.add('attachment-delete', { attachmentId: attachment._id }); + } +} + +module.exports = { + slug: 'attachment', + name: 'attachment', + create: (dtp) => { return new AttachmentService(dtp); }, +}; \ No newline at end of file diff --git a/app/services/chat.js b/app/services/chat.js index e8c32ad..6194919 100644 --- a/app/services/chat.js +++ b/app/services/chat.js @@ -4,8 +4,6 @@ 'use strict'; -const Redis = require('ioredis'); - const mongoose = require('mongoose'); const ChatRoom = mongoose.model('ChatRoom'); @@ -16,6 +14,7 @@ const EmojiReaction = mongoose.model('EmojiReaction'); const ioEmitter = require('socket.io-emitter'); +const moment = require('moment'); const marked = require('marked'); const hljs = require('highlight.js'); @@ -29,15 +28,19 @@ class ChatService extends SiteService { constructor (dtp) { super(dtp, module.exports); + } + + async start ( ) { + const { user: userService, limiter: limiterService } = this.dtp.services; + await super.start(); - const USER_SELECT = '_id username username_lc displayName picture'; this.populateChatMessage = [ { path: 'channel', }, { path: 'author', - select: USER_SELECT, + select: userService.USER_SELECT, }, { path: 'stickers', @@ -47,11 +50,11 @@ class ChatService extends SiteService { this.populateChatRoom = [ { path: 'owner', - select: USER_SELECT, + select: userService.USER_SELECT, }, { path: 'members.member', - select: USER_SELECT, + select: userService.USER_SELECT, }, ]; @@ -61,18 +64,20 @@ class ChatService extends SiteService { populate: [ { path: 'owner', - select: USER_SELECT, - }, - { - path: 'members.member', - select: USER_SELECT, + select: userService.USER_SELECT, }, ], }, + { + path: 'member', + select: userService.USER_SELECT, + }, ]; - } - async start ( ) { + this.templates = { + chatMessage: this.loadViewTemplate('chat/components/message-standalone.pug'), + }; + this.markedRenderer = new marked.Renderer(); this.markedRenderer.link = (href, title, text) => { return text; }; this.markedRenderer.image = (href, title, text) => { return text; }; @@ -93,24 +98,7 @@ class ChatService extends SiteService { xhtml: false, }; - /* - * The chat message rate limiter uses Redis to provide accurate atomic - * accounting regardless of which host is currently hosting a user's chat - * connection and session. - */ - - const { RateLimiterRedis } = require('rate-limiter-flexible'); - const rateLimiterRedisClient = new Redis({ - host: process.env.REDIS_HOST, - port: process.env.REDIS_PORT, - password: process.env.REDIS_PASSWORD, - keyPrefix: process.env.REDIS_KEY_PREFIX || 'dtp', - lazyConnect: false, - enableOfflineQueue: false, - }); - - this.chatMessageLimiter = new RateLimiterRedis({ - storeClient: rateLimiterRedisClient, + this.chatMessageLimiter = limiterService.createRateLimiter({ points: 20, duration: 60, blockDuration: 60 * 3, @@ -118,8 +106,7 @@ class ChatService extends SiteService { keyPrefix: 'rl:chatmsg', }); - this.reactionLimiter = new RateLimiterRedis({ - storeClient: rateLimiterRedisClient, + this.reactionLimiter = limiterService.createRateLimiter({ points: 60, duration: 60, blockDuration: 60 * 3, @@ -133,6 +120,18 @@ class ChatService extends SiteService { */ this.emitter = ioEmitter(this.dtp.redis); + + this.queues = { + reeeper: await this.getJobQueue('reeeper'), + }; + } + + async renderTemplate (which, viewModel) { + if (!this.templates || !this.templates[which]) { + throw new Error('Chat service template does not exist'); + } + viewModel = Object.assign(viewModel, this.dtp.app.locals); + return this.templates[which](viewModel); } middleware (options) { @@ -168,13 +167,16 @@ class ChatService extends SiteService { room.ownerType = owner.type; room.owner = owner._id; + if (!roomDefinition.name || !roomDefinition.name.length) { + throw new SiteError(400, 'Must provide room name'); + } room.name = this.filterText(roomDefinition.name); - if (roomDefinition.description) { + if (roomDefinition.description && (roomDefinition.description.length > 0)) { room.description = this.filterText(roomDefinition.description); } - if (roomDefinition.policy) { + if (roomDefinition.policy && (roomDefinition.policy.length > 0)) { room.policy = this.filterText(roomDefinition.policy); } @@ -196,15 +198,36 @@ class ChatService extends SiteService { $unset: { }, }; + if (!roomDefinition.name && !roomDefinition.name.length) { + throw new SiteError(400, 'Must provide room name'); + } updateOp.$set.name = this.filterText(roomDefinition.name); - if (roomDefinition.description && roomDefinition.description.length > 0) { + if (roomDefinition.description && (roomDefinition.description.length > 0)) { updateOp.$set.description = this.filterText(roomDefinition.description); } else { updateOp.$unset.description = 1; } - await ChatRoom.updateOne({ _id: room._id }, updateOp); + if (roomDefinition.policy && (roomDefinition.policy.length > 0)) { + updateOp.$set.policy = this.filterText(roomDefinition.policy); + } else { + updateOp.$unset.policy = 1; + } + + if (!roomDefinition.visibility || !roomDefinition.visibility.length) { + throw new SiteError(400, 'Must specify room visibility'); + } + updateOp.$set.visibility = roomDefinition.visibility.trim(); + + if (!roomDefinition.membershipPolicy || !roomDefinition.membershipPolicy.length) { + throw new SiteError(400, 'Must specify room membership policy'); + } + updateOp.$set.membershipPolicy = roomDefinition.membershipPolicy.trim(); + + const response = await ChatRoom.findOneAndUpdate({ _id: room._id }, updateOp, { new: true }); + this.log.debug('chat room update', { response }); + return response; } async getRoomsForOwner (owner, pagination) { @@ -243,7 +266,7 @@ class ChatService extends SiteService { cpp: 50 }, pagination); const rooms = await ChatRoom - .find({ 'flags.isPublic': true }) + .find({ visibility: 'public' }) .sort({ lastActivity: -1, created: -1 }) .skip(pagination.skip) .limit(pagination.cpp) @@ -260,8 +283,12 @@ class ChatService extends SiteService { return room; } + async deleteRoom (room) { + return this.queues.reeeper.add('chat-room-delete', { roomId: room._id }); + } + async joinRoom (room, member) { - if (!room.flags.isOpen) { + if (room.membershipPolicy !== 'open') { throw new SiteError(403, 'The room is not open'); } await ChatRoom.updateOne( @@ -286,24 +313,32 @@ class ChatService extends SiteService { ); } - async sendRoomInvite (room, member) { + async sendRoomInvite (room, member, inviteDefinition) { + const { coreNode: coreNodeService } = this.dtp.services; const NOW = new Date(); - /* - * See if there's already an outstanding invite, and return it. - */ + if (await this.isRoomMember(room, member)) { + throw new SiteError(400, `${member.username} is already a member of ${room.name}`); + } let invite = await ChatRoomInvite .findOne({ room: room._id, member: member._id }) .populate(this.populateChatRoomInvite) .lean(); if (invite) { - return invite; - } + switch (invite.status) { + case 'new': + throw new SiteError(400, `${member.displayName || member.username} was invited to join ${moment(invite.created).fromNow()}, but has not yet responded.`); - /* - * Create new invite - */ + case 'rejected': + throw new SiteError(400, `${member.displayName || member.username} rejected your invitation to join.`); + + default: + this.log.alert('deleting damaged ChatRoomInvite document', { _id: invite._id }); + await ChatRoomInvite.deleteOne({ _id: invite._id }); + break; // create a new one by proceeding + } + } invite = new ChatRoomInvite(); invite.created = NOW; @@ -311,7 +346,13 @@ class ChatService extends SiteService { invite.memberType = member.type; invite.member = member._id; invite.status = 'new'; + + if (inviteDefinition && inviteDefinition.message) { + invite.message = this.filterText(inviteDefinition.message); + } + await invite.save(); + invite = invite.toObject(); this.log.info('chat room invite created', { roomId: room._id, @@ -319,7 +360,39 @@ class ChatService extends SiteService { inviteId: invite._id, }); - return invite.toObject(); + /* + * Send the invite notification using DTP Core services. It will figure out + * who needs to receive the event and how to get it to them. + */ + + room.owner.type = room.ownerType; + const event = { + action: 'room-invite-create', + emitter: room.owner, + label: 'Chat Room Invitation', + content: invite.message || `Join my chat room on ${this.dtp.config.site.name}!`, + href: coreNodeService.getLocalUrl(`/chat/room/${room._id}/invite/${invite._id}`), + }; + await coreNodeService.sendKaleidoscopeEvent(event, member); + + return invite; + } + + async getRoomInvites (room, status) { + const invites = await ChatRoomInvite + .find({ room: room._id, status }) + .sort({ created: 1 }) + .populate(this.populateChatRoomInvite) + .lean(); + return invites; + } + + async getRoomInviteById (inviteId) { + const invite = await ChatRoomInvite + .findById(inviteId) + .populate(this.populateChatRoomInvite) + .lean(); + return invite; } async acceptRoomInvite (invite) { @@ -343,7 +416,7 @@ class ChatService extends SiteService { await ChatRoomInvite.updateOne( { _id: invite._id }, { - $set: { stats: 'accepted' }, + $set: { status: 'accepted' }, }, ); } @@ -356,13 +429,31 @@ class ChatService extends SiteService { }); await ChatRoomInvite.updateOne( { _id: invite._id }, - { $set: { status: 'rejected' } }, + { + $set: { status: 'rejected' }, + }, ); } + /** + * Marks an invitation as deleted, but does not physically remove it from the + * database. They expire after 30 days and will self-delete. This serves as a + * guard against invite spam. The person asking you to change this behavior + * wants to use invite spam as a form of abuse. The answer is: No. + * + * @param {ChatRoomInvite} invite The invitation to be marked as deleted. + */ async deleteRoomInvite (invite) { + if (invite.status !== 'new') { + throw new SiteError(400, "Can't delete selected room invite"); + } this.log.info('deleting chat room invite', { inviteId: invite._id }); - await ChatRoomInvite.deleteOne({ _id: invite._id }); + await ChatRoomInvite.updateOne({ _id: invite._id }, { $set: { status: 'deleted' } }); + } + + async isRoomMember (room, userId) { + const member = await ChatRoom.findOne({ 'members.member': userId }).lean(); + return !!member; } async createMessage (author, messageDefinition) { @@ -373,6 +464,13 @@ class ChatService extends SiteService { throw new SiteError(403, `You are not permitted to chat at all on ${this.dtp.config.site.name}`); } + try { + const userKey = author._id.toString(); + await this.chatMessageLimiter.consume(userKey, 1); + } catch (error) { + throw new SiteError(429, 'You are sending chat messages too quickly'); + } + const NOW = new Date(); /* @@ -390,6 +488,9 @@ class ChatService extends SiteService { message.content = this.filterText(messageDefinition.content); message.analysis = await this.analyzeContent(author, message.content); + if (message.analysis.similarity > 3.0) { + throw new SiteError(429, 'Message rejected as spam (too repetitive)'); + } const stickerSlugs = this.findStickers(message.content); stickerSlugs.forEach((sticker) => { @@ -420,23 +521,30 @@ class ChatService extends SiteService { const renderedContent = this.renderMessageContent(message.content); const payload = { _id: message._id, + created: message.created, user: { _id: author._id, displayName: author.displayName, username: author.username, - picture: { - large: { - _id: author.picture.large._id, - }, - small: { - _id: author.picture.small._id, - }, - }, }, content: renderedContent, stickers, }; + if (author.picture) { + payload.user.picture = { }; + if (author.picture.large) { + payload.user.picture.large ={ + _id: author.picture.large._id, + }; + } + if (author.picture.small) { + payload.user.picture.small = { + _id: author.picture.small._id, + }; + } + } + /* * Return both things */ @@ -500,6 +608,50 @@ class ChatService extends SiteService { return messages.reverse(); } + async getRoomMemberships (user, options) { + options = Object.assign({ + withPopulate: true, + }, options || { }); + const search = { + $or: [ + { owner: user._id }, + { 'members.member': user._id }, + ], + }; + + let q = ChatRoom.find(search).sort({ name: 1 }); + if (options.pagination) { + q = q.skip(options.pagination.skip).limit(options.pagination.cpp); + } + if (options.withPopulate) { + q = q.populate(this.populateChatRoom); + } + + const memberships = await q.lean(); + return memberships; + } + + /** + * This service is never called using user-supplied lists of room IDs. Don't + * do that, there is no membership check. Instead, every request knows the + * member's list of rooms owned and rooms joined. When this method was + * written, those arrays are being merged to build the list of roomIds. + * @param {Array} roomIds an array of Room._id values + * @param {*} pagination pagination params for the timeline + * @returns an array of messages in chronological order from all room IDs + * specified. + */ + async getMultiRoomTimeline (roomIds, pagination) { + const messages = await ChatMessage + .find({ room: { $in: roomIds } }) + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateChatMessage) + .lean(); + return messages.reverse(); + } + /** * Filters an input string to remove "zalgo" text and to strip all HTML tags. * This prevents cross-site scripting and the malicious destruction of text @@ -547,10 +699,13 @@ class ChatService extends SiteService { } async sendMessage (channel, messageName, payload) { + if (typeof channel !== 'string') { + channel = channel.toString(); + } this.emitter.to(channel).emit(messageName, payload); } - async sendSystemMessage (socket, content, options) { + async sendSystemMessage (content, options) { const NOW = new Date(); options = Object.assign({ @@ -562,17 +717,31 @@ class ChatService extends SiteService { type: options.type, content, }; - if (options.channelId) { - socket.to(options.channelId).emit('system-message', payload); + this.emitter.to(options.channelId).emit('system-message', payload); return; } - - socket.emit('system-message', payload); + if (options.userId) { + this.emitter.to(options.userId).emit('system-message', payload); + } } async createEmojiReaction (user, reactionDefinition) { + const { user: userService } = this.dtp.services; const NOW = new Date(); + + const userCheck = await userService.getUserAccount(user._id); + if (!userCheck || !userCheck.permissions || !userCheck.permissions.canChat) { + throw new SiteError(403, 'You are not permitted to chat'); + } + + try { + const userKey = user._id.toString(); + await this.reactionLimiter.consume(userKey, 1); + } catch (error) { + throw new SiteError(429, 'You are sending reactions too quickly'); + } + const reaction = new EmojiReaction(); reaction.created = NOW; diff --git a/app/services/comment.js b/app/services/comment.js index bf2f12c..191483f 100644 --- a/app/services/comment.js +++ b/app/services/comment.js @@ -49,6 +49,8 @@ class CommentService extends SiteService { } async start ( ) { + await super.start(); + this.templates = { }; this.templates.comment = pug.compileFile(path.join(this.dtp.config.root, 'app', 'views', 'comment', 'components', 'comment-standalone.pug')); diff --git a/app/services/content-report.js b/app/services/content-report.js index b94c06b..dddd2d2 100644 --- a/app/services/content-report.js +++ b/app/services/content-report.js @@ -37,6 +37,8 @@ class ContentReportService extends SiteService { } async start ( ) { + await super.start(); + this.templates = { }; this.templates.comment = pug.compileFile(path.join(this.dtp.config.root, 'app', 'views', 'comment', 'components', 'comment-standalone.pug')); } diff --git a/app/services/content-vote.js b/app/services/content-vote.js index df6948e..66cef90 100644 --- a/app/services/content-vote.js +++ b/app/services/content-vote.js @@ -18,6 +18,7 @@ class ContentVoteService extends SiteService { } async start ( ) { + await super.start(); this.emitter = ioEmitter(this.dtp.redis); } diff --git a/app/services/core-node.js b/app/services/core-node.js index 7235d27..a26990b 100644 --- a/app/services/core-node.js +++ b/app/services/core-node.js @@ -20,7 +20,7 @@ const OAuth2Strategy = require('passport-oauth2'); const striptags = require('striptags'); -const { SiteService, SiteError } = require('../../lib/site-lib'); +const { SiteService, SiteError, SiteAsync } = require('../../lib/site-lib'); class CoreAddress { @@ -58,7 +58,10 @@ class CoreNodeService extends SiteService { } async start ( ) { + await super.start(); + const cores = await this.getConnectedCores(null, true); + this.log.info('Core Node service starting', { connectedCoreCount: cores.length }); cores.forEach((core) => this.registerPassportCoreOAuth2(core)); } @@ -109,90 +112,6 @@ class CoreNodeService extends SiteService { }); } - registerPassportCoreOAuth2 (core) { - const { coreNode: coreNodeService } = this.dtp.services; - const AUTH_SCHEME = coreNodeService.getCoreRequestScheme(); - - const coreAuthStrategyName = this.getCoreAuthStrategyName(core); - const authorizationHost = `${core.address.host}:${core.address.port}`; - const authorizationURL = `${AUTH_SCHEME}://${authorizationHost}/oauth2/authorize`; - const tokenURL = `${AUTH_SCHEME}://${authorizationHost}/oauth2/token`; - const callbackURL = `${AUTH_SCHEME}://${process.env.DTP_SITE_DOMAIN}/auth/core/${core._id}/callback`; - - const coreAuthStrategy = new OAuth2Strategy( - { - authorizationURL, - tokenURL, - clientID: core.oauth.clientId.toString(), - clientSecret: core.oauth.clientSecret, - callbackURL, - }, - async (accessToken, refreshToken, params, profile, cb) => { - const NOW = new Date(); - try { - const coreUserId = mongoose.Types.ObjectId(params.coreUserId); - let user = await CoreUser.findOneAndUpdate( - { - core: core._id, - coreUserId, - }, - { - $setOnInsert: { - created: NOW, - core: core._id, - coreUserId, - flags: { - isAdmin: false, - isModerator: false, - }, - permissions: { - canLogin: true, - canChat: true, - canComment: true, - canReport: true, - }, - optIn: { - system: true, - marketing: false, - }, - theme: 'dtp-light', - stats: { - uniqueVisitCount: 0, - totalVisitCount: 0, - }, - }, - $set: { - updated: NOW, - username: params.username, - username_lc: params.username_lc, - displayName: params.displayName, - bio: params.bio, - }, - }, - { - upsert: true, - new: true, - }, - ); - user = user.toObject(); - user.type = 'CoreUser'; - return cb(null, user); - } catch (error) { - return cb(error); - } - }, - ); - - this.log.info('registering Core auth strategy', { - name: coreAuthStrategyName, - host: core.address.host, - port: core.address.port, - clientID: core.oauth.clientId.toString(), - callbackURL, - }); - passport.use(coreAuthStrategyName, coreAuthStrategy); - } - parseCoreAddress (host) { const address = new CoreAddress(); return address.parse(host); @@ -215,6 +134,25 @@ class CoreNodeService extends SiteService { return core; } + getCoreAuthStrategyName (core) { + return `dtp:${core.meta.domainKey}`; + } + + getCoreRequestScheme ( ) { + return process.env.DTP_CORE_AUTH_SCHEME || 'https'; + } + + getCoreRequestUrl (core, requestUrl) { + const coreScheme = this.getCoreRequestScheme(); + return `${coreScheme}://${core.address.host}:${core.address.port}${requestUrl}`; + } + + getLocalUrl (url) { + const CORE_SCHEME = this.getCoreRequestScheme(); + const { site } = this.dtp.config; + return `${CORE_SCHEME}://${site.domain}${url}`; + } + /** * First ensures that a record exists in the local database for the Core node. * Then, calls the node's info services to resolve more metadata about the @@ -276,11 +214,25 @@ class CoreNodeService extends SiteService { return { connectedCount, pendingCount, potentialReach }; } - async sendKaleidoscopeEvent (event) { - const CORE_SCHEME = this.getCoreRequestScheme(); + /** + * Sends a Kaleidoscope event to an array of recipients, a single recipient, + * or no recipients (undefined, a broadcast). + * @param {Object} event The event to be sent + * @param {Array} recipients Array of CoreUser to receive the event. Leave + * undefined to broadcast the event to all connected Core nodes. + * @returns Array of results, one per recipient. + */ + async sendKaleidoscopeEvent (event, recipients) { + const { hive: hiveService, userNotification: userNotificationService } = this.dtp.services; const { pkg } = this.dtp; const { site } = this.dtp.config; + const CORE_SCHEME = this.getCoreRequestScheme(); + + if (recipients && !Array.isArray(recipients)) { + recipients = [recipients]; + } + event.source = Object.assign({ pkg: { name: pkg.name, version: pkg.version }, site, @@ -304,7 +256,48 @@ class CoreNodeService extends SiteService { body: { event }, }; - return this.broadcast(request); + if (!recipients) { + return this.broadcast(request); + } + + let localEvent; // will be created if any local recipients + const results = [ ]; + + await SiteAsync.each(recipients, async (recipient) => { + switch (recipient.type) { + case 'CoreUser': + try { + const response = await this.sendRequest(recipient.core, request); + results.push({ success: true, recipient, request, response }); + } catch (error) { + this.log.error('failed to deliver request to Core node', { + coreId: recipient.core._id, + request, error, + }); + results.push({ success: false, recipient, request, error }); + } + break; + + case 'User': + try { + if (!localEvent) { + localEvent = await hiveService.createKaleidoscopeEvent(event); + } + await userNotificationService.create(recipient, localEvent); + results.push({ success: true, recipient, localEvent }); + } catch (error) { + this.log.error('failed to deliver Kaleidoscope event to local user', { recipient, event, error }); + results.push({ success: false, error }); + } + break; + + default: + results.push({ recipient, error: new SiteError(400, 'Recipient does not have a valid type')}); + break; + } + }, 4); + + return results; } async broadcast (request) { @@ -329,25 +322,6 @@ class CoreNodeService extends SiteService { return results; } - getCoreAuthStrategyName (core) { - return `dtp:${core.meta.domainKey}`; - } - - getCoreRequestScheme ( ) { - return process.env.DTP_CORE_AUTH_SCHEME || 'https'; - } - - getCoreRequestUrl (core, requestUrl) { - const coreScheme = this.getCoreRequestScheme(); - return `${coreScheme}://${core.address.host}:${core.address.port}${requestUrl}`; - } - - getLocalUrl (url) { - const CORE_SCHEME = this.getCoreRequestScheme(); - const { site } = this.dtp.config; - return `${CORE_SCHEME}://${site.domain}${url}`; - } - async sendRequest (core, request) { try { const req = new CoreNodeRequest(); @@ -552,6 +526,90 @@ class CoreNodeService extends SiteService { ); } + registerPassportCoreOAuth2 (core) { + const { coreNode: coreNodeService } = this.dtp.services; + const AUTH_SCHEME = coreNodeService.getCoreRequestScheme(); + + const coreAuthStrategyName = this.getCoreAuthStrategyName(core); + const authorizationHost = `${core.address.host}:${core.address.port}`; + const authorizationURL = `${AUTH_SCHEME}://${authorizationHost}/oauth2/authorize`; + const tokenURL = `${AUTH_SCHEME}://${authorizationHost}/oauth2/token`; + const callbackURL = `${AUTH_SCHEME}://${process.env.DTP_SITE_DOMAIN}/auth/core/${core._id}/callback`; + + const coreAuthStrategy = new OAuth2Strategy( + { + authorizationURL, + tokenURL, + clientID: core.oauth.clientId.toString(), + clientSecret: core.oauth.clientSecret, + callbackURL, + }, + async (accessToken, refreshToken, params, profile, cb) => { + const NOW = new Date(); + try { + const coreUserId = mongoose.Types.ObjectId(params.coreUserId); + let user = await CoreUser.findOneAndUpdate( + { + core: core._id, + coreUserId, + }, + { + $setOnInsert: { + created: NOW, + core: core._id, + coreUserId, + flags: { + isAdmin: false, + isModerator: false, + }, + permissions: { + canLogin: true, + canChat: true, + canComment: true, + canReport: true, + }, + optIn: { + system: true, + marketing: false, + }, + theme: 'dtp-light', + stats: { + uniqueVisitCount: 0, + totalVisitCount: 0, + }, + }, + $set: { + updated: NOW, + username: params.username, + username_lc: params.username_lc, + displayName: params.displayName, + bio: params.bio, + }, + }, + { + upsert: true, + new: true, + }, + ); + user = user.toObject(); + user.type = 'CoreUser'; + return cb(null, user); + } catch (error) { + return cb(error); + } + }, + ); + + this.log.info('registering Core auth strategy', { + name: coreAuthStrategyName, + host: core.address.host, + port: core.address.port, + clientID: core.oauth.clientId.toString(), + callbackURL, + }); + passport.use(coreAuthStrategyName, coreAuthStrategy); + } + async getConnectedCores (pagination, withOAuth = false) { let q = CoreNode.find({ 'flags.isConnected': true }); if (!withOAuth) { diff --git a/app/services/display-engine.js b/app/services/display-engine.js index 3dd76eb..968416d 100644 --- a/app/services/display-engine.js +++ b/app/services/display-engine.js @@ -33,6 +33,13 @@ class DisplayList { }); } + closeModal ( ) { + this.commands.push({ + action: 'closeModal', + params: { }, + }); + } + addElement (selector, where, html) { this.commands.push({ selector, action: 'addElement', diff --git a/app/services/host-cache.js b/app/services/host-cache.js index f2348ec..f4eb246 100644 --- a/app/services/host-cache.js +++ b/app/services/host-cache.js @@ -36,6 +36,7 @@ class HostCacheService extends SiteService { this.hostCache.disconnect(); delete this.hostCache; } + await super.stop(); } async getFile (bucket, key) { diff --git a/app/services/image.js b/app/services/image.js index a7a99dd..2ad83d5 100644 --- a/app/services/image.js +++ b/app/services/image.js @@ -1,4 +1,4 @@ -// minio.js +// image.js // Copyright (C) 2022 DTP Technologies, LLC // License: Apache-2.0 diff --git a/app/services/job-queue.js b/app/services/job-queue.js index 472a304..f59510b 100644 --- a/app/services/job-queue.js +++ b/app/services/job-queue.js @@ -15,10 +15,6 @@ class JobQueueService extends SiteService { this.queues = { }; } - async start ( ) { } - - async stop ( ) { } - getJobQueue (name, defaultJobOptions) { /* * If we have a named queue, return it. diff --git a/app/services/limiter.js b/app/services/limiter.js index 6d51283..b7a8259 100644 --- a/app/services/limiter.js +++ b/app/services/limiter.js @@ -5,6 +5,8 @@ 'use strict'; const path = require('path'); + +const { RateLimiterRedis } = require('rate-limiter-flexible'); const expressLimiter = require('express-limiter'); const { SiteService, SiteError } = require('../../lib/site-lib'); @@ -16,14 +18,15 @@ class LimiterService extends SiteService { this.config = require(path.resolve(dtp.config.root, 'config', 'limiter.js')); this.limiter = expressLimiter(this.dtp.app, this.dtp.redis); - this.handlers = { lookup: this.limiterLookup.bind(this), whitelist: this.limiterWhitelist.bind(this), }; + + this.rateLimiters = { }; } - create (config) { + createMiddleware (config) { const options = { total: config.total, expire: config.expire, @@ -52,6 +55,17 @@ class LimiterService extends SiteService { limiterWhitelist (req) { return req.user && req.user.flags.isAdmin; } + + createRateLimiter (id, config) { + if (this.rateLimiters[id]) { + return this.rateLimiters[id]; + } + config = Object.assign({ + storeClient: this.dtp.redis, + }, config); + this.rateLimiters[id] = new RateLimiterRedis(config); + return this.rateLimiters[id]; + } } module.exports = { diff --git a/app/services/markdown.js b/app/services/markdown.js index 9e62441..e7a5462 100644 --- a/app/services/markdown.js +++ b/app/services/markdown.js @@ -17,6 +17,7 @@ class MarkdownService extends SiteService { } async start ( ) { + await super.start(); this.markedRenderer = new marked.Renderer(); } diff --git a/app/services/minio.js b/app/services/minio.js index 6d23a7e..8856982 100644 --- a/app/services/minio.js +++ b/app/services/minio.js @@ -30,6 +30,7 @@ class MinioService extends SiteService { async stop ( ) { this.log.info(`stopping ${module.exports.name} service`); + await super.stop(); } async makeBucket (name, region) { diff --git a/app/services/oauth2.js b/app/services/oauth2.js index 00f0820..ec8532f 100644 --- a/app/services/oauth2.js +++ b/app/services/oauth2.js @@ -40,6 +40,8 @@ class OAuth2Service extends SiteService { } async start ( ) { + await super.start(); + const serverOptions = { }; this.log.info('creating OAuth2 server instance', { serverOptions }); diff --git a/app/services/otp-auth.js b/app/services/otp-auth.js index 8f9a532..85fb0b4 100644 --- a/app/services/otp-auth.js +++ b/app/services/otp-auth.js @@ -21,9 +21,6 @@ class OtpAuthService extends SiteService { constructor (dtp) { super(dtp, module.exports); - } - - async start ( ) { authenticator.options = { algorithm: 'sha1', step: 30, diff --git a/app/services/session.js b/app/services/session.js index e342fb8..177878e 100644 --- a/app/services/session.js +++ b/app/services/session.js @@ -17,6 +17,7 @@ class SessionService extends SiteService { } async start ( ) { + await super.start(); this.log.info(`starting ${module.exports.name} service`); passport.serializeUser(this.serializeUser.bind(this)); passport.deserializeUser(this.deserializeUser.bind(this)); @@ -24,6 +25,7 @@ class SessionService extends SiteService { async stop ( ) { this.log.info(`stopping ${module.exports.name} service`); + await super.stop(); } middleware ( ) { diff --git a/app/services/sms.js b/app/services/sms.js index 9e1a167..10abfba 100644 --- a/app/services/sms.js +++ b/app/services/sms.js @@ -17,11 +17,13 @@ class SmsService extends SiteService { } async start ( ) { + await super.start(); this.log.info(`starting ${module.exports.name} service`); } async stop ( ) { this.log.info(`stopping ${module.exports.name} service`); + await super.stop(); } async send (message) { diff --git a/app/services/sticker.js b/app/services/sticker.js index 2f153a4..1a35ad3 100644 --- a/app/services/sticker.js +++ b/app/services/sticker.js @@ -20,20 +20,20 @@ class StickerService extends SiteService { constructor (dtp) { super(dtp, module.exports); + } + + async start ( ) { + await super.start(); + + const { user: userService } = this.dtp.services; this.populateSticker = [ { path: 'owner', - select: '-email -passwordSalt -password', + select: userService.USER_SELECT, }, ]; - } - - async start ( ) { - const { jobQueue: jobQueueService } = this.dtp.services; - this.jobQueue = jobQueueService.getJobQueue('sticker-ingest', { - attempts: 3, - }); + this.queue = this.getJobQueue('media'); this.stickerTemplate = this.loadViewTemplate('sticker/components/sticker-standalone.pug'); } @@ -47,7 +47,7 @@ class StickerService extends SiteService { const currentStickers = await Sticker.find({ owner: owner._id }).select('_id').lean(); switch (ownerType) { - case 'Channel': + case 'ChatRoom': if (currentStickers.length >= MAX_CHANNEL_STICKERS) { throw new SiteError(508, `You have ${MAX_CHANNEL_STICKERS} stickers. Please remove a sticker before adding a new one.`); } @@ -87,7 +87,7 @@ class StickerService extends SiteService { await sticker.save(); - await this.jobQueue.add('sticker-ingest', { stickerId: sticker._id }); + await this.queue.add('sticker-ingest', { stickerId: sticker._id }); return sticker.toObject(); } @@ -140,7 +140,7 @@ class StickerService extends SiteService { return sticker; } - async setStickerStatus (sticker, status) { + async setStatus (sticker, status) { await Sticker.updateOne({ _id: sticker._id }, { $set: { status } }); } @@ -159,7 +159,7 @@ class StickerService extends SiteService { async removeSticker (sticker) { const stickerId = sticker._id; this.log.info('creating sticker delete job', { stickerId }); - await this.jobQueue.add('sticker-delete', { stickerId }); + await this.queue.add('sticker-delete', { stickerId }); } async render (sticker, stickerOptions) { diff --git a/app/services/user-notification.js b/app/services/user-notification.js index 922a280..9c1a4db 100644 --- a/app/services/user-notification.js +++ b/app/services/user-notification.js @@ -23,16 +23,46 @@ class UserNotificationService extends SiteService { select: '_id username username_lc displayName picture', }, { - path: 'attachment', + path: 'event', + populate: [ + { + path: 'attachment', + }, + ], }, ]; } async start ( ) { + await super.start(); this.templates = { }; this.templates.notification = pug.compileFile(path.join(this.dtp.config.root, 'app', 'views', 'notification', 'components', 'notification-standalone.pug')); } + middleware (options) { + options = Object.assign({ + withNotifications: false, + }, options || { }); + return async (req, res, next) => { + res.locals.middleware = res.locals.middleware || { }; + const data = res.locals.middleware.notifications = { }; + if (!req.user) { + // requests with no user don't matter to this middleware + return next(); + } + try { + data.newCount = await this.getNewCountForUser(req.user); + if (options.withNotifications) { + data.new = await this.getForUser(req.user, { skip: 0, cpp: 10 }); + } + return next(); + } catch (error) { + this.log.error('failed to populate route with notifications data', { error }); + return next(error); + } + }; + } + async create (user, event) { const notification = new UserNotification(); notification.created = event.created; @@ -41,32 +71,29 @@ class UserNotificationService extends SiteService { notification.event = event._id; await notification.save(); + await this.dtp.redis.hincrby(`user:${user._id}:notification`, 'newCount', 1); + return notification.toObject(); } async getNewCountForUser (user) { - const result = await UserNotification.aggregate([ - { - $match: { - user: user._id, - status: 'new', - }, - }, - { - $group: { - _id: { user: 1 }, - count: { $sum: 1 }, - }, - }, - { - $project: { - _id: -1, - count: '$count', - }, - }, - ]); - this.log('getNewCountForUser', { result }); - return result[0].count; + const userKey = `user:${user._id}:notification`; + let count; + + const value = await this.dtp.redis.hget(userKey, 'newCount'); + if (!value) { + count = await UserNotification.countDocuments({ user: user._id, status: 'new' }); + await this.dtp.redis.hset(userKey, 'newCount', count); + return count; + } + + count = parseInt(value, 10); + if (count < 0) { + count = await UserNotification.countDocuments({ user: user._id, status: 'new' }); + await this.dtp.redis.hset(userKey, 'newCount', count); + } + + return count; } async getForUser (user, pagination) { @@ -77,19 +104,34 @@ class UserNotificationService extends SiteService { .limit(pagination.cpp) .populate(this.populateUserNotification) .lean(); - const newNotifications = notifications.map((notif) => notif.status === 'new'); + + const newNotifications = notifications.filter((notif) => notif.status === 'new'); if (newNotifications.length > 0) { await UserNotification.updateMany( { _id: { $in: newNotifications.map((notif) => notif._id) } }, - { $set: { stats: 'seen' } }, + { $set: { status: 'seen' } }, + ); + await this.dtp.redis.hincrby( + `user:${user._id}:notification`, + 'newCount', + -(newNotifications.length), ); } + return notifications; } + + async getById (notificationId) { + const notification = await UserNotification + .findById(notificationId) + .populate(this.populateUserNotification) + .lean(); + return notification; + } } module.exports = { - slug: 'user-notification', name: 'userNotification', + slug: 'user-notification', create: (dtp) => { return new UserNotificationService(dtp); }, }; \ No newline at end of file diff --git a/app/services/user.js b/app/services/user.js index e1446a9..75a9eea 100644 --- a/app/services/user.js +++ b/app/services/user.js @@ -9,6 +9,7 @@ const path = require('path'); const mongoose = require('mongoose'); const User = mongoose.model('User'); +const CoreUser = mongoose.model('CoreUser'); const UserBlock = mongoose.model('UserBlock'); const passport = require('passport'); @@ -24,6 +25,8 @@ class UserService extends SiteService { constructor (dtp) { super(dtp, module.exports); + this.USER_SELECT = '_id username username_lc displayName picture'; + this.reservedNames = require(path.join(this.dtp.config.root, 'config', 'reserved-names')); this.populateUser = [ @@ -37,6 +40,7 @@ class UserService extends SiteService { } async start ( ) { + await super.start(); this.log.info(`starting ${module.exports.name} service`); this.registerPassportLocal(); @@ -48,6 +52,7 @@ class UserService extends SiteService { async stop ( ) { this.log.info(`stopping ${module.exports.name} service`); + await super.stop(); } async create (userDefinition) { @@ -443,22 +448,46 @@ class UserService extends SiteService { } async getPublicProfile (username) { - if (!username || (typeof username !== 'string') || (username.length === 0)) { + if (!username || (typeof username !== 'string')) { throw new SiteError(406, 'Invalid username'); } + username = username.trim().toLowerCase(); - const user = await User + if (username.length === 0) { + throw new SiteError(406, 'Invalid username'); + } + + /** + * Try to resolve the user as a CoreUser + */ + let user = await CoreUser .findOne({ username_lc: username }) - .select('_id created username username_lc displayName bio picture header') + .select('_id created username username_lc displayName bio picture header core') .populate(this.populateUser) .lean(); + if (user) { + user.type = 'CoreUser'; + } else { + /* + * Try to resolve the user as a local User + */ + user = await User + .findOne({ username_lc: username }) + .select('_id created username username_lc displayName bio picture header') + .populate(this.populateUser) + .lean(); + if (user) { + user.type = 'User'; + } + } + return user; } async getRecent (maxCount = 3) { const users = User .find() - .select('_id created username username_lc displayName picture') + .select(UserService.USER_SELECT) .sort({ created: -1 }) .limit(maxCount) .lean(); diff --git a/app/views/chat/components/input-form.pug b/app/views/chat/components/input-form.pug index b058114..17f418d 100644 --- a/app/views/chat/components/input-form.pug +++ b/app/views/chat/components/input-form.pug @@ -9,12 +9,15 @@ mixin renderChatInputForm (room, options = { }) input(type="hidden", name="roomType", value= "ChatRoom") input(type="hidden", name="room", value= room._id) - .uk-card.uk-card-secondary.uk-card-body.uk-padding-small(style="border-top: solid 1px #3a3a3a;") + #site-emoji-picker + div THIS IS THE EMOJI PICKER + + .uk-padding-small.uk-padding-remove-bottom textarea( id="chat-input-text", name="content", rows="2", - hidden, + hidden= options.inputHidden, ).uk-textarea.uk-margin-small div(uk-grid).uk-grid-small.uk-flex-middle @@ -23,9 +26,8 @@ mixin renderChatInputForm (room, options = { }) type= "button", title= "Insert emoji", data-target-element= "chat-input-text", - uk-tooltip={ title: 'Add Emoji', delay: 500 }, - onclick= "return dtp.app.showEmojiPicker(event);", - ).uk-button.dtp-button-default.uk-button-small + onclick= "return dtp.app.chat.toggleEmojiPicker(event);", + ).uk-button.uk-button-default.uk-button-small span i.far.fa-laugh-beam @@ -34,9 +36,8 @@ mixin renderChatInputForm (room, options = { }) type= "button", title= "Sticker Picker", uk-toggle={ target: '#sticker-picker'}, - uk-tooltip={ title: 'Add Sticker', delay: 500 }, onclick="return dtp.app.chat.openChatInput();", - ).uk-button.dtp-button-default.uk-button-small + ).uk-button.uk-button-default.uk-button-small span i.far.fa-image #sticker-picker(uk-modal) @@ -99,19 +100,19 @@ mixin renderChatInputForm (room, options = { }) else .uk-text-center You haven't saved any Favorite stickers - //- .uk-width-auto - //- button( - //- type= "button", - //- title= "Attach image", - //- onclick="return dtp.app.chat.attachChatImage(event);", - //- ).uk-button.dtp-button-default.uk-button-small - //- span - //- i.fas.fa-file-image + .uk-width-auto + button( + type= "button", + title= "Attach image", + onclick="return dtp.app.chat.createAttachment(event);", + ).uk-button.uk-button-default.uk-button-small + span + i.fas.fa-file-image .uk-width-expand if !options.hideHomeNav .uk-text-small.uk-text-center.uk-text-truncate - a(href="/chat", uk-tooltip={ title: "Chat Home", delay: 500 }).uk-button.dtp-button-secondary.uk-button-small + a(href="/chat", title= "Chat Home").uk-button.uk-button-default.uk-button-small span i.fas.fa-home @@ -120,8 +121,8 @@ mixin renderChatInputForm (room, options = { }) id="chat-input-btn", type="button", onclick="return dtp.app.chat.toggleChatInput(event);", - uk-tooltip={ title: "Toggle Chat Input", delay: 500 }, - ).uk-button.dtp-button-secondary.uk-button-small + title= "Toggle Chat Input", + ).uk-button.uk-button-default.uk-button-small span i.fas.fa-edit @@ -129,23 +130,22 @@ mixin renderChatInputForm (room, options = { }) button( id="chat-send-btn", type="submit", - uk-tooltip={ title: "Send Message", delay: 500 }, - ).uk-button.dtp-button-primary.uk-button-small + title= "Send Message", + ).uk-button.uk-button-primary.uk-button-small span i.far.fa-paper-plane - div(uk-grid).uk-flex-between.uk-grid-small - .uk-width-auto - +renderReactionButton('Applaud/clap', '👏', 'clap') - .uk-width-auto - +renderReactionButton("On Fire!", '🔥', 'fire') - .uk-width-auto - +renderReactionButton("Happy", "🤗", "happy") - .uk-width-auto - +renderReactionButton("Laugh", "🤣", "laugh") - .uk-width-auto - +renderReactionButton("Angry", "🤬", "angry") - .uk-width-auto - +renderReactionButton("Honk", "🤡", "honk") - - //- .chat-menubar \ No newline at end of file + div(style="margin-top: 4px;") + .uk-flex.uk-flex-between + .uk-width-auto + +renderReactionButton('Applaud/clap', '👏', 'clap') + .uk-width-auto + +renderReactionButton("On Fire!", '🔥', 'fire') + .uk-width-auto + +renderReactionButton("Happy", "🤗", "happy") + .uk-width-auto + +renderReactionButton("Laugh", "🤣", "laugh") + .uk-width-auto + +renderReactionButton("Angry", "🤬", "angry") + .uk-width-auto + +renderReactionButton("Honk", "🤡", "honk") \ No newline at end of file diff --git a/app/views/chat/components/menubar.pug b/app/views/chat/components/menubar.pug deleted file mode 100644 index e69de29..0000000 diff --git a/app/views/chat/components/message-standalone.pug b/app/views/chat/components/message-standalone.pug new file mode 100644 index 0000000..1b579e6 --- /dev/null +++ b/app/views/chat/components/message-standalone.pug @@ -0,0 +1,4 @@ +include ../../user/components/profile-icon +include message + ++renderChatMessage(message) \ No newline at end of file diff --git a/app/views/chat/components/message.pug b/app/views/chat/components/message.pug index 255d583..ea5f39b 100644 --- a/app/views/chat/components/message.pug +++ b/app/views/chat/components/message.pug @@ -1,32 +1,40 @@ include ../../sticker/components/sticker mixin renderChatMessage (message, options = { }) - var authorName = message.author.displayName || message.author.username; - div(data-message-id= message._id, data-author-id= message.author._id).chat-message - div(uk-grid).uk-grid-small.uk-flex-bottom - .uk-width-expand - .uk-text-small.chat-username.uk-text-truncate= authorName - - if message.author.picture && message.author.picture.small + div( + data-message-id= message._id, data-author-id= message.author._id + ).chat-message + .uk-margin-small + div(uk-grid).uk-grid-small .uk-width-auto - img(src=`/image/${message.author.picture.small._id}`, alt= `${authorName}'s profile picture`).chat-author-image + +renderProfileIcon(message.author, message.author.displayName || message.author.username, 'xsmall') + + .uk-width-expand + .chat-username.uk-text-truncate= message.author.displayName || message.author.username + .uk-text-small.uk-text-muted.uk-text-truncate= message.author.username - if !options.hideMenu && !message.author._id.equals(user._id) - .uk-width-auto.chat-user-menu - button(type="button").uk-button.uk-button-link.chat-menu-button - i.fas.fa-ellipsis-h - div(data-message-id= message._id, uk-dropdown="mode: click").dtp-chatmsg-menu - ul.uk-nav.uk-dropdown-nav - li - a( - href="", - data-message-id= message._id, - data-user-id= message.author._id, - data-username= message.author.username, - onclick="return dtp.app.muteChatUser(event);", - ) Mute #{authorName} + if !options.hideMenu && (user && !message.author._id.equals(user._id)) + .uk-width-auto.chat-user-menu + button(type="button").uk-button.uk-button-link.chat-menu-button + i.fas.fa-ellipsis-h + div(data-message-id= message._id, uk-dropdown="mode: click").dtp-chatmsg-menu + ul.uk-nav.uk-dropdown-nav + li + a( + href="", + data-message-id= message._id, + data-user-id= message.author._id, + data-username= message.author.username, + onclick="return dtp.app.muteChatUser(event);", + ) Mute #{authorName} + //- we're gonna go ahead and force long lines to break, and the content was + //- filtered at ingest for zalgo and HTML/XSS .chat-content.uk-text-break!= marked.parse(message.content) - .chat-timestamp(data-created= message.created).uk-text-small + + //- "time" is filled in by the JavaScript client using the browser's locale + //- information so that "time" is always in the user's display timezone. + .chat-timestamp(data-dtp-timestamp= message.created).uk-text-small if Array.isArray(message.stickers) && (message.stickers.length > 0) each sticker in message.stickers diff --git a/app/views/chat/components/reaction-button.pug b/app/views/chat/components/reaction-button.pug index 1b577d4..e742987 100644 --- a/app/views/chat/components/reaction-button.pug +++ b/app/views/chat/components/reaction-button.pug @@ -1,6 +1,6 @@ mixin renderReactionButton (title, emoji, reaction) button( - uk-tooltip={ title, delay: 500 }, + title= title, data-reaction= reaction, onclick="return dtp.app.chat.sendReaction(event);", ).dtp-button-reaction diff --git a/app/views/chat/components/room-list.pug b/app/views/chat/components/room-list.pug new file mode 100644 index 0000000..0c07821 --- /dev/null +++ b/app/views/chat/components/room-list.pug @@ -0,0 +1,4 @@ +mixin renderRoomList (rooms) + each room in rooms + li.uk-active + a(href=`/chat/room/${room._id}`)= room.name \ No newline at end of file diff --git a/app/views/chat/components/user-list-entry.pug b/app/views/chat/components/user-list-entry.pug new file mode 100644 index 0000000..10d4957 --- /dev/null +++ b/app/views/chat/components/user-list-entry.pug @@ -0,0 +1,12 @@ +mixin renderUserListEntry (user, label) + div(uk-grid).uk-grid-small + div(class="uk-width-1-1 uk-width-auto@m") + a(href= getUserProfileUrl(user)) + +renderProfileIcon(user, user.displayName || user.username, 'small') + + div(class="uk-width-1-1 uk-width-expand@m").no-select + .uk-margin-small + .uk-text-bold.dtp-text-tight= user.displayName || user.username + .uk-text-small.dtp-text-tight @#{user.username} + if label + .uk-label= label \ No newline at end of file diff --git a/app/views/chat/index.pug b/app/views/chat/index.pug index 990552f..b51e8d6 100644 --- a/app/views/chat/index.pug +++ b/app/views/chat/index.pug @@ -1,12 +1,22 @@ extends layouts/room block content - .content-block.uk-height-1-1.uk-overflow-auto + include components/message - h1 #{site.name} Chat - - p You can #[a(href='/chat/room/create') create] public and private chat rooms. A public room is listed in the public room directory, shown below. Private rooms are not listed in the directory and are unable to be found in search. - - p Rooms can be open, which means anyone can join them if they have them link. Rooms can also be closed, which means the room owner must invite people to join (and they have to accept). + #site-chat-container.uk-flex.uk-flex-column.uk-height-1-1 + .chat-menubar.uk-padding-small + div(uk-grid).uk-grid-small + .uk-width-auto + img(src=`/img/icon/${site.domainKey}/icon-48x48.png`, alt=`${site.name} icon`) + .uk-width-expand + h1.uk-margin-remove #{site.name} Chat Timeline - h2 Public Rooms \ No newline at end of file + .chat-content-wrapper + #chat-message-list-wrapper.uk-height-1-1 + #chat-message-list + each message in timeline + +renderChatMessage(message, { includeRoomInfo: true }) + .chat-message-menu + button(type="button", onclick="return dtp.app.chat.resumeChatScroll(event);").chat-scroll-return Resume scrolling + + //- pre= JSON.stringify(userTimeline, null, 2) \ No newline at end of file diff --git a/app/views/chat/layouts/room.pug b/app/views/chat/layouts/room.pug index a91c437..283389a 100644 --- a/app/views/chat/layouts/room.pug +++ b/app/views/chat/layouts/room.pug @@ -2,32 +2,30 @@ extends ../../layouts/main block page-footer block content-container - mixin renderRoomList (rooms) - each room in ownedChatRooms - li.uk-active - a(href=`/chat/room/${room._id}`)= room.name + include ../components/user-list-entry + include ../components/room-list section.site-chat-section div(uk-grid).uk-height-1-1 div(class="uk-width-1-1 uk-width-1-5@l uk-flex-last uk-flex-first@l").uk-height-1-1.uk-overflow-auto - .content-block.uk-border-rounded.uk-margin + .site-chat-sidebar-widget.uk-border-rounded.uk-margin if Array.isArray(ownedChatRooms) && (ownedChatRooms.length > 0) ul#room-list.uk-nav.uk-nav-default li.uk-nav-header div(uk-grid).uk-grid-small - .uk-width-expand Your Rooms + .uk-text-bold.uk-width-expand Your Rooms .uk-width-auto - a(href='/chat/room/create', uk-tooltip="Create new chat room...").uk-link-reset + a(href='/chat/room/create', title= "Create new chat room...").uk-link-reset i.fas.fa-plus +renderRoomList(ownedChatRooms) else div You don't own any chat rooms. - .content-block.uk-border-rounded + .site-chat-sidebar-widget.uk-border-rounded if Array.isArray(joinedChatRooms) && (joinedChatRooms.length > 0) ul#room-list.uk-nav.uk-nav-default li.uk-nav-header Joined Rooms - +renderRoomList(ownedChatRooms) + +renderRoomList(joinedChatRooms) else div You haven't joined any chat rooms. @@ -36,19 +34,21 @@ block content-container block content div(class="uk-width-1-1 uk-width-1-5@l").uk-height-1-1.uk-overflow-auto - .content-block.uk-border-rounded - if chatRoom - if Array.isArray(chatRoom.members) && (chatRoom.members.length > 0) - ul#room-member-list.uk-nav.uk-nav-default - li.uk-nav-header Room Members - each member in chatRoom.members + if room + .site-chat-sidebar-widget.uk-border-rounded + ul#room-member-list.uk-nav.uk-nav-default + li.uk-nav-header Room Owner + li + +renderUserListEntry(room.owner, 'owner') + + if room.moderators && (room.moderators.length > 0) + li.uk-nav-header Moderators + each membership in room.moderators li - a(href="")= member.displayName || member.username - else - div The room has no members - else - div Not in a room + +renderUserListEntry(membership.member, 'moderator') -block viewjs - script. - window.dtp.room = !{JSON.stringify(room)}; \ No newline at end of file + if Array.isArray(room.members) && (room.members.length > 0) + li.uk-nav-header Members + each membership in room.members + li + +renderUserListEntry(membership.member, 'member') \ No newline at end of file diff --git a/app/views/chat/room/editor.pug b/app/views/chat/room/editor.pug index 7c56b46..4022ba0 100644 --- a/app/views/chat/room/editor.pug +++ b/app/views/chat/room/editor.pug @@ -1,23 +1,34 @@ extends ../layouts/room block content + - var actionUrl = room ? `/chat/room/${room._id}` : '/chat/room'; + .content-block.uk-height-1-1.uk-overflow-auto - form(method="POST", action="/chat").uk-form + form(method="POST", action= actionUrl).uk-form .uk-card.uk-card-default.uk-card-small .uk-card-header - h1.uk-card-title Create Chat Room + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-expand + h1.uk-card-title.uk-text-truncate #{room ? room.name : 'Create Chat Room'} + if room + .uk-width-auto + button(type="button", onclick="return dtp.app.chat.deleteChatRoom(event);").uk-button.uk-button-danger.uk-border-rounded + span + i.fas.fa-trash + span.uk-margin-small-left Delete + .uk-card-body .uk-margin label(for="name").uk-form-label Room name - input(id="name", name="name", type="text", placeholder="Enter room name").uk-input + input(id="name", name="name", type="text", placeholder="Enter room name", value= room ? room.name : undefined).uk-input .uk-margin label(for="description").uk-form-label Room description - textarea(id="description", name="description", rows="2", placeholder="Enter room description").uk-textarea + textarea(id="description", name="description", rows="2", placeholder="Enter room description").uk-textarea= room ? room.description : undefined .uk-margin label(for="policy").uk-form-label Room policy - textarea(id="policy", name="policy", rows="2", placeholder="Enter room use policy").uk-textarea + textarea(id="policy", name="policy", rows="2", placeholder="Enter room use policy").uk-textarea= room ? room.policy : undefined .uk-margin div(uk-grid) @@ -27,11 +38,11 @@ block content div(uk-grid).uk-grid-small.uk-flex-middle .uk-width-auto label - input(id="is-public", name="visibility", type="radio", value="public", checked).uk-radio + input(id="is-public", name="visibility", type="radio", value="public", checked= room ? room.visibility === 'public' : true).uk-radio | Public .ui-width-auto label - input(id="is-private", name="visibility", type="radio", value="private").uk-radio + input(id="is-private", name="visibility", type="radio", value="private", checked= room ? room.visibility === 'private' : false).uk-radio | Private .uk-width-auto @@ -40,11 +51,11 @@ block content div(uk-grid).uk-grid-small.uk-flex-middle .uk-width-auto label - input(id="membership-open", name="policy", type="radio", value="open", checked).uk-radio + input(id="membership-open", name="membershipPolicy", type="radio", value="open", checked= room ? room.membershipPolicy === 'open' : true).uk-radio | Open .uk-width-auto label - input(id="membership-closed", name="policy", type="radio", value="closed").uk-radio + input(id="membership-closed", name="membershipPolicy", type="radio", value="closed", checked= room ? room.membershipPolicy === 'closed' : false).uk-radio | Closed .uk-card-footer @@ -52,4 +63,4 @@ block content .uk-width-expand +renderBackButton() .uk-width-auto - button(type="submit").uk-button.uk-button-primary.uk-border-rounded Create room \ No newline at end of file + button(type="submit").uk-button.uk-button-primary.uk-border-rounded #{room ? 'Update' : 'Create'} room \ No newline at end of file diff --git a/app/views/chat/room/form/invite-member.pug b/app/views/chat/room/form/invite-member.pug new file mode 100644 index 0000000..5dc4c2a --- /dev/null +++ b/app/views/chat/room/form/invite-member.pug @@ -0,0 +1,22 @@ +button(type="button", uk-close).uk-modal-close-default +form( + method="POST", + action=`/chat/room/${room._id}/invite`, + onsubmit="return dtp.app.submitForm(event, 'invite chat member');" +).uk-form + .uk-card.uk-card-default.uk-card-small + .uk-card-header + h1.uk-card-title Invite Member + + .uk-card-body + p You are inviting a new member to #{room.name} + .uk-margin + label(for="username").uk-form-label Username + input(id="username", name="username", type="text", maxlength="100", required).uk-input + .uk-margin + label(for="message").uk-form-label Message + textarea(id="message", name="message", rows="2", placeholder="Enter message for recipient").uk-textarea + + .uk-card-footer.uk-flex.uk-flex-right + .uk-width-auto + button(type="submit").uk-button.uk-button-primary.uk-border-rounded Send \ No newline at end of file diff --git a/app/views/chat/room/index.pug b/app/views/chat/room/index.pug new file mode 100644 index 0000000..0711e78 --- /dev/null +++ b/app/views/chat/room/index.pug @@ -0,0 +1,29 @@ +extends ../layouts/room +block content + + mixin renderRoomTile (room) + div(data-room-id= room._id, data-room-name= room.name).uk-tile.uk-tile-default.uk-tile-small + .uk-tile-body + div(uk-grid).uk-grid-small + .uk-width-auto + .uk-width-expand + .uk-margin-small + div(title= room.name).uk-text-bold.uk-text-truncate= room.name + .uk-text-small.uk-text-truncate= room.description + div(uk-grid).uk-grid-small.uk-text-small.uk-text-muted.no-select + .uk-width-expand + a(href= getUserProfileUrl(room.owner))= room.owner.username + .uk-width-auto + span + i.fas.fa-users + span.uk-margin-small-left= formatCount(room.members.length) + + .uk-height-1-1.uk-overflow-auto + + h1 Public Rooms + div(uk-grid) + each room in publicRooms + .uk-width-1-3 + +renderRoomTile(room) + + pre= JSON.stringify(publicRooms, null, 2) \ No newline at end of file diff --git a/app/views/chat/room/invite/components/invite-list-item.pug b/app/views/chat/room/invite/components/invite-list-item.pug new file mode 100644 index 0000000..a88878f --- /dev/null +++ b/app/views/chat/room/invite/components/invite-list-item.pug @@ -0,0 +1,25 @@ +mixin renderInviteListItem (invite) + .uk-margin-small + div(uk-grid).uk-grid-small + .uk-width-auto + +renderProfileIcon(invite.member, 'Invited member') + .uk-width-expand + .uk-text-bold.uk-text-truncate= invite.member.displayName || invite.member.username + .uk-text-small.uk-text-muted + .uk-text-truncate= invite.member.username + .uk-text-truncate= moment(invite.created).fromNow() + if invite.status === 'new' + .uk-width-auto + button( + type="button", + data-room-id= invite.room._id, + data-invite-id= invite._id, + onclick='return dtp.app.chat.deleteInvite(event);', + ).uk-button.uk-button-danger.uk-button-small.uk-border-rounded + span + i.fas.fa-trash + span.uk-margin-small-left DELETE + + if invite.message + label.uk-form-label Message to @#{invite.member.username}: + div= invite.message \ No newline at end of file diff --git a/app/views/chat/room/invite/components/invite-list.pug b/app/views/chat/room/invite/components/invite-list.pug new file mode 100644 index 0000000..c677a92 --- /dev/null +++ b/app/views/chat/room/invite/components/invite-list.pug @@ -0,0 +1,6 @@ +include invite-list-item +mixin renderInviteList (invites) + ul.uk-list + each invite in invites + li(data-invite-id= invite._id) + +renderInviteListItem(invite) \ No newline at end of file diff --git a/app/views/chat/room/invite/index.pug b/app/views/chat/room/invite/index.pug new file mode 100644 index 0000000..c9a2c88 --- /dev/null +++ b/app/views/chat/room/invite/index.pug @@ -0,0 +1,34 @@ +extends ../../layouts/room +block content + + include components/invite-list + + .uk-card.uk-card-default.uk-card-small.uk-flex.uk-flex-column.uk-height-1-1 + .uk-card-header + h1.uk-card-title.uk-margin-remove #{room.name} + div Membership invitation manager + + .uk-card-body.uk-flex-1.uk-overflow-auto + +renderSectionTitle('Sent') + .uk-margin + if (Array.isArray(invites.new) && (invites.new.length > 0)) + +renderInviteList(invites.new) + else + div No unresolved invitations. + + +renderSectionTitle('Accepted') + .uk-margin + if (Array.isArray(invites.accepted) && (invites.accepted.length > 0)) + +renderInviteList(invites.accepted) + else + div No accepted invitations. + + +renderSectionTitle('Rejected') + .uk-margin + if (Array.isArray(invites.rejected) && (invites.rejected.length > 0)) + +renderInviteList(invites.rejected) + else + div No outstanding rejected invitations. + + .uk-card-footer + +renderBackButton() \ No newline at end of file diff --git a/app/views/chat/room/invite/view.pug b/app/views/chat/room/invite/view.pug new file mode 100644 index 0000000..64d3383 --- /dev/null +++ b/app/views/chat/room/invite/view.pug @@ -0,0 +1,59 @@ +extends ../../layouts/room +block content + + include ../../../kaleidoscope/components/event + include ../../../user/components/attribution-header + + form( + method="POST", + action=`/chat/room/${invite.room._id}/invite/${invite._id}/action`, + onsubmit='return dtp.app.submitForm(event, "chat-invite-action");' + ).uk-height-1-1 + .uk-card.uk-card-default.uk-card-small.uk-flex.uk-flex-column.uk-height-1-1 + .uk-card-header + div(uk-grid).uk-grid-small + .uk-width-auto + a(href= getUserProfileUrl(invite.room.owner)) + +renderProfileIcon(invite.room.owner, invite.room.owner.displayName || invite.room.owner.username, 'small') + .uk-width-expand + h1.uk-card-title.uk-margin-remove.dtp-text-tight #{invite.room.name} + if invite.room.description && (invite.room.description.length > 0) + .uk-text-small.dtp-text-tight + div!= marked.parse(invite.room.description) + div(uk-grid).uk-text-small.uk-text-muted + .uk-width-auto + div(title= "Member count").no-select + span + i.fas.fa-users + span.uk-margin-small-left= formatCount(invite.room.members.length) + + .uk-card-body.uk-flex-1.uk-overflow-auto + .uk-margin + .uk-text-bold Status + div(class={ + 'uk-text-info': (invite.status === 'new'), + 'uk-text-success': (invite.status === 'accepted'), + 'uk-text-error': (invite.status === 'rejected'), + 'uk-text-muted': (invite.status === 'deleted'), + })= invite.status + + .uk-margin + .uk-text-bold Invite message + div!= marked.parse(invite.message) + + if invite.room.policy && (invite.room.policy.length > 0) + .uk-margin + .uk-text-bold Room policy + div!= marked.parse(invite.room.policy) + + .uk-card-footer + div(uk-grid) + .uk-width-expand + +renderBackButton() + + .uk-width-auto(hidden= invite.status !== 'new') + div(uk-grid).uk-grid-small + .uk-width-auto + button(type="submit", name="response", value="reject").uk-button.uk-button-danger.uk-border-rounded Reject + .uk-width-auto + button(type="submit", name="response", value="accept").uk-button.uk-button-primary.uk-border-rounded Accept \ No newline at end of file diff --git a/app/views/chat/room/view.pug b/app/views/chat/room/view.pug index 35121d0..738f5ea 100644 --- a/app/views/chat/room/view.pug +++ b/app/views/chat/room/view.pug @@ -1,55 +1,93 @@ extends ../layouts/room block content + include ../../user/components/profile-icon + include ../components/input-form include ../components/message #site-chat-container.uk-flex.uk-flex-column.uk-height-1-1 - div(uk-grid).uk-flex-middle.chat-menubar - div(uk-tooltip="Room details").uk-width-expand - h1.uk-card-title.uk-margin-remove= room.name - div= room.description - - div(uk-tooltip="Active Members").uk-width-auto.no-select - span - i.fas.fa-user - span(data-room-id= room._id).uk-margin-small-left.active-member-count= numeral(room.members.length).format('0,0') - - div(uk-tooltip="Total Members", class="uk-hidden@m").uk-width-auto.no-select - span - i.fas.fa-user - span.uk-margin-small-left= formatCount(room.members.length) - - - .uk-width-auto - button( - type="button", - data-room-id= room._id, - onclick="return dtp.app.chat.leaveRoom(event);", - ).uk-button.dtp-button-default.uk-button-small.uk-border-pill.uk-text-bold + .chat-menubar.uk-padding-small + div(uk-grid).uk-grid-small.uk-flex-middle + div(class="uk-width-expand").no-select + div(title= room.name).chat-room-name.uk-margin-remove.uk-text-truncate= room.name + .uk-text-small.uk-text-muted.uk-text-truncate= room.description + + div(title="Total Members", class="uk-visible@m").uk-width-auto.no-select span - i.fas.fa-sign-out-alt - span.uk-margin-small-left Leave Room - - .uk-width-auto - .uk-inline - button(type="button").uk-button.uk-button-link.uk-button-small - i.fas.fa-ellipsis-h - div(uk-dropdown={ pos: 'bottom-right', mode: 'click' }) - ul.uk-nav.uk-dropdown-nav - li.uk-nav-heading= room.name - li.uk-nav-divider - li - a(href=`/chat/${room._id}/pop-out`, target="_blank") Pop-Out Chat - - #chat-message-list-wrapper - #chat-reactions - #chat-message-list - each message in chatMessages || [ ] - +renderChatMessage(message) - - .chat-message-menu - button(type="button", onclick="return dtp.app.chat.resumeChatScroll(event);").chat-scroll-return Resume scrolling - - div - +renderChatInputForm(room) \ No newline at end of file + i.fas.fa-users + span.uk-margin-small-left= formatCount(room.members.length) + + if !user || !room.owner._id.equals(user._id) + + .uk-width-auto + .uk-inline + button(type="button").uk-button.uk-button-link.uk-button-small + i.fas.fa-ellipsis-h + #chat-room-menu(uk-dropdown={ pos: 'bottom-right', mode: 'click' }) + ul.uk-nav.uk-nav-default.uk-dropdown-nav + li + a(href=`/chat/room/${room._id}/widget`, target="_blank") + span.nav-item-icon + i.fas.fa-comment-dots + span Pop-Out Chat + + + if user && !room.owner._id.equals(user._id) + a( + href="" + data-room-id= room._id, + onclick="return dtp.app.chat.leaveRoom(event);", + ) + span.nav-item-icon + i.fas.fa-sign-out-alt + span Leave Room + + if user && room.owner._id.equals(user._id) + li.uk-nav-divider + + li + a( + href="", + data-room-id= room._id, + onclick=`return dtp.app.chat.showForm(event, '${room._id}', 'invite-member');` + ) + span.nav-item-icon + i.fas.fa-user-plus + span Invite New Member + + li + a( + href=`/chat/room/${room._id}/invite`, + data-room-id= room._id, + ) + span.nav-item-icon + i.fas.fa-mail-bulk + span Manage Invites + + li.uk-nav-divider + + li + a(href=`/chat/room/${room._id}/settings`) + span.nav-item-icon + i.fas.fa-cog + span Settings + + + .chat-content-wrapper + div(uk-grid).uk-grid-small.uk-height-1-1 + #chat-webcam-container(hidden).uk-width-auto + ul#chat-webcam-list.uk-list + + .uk-width-expand + #chat-message-list-wrapper.uk-height-1-1 + #chat-message-list + each message in chatMessages || [ ] + +renderChatMessage(message) + + #chat-reactions.no-select + + .chat-message-menu + button(type="button", onclick="return dtp.app.chat.resumeChatScroll(event);").chat-scroll-return Resume scrolling + + +renderChatInputForm(room) \ No newline at end of file diff --git a/app/views/components/button-icon.pug b/app/views/components/button-icon.pug index 1bb88bf..3dac976 100644 --- a/app/views/components/button-icon.pug +++ b/app/views/components/button-icon.pug @@ -1,4 +1,5 @@ mixin renderButtonIcon (buttonClass, buttonLabel) span i(class=`fas ${buttonClass}`) - span(class="uk-visible@m").uk-margin-small-left= buttonLabel \ No newline at end of file + if buttonLabel + span(class="uk-visible@m").uk-margin-small-left= buttonLabel \ No newline at end of file diff --git a/app/views/components/library.pug b/app/views/components/library.pug index db9a59b..d6ca080 100644 --- a/app/views/components/library.pug +++ b/app/views/components/library.pug @@ -14,6 +14,13 @@ include section-title return numeral(value).format(value > 1000 ? '0,0.0a' : '0,0'); } + function getUserProfileUrl (user) { + if (user.core) { + return `/user/core/${user._id}`; + } + return `/user/${user._id}`; + } + mixin renderCell (label, value, className) div(title=`${label}: ${numeral(value).format('0,0')}`).uk-tile.uk-tile-default.uk-padding-remove.no-select div(class=className)!= value diff --git a/app/views/components/navbar.pug b/app/views/components/navbar.pug index aa3255e..594e1fc 100644 --- a/app/views/components/navbar.pug +++ b/app/views/components/navbar.pug @@ -27,12 +27,17 @@ nav(uk-navbar).uk-navbar-container.uk-position-fixed.uk-position-top .uk-navbar-right if user ul.uk-navbar-nav - + li(class={ 'uk-active': currentView === 'notification' }) + a(href="/notification", title="Notifications") + .uk-position-relative + i.fas.fa-bell.uk-text-large + if middleware.notifications.newCount > 0 + span(style="top: -11px; right: -15px; padding: 0 3px; border-radius: 4px; background-color: #ff0013; color: #e8e8e8;").uk-position-absolute= formatCount(middleware.notifications.newCount) .uk-navbar-item if user div.no-select - +renderProfileIcon(user, "Member Menu") + +renderProfileIcon(user, `${user.displayName || user.username}'s Menu`, 'navbar') div(uk-dropdown={ mode: 'click' }).uk-navbar-dropdown ul.uk-nav.uk-navbar-dropdown-nav diff --git a/app/views/kaleidoscope/components/event.pug b/app/views/kaleidoscope/components/event.pug new file mode 100644 index 0000000..88ee8b3 --- /dev/null +++ b/app/views/kaleidoscope/components/event.pug @@ -0,0 +1,43 @@ +mixin renderKaleidoscopeEvent (event) + div( + data-event-id= event._id, + data-event-source= event.source.pkg.name, + data-event-action= event.action, + ).kaleidoscope-event + if event.thumbnail + img(src= event.thumbnail).event-feature-img + + header.event-header + if event.label + h4.uk-comment-title.uk-margin-small= event.label + + div(uk-grid).uk-grid-small.uk-flex-middle + if event.source.emitter + .uk-width-auto + a(href= event.source.emitter.href, uk-title= `Visit ${event.source.emitter.displayName || event.source.emitter.username } at ${event.source.site.name}`) + img(src=`//${event.source.site.domain}/hive/user/${event.source.emitter.emitterId}/picture`).site-profile-picture.sb-xsmall + .uk-width-expand + if event.source.emitter + .uk-text-bold= event.source.emitter.displayName + .uk-text-small + a( + href= event.source.emitter.href, + title= `Visit ${event.source.emitter.displayName || event.source.emitter.username } at ${event.source.site.name}`, + ) #{event.source.emitter.username}@#{event.source.site.domainKey} + + .event-content!= marked.parse(event.content) + + .event-footer + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-auto + .uk-text-small.uk-text-muted + a(href= event.href, title= "Open destination")= moment(event.created).fromNow() + .uk-width-auto + .uk-text-small.uk-text-muted #[span= event.source.pkg.name] + .uk-width-expand + .uk-text-small.uk-text-muted= event.action + .uk-width-auto + a(href=`//${event.source.site.domain}`, title= event.source.site.name) + img( + src=`//${event.source.site.domain}/img/icon/${event.source.site.domainKey}/icon-16x16.png`, + ).site-favicon diff --git a/app/views/layouts/main.pug b/app/views/layouts/main.pug index 954403a..fc94731 100644 --- a/app/views/layouts/main.pug +++ b/app/views/layouts/main.pug @@ -99,9 +99,9 @@ html(lang='en') window.dtp.domain = !{JSON.stringify(site.domain)}; window.dtp.env = !{JSON.stringify(env.NODE_ENV)}; - if channel + if room script. - dtp.channel = !{JSON.stringify(channel || null)}; + dtp.room = !{JSON.stringify(room || null)}; if DTP_SCRIPT_DEBUG script(src=`/dist/js/dtpweb-app.js?v=${pkg.version}`, type="module") diff --git a/app/views/notification/index.pug b/app/views/notification/index.pug new file mode 100644 index 0000000..b8c1ae6 --- /dev/null +++ b/app/views/notification/index.pug @@ -0,0 +1,14 @@ +extends ../layouts/main-sidebar +block content + + include ../kaleidoscope/components/event + + +renderSectionTitle('Notifications') + + if Array.isArray(notifications) && (notifications.length > 0) + ul.uk-list + each notification in notifications + li + +renderKaleidoscopeEvent(notification.event) + else + div No notifications \ No newline at end of file diff --git a/app/views/user/components/attribution-header.pug b/app/views/user/components/attribution-header.pug new file mode 100644 index 0000000..b15f990 --- /dev/null +++ b/app/views/user/components/attribution-header.pug @@ -0,0 +1,8 @@ +mixin renderUserAttributionHeader (user) + div(uk-grid).uk-grid-small + .uk-width-auto + +renderProfileIcon(user) + .uk-width-expand + .uk-text-bold(style="line-height: 1;")= user.displayName || user.username + .uk-text-small.uk-text-muted + a(href= getUserProfileUrl(user))= user.username \ No newline at end of file diff --git a/app/views/user/components/profile-icon.pug b/app/views/user/components/profile-icon.pug index 95c6628..43099b9 100644 --- a/app/views/user/components/profile-icon.pug +++ b/app/views/user/components/profile-icon.pug @@ -1,17 +1,35 @@ -mixin renderProfileIcon (user, title, size) +- + var sizeMap = { + "xxsmall": "small", + "xsmall": "small", + "list-item": "small", + "navbar": "small", + "small": "small", + "medium": "large", + "large": "large", + "full": "full", + }; + +mixin renderProfileIcon (user, title, size = "small") if user.coreUserId img( - src=`http://${user.core.meta.domain}/core/user/${user.coreUserId}/picture?s=${size || 'small'}`, + src=`http://${user.core.meta.domain}/core/user/${user.coreUserId}/picture?s=${sizeMap[size]}`, + class= "site-profile-picture", + class= `sb-${size}`, title= title, - ).site-profile-picture.sb-navbar + ) else if user.picture && user.picture.small img( - src= `/image/${user.picture.small._id}`, + src= `/image/${user.picture[sizeMap[size]]._id}`, + class= "site-profile-picture", + class= `sb-${size}`, title= title, - ).site-profile-picture.sb-navbar + ) else img( src= "/img/default-member.png", + class= "site-profile-picture", + class= `sb-${size}`, title= title, - ).site-profile-picture.sb-navbar + ) diff --git a/app/workers/chat/job/chat-room-clear.js b/app/workers/chat/job/chat-room-clear.js new file mode 100644 index 0000000..d51b2a7 --- /dev/null +++ b/app/workers/chat/job/chat-room-clear.js @@ -0,0 +1,61 @@ +// chat/job/chat-room-clear.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); + +const mongoose = require('mongoose'); + +const ChatRoom = mongoose.model('ChatRoom'); +const ChatRoomInvite = mongoose.model('ChatRoomInvite'); +const ChatMessage = mongoose.model('ChatMessage'); +const EmojiReaction = mongoose.model('EmojiReaction'); + +const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); + +/** + * DTP Core Chat sticker processor can receive requests to ingest and delete + * stickers to be executed as background jobs in a queue. This processor + * attaches to the `media` queue and registers processors for `sticker-ingest` + * and `sticker-delete`. + */ +class ChatRoomClearJob extends SiteWorkerProcess { + + static get COMPONENT ( ) { + return { + name: 'charRoomClearJob', + slug: 'chat-room-clear-job', + }; + } + + constructor (worker) { + super(worker, ChatRoomClearJob.COMPONENT); + } + + async start ( ) { + await super.start(); + + const queue = this.getJobQueue('chat'); + + this.log.info('registering job processor', { queue: this.queue.name, name: 'chat-room-clear' }); + queue.process('chat-room-clear', this.processChatRoomClear.bind(this)); + } + + async stop ( ) { + await super.stop(); + } + + async processChatRoomClear (job) { + const { roomId } = job.data; + this.log.info('received chat room clear job', { id: job.id, roomId }); + + await ChatMessage + .find({ room: roomId }) + .cursor() + .eachAsync(this.worker.deleteChatMessage.bind(this), 4); + } +} + +module.exports = ChatRoomClearJob; \ No newline at end of file diff --git a/app/workers/chat/job/chat-room-delete.js b/app/workers/chat/job/chat-room-delete.js new file mode 100644 index 0000000..586496e --- /dev/null +++ b/app/workers/chat/job/chat-room-delete.js @@ -0,0 +1,102 @@ +// chat/job/chat-room-delete.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); + +const mongoose = require('mongoose'); + +const ChatRoom = mongoose.model('ChatRoom'); +const ChatRoomInvite = mongoose.model('ChatRoomInvite'); +const ChatMessage = mongoose.model('ChatMessage'); +const EmojiReaction = mongoose.model('EmojiReaction'); + +const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); + +/** + * DTP Core Chat sticker processor can receive requests to ingest and delete + * stickers to be executed as background jobs in a queue. This processor + * attaches to the `media` queue and registers processors for `sticker-ingest` + * and `sticker-delete`. + */ +class ChatRoomDeleteJob extends SiteWorkerProcess { + + static get COMPONENT ( ) { + return { + name: 'chatRoomProcessor', + slug: 'chat-room-processor', + }; + } + + constructor (worker) { + super(worker, ChatRoomDeleteJob.COMPONENT); + } + + async start ( ) { + await super.start(); + + const queue = this.getJobQueue('chat'); + + this.log.info('registering job processor', { queue: this.queue.name, name: 'chat-room-delete' }); + queue.process('chat-room-delete', this.processChatRoomDelete.bind(this)); + } + + async stop ( ) { + await super.stop(); + } + + async processChatRoomDelete (job) { + const { roomId } = job.data; + this.log.info('received chat room delete job', { id: job.id, roomId }); + + await EmojiReaction + .find({ subject: roomId }) + .cursor() + .eachAsync(this.deleteEmojiReaction.bind(this)); + + await ChatMessage + .find({ room: roomId }) + .cursor() + .eachAsync(this.worker.deleteChatMessage.bind(this), 4); + + await ChatRoomInvite + .find({ room: roomId }) + .cursor() + .eachAsync(this.deleteChatRoomInvite.bind(this), 4); + + await ChatRoom.deleteOne({ _id: roomId }); + } + + async deleteEmojiReaction (reaction) { + if (!reaction || !reaction._id) { + this.log.error('skipping invalid emoji reaction for delete'); + return; + } + + const EmojiReaction = mongoose.model('EmojiReaction'); + try { + await EmojiReaction.deleteOne({ _id: reaction._id }); + } catch (error) { + this.log.error('failed to delete chat message', { reactionId: reaction._id, error }); + } + } + + async deleteChatRoomInvite (invite) { + if (!invite || !invite._id) { + this.log.error('skipping invalid invite for delete'); + return; + } + + const ChatRoomInvite = mongoose.model('ChatRoomInvite'); + try { + await ChatRoomInvite.deleteOne({ _id: invite._id }); + } catch (error) { + this.log.error('failed to delete chat room invite', { inviteId: invite._id, error }); + } + } + +} + +module.exports = ChatRoomDeleteJob; \ No newline at end of file diff --git a/app/workers/media.js b/app/workers/media.js new file mode 100644 index 0000000..76706a7 --- /dev/null +++ b/app/workers/media.js @@ -0,0 +1,86 @@ +// media.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); +const fs = require('fs'); +require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); + +const mongoose = require('mongoose'); + +const { SitePlatform, SiteLog, SiteWorker } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); + +module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); +module.config = { + environment: process.env.NODE_ENV, + root: path.resolve(__dirname, '..', '..'), + component: { name: 'mediaWorker', slug: 'media-worker' }, +}; + +/** + * Provides background media processing for the DTP ecosystem. + * + * Background media processing is simply a way of life for scalable Web + * architectures. You don't want to force your site member to sit there and + * watch a process run. You want to accept their file, toss it to storage, and + * create a job to have whatever work needs done performed. + * + * This obviously induces a variable amount of time from when the site member + * uploads the file until it's ready for online distribution. The system + * therefore facilitates ways to query the status of the job and to receive a + * notification when the work is complete. + * + * This worker serves as a starting point or demonstration of how to do + * background media processing at scale and in production. This is the exact + * code we use to run the Digital Telepresence Platform every day. + */ +class MediaWorker extends SiteWorker { + + constructor (dtp) { + super(dtp, dtp.config.component); + } + + async start ( ) { + await super.start(); + + if (process.argv[2]) { + const stickerId = mongoose.Types.ObjectId(process.argv[2]); + this.log.info('creating sticker processing job', { stickerId }); + + const queue = this.getJobQueue('media'); + await queue.add('sticker-ingest', { stickerId }); + } + + await this.loadProcessor(path.join(__dirname, 'media', 'job', 'sticker-ingest.js')); + await this.loadProcessor(path.join(__dirname, 'media', 'job', 'sticker-delete.js')); + + await this.loadProcessor(path.join(__dirname, 'media', 'job', 'attachment-ingest.js')); + await this.loadProcessor(path.join(__dirname, 'media', 'job', 'attachment-delete.js')); + + await this.startProcessors(); + } + + async stop ( ) { + await super.stop(); + } +} + +(async ( ) => { + try { + module.log = new SiteLog(module, module.config.component); + await SitePlatform.startPlatform(module, module.config.component); + + module.worker = new MediaWorker(module); + await module.worker.start(); + + module.log.info(`${module.pkg.name} v${module.pkg.version} ${module.config.component.name} started`); + } catch (error) { + module.log.error('failed to start worker', { + component: module.config.component, + error, + }); + process.exit(-1); + } +})(); \ No newline at end of file diff --git a/app/workers/media/job/attachment-delete.js b/app/workers/media/job/attachment-delete.js new file mode 100644 index 0000000..8791002 --- /dev/null +++ b/app/workers/media/job/attachment-delete.js @@ -0,0 +1,72 @@ +// media/job/attachment-delete.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); + +const mongoose = require('mongoose'); +const Attachment = mongoose.model('Attachment'); + +const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); + +class AttachmentDeleteJob extends SiteWorkerProcess { + + static get COMPONENT ( ) { + return { + name: 'attachmentDeleteJob', + slug: 'attachment-delete-job', + }; + } + + constructor (worker) { + super(worker, AttachmentDeleteJob.COMPONENT); + } + + async start ( ) { + await super.start(); + + this.queue = await this.getJobQueue('media'); + + this.log.info('registering job processor', { queue: this.queue.name, name: 'attachment-delete' }); + this.queue.process('attachment-delete', 1, this.processAttachmentDelete.bind(this)); + } + + async stop ( ) { + await super.stop(); + } + + async processAttachmentDelete (job) { + try { + const { attachment: attachmentService } = this.dtp.services; + const attachment = job.data.attachment = await attachmentService.getById( + job.data.attachmentId, + { withOriginal: true }, + ); + + await this.deleteAttachmentFile(attachment, 'processed'); + await this.deleteAttachmentFile(attachment, 'original'); + + this.log.info('deleting attachment', { _id: attachment._id }); + await Attachment.deleteOne({ _id: attachment._id }); + } catch (error) { + this.log.error('failed to delete attachment', { attachmentId: job.data.attachmentId, error }); + throw error; + } + } + + async deleteAttachmentFile (attachment, which) { + this.log.info('removing attachment file', { _id: attachment._id, which }); + + const file = attachment[which]; + if (!file || !file.bucket || !file.key) { + return; + } + + const { minio: minioService } = this.dtp.services; + await minioService.removeObject(file.bucket, file.key); + } +} + +module.exports = AttachmentDeleteJob; \ No newline at end of file diff --git a/app/workers/media/job/attachment-ingest.js b/app/workers/media/job/attachment-ingest.js new file mode 100644 index 0000000..d5b9a18 --- /dev/null +++ b/app/workers/media/job/attachment-ingest.js @@ -0,0 +1,270 @@ +// media/job/attachment-ingest.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const sharp = require('sharp'); + +const ATTACHMENT_IMAGE_HEIGHT = 540; + +const mongoose = require('mongoose'); +const { response } = require('express'); +const Attachment = mongoose.model('Attachment'); + +const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); + +class AttachmentIngestJob extends SiteWorkerProcess { + + static get COMPONENT ( ) { + return { + name: 'attachmentIngestJob', + slug: 'attachment-ingest-job', + }; + } + + constructor (worker) { + super(worker, AttachmentIngestJob.COMPONENT); + this.processors = { + processAttachmentSharp: this.processAttachmentSharp.bind(this), + processAttachmentFFMPEG: this.processAttachmentFFMPEG.bind(this), + }; + } + + async start ( ) { + await super.start(); + + this.queue = await this.getJobQueue('media'); + + this.log.info('registering job processor', { queue: this.queue.name, name: 'attachment-ingest' }); + this.queue.process('attachment-ingest', 1, this.processAttachmentIngest.bind(this)); + } + + async stop ( ) { + await super.stop(); + } + + async processAttachmentIngest (job) { + const { attachment: attachmentService } = this.dtp.services; + + const { attachmentId } = job.data; + this.log.info('received attachment-ingest job', { id: job.id, attachmentId }); + + try { + job.data.attachment = await attachmentService.getById(attachmentId, { withOriginal: true }); + + await this.resetAttachment(job); + await this.fetchAttachmentFile(job); + await this.processors[job.data.processor](job); + + //TODO: emit a completion event which should cause a refresh of the + // creator's view to display the processed attachment + + } catch (error) { + this.log.error('failed to process attachment for ingest', { attachmentId: job.data.attachmentId, error }); + throw error; + } finally { + if (job.data.workPath) { + this.log.info('removing attachment work path'); + await fs.promises.rmdir(job.data.workPath, { recursive: true, force: true }); + delete job.data.workPath; + } + } + } + + async fetchAttachmentFile (job) { + const { minio: minioService } = this.dtp.services; + try { + const { attachment } = job.data; + + job.data.workPath = path.join( + process.env.DTP_ATTACHMENT_WORK_PATH, + AttachmentIngestJob.COMPONENT.slug, + attachment._id.toString(), + ); + + this.jobLog(job, 'creating work directory', { worthPath: job.data.workPath }); + await fs.promises.mkdir(job.data.workPath, { recursive: true }); + + switch (attachment.original.mime) { + case 'image/jpeg': + job.data.origFilePath = path.join(job.data.workPath, `${attachment._id}.jpg`); + job.data.procFilePath = path.join(job.data.workPath, `${attachment._id}.proc.jpg`); + job.data.processor = 'processAttachmentSharp'; + job.data.sharpFormat = 'jpeg'; + job.data.sharpFormatParameters = { quality: 85 }; + break; + + case 'image/png': + job.data.origFilePath = path.join(job.data.workPath, `${attachment._id}.png`); + job.data.procFilePath = path.join(job.data.workPath, `${attachment._id}.proc.png`); + job.data.processor = 'processAttachmentSharp'; + job.data.sharpFormat = 'png'; + job.data.sharpFormatParameters = { compression: 9 }; + break; + + case 'image/gif': + job.data.origFilePath = path.join(job.data.workPath, `${attachment._id}.gif`); + job.data.procFilePath = path.join(job.data.workPath, `${attachment._id}.proc.mp4`); + job.data.processor = 'processAttachmentFFMPEG'; + break; + + case 'image/webp': // process as PNG + job.data.origFilePath = path.join(job.data.workPath, `${attachment._id}.webp`); + job.data.procFilePath = path.join(job.data.workPath, `${attachment._id}.proc.png`); + job.data.processor = 'processAttachmentSharp'; + job.data.sharpFormat = 'png'; + job.data.sharpFormatParameters = { compression: 9 }; + break; + + default: + throw new Error(`unsupported attachment type: ${attachment.original.mime}`); + } + + this.jobLog(job, 'fetching attachment original file', { + attachmentId: attachment._id, + mime: attachment.original.mime, + size: attachment.original.size, + worthPath: job.data.origFilePath, + }); + await minioService.downloadFile({ + bucket: attachment.original.bucket, + key: attachment.original.key, + filePath: job.data.origFilePath, + }); + } catch (error) { + this.log.error('failed to fetch attachment file', { attachmentId: job.data.attachmentId, error }); + throw error; + } + } + + async resetAttachment (job) { + const { minio: minioService } = this.dtp.services; + const { attachment } = job.data; + + const updateOp = { $set: { status: 'processing' } }; + + if (attachment.encoded) { + this.log.info('removing existing encoded attachment file', { file: attachment.encoded }); + await minioService.removeObject(attachment.encoded.bucket, attachment.encoded.key); + delete attachment.encoded; + updateOp.$unset = { encoded: '' }; + } + + await Attachment.updateOne({ _id: attachment._id }, updateOp); + } + + async processAttachmentSharp (job) { + const { attachment: attachmentService, minio: minioService } = this.dtp.services; + const { attachment } = job.data; + const attachmentId = attachment._id; + + const sharpImage = sharp(job.data.origFilePath); + const metadata = await sharpImage.metadata(); + this.log.info('attachment metadata from Sharp', { attachmentId, metadata }); + + let chain = sharpImage + .clone() + .toColorspace('srgb') + .resize({ height: ATTACHMENT_IMAGE_HEIGHT }); + chain = chain[job.data.sharpFormat](job.data.sharpFormatParameters); + await chain.toFile(job.data.procFilePath); + + job.data.outFileStat = await fs.promises.stat(job.data.procFilePath); + + const bucket = process.env.MINIO_ATTACHMENT_BUCKET; + const key = attachmentService.getAttachmentKey(attachment, 'processed'); + + const response = await minioService.uploadFile({ + bucket, + key, + filePath: job.data.procFilePath, + metadata: { + 'Content-Type': `image/${job.data.sharpFormat}`, + 'Content-Length': job.data.outFileStat.size, + }, + }); + + await Attachment.updateOne( + { _id: job.data.attachment._id }, + { + $set: { + status: 'live', + encoded: { + bucket, + key, + mime: `image/${job.data.sharpFormat}`, + size: job.data.outFileStat.size, + etag: response.etag, + }, + }, + }, + ); + } + + async processAttachmentFFMPEG (job) { + const { + attachment: attachmentService, + media: mediaService, + minio: minioService, + } = this.dtp.services; + + const { attachment } = job.data; + const codecVideo = (process.env.DTP_GPU_ACCELERATION === 'enabled') ? 'h264_nvenc' : 'libx264'; + + // generate the encoded attachment + // Output height is 100 lines by [aspect] width with width and height being + // padded to be divisible by 2. The video stream is given a bit rate of + // 128Kbps, and the media is flagged for +faststart. Audio is stripped if + // present. + + const ffmpegArgs = [ + '-y', '-i', job.data.origFilePath, + '-vf', `scale=-1:${ATTACHMENT_IMAGE_HEIGHT},pad=ceil(iw/2)*2:ceil(ih/2)*2`, + '-pix_fmt', 'yuv420p', + '-c:v', codecVideo, + '-b:v', '128k', + '-movflags', '+faststart', + '-an', + job.data.procFilePath, + ]; + + this.log.debug('transcoding attachment', { ffmpegArgs }); + await mediaService.ffmpeg(ffmpegArgs); + + job.data.outFileStat = await fs.promises.stat(job.data.procFilePath); + + const bucket = process.env.MINIO_VIDEO_BUCKET; + const key = attachmentService.getAttachmentKey(attachment, 'processed'); + + this.jobLog(job, 'uploading processed media file'); + const response = await minioService.uploadFile({ + bucket, key, + filePath: job.data.procFilePath, + metadata: { + 'Content-Type': 'video/mp4', + 'Content-Length': job.data.outFileStat.size, + }, + }); + + await Attachment.updateOne( + { _id: attachment._id }, + { + $set: { + status: 'live', + encoded: { + bucket, + key, + mime: 'video/mp4', + size: job.data.outFileStat.size, + etag: response.etag, + }, + }, + }, + ); + } +} + +module.exports = AttachmentIngestJob; \ No newline at end of file diff --git a/app/workers/media/job/sticker-delete.js b/app/workers/media/job/sticker-delete.js new file mode 100644 index 0000000..85fff12 --- /dev/null +++ b/app/workers/media/job/sticker-delete.js @@ -0,0 +1,62 @@ +// media/job/sticker-delete.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); + +const mongoose = require('mongoose'); +const Sticker = mongoose.model('Sticker'); + +const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); + +class StickerDeleteJob extends SiteWorkerProcess { + + static get COMPONENT ( ) { + return { + name: 'stickerDeleteJob', + slug: 'sticker-delete-job', + }; + } + + constructor (worker) { + super(worker, StickerDeleteJob.COMPONENT); + } + + async start ( ) { + await super.start(); + + this.queue = await this.getJobQueue('media'); + + this.log.info('registering job processor', { queue: this.queue.name, name: 'sticker-ingest' }); + this.queue.process('sticker-delete', 1, this.processStickerDelete.bind(this)); + } + + async stop ( ) { + await super.stop(); + } + + async processStickerDelete (job) { + const { minio: minioService, sticker: stickerService } = this.dtp.services; + try { + const sticker = await stickerService.getById(job.data.stickerId, true); + + this.log.info('removing original media', { stickerId: sticker._id, slug: sticker.slug }); + await minioService.removeObject(sticker.original.bucket, sticker.original.key); + + if (sticker.encoded) { + this.log.info('removing encoded media', { stickerId: sticker._id, slug: sticker.slug }); + await minioService.removeObject(sticker.encoded.bucket, sticker.encoded.key); + } + + this.log.info('removing sticker', { stickerId: sticker._id, slug: sticker.slug }); + await Sticker.deleteOne({ _id: sticker._id }); + } catch (error) { + this.log.error('failed to delete sticker', { stickerId: job.data.stickerId, error }); + throw error; // for job report + } + } +} + +module.exports = StickerDeleteJob; \ No newline at end of file diff --git a/app/workers/stickers.js b/app/workers/media/job/sticker-ingest.js similarity index 58% rename from app/workers/stickers.js rename to app/workers/media/job/sticker-ingest.js index f774f2f..42981d6 100644 --- a/app/workers/stickers.js +++ b/app/workers/media/job/sticker-ingest.js @@ -1,4 +1,4 @@ -// stickers.js +// media/job/sticker-ingest.js // Copyright (C) 2022 DTP Technologies, LLC // License: Apache-2.0 @@ -8,26 +8,25 @@ const DTP_STICKER_HEIGHT = 100; const path = require('path'); const fs = require('fs'); -require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); const mongoose = require('mongoose'); - -const { SitePlatform, SiteLog, SiteWorker } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); +const Sticker = mongoose.model('Sticker'); const sharp = require('sharp'); -module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); -module.config = { - environment: process.env.NODE_ENV, - root: path.resolve(__dirname, '..', '..'), - component: { name: 'stickersWorker', slug: 'stickers-worker' }, -}; +const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); -class StickerWorker extends SiteWorker { +class StickerIngestJob extends SiteWorkerProcess { - constructor (dtp) { - super(dtp, dtp.config.component); + static get COMPONENT ( ) { + return { + name: 'stickerIngestJob', + slug: 'sticker-ingest-job', + }; + } + constructor (worker) { + super(worker, StickerIngestJob.COMPONENT); this.processors = { processStickerSharp: this.processStickerSharp.bind(this), processStickerFFMPEG: this.processStickerFFMPEG.bind(this), @@ -36,38 +35,21 @@ class StickerWorker extends SiteWorker { async start ( ) { await super.start(); - const { jobQueue: jobQueueService } = this.dtp.services; - this.log.info('registering sticker-ingest job processor', { - config: this.dtp.config.jobQueues['sticker-ingest'], - }); - this.stickerProcessingQueue = jobQueueService.getJobQueue( - 'sticker-ingest', - this.dtp.config.jobQueues['sticker-ingest'], - ); + this.queue = await this.getJobQueue('media'); - this.stickerProcessingQueue.process('sticker-ingest', 1, this.processStickerIngest.bind(this)); - this.stickerProcessingQueue.process('sticker-delete', 1, this.processStickerDelete.bind(this)); + this.log.info('registering job processor', { queue: this.queue.name, name: 'sticker-ingest' }); + this.queue.process('sticker-ingest', 1, this.processStickerIngest.bind(this)); } async stop ( ) { - if (this.stickerProcessingQueue) { - try { - this.log.info('closing sticker-ingest job queue'); - await this.stickerProcessingQueue.close(); - delete this.stickerProcessingQueue; - } catch (error) { - this.log.error('failed to close sticker ingest job queue', { error }); - // fall through - } - } await super.stop(); } async processStickerIngest (job) { try { this.log.info('received sticker ingest job', { id: job.id, data: job.data }); - await this.fetchSticker(job); // defines jobs.data.processor + await this.fetchSticker(job); await this.resetSticker(job); // call the chosen file processor to render the sticker for distribution @@ -102,7 +84,7 @@ class StickerWorker extends SiteWorker { switch (job.data.sticker.original.type) { case 'image/jpeg': job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.jpg`); - job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.jpg`); + job.data.procFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.jpg`); job.data.processor = 'processStickerSharp'; job.data.sharpFormat = 'jpeg'; job.data.sharpFormatParameters = { quality: 85 }; @@ -110,7 +92,7 @@ class StickerWorker extends SiteWorker { case 'image/png': job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.png`); - job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.png`); + job.data.procFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.png`); job.data.processor = 'processStickerSharp'; job.data.sharpFormat = 'png'; job.data.sharpFormatParameters = { compression: 9 }; @@ -118,13 +100,13 @@ class StickerWorker extends SiteWorker { case 'image/gif': job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.gif`); - job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.mp4`); + job.data.procFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.mp4`); job.data.processor = 'processStickerFFMPEG'; break; case 'image/webp': // process as PNG job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.webp`); - job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.png`); + job.data.procFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.png`); job.data.processor = 'processStickerSharp'; job.data.sharpFormat = 'png'; job.data.sharpFormatParameters = { compression: 9 }; @@ -132,13 +114,13 @@ class StickerWorker extends SiteWorker { case 'image/webm': // process as MP4 job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.webm`); - job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.mp4`); + job.data.procFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.mp4`); job.data.processor = 'processStickerFFMPEG'; break; case 'video/mp4': job.data.origFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.mp4`); - job.data.outFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.mp4`); + job.data.procFilePath = path.join(job.data.workPath, `${job.data.sticker._id}.proc.mp4`); job.data.processor = 'processStickerFFMPEG'; break; @@ -162,29 +144,17 @@ class StickerWorker extends SiteWorker { async resetSticker (job) { const { minio: minioService } = this.dtp.services; const { sticker } = job.data; - - if (!sticker.encoded) { - return; + + const updateOp = { $set: { status: 'processing' } }; + + if (sticker.encoded) { + this.log.info('removing existing encoded sticker media', { media: sticker.encoded }); + await minioService.removeObject(sticker.encoded.bucket, sticker.encoded.key); + delete sticker.encoded; + updateOp.$unset = { encoded: '' }; } - this.log.info('removing existing encoded sticker media', { media: sticker.encoded }); - await minioService.removeObject(sticker.encoded.bucket, sticker.encoded.key); - - // switch sticker back to 'processing' status to prevent use in the app - const Sticker = mongoose.model('Sticker'); - await Sticker.updateOne( - { _id: job.data.sticker._id }, - { - $set: { - status: 'processing', - }, - $unset: { - encoded: '', - }, - }, - ); - - delete sticker.encoded; + await Sticker.updateOne({ _id: sticker._id }, updateOp); } async processStickerSharp (job) { @@ -201,9 +171,9 @@ class StickerWorker extends SiteWorker { chain = chain[job.data.sharpFormat](job.data.sharpFormatParameters); - await chain.toFile(job.data.outFilePath); + await chain.toFile(job.data.procFilePath); - job.data.outFileStat = await fs.promises.stat(job.data.outFilePath); + job.data.outFileStat = await fs.promises.stat(job.data.procFilePath); const bucket = process.env.MINIO_VIDEO_BUCKET; const key = `/stickers/${job.data.sticker._id.toString().slice(0, 3)}/${job.data.sticker._id}.encoded.${job.data.sharpFormat}`; @@ -211,14 +181,13 @@ class StickerWorker extends SiteWorker { await minioService.uploadFile({ bucket, key, - filePath: job.data.outFilePath, + filePath: job.data.procFilePath, metadata: { 'Content-Type': `image/${job.data.sharpFormat}`, 'Content-Length': job.data.outFileStat.size, }, }); - const Sticker = mongoose.model('Sticker'); await Sticker.updateOne( { _id: job.data.sticker._id }, { @@ -254,14 +223,14 @@ class StickerWorker extends SiteWorker { '-b:v', '128k', '-movflags', '+faststart', '-an', - job.data.outFilePath, + job.data.procFilePath, ]; this.jobLog(job, `transcoding motion sticker: ${job.data.sticker.slug}`); this.log.debug('transcoding motion sticker', { ffmpegStickerArgs }); await mediaService.ffmpeg(ffmpegStickerArgs); - job.data.outFileStat = await fs.promises.stat(job.data.outFilePath); + job.data.outFileStat = await fs.promises.stat(job.data.procFilePath); const bucket = process.env.MINIO_VIDEO_BUCKET; const key = `/stickers/${job.data.sticker._id.toString().slice(0, 3)}/${job.data.sticker._id}.encoded.mp4`; @@ -269,7 +238,7 @@ class StickerWorker extends SiteWorker { this.jobLog(job, 'uploading encoded media file'); await minioService.uploadFile({ bucket, key, - filePath: job.data.outFilePath, + filePath: job.data.procFilePath, metadata: { 'Content-Type': 'video/mp4', 'Content-Length': job.data.outFileStat.size, @@ -278,7 +247,6 @@ class StickerWorker extends SiteWorker { this.jobLog(job, 'updating Sticker to live status'); - const Sticker = mongoose.model('Sticker'); await Sticker.updateOne( { _id: job.data.sticker._id }, { @@ -294,63 +262,6 @@ class StickerWorker extends SiteWorker { }, ); } - - async processStickerDelete (job) { - const { minio: minioService, sticker: stickerService } = this.dtp.services; - const Sticker = mongoose.model('Sticker'); - try { - const sticker = await stickerService.getById(job.data.stickerId, true); - - this.log.info('removing original media', { stickerId: sticker._id, slug: sticker.slug }); - await minioService.removeObject(sticker.original.bucket, sticker.original.key); - - if (sticker.encoded) { - this.log.info('removing encoded media', { stickerId: sticker._id, slug: sticker.slug }); - await minioService.removeObject(sticker.encoded.bucket, sticker.encoded.key); - } - - this.log.info('removing sticker', { stickerId: sticker._id, slug: sticker.slug }); - await Sticker.deleteOne({ _id: sticker._id }); - } catch (error) { - this.log.error('failed to delete sticker', { stickerId: job.data.stickerId, error }); - throw error; // for job report - } - } - - async jobLog (job, message, data = { }) { - job.log(message); - this.log.info(message, { jobId: job.id, ...data }); - } } -(async ( ) => { - try { - module.log = new SiteLog(module, module.config.component); - - /* - * Platform startup - */ - await SitePlatform.startPlatform(module, module.config.component); - - module.worker = new StickerWorker(module); - await module.worker.start(); - - /* - * Worker startup - */ - - if (process.argv[2]) { - const stickerId = mongoose.Types.ObjectId(process.argv[2]); - this.log.info('creating sticker processing job', { stickerId }); - await module.worker.stickerProcessingQueue.add('sticker-ingest', { stickerId }); - } - - module.log.info(`${module.pkg.name} v${module.pkg.version} ${module.config.component.name} started`); - } catch (error) { - module.log.error('failed to start worker', { - component: module.config.component, - error, - }); - process.exit(-1); - } -})(); \ No newline at end of file +module.exports = StickerIngestJob; \ No newline at end of file diff --git a/app/workers/reeeper.js b/app/workers/reeeper.js index 2103a9c..57d263d 100644 --- a/app/workers/reeeper.js +++ b/app/workers/reeeper.js @@ -8,15 +8,11 @@ const path = require('path'); require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); -const mongoose = require('mongoose'); - const { SiteLog, SiteWorker, } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); -const { CronJob } = require('cron'); - module.rootPath = path.resolve(__dirname, '..', '..'); module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); @@ -36,39 +32,15 @@ class ReeeperWorker extends SiteWorker { } async start ( ) { - const CRON_TIMEZONE = 'America/New_York'; await super.start(); - await this.expireCrashedHosts(); // first-run the expirations - this.expireJob = new CronJob('*/5 * * * * *', this.expireCrashedHosts.bind(this), null, true, CRON_TIMEZONE); + await this.loadProcessor(path.join(__dirname, 'reeeper', 'cron', 'expire-crashed-hosts.js')); + await this.startProcessors(); } async stop ( ) { - if (this.expireJob) { - this.log.info('stopping host expire job'); - this.expireJob.stop(); - delete this.expireJob; - } - await super.stop(); } - - async expireCrashedHosts ( ) { - const NetHost = mongoose.model('NetHost'); - try { - await NetHost - .find({ status: 'crashed' }) - .select('_id hostname') - .lean() - .cursor() - .eachAsync(async (host) => { - this.log.info('deactivating crashed host', { hostname: host.hostname }); - await NetHost.updateOne({ _id: host._id }, { $set: { status: 'inactive' } }); - }); - } catch (error) { - this.log.error('failed to expire crashed hosts', { error }); - } - } } (async ( ) => { diff --git a/app/workers/reeeper/cron/expire-crashed-hosts.js b/app/workers/reeeper/cron/expire-crashed-hosts.js new file mode 100644 index 0000000..e85940f --- /dev/null +++ b/app/workers/reeeper/cron/expire-crashed-hosts.js @@ -0,0 +1,75 @@ +// reeeper/cron/expire-crashed-hosts.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); + +const mongoose = require('mongoose'); +const NetHost = mongoose.model('NetHost'); + +const { CronJob } = require('cron'); + +const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); + +/** + * DTP Core Chat sticker processor can receive requests to ingest and delete + * stickers to be executed as background jobs in a queue. This processor + * attaches to the `media` queue and registers processors for `sticker-ingest` + * and `sticker-delete`. + */ +class CrashedHostsCron extends SiteWorkerProcess { + + static get COMPONENT ( ) { + return { + name: 'crashedHostsCron', + slug: 'crashed-hosts-cron', + }; + } + + constructor (worker) { + super(worker, CrashedHostsCron.COMPONENT); + } + + async start ( ) { + await super.start(); + + await this.expireCrashedHosts(); // first-run the expirations + + this.job = new CronJob( + '*/5 * * * * *', + this.expireCrashedHosts.bind(this), + null, + true, + process.env.DTP_CRON_TIMEZONE || 'America/New_York', + ); + } + + async stop ( ) { + if (this.job) { + this.log.info('stopping host expire job'); + this.job.stop(); + delete this.job; + } + await super.stop(); + } + + async expireCrashedHosts ( ) { + try { + await NetHost + .find({ status: 'crashed' }) + .select('_id hostname') + .lean() + .cursor() + .eachAsync(async (host) => { + this.log.info('deactivating crashed host', { hostname: host.hostname }); + await NetHost.updateOne({ _id: host._id }, { $set: { status: 'inactive' } }); + }); + } catch (error) { + this.log.error('failed to expire crashed hosts', { error }); + } + } +} + +module.exports = CrashedHostsCron; \ No newline at end of file diff --git a/client/js/index.js b/client/js/index.js index 31619cc..fc6399b 100644 --- a/client/js/index.js +++ b/client/js/index.js @@ -37,10 +37,10 @@ window.addEventListener('load', async ( ) => { }); document.addEventListener('socketConnected', async ( ) => { - if (dtp.channel) { - dtp.app.socket.joinChannel(dtp.channel._id); - if (dtp.user && (dtp.user._id === dtp.channel.owner._id)) { - dtp.app.socket.joinChannel(`broadcast:${dtp.channel._id}`); + if (dtp.room) { + dtp.app.socket.joinChannel(dtp.room._id); + if (dtp.user && (dtp.user._id === dtp.room.owner._id)) { + dtp.app.socket.joinChannel(`broadcast:${dtp.room._id}`); } } }); \ No newline at end of file diff --git a/client/js/site-app.js b/client/js/site-app.js index 7ccf1c6..e8a51d9 100644 --- a/client/js/site-app.js +++ b/client/js/site-app.js @@ -13,7 +13,6 @@ import UIkit from 'uikit'; import QRCode from 'qrcode'; import Cropper from 'cropperjs'; -import { EmojiButton } from '@joeattardi/emoji-button'; import SiteChat from './site-chat'; const GRID_COLOR = 'rgb(64, 64, 64)'; @@ -42,12 +41,8 @@ export default class DtpSiteApp extends DtpApp { isIOS: this.isIOS, }); - this.emojiPicker = new EmojiButton({ theme: 'dark' }); - this.emojiPicker.on('emoji', this.onEmojiSelected.bind(this)); - this.chat = new SiteChat(this); - - this.charts = {/* will hold rendered charts */}; + this.charts = { /* will hold rendered charts */ }; this.scrollToHash(); } @@ -69,19 +64,12 @@ export default class DtpSiteApp extends DtpApp { await DtpApp.prototype.connect.call(this, { withRetry: true, withError: false }); if (this.user) { const { socket } = this.socket; - socket.on('user-chat', this.onUserChat.bind(this)); - socket.on('user-react', this.onUserReact.bind(this)); + socket.on('system-message', this.chat.appendSystemMessage.bind(this.chat)); + socket.on('user-chat', this.chat.appendUserChat.bind(this.chat)); + socket.on('user-react', this.chat.createEmojiReact.bind(this.chat)); } } - async onUserChat (message) { - await this.chat.appendUserChat(message); - } - - async onUserReact (message) { - await this.chat.createEmojiReact(message); - } - async goBack ( ) { if (document.referrer && (document.referrer.indexOf(`://${window.dtp.domain}`) >= 0)) { window.history.back(); @@ -91,38 +79,6 @@ export default class DtpSiteApp extends DtpApp { return false; } - async submitForm (event, userAction) { - event.preventDefault(); - event.stopPropagation(); - - try { - const formElement = event.currentTarget || event.target; - const form = new FormData(formElement); - - this.log.info('submitForm', userAction, { event, action: formElement.action }); - const response = await fetch(formElement.action, { - method: formElement.method, - body: form, - }); - - if (!response.ok) { - let json; - try { - json = await response.json(); - } catch (error) { - throw new Error('Server error'); - } - throw new Error(json.message || 'Server error'); - } - - await this.processResponse(response); - } catch (error) { - UIkit.modal.alert(`Failed to ${userAction}: ${error.message}`); - } - - return; - } - async submitImageForm (event) { event.preventDefault(); event.stopPropagation(); @@ -460,19 +416,6 @@ export default class DtpSiteApp extends DtpApp { label.textContent = numeral(event.target.value.length).format('0,0'); } - async showEmojiPicker (event) { - const targetElementName = (event.currentTarget || event.target).getAttribute('data-target-element'); - this.emojiTargetElement = document.getElementById(targetElementName); - if (!this.emojiTargetElement) { - return UIkit.modal.alert('Emoji picker target element does not exist'); - } - this.emojiPicker.togglePicker(this.emojiTargetElement); - } - - async onEmojiSelected (selection) { - this.emojiTargetElement.value += selection.emoji; - } - async showReportCommentForm (event) { event.preventDefault(); event.stopPropagation(); diff --git a/client/js/site-chat.js b/client/js/site-chat.js index 0a345e0..22e38ce 100644 --- a/client/js/site-chat.js +++ b/client/js/site-chat.js @@ -11,7 +11,9 @@ const EMOJI_EXPLOSION_DURATION = 8000; const EMOJI_EXPLOSION_INTERVAL = 100; import DtpLog from 'dtp/dtp-log.js'; +import UIkit from 'uikit'; import SiteReactions from './site-reactions.js'; +import * as picmo from 'picmo'; export default class SiteChat { @@ -20,11 +22,13 @@ export default class SiteChat { this.log = new DtpLog(DTP_COMPONENT); this.ui = { + menu: document.querySelector('#chat-room-menu'), form: document.querySelector('#chat-input-form'), messageList: document.querySelector('#chat-message-list'), messages: [ ], messageMenu: document.querySelector('.chat-message-menu'), input: document.querySelector('#chat-input-text'), + emojiPicker: document.querySelector('#site-emoji-picker'), isAtBottom: true, isModifying: false, }; @@ -39,10 +43,19 @@ export default class SiteChat { this.ui.reactions = new SiteReactions(); this.lastReaction = new Date(); } + if (this.ui.input) { this.ui.input.addEventListener('keydown', this.onChatInputKeyDown.bind(this)); } + if (this.ui.emojiPicker) { + this.ui.picmo = picmo.createPicker({ + rootElement: this.ui.emojiPicker, + theme: picmo.darkTheme, + }); + this.ui.picmo.addEventListener('emoji:select', this.onEmojiSelected.bind(this)); + } + this.lastReaction = new Date(); if (window.localStorage) { @@ -155,8 +168,6 @@ export default class SiteChat { async appendUserChat (message) { const isAtBottom = this.ui.isAtBottom; - - this.log.info('appendUserChat', 'message received', { user: message.user, content: message.content }); if (this.mutedUsers.find((block) => block.userId === message.user._id)) { this.log.info('appendUserChat', 'message is from blocked user', { _id: message.user._id, @@ -165,123 +176,13 @@ export default class SiteChat { return; // sender is blocked by local user on this device } - const chatMessage = document.createElement('div'); - chatMessage.setAttribute('data-message-id', message._id); - chatMessage.setAttribute('data-author-id', message.user._id); - chatMessage.classList.add('uk-margin-small'); - chatMessage.classList.add('chat-message'); - - const userGrid = document.createElement('div'); - userGrid.setAttribute('uk-grid', ''); - userGrid.classList.add('uk-grid-small'); - userGrid.classList.add('uk-flex-middle'); - chatMessage.appendChild(userGrid); - - const usernameColumn = document.createElement('div'); - usernameColumn.classList.add('uk-width-expand'); - userGrid.appendChild(usernameColumn); - - const chatUser = document.createElement('div'); - const authorName = message.user.displayName || message.user.username; - chatUser.classList.add('uk-text-small'); - chatUser.classList.add('chat-username'); - chatUser.textContent = authorName; - usernameColumn.appendChild(chatUser); - - if (message.user.picture && message.user.picture.small) { - const chatUserPictureColumn = document.createElement('div'); - chatUserPictureColumn.classList.add('uk-width-auto'); - userGrid.appendChild(chatUserPictureColumn); - - const chatUserPicture = document.createElement('img'); - chatUserPicture.classList.add('chat-author-image'); - chatUserPicture.setAttribute('src', `/image/${message.user.picture.small._id}`); - chatUserPicture.setAttribute('alt', `${authorName}'s profile picture`); - chatUserPictureColumn.appendChild(chatUserPicture); - } - - if (dtp.user && (dtp.user._id !== message.user._id)) { - const menuColumn = document.createElement('div'); - menuColumn.classList.add('uk-width-auto'); - menuColumn.classList.add('chat-user-menu'); - userGrid.appendChild(menuColumn); - - const menuButton = document.createElement('button'); - menuButton.setAttribute('type', 'button'); - menuButton.classList.add('uk-button'); - menuButton.classList.add('uk-button-link'); - menuButton.classList.add('uk-button-small'); - menuColumn.appendChild(menuButton); - - const menuIcon = document.createElement('i'); - menuIcon.classList.add('fas'); - menuIcon.classList.add('fa-ellipsis-h'); - menuButton.appendChild(menuIcon); - - const menuDropdown = document.createElement('div'); - menuDropdown.setAttribute('data-message-id', message._id); - menuDropdown.setAttribute('uk-dropdown', 'mode: click'); - menuColumn.appendChild(menuDropdown); - - const dropdownList = document.createElement('ul'); - dropdownList.classList.add('uk-nav'); - dropdownList.classList.add('uk-dropdown-nav'); - menuDropdown.appendChild(dropdownList); - - let dropdownListItem = document.createElement('li'); - dropdownList.appendChild(dropdownListItem); - - let link = document.createElement('a'); - link.setAttribute('href', ''); - link.setAttribute('data-message-id', message._id); - link.setAttribute('data-user-id', message.user._id); - link.setAttribute('data-username', message.user.username); - link.setAttribute('onclick', "return dtp.app.muteChatUser(event);"); - link.textContent = `Mute ${message.user.displayName || message.user.username}`; - dropdownListItem.appendChild(link); - } - - const chatContent = document.createElement('div'); - chatContent.classList.add('chat-content'); - chatContent.classList.add('uk-text-break'); - chatContent.innerHTML = message.content; - chatMessage.appendChild(chatContent); - - const chatTimestamp = document.createElement('div'); - chatTimestamp.classList.add('chat-timestamp'); - chatTimestamp.classList.add('uk-text-small'); - chatTimestamp.textContent = moment(message.created).format('hh:mm:ss a'); - chatMessage.appendChild(chatTimestamp); - - if (Array.isArray(message.stickers) && message.stickers.length) { - message.stickers.forEach((sticker) => { - const chatContent = document.createElement('div'); - chatContent.classList.add('chat-sticker'); - chatContent.setAttribute('title', `:${sticker.slug}:`); - chatContent.setAttribute('data-sticker-id', sticker._id); - switch (sticker.encoded.type) { - case 'video/mp4': - chatContent.innerHTML = ``; - break; - case 'image/png': - chatContent.innerHTML = ``; - break; - case 'image/jpeg': - chatContent.innerHTML = ``; - break; - } - chatMessage.appendChild(chatContent); - }); - } + const fragment = document.createDocumentFragment(); + fragment.innerHTML = message.html; this.ui.isModifying = true; - this.ui.messageList.appendChild(chatMessage); - this.ui.messages.push(chatMessage); - - while (this.ui.messages.length > 50) { - const message = this.ui.messages.shift(); - this.ui.messageList.removeChild(message); - } + this.ui.messageList.insertAdjacentHTML('beforeend', message.html); + this.trimMessages(); + this.updateTimestamps(); if (isAtBottom) { /* @@ -299,16 +200,54 @@ export default class SiteChat { } } + async appendSystemMessage (message) { + this.log.debug('appendSystemMessage', 'received system message', { message }); + + const systemMessage = document.createElement('div'); + systemMessage.setAttribute('data-message-type', message.type); + systemMessage.classList.add('uk-margin-small'); + systemMessage.classList.add('chat-message'); + systemMessage.classList.add('system-message'); + + const chatContent = document.createElement('div'); + chatContent.classList.add('chat-content'); + chatContent.classList.add('uk-text-break'); + chatContent.innerHTML = message.content; + systemMessage.appendChild(chatContent); + + const chatTimestamp = document.createElement('div'); + chatTimestamp.classList.add('chat-timestamp'); + chatTimestamp.classList.add('uk-text-small'); + chatTimestamp.innerHTML = moment(message.created).format('hh:mm:ss a'); + systemMessage.appendChild(chatTimestamp); + + this.ui.messageList.appendChild(systemMessage); + this.trimMessages(); + this.updateTimestamps(); + + if (this.ui.isAtBottom) { + this.ui.messageList.scrollTo(0, this.ui.messageList.scrollHeight); + } + } + + trimMessages ( ) { + while (this.ui.messageList.childNodes.length > 50) { + this.ui.messageList.removeChild(this.ui.messageList.childNodes.item(0)); + } + } + updateTimestamps ( ) { - const timestamps = document.querySelectorAll('div.chat-timestamp[data-created]'); + const timestamps = document.querySelectorAll('[data-dtp-timestamp]'); timestamps.forEach((timestamp) => { - const created = timestamp.getAttribute('data-created'); - timestamp.textContent = moment(created).format('hh:mm:ss a'); + const created = timestamp.getAttribute('data-dtp-timestamp'); + const format = timestamp.getAttribute('data-dtp-time-format'); + timestamp.textContent = moment(created).format(format || 'MMM DD, YYYY, [at] hh:mm:ss a'); }); } createEmojiReact (message) { this.ui.reactions.create(message.reaction); + this.triggerEmojiExplosion(); } triggerEmojiExplosion ( ) { @@ -344,4 +283,70 @@ export default class SiteChat { clearInterval(this.emojiExplosionInterval); delete this.emojiExplosionInterval; } + + async showForm (event, roomId, formName) { + try { + UIkit.dropdown(this.ui.menu).hide(false); + await this.app.showForm(event, `/chat/room/${roomId}/form/${formName}`); + } catch (error) { + UIkit.modal.alert(`Failed to display form: ${error.message}`); + } + } + + async toggleEmojiPicker (/* event */) { + this.ui.emojiPicker.classList.toggle('picker-open'); + } + + async onEmojiSelected (event) { + this.ui.emojiPicker.classList.remove('picker-open'); + return this.insertContentAtCursor(event.emoji); + } + + async insertContentAtCursor (content) { + this.ui.input.focus(); + + if (document.selection) { + let sel = document.selection.createRange(); + sel.text = content; + } else if (this.ui.input.selectionStart || (this.ui.input.selectionStart === 0)) { + let startPos = this.ui.input.selectionStart; + let endPos = this.ui.input.selectionEnd; + + let oldLength = this.ui.input.value.length; + this.ui.input.value = + this.ui.input.value.substring(0, startPos) + + content + + this.ui.input.value.substring(endPos, this.ui.input.value.length); + + this.ui.input.selectionStart = startPos + (this.ui.input.value.length - oldLength); + this.ui.input.selectionEnd = this.ui.input.selectionStart; + } else { + this.ui.input.value += content; + } + } + + async deleteInvite (event) { + const target = event.currentTarget || event.target; + const roomId = target.getAttribute('data-room-id'); + const inviteId = target.getAttribute('data-invite-id'); + try { + const response = await fetch(`/chat/room/${roomId}/invite/${inviteId}`, { method: 'DELETE' }); + await this.app.processResponse(response); + } catch (error) { + console.log('delete canceled', error); + return; + } + } + + async deleteChatRoom (event) { + const target = event.currentTarget || event.target; + const roomId = target.getAttribute('data-room-id'); + try { + const response = await fetch(`/chat/room/${roomId}`, { method: 'DELETE' }); + await this.app.processResponse(response); + } catch (error) { + console.log('delete canceled', error); + return; + } + } } \ No newline at end of file diff --git a/client/less/site/button.less b/client/less/site/button.less index 803bc65..b29f555 100644 --- a/client/less/site/button.less +++ b/client/less/site/button.less @@ -151,13 +151,25 @@ button.uk-button.dtp-button-danger { } .dtp-button-reaction { - background: none; + background-color: transparent; border: none; + border-radius: 6px; outline: none; cursor: pointer; + font-size: 14px; + line-height: 18px; + + &:active { + background-color: @emoji-react-button-active-color; + font-size: 16px; + } + span.button-icon { - font-size: 18px; + display: inline-block; + width: 32px; + font-size: inherit; + line-height: inherit; } span.count-label { diff --git a/client/less/site/chat.less b/client/less/site/chat.less index 1928942..b227219 100644 --- a/client/less/site/chat.less +++ b/client/less/site/chat.less @@ -1,21 +1,78 @@ +@emoji-picker-width: 380px; +@emoji-picker-height: 452px; + .site-chat-section { height: calc(100% - @navbar-nav-item-height); } +.site-chat-sidebar-widget { + box-sizing: border-box; + padding: @grid-small-gutter-vertical @grid-small-gutter-horizontal; + margin: @grid-small-gutter-vertical @grid-small-gutter-horizontal; + border: solid 1px @content-border-color; + background-color: @content-background-color; +} + #site-chat-container { - align-self: stretch; - overflow: scroll; + overflow: auto; + background-color: @content-container-color; .chat-menubar { - padding: 10px @grid-small-gutter-horizontal; + background-color: @page-background-color; + color: @global-color; + border-bottom: solid 1px @content-border-color; + border-left: solid 1px @content-border-color; + border-right: solid 1px @content-border-color; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + + .uk-text-muted { + color: @global-muted-color !important; + } + + .chat-room-name { + font-size: 24px; + font-weight: bold; + line-height: 1.1em; + } } #chat-input-form { + position: relative; + background-color: @page-background-color; + border-top: solid 1px @content-border-color; + border-left: solid 1px @content-border-color; + border-right: solid 1px @content-border-color; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + textarea.uk-textarea { padding: 2px 6px; resize: none; } + + #site-emoji-picker { + position: absolute; + top: 0; + left: @grid-small-gutter-horizontal; + + width: @emoji-picker-width; + height: 0; + overflow: hidden; + + margin: 0 auto; + padding: 0; + opacity: 0; + + transition: top 0.6s, height 0.6s, opacity 0.6s; + + &.picker-open { + top: -@emoji-picker-height; + height: @emoji-picker-height; + opacity: 1; + } + } } .fundraising-progress-overlay { @@ -30,15 +87,21 @@ } } - #chat-message-list-wrapper { + .chat-content-wrapper { position: relative; flex: 1; + } + + #chat-message-list-wrapper { + position: relative; + #chat-reactions { position: absolute; top: 0; right: 0; bottom: 0; left: 0; background: transparent; overflow: hidden; + pointer-events: none; span.reaction-icon { display: block; @@ -60,20 +123,56 @@ scroll-behavior: auto; &::-webkit-scrollbar { - display: none; /* Chrome */ + width: 10px; + border: none; + outline: none; + padding: 8px 0; + } + + &::-webkit-scrollbar-track { + background: transparent; + border-left: solid 1px @scrollbar-border-color; } - scrollbar-width: none; /* Firefox */ + &::-webkit-scrollbar-thumb { + outline: none; + + border: none; + border-top-right-radius: 8px; + border-bottom-right-radius: 8px; + + overflow: hidden; + + background-color: @scrollbar-thumb-color; + } .chat-message { - color: var(--dtp-chat-color); - background: var(--dtp-chat-background); + padding: @grid-small-gutter-vertical @grid-small-gutter-horizontal; + margin: (@grid-small-gutter-vertical / 2) @grid-small-gutter-horizontal; + + border: solid 1px @content-border-color; border-radius: 8px; + + background: @content-background-color; + color: inherit; font-size: var(--dtp-chat-font-size); - margin: 5px @grid-small-gutter-horizontal; &.system-message { - background: rgba(255,255,255, 0.1); + background: #e8e8e8; + color: #1a1a1a; + + &[data-message-type="info"] { + background: #068be4; + color: white; + } + &[data-message-type="warning"] { + background: #e4c306; + color: white; + } + &[data-message-type="error"] { + background: #ff00131a; + color: white; + } } .chat-username { @@ -85,7 +184,7 @@ img.chat-author-image { width: auto; - height: 2em; + height: 40px; border-radius: 4px; } @@ -159,78 +258,4 @@ } } } -} - -/* - * OBS Chat Widget specializations - */ - -body[data-obs-widget="chat"] { - -.site-player-view { - - #site-chat-container { - - #chat-message-list-wrapper { - background-color: transparent; - - #chat-reactions { - background-color: transparent; - } - } - } -} -} - -/* - * Mobile view layout - */ -@media screen and (max-width: 959px) { - - body[data-current-view="channel-broadcast"], - body[data-current-view="dvr-player"] { - position: fixed; - top: 0; right: 0; bottom: 0; left: 0; - padding: 0; - margin: 0; - width: 100%; - height: 100%; - - .site-player-view { - position: absolute; - top: 64px; right: 0; bottom: 0; left: 0; - overflow: hidden; - - display: flex; - flex-direction: column; - flex-wrap: nowrap; - - #site-video-container { - flex: 0; - } - - #site-chat-container { - position: relative; - flex: 1; - - #chat-message-list { - flex: 1; - } - - #chat-input-form { - flex: 0; - } - } - } - } -} - -body[data-obs-widget="chat"] { - - .chat-message { - - .chat-user-menu { - display: none; - } - } } \ No newline at end of file diff --git a/client/less/site/content.less b/client/less/site/content.less index f4f99ce..29d28ba 100644 --- a/client/less/site/content.less +++ b/client/less/site/content.less @@ -1,5 +1,5 @@ .content-block { - padding: @global-gutter; + padding: @grid-small-gutter-horizontal; background-color: @content-background-color; :last-child { diff --git a/client/less/site/image.less b/client/less/site/image.less index 16796b0..d5ca491 100644 --- a/client/less/site/image.less +++ b/client/less/site/image.less @@ -30,7 +30,7 @@ img.site-profile-picture { height: auto; margin-left: auto; margin-right: auto; - border-radius: 5%; + border-radius: 8px; background-color: #8a8a8a; } diff --git a/client/less/site/kaleidoscope-event.less b/client/less/site/kaleidoscope-event.less new file mode 100644 index 0000000..37cbad6 --- /dev/null +++ b/client/less/site/kaleidoscope-event.less @@ -0,0 +1,48 @@ +.kaleidoscope-event { + box-sizing: border-box; + position: relative; + border: solid 2px @content-border-color; + border-radius: 6px; + overflow: hidden; + margin-bottom: @global-margin; + background-color: @content-background-color; + + box-shadow: 0 1px 6px rgba(0,0,0, 0.3); + + &[data-event-source="dtp-social"] { + border-color: #da0012; + } + + &[data-event-source="dtp-sites"] { + border-color: #0eaa00; + } + + &[data-event-action="room-invite-create"] { + border-color: #0082aa; + } + + .event-feature-img { + display: block; + line-height: 1; + width: 100%; + height: auto; + padding: 0; + margin: 0; + } + + .event-header { + padding: 4px @global-small-gutter; + } + + .event-content { + padding: 4px @global-small-gutter; + + p:last-child { + margin-bottom: 0; + } + } + + .event-footer { + padding: 4px @global-small-gutter; + } +} \ No newline at end of file diff --git a/client/less/site/main.less b/client/less/site/main.less index f2a7ad4..c37831c 100644 --- a/client/less/site/main.less +++ b/client/less/site/main.less @@ -6,6 +6,7 @@ html, body { body { padding-top: @site-navbar-height; + background-color: @page-background-color; &[data-current-view="chat"] { position: fixed; @@ -56,4 +57,8 @@ body { color:white; } } +} + +.dtp-text-tight { + line-height: 1; } \ No newline at end of file diff --git a/client/less/site/uikit-theme.dtp-dark.less b/client/less/site/uikit-theme.dtp-dark.less index 7ef0aef..1c87695 100644 --- a/client/less/site/uikit-theme.dtp-dark.less +++ b/client/less/site/uikit-theme.dtp-dark.less @@ -5,6 +5,7 @@ @page-background-color: #000000; @content-background-color: #2a2a2a; @content-border-color: #4a4a4a; +@content-container-color: #2a2a2a; @site-brand-color: #ff0013; @button-label-color: #e8e8e8; @@ -12,6 +13,11 @@ @social-link-color: #e8e8e8; @checkout-button-text-color: #e8e8e8; +@emoji-react-button-active-color: #adc7a0; + +@scrollbar-border-color: @content-border-color; +@scrollbar-thumb-color: #ff001380; + @global-background: #1a1a1a; @global-muted-background: #3a3a3a; @global-primary-background: #1e87f0; @@ -23,7 +29,7 @@ @global-color: #c8c8c8; @global-emphasis-color: #ffffff; -@global-muted-color: #4a4a4a; +@global-muted-color: #9a9a9a; @global-link-color: #e00000; @global-link-hover-color: #ff0000; @@ -59,7 +65,8 @@ @button-text-hover-color: #000000; @button-text-disabled-color: #000000; -button.uk-button.uk-button-default { +button.uk-button.uk-button-default, +a.uk-button.uk-button-default { color: @global-color; } diff --git a/client/less/site/uikit-theme.dtp-light.less b/client/less/site/uikit-theme.dtp-light.less index af69641..7711a9f 100644 --- a/client/less/site/uikit-theme.dtp-light.less +++ b/client/less/site/uikit-theme.dtp-light.less @@ -5,6 +5,7 @@ @page-background-color: #e8e8e8; @content-background-color: #ffffff; @content-border-color: #a8a8a8; +@content-container-color: #c8c8c8; @site-brand-color: #ff0013; @button-label-color: #2a2a2a; @@ -12,6 +13,11 @@ @social-link-color: #2a2a2a; @checkout-button-text-color: #2a2a2a; +@emoji-react-button-active-color: #adc7a0; + +@scrollbar-border-color: @content-border-color; +@scrollbar-thumb-color: #ff001380; + // // Component: Navbar // diff --git a/client/less/style.common.less b/client/less/style.common.less index 2dc2d9e..86c5642 100644 --- a/client/less/style.common.less +++ b/client/less/style.common.less @@ -7,6 +7,7 @@ @import "site/figure.less"; @import "site/header-section.less"; @import "site/image.less"; +@import "site/kaleidoscope-event.less"; @import "site/nav.less"; @import "site/content.less"; diff --git a/config/job-queues.js b/config/job-queues.js index 67f8f85..1fb3b31 100644 --- a/config/job-queues.js +++ b/config/job-queues.js @@ -5,8 +5,16 @@ 'use strict'; module.exports = { + 'media': { + attempts: 3, + removeOnComplete: true, + }, 'newsletter': { attempts: 3, - removeOnComplete: false, + removeOnComplete: true, + }, + 'reeeper': { + attempts: 3, + removeOnComplete: true, }, }; \ No newline at end of file diff --git a/config/limiter.js b/config/limiter.js index 1fb8146..e9676fd 100644 --- a/config/limiter.js +++ b/config/limiter.js @@ -55,6 +55,16 @@ module.exports = { * ChatController */ chat: { + postRoomInviteAction: { + total: 20, + expire: ONE_MINUTE, + message: 'You are sending room invite actions too quickly', + }, + postRoomInvite: { + total: 25, + expire: ONE_MINUTE, + message: 'You are sending room invites too quickly', + }, postRoomUpdate: { total: 10, expire: ONE_MINUTE, @@ -65,16 +75,46 @@ module.exports = { expire: ONE_MINUTE * 5, message: 'You are creating chat rooms too quickly', }, + getRoomForm: { + total: 30, + expire: ONE_MINUTE, + message: 'You are loading chat room forms too quickly', + }, + getRoomInviteView: { + total: 15, + expire: ONE_MINUTE, + message: 'You are loading chat room invite view too quickly', + }, + getRoomSettings: { + total: 15, + expire: ONE_MINUTE, + message: 'You are loading chat rooms too quickly', + }, getRoomView: { total: 15, expire: ONE_MINUTE, message: 'You are loading chat rooms too quickly', }, + getRoomHome: { + total: 20, + expire: ONE_MINUTE, + message: 'You are loading chat home too quickly', + }, getHome: { total: 30, expire: ONE_MINUTE, message: 'You are loading chat home too quickly', }, + deleteInvite: { + total: 10, + expire: ONE_MINUTE, + message: 'You are deleting chat room invites too quickly', + }, + deleteRoom: { + total: 4, + expire: ONE_MINUTE, + message: 'You are deleting chat rooms too quickly', + }, }, comment: { @@ -117,6 +157,14 @@ module.exports = { }, }, + form: { + getForm: { + total: 20, + expire: ONE_MINUTE, + message: "You are requesting forms too quickly.", + }, + }, + /* * HomeController */ @@ -158,6 +206,22 @@ module.exports = { } }, + /* + * NotificationController + */ + notification: { + getNotificationView: { + total: 60, + expire: ONE_MINUTE, + message: 'You are fetching notifications too quickly', + }, + getNotificationHome: { + total: 30, + expire: ONE_MINUTE, + message: 'You are refreshing notifications too quickly', + }, + }, + /* * NewsletterController */ diff --git a/config/reserved-names.js b/config/reserved-names.js index ae3f5d5..880055c 100644 --- a/config/reserved-names.js +++ b/config/reserved-names.js @@ -24,6 +24,7 @@ module.exports = [ 'manifest.json', 'moment', 'newsletter', + 'notification', 'numeral', 'socket.io', 'uikit', diff --git a/lib/client/js/dtp-app.js b/lib/client/js/dtp-app.js index 71c6a20..9e625ed 100644 --- a/lib/client/js/dtp-app.js +++ b/lib/client/js/dtp-app.js @@ -33,6 +33,61 @@ export default class DtpApp { } } + async showForm (event, formUrl) { + event.preventDefault(); + event.stopPropagation(); + try { + const response = await fetch(formUrl, { method: 'GET' }); + const html = await response.text(); + this.currentModal = UIkit.modal.dialog(html); + } catch (error) { + UIkit.modal.alert(`Failed to display form: ${error.message}`); + } + return true; + } + + async submitForm (event, userAction) { + event.preventDefault(); + event.stopPropagation(); + + try { + const formElement = event.currentTarget || event.target; + const form = new FormData(formElement); + + // include the submitter if we have one and it presents all required data + if (event.submitter && event.submitter.name && event.submitter.value) { + form.append(event.submitter.name, event.submitter.value); + } + + this.log.info('submitForm', userAction, { event, action: formElement.action }); + const response = await fetch(formElement.action, { + method: formElement.method, + body: form, + }); + + if (!response.ok) { + let json; + try { + json = await response.json(); + } catch (error) { + throw new Error('Server error'); + } + throw new Error(json.message || 'Server error'); + } + + await this.processResponse(response); + } catch (error) { + UIkit.modal.alert(`Failed to ${userAction}: ${error.message}`); + } finally { + if (this.currentModal) { + this.currentModal.hide(); + delete this.currentModal; + } + } + + return; + } + async processResponse (response) { const json = await response.json(); if (!json.success) { diff --git a/lib/client/js/dtp-display-engine.js b/lib/client/js/dtp-display-engine.js index 364e51a..0005dcc 100644 --- a/lib/client/js/dtp-display-engine.js +++ b/lib/client/js/dtp-display-engine.js @@ -6,11 +6,13 @@ const DTP_COMPONENT = { name: 'Display Engine', slug: 'display-engine' }; +import UIkit from 'uikit'; import DtpLog from './dtp-log.js'; export default class DtpDisplayEngine { - constructor ( ) { + constructor (app) { + this.app = app; this.processors = { }; this.log = new DtpLog(DTP_COMPONENT); } @@ -210,7 +212,15 @@ export default class DtpDisplayEngine { } async showModal (displayList, command) { - UIkit.modal.dialog(command.params.html); + this.app.currentModal = UIkit.modal.dialog(command.params.html); + } + + async closeModal ( ) { + if (!this.app.currentModal) { + return; + } + this.app.currentModal.hide(); + delete this.app.currentModal; } async navigateTo (displayList, command) { diff --git a/lib/site-common.js b/lib/site-common.js index 10cc8c6..fd363ff 100644 --- a/lib/site-common.js +++ b/lib/site-common.js @@ -8,6 +8,7 @@ const path = require('path'); const pug = require('pug'); const { SiteLog } = require(path.join(__dirname, 'site-log')); +const { SiteAsync } = require(path.join(__dirname, 'site-async')); const Events = require('events'); class SiteCommon extends Events { @@ -20,6 +21,35 @@ class SiteCommon extends Events { this.log = new SiteLog(dtp, component); this.appTemplateRoot = path.join(this.dtp.config.root, 'app', 'templates'); + + this.jobQueues = { }; + } + + async start ( ) {/* I will need this, do not delete. */} + + async stop ( ) { + let slugs = Object.keys(this.jobQueues); + await SiteAsync.each(slugs, async (slug) => { + const queue = this.jobQueues[slug]; + try { + this.log.info('closing job queue'); + await queue.close(); + delete this.jobQueues[slug]; + } catch (error) { + this.log.error('failed to close job queue', { name: slug, error }); + } + }, 1); + } + + async getJobQueue (name) { + const { jobQueue: jobQueueService } = this.dtp.services; + const config = this.dtp.config.jobQueues[name]; + + this.log.info('connecting to job queue', { name, config }); + const queue = jobQueueService.getJobQueue(name, config); + this.jobQueues[name] = queue; + + return queue; } regenerateSession (req) { @@ -56,6 +86,18 @@ class SiteCommon extends Events { const scriptFile = path.join(this.dtp.config.root, 'app', 'views', filename); return pug.compileFile(scriptFile); } + + compileViewTemplate (filename, options) { + const scriptFile = path.join(this.dtp.config.root, 'app', 'views', filename); + const pathObj = path.parse(scriptFile); + options = Object.assign({ + filename: scriptFile, + basedir: path.join(this.dtp.config.root, 'app', 'views'), + doctype: 'html', + name: pathObj.name, + }, options); + return pug.compileFileClient(scriptFile, options); + } } module.exports.SiteCommon = SiteCommon; \ No newline at end of file diff --git a/lib/site-ioserver.js b/lib/site-ioserver.js index 5a1f80f..49bfdec 100644 --- a/lib/site-ioserver.js +++ b/lib/site-ioserver.js @@ -12,7 +12,6 @@ const Redis = require('ioredis'); const mongoose = require('mongoose'); const ConnectToken = mongoose.model('ConnectToken'); -const striptags = require('striptags'); const marked = require('marked'); const { SiteLog } = require(path.join(__dirname, 'site-log')); @@ -30,8 +29,6 @@ class SiteIoServer extends Events { const { Server } = require('socket.io'); const { createAdapter } = require('@socket.io/redis-adapter'); - this.createRateLimiters(); - this.markedRenderer = new marked.Renderer(); this.markedRenderer.link = (href, title, text) => { return text; }; this.markedRenderer.image = (href, title, text) => { return text; }; @@ -72,37 +69,6 @@ class SiteIoServer extends Events { this.io.on('connection', this.onSocketConnect.bind(this)); } - createRateLimiters ( ) { - const { RateLimiterRedis } = require('rate-limiter-flexible'); - - const rateLimiterRedisClient = new Redis({ - host: process.env.REDIS_HOST, - port: process.env.REDIS_PORT, - password: process.env.REDIS_PASSWORD, - key: process.env.REDIS_KEY_PREFIX || 'dtp', - enableOfflineQueue: false, - lazyConnect: false, - }); - - this.chatMessageLimiter = new RateLimiterRedis({ - storeClient: rateLimiterRedisClient, - points: 20, - duration: 60, - blockDuration: 60 * 3, - execEvenly: false, - keyPrefix: 'rl:chatmsg', - }); - - this.reactionLimiter = new RateLimiterRedis({ - storeClient: rateLimiterRedisClient, - points: 60, - duration: 60, - blockDuration: 60 * 3, - execEvenly: false, - keyPrefix: 'rl:react', - }); - } - async onRedisError (error) { this.log.error('Redis error', { error }); } @@ -149,6 +115,7 @@ class SiteIoServer extends Events { created: token.user.created, username: token.user.username, displayName: token.user.displayName, + picture: token.user.picture, }, socket, }; @@ -192,133 +159,30 @@ class SiteIoServer extends Events { } async onUserChat (session, messageDefinition) { - const { chat: chatService, user: userService } = this.dtp.services; - const channelId = messageDefinition.channel; + const { chat: chatService } = this.dtp.services; if (!messageDefinition.content || (messageDefinition.content.length === 0)) { - this.log.info('dropping empty chat message'); - return; - } - - /* - * First, implement the rate limiter check. If rate-limited, abort all - * further processing. Store nothing in the database. Send nothing to the - * chat room. - */ - try { - const userKey = session.user._id.toString(); - await this.chatMessageLimiter.consume(userKey, 1); - } catch (rateLimiter) { - const NOW = new Date(); - if (!session.notifySpamMuzzle) { - this.log.alert('preventing chat spam', { userId: session.user._id, rateLimiter }); - session.socket.to(channelId).emit('system-message', { - created: NOW, - content: `${session.user.displayName || session.user.username} has been muted for a while.`, - }); - session.notifySpamMuzzle = true; - } - session.socket.emit('system-message', { - created: NOW, - content: `You are rate limited for ${numeral(rateLimiter.msBeforeNext / 1000.0).format('0,0.0')} seconds.`, - rateLimiter, - }); return; } - /* - * Pull the author's current User record from the db and verify that they - * have permission to chat. This read must happen with every chat message - * until permission update notifications are implemented on Redis pub/sub. - */ - try { - const userCheck = await userService.getUserAccount(session.user._id); - if (!userCheck || !userCheck.permissions || !userCheck.permissions.canChat) { - session.socket.emit('system-message', { - created: new Date(), - content: `You are not allowed to chat on ${this.dtp.config.site.name}.`, - }); - return; // permission denied - } - - //TODO: Forked apps may want to implement channel-level moderation, and - // this is where to implement those checks. - - } catch (error) { - this.log.error('failed to implement user permissions check', { userId: session.user._id, error }); - return; // can't verify permissions? No chat for you. - } - try { const { message, payload } = await chatService.createMessage(session.user, messageDefinition); - if (message.analysis.similarity > 0.9) { - await chatService.sendSystemMessage( - session.socket, - "Your flow feels a little spammy, so that one didn't go through.", - { type: 'warning' }, - ); - return; - } - - // use chat service emitter to deliver to channel (more efficient) - // than socket.io API + message.author = session.user; + payload.html = await chatService.renderTemplate('chatMessage', { user: session.user, message }); await chatService.sendMessage(message.channel, 'user-chat', payload); - - // use the socket itself to emit back to the sender - session.socket.emit('user-chat', payload); - - session.notifySpamMuzzle = false; } catch (error) { this.log.error('failed to process user chat message', { error }); - await chatService.sendSystemMessage( - session.socket, - `Failed to send chat: ${error.message}`, - { type: 'error' }, - ); + await chatService.sendSystemMessage(`Failed to send chat: ${error.message}`, { + type: 'error', + userId: session.user._id.toString(), + }); return; } } - findStickers (content) { - const tokens = content.split(' '); - const stickers = [ ]; - tokens.forEach((token) => { - if ((token[0] !== ':') || (token[token.length -1 ] !== ':')) { - return; - } - - token = token.slice(1, token.length - 1 ).toLowerCase(); - if (token.includes('/') || token.includes(':') || token.includes(' ')) { - return; // trimmed token includes invalid characters - } - - this.log.debug('found sticker request', { token }); - if (!stickers.includes(token)) { - stickers.push(striptags(token)); - } - }); - return stickers; - } - async onUserReact (session, message) { - const { chat: chatService, user: userService } = this.dtp.services; + const { chat: chatService } = this.dtp.services; try { - const userCheck = await userService.getUserAccount(session.user._id); - if (!userCheck || !userCheck.permissions || !userCheck.permissions.canChat) { - session.socket.emit('system-message', { - created: new Date(), - content: `You are not allowed to chat on ${this.dtp.config.site.name}.`, - }); - return; // permission denied - } - - try { - const userKey = session.user._id.toString(); - await this.reactionLimiter.consume(userKey, 1); - } catch (error) { - return; // rate-limited - } - const reaction = await chatService.createEmojiReaction(session.user, message); reaction.user = session.user; @@ -329,6 +193,10 @@ class SiteIoServer extends Events { session.socket.emit('user-react', payload); } catch (error) { this.log.error('failed to process reaction', { message, error }); + session.socket.emit('system-message', { + created: new Date(), + content: `You are not allowed to chat on ${this.dtp.config.site.name}.`, + }); return; } } diff --git a/lib/site-lib.js b/lib/site-lib.js index 14cf415..f28bdf7 100644 --- a/lib/site-lib.js +++ b/lib/site-lib.js @@ -15,4 +15,5 @@ module.exports = { SiteController: require(path.join(__dirname, 'site-controller')).SiteController, SiteService: require(path.join(__dirname, 'site-service')).SiteService, SiteWorker: require(path.join(__dirname, 'site-worker')).SiteWorker, + SiteWorkerProcess: require(path.join(__dirname, 'site-worker-process')).SiteWorkerProcess, }; \ No newline at end of file diff --git a/lib/site-platform.js b/lib/site-platform.js index 49bfc4e..ed5b587 100644 --- a/lib/site-platform.js +++ b/lib/site-platform.js @@ -319,6 +319,7 @@ module.exports.startWebServer = async (dtp) => { module.services.oauth2.registerPassport(); module.app.use(module.services.session.middleware()); + module.app.use(module.services.userNotification.middleware({ withNotifications: false })); /* * Application logic middleware diff --git a/lib/site-worker-process.js b/lib/site-worker-process.js new file mode 100644 index 0000000..5d5ac46 --- /dev/null +++ b/lib/site-worker-process.js @@ -0,0 +1,47 @@ +// site-worker-process.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); + +const { SiteCommon } = require(path.join(__dirname, 'site-common')); + +/** + * Your actual worker processor will extend SiteWorkerProcess and implement the + * expected interface including the. + * + * Your derived class must implement a static getter for COMPONENT as follows: + * + * ``` + * static get COMPONENT ( ) { return { name: '', slug: '' }; } + * ``` + * + * It must pass that object to this constructor (super) along with the worker + * reference you are given at your constructor. + * + * Your worker logic script can then be a fully-managed process within the DTP + * ecosystem. + */ +class SiteWorkerProcess extends SiteCommon { + + constructor (worker, component) { + super(worker.dtp, component); + this.worker = worker; + } + + /** + * Utility/convenience method that logs a message to both a Bull Queue job log + * and also DTP's logging infrastructure for the worker process. + * @param {Job} job Bull queue job for which a log is written + * @param {*} message The message to be written + * @param {*} data An object containing any data to be logged + */ + async jobLog (job, message, data = { }) { + job.log(message); + this.log.info(message, { jobId: job.id, ...data }); + } +} + +module.exports.SiteWorkerProcess = SiteWorkerProcess; \ No newline at end of file diff --git a/lib/site-worker.js b/lib/site-worker.js index 2b9bd00..9b79951 100644 --- a/lib/site-worker.js +++ b/lib/site-worker.js @@ -1,4 +1,4 @@ -// site-service.js +// site-worker.js // Copyright (C) 2022 DTP Technologies, LLC // License: Apache-2.0 @@ -7,22 +7,26 @@ const path = require('path'); const SitePlatform = require(path.join(__dirname, 'site-platform')); + +const { SiteAsync } = require(path.join(__dirname, 'site-async')); const { SiteCommon } = require(path.join(__dirname, 'site-common')); class SiteWorker extends SiteCommon { constructor (dtp, component) { super(dtp, component); + this.processors = { }; } async start ( ) { try { - process.on('unhandledRejection', (error, p) => { + process.on('unhandledRejection', async (error, p) => { this.log.error('Unhandled rejection', { - error: error, promise: p, + message: error.message, stack: error.stack }); + process.exit(-2); }); process.on('warning', (error) => { @@ -54,8 +58,46 @@ class SiteWorker extends SiteCommon { } } - async stop ( ) { + /** + * Load a script as a Worker processor. It must be derived from + * SiteWorkerProcess and implement the expected interface. + * @param {String} scriptFile the filename of the script to load as a Worker + * processor. + * @returns the new processor instance + */ + async loadProcessor (scriptFile) { + const ProcessorClass = require(scriptFile); + const processor = new ProcessorClass(this); + const { COMPONENT } = ProcessorClass; + + this.log.info('registering worker processor', { component: COMPONENT }); + this.processors[COMPONENT.name] = processor; + + return processor; + } + + /** + * Start all loaded processors. The assumption here is if you load any + * additional processors *after* calling this method, you will start them + * yourself in some way. + */ + async startProcessors ( ) { + const slugs = Object.keys(this.processors); + await SiteAsync.each(slugs, async (slug) => { + this.log.info('starting worker processor', { slug }); + await this.processors[slug].start(); + }, 1); + } + /** + * Stops any running child processors and terminates the worker. + */ + async stop ( ) { + const slugs = Object.keys(this.processors); + await SiteAsync.each(slugs, async (slug) => { + this.log.info('stopping worker processor', { slug }); + await this.processors[slug].stop(); + }, 1); } } diff --git a/package.json b/package.json index 704edb5..c63e1f4 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,6 @@ }, "dependencies": { "@fortawesome/fontawesome-free": "^5.15.4", - "@joeattardi/emoji-button": "^4.6.2", "@socket.io/redis-adapter": "^7.1.0", "anchorme": "^2.1.2", "ansicolor": "^1.1.100", @@ -41,7 +40,7 @@ "glob": "^7.2.0", "highlight.js": "^11.4.0", "html-filter": "^4.3.2", - "ioredis": "^4.28.5", + "ioredis": "^5.2.2", "jsdom": "^19.0.0", "libphonenumber-js": "^1.9.49", "marked": "^4.0.12", @@ -63,6 +62,7 @@ "passport-oauth2": "^1.6.1", "passport-oauth2-client-password": "^0.1.2", "password-generator": "^2.3.2", + "picmo": "^5.4.0", "pug": "^3.0.2", "qrcode": "^1.5.0", "rate-limiter-flexible": "^2.3.6", diff --git a/yarn.lock b/yarn.lock index fb0aa53..91ce3f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -885,53 +885,15 @@ "@babel/helper-validator-identifier" "^7.15.7" to-fast-properties "^2.0.0" -"@fortawesome/fontawesome-common-types@^0.2.36": - version "0.2.36" - resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz#b44e52db3b6b20523e0c57ef8c42d315532cb903" - integrity sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg== - "@fortawesome/fontawesome-free@^5.15.4": version "5.15.4" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz#ecda5712b61ac852c760d8b3c79c96adca5554e5" integrity sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg== -"@fortawesome/fontawesome-svg-core@^1.2.28": - version "1.2.36" - resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.36.tgz#4f2ea6f778298e0c47c6524ce2e7fd58eb6930e3" - integrity sha512-YUcsLQKYb6DmaJjIHdDWpBIGCcyE/W+p/LMGvjQem55Mm2XWVAP5kWTMKWLv9lwpCVjpLxPyOMOyUocP1GxrtA== - dependencies: - "@fortawesome/fontawesome-common-types" "^0.2.36" - -"@fortawesome/free-regular-svg-icons@^5.13.0": - version "5.15.4" - resolved "https://registry.yarnpkg.com/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.15.4.tgz#b97edab436954333bbeac09cfc40c6a951081a02" - integrity sha512-9VNNnU3CXHy9XednJ3wzQp6SwNwT3XaM26oS4Rp391GsxVYA+0oDR2J194YCIWf7jNRCYKjUCOduxdceLrx+xw== - dependencies: - "@fortawesome/fontawesome-common-types" "^0.2.36" - -"@fortawesome/free-solid-svg-icons@^5.13.0": - version "5.15.4" - resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.4.tgz#2a68f3fc3ddda12e52645654142b9e4e8fbb6cc5" - integrity sha512-JLmQfz6tdtwxoihXLg6lT78BorrFyCf59SAwBM6qV/0zXyVeDygJVb3fk+j5Qat+Yvcxp1buLTY5iDh1ZSAQ8w== - dependencies: - "@fortawesome/fontawesome-common-types" "^0.2.36" - -"@joeattardi/emoji-button@^4.6.2": - version "4.6.2" - resolved "https://registry.yarnpkg.com/@joeattardi/emoji-button/-/emoji-button-4.6.2.tgz#75baf4ce27324e4d6fb90292f8b248235f638ad0" - integrity sha512-FhuzTmW3nVHLVp2BJfNX17CYV77fqtKZlx328D4h6Dw3cPTT1gJRNXN0jV7BvHgsl6Q/tN8DIQQxTUIO4jW3gQ== - dependencies: - "@fortawesome/fontawesome-svg-core" "^1.2.28" - "@fortawesome/free-regular-svg-icons" "^5.13.0" - "@fortawesome/free-solid-svg-icons" "^5.13.0" - "@popperjs/core" "^2.4.0" - "@types/twemoji" "^12.1.1" - escape-html "^1.0.3" - focus-trap "^5.1.0" - fuzzysort "^1.1.4" - tiny-emitter "^2.1.0" - tslib "^2.0.0" - twemoji "^13.0.0" +"@ioredis/commands@^1.1.1": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" + integrity sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg== "@otplib/core@^12.0.1": version "12.0.1" @@ -971,11 +933,6 @@ "@otplib/plugin-crypto" "^12.0.1" "@otplib/plugin-thirty-two" "^12.0.1" -"@popperjs/core@^2.4.0": - version "2.11.0" - resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.0.tgz#6734f8ebc106a0860dff7f92bf90df193f0935d7" - integrity sha512-zrsUxjLOKAzdewIDRWy9nsV1GQsKBCWaGwsZQlCgr6/q+vjyZhFgqedLfFBuI9anTPEUT4APq9Mu0SZBTzIcGQ== - "@rollup/plugin-babel@^5.2.0": version "5.3.0" resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.0.tgz#9cb1c5146ddd6a4968ad96f209c50c62f92f9879" @@ -1118,11 +1075,6 @@ resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756" integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg== -"@types/twemoji@^12.1.1": - version "12.1.2" - resolved "https://registry.yarnpkg.com/@types/twemoji/-/twemoji-12.1.2.tgz#52578fd22665311e6a78d04f800275449d51c97e" - integrity sha512-3eMyKenMi0R1CeKzBYtk/Z2JIHsTMQrIrTah0q54o45pHTpWVNofU2oHx0jS8tqsDRhis2TbB6238WP9oh2l2w== - "@types/webidl-conversions@*": version "6.1.1" resolved "https://registry.yarnpkg.com/@types/webidl-conversions/-/webidl-conversions-6.1.1.tgz#e33bc8ea812a01f63f90481c666334844b12a09e" @@ -2835,6 +2787,13 @@ debug@^3.2.6, debug@^3.2.7: dependencies: ms "^2.1.1" +debug@^4.3.4: + version "4.3.4" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + debug@~4.1.0: version "4.1.1" resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" @@ -3198,6 +3157,11 @@ emoji-regex@^8.0.0: resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== +emojibase@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/emojibase/-/emojibase-6.1.0.tgz#c3bc281e998a0e06398416090c23bac8c5ed3ee8" + integrity sha512-1GkKJPXP6tVkYJHOBSJHoGOr/6uaDxZ9xJ6H7m6PfdGXTmQgbALHLWaVRY4Gi/qf5x/gT/NUXLPuSHYLqtLtrQ== + encode-utf8@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda" @@ -3401,7 +3365,7 @@ escape-goat@^2.0.0: resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q== -escape-html@^1.0.3, escape-html@~1.0.3: +escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= @@ -3800,14 +3764,6 @@ flush-write-stream@^1.0.2: inherits "^2.0.3" readable-stream "^2.3.6" -focus-trap@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-5.1.0.tgz#64a0bfabd95c382103397dbc96bfef3a3cf8e5ad" - integrity sha512-CkB/nrO55069QAUjWFBpX6oc+9V90Qhgpe6fBWApzruMq5gnlh90Oo7iSSDK7pKiV5ugG6OY2AXM5mxcmL3lwQ== - dependencies: - tabbable "^4.0.0" - xtend "^4.0.1" - follow-redirects@^1.0.0, follow-redirects@^1.14.0: version "1.14.5" resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.5.tgz#f09a5848981d3c772b5392309778523f8d85c381" @@ -3870,15 +3826,6 @@ fs-extra@3.0.1: jsonfile "^3.0.0" universalify "^0.1.0" -fs-extra@^8.0.1: - version "8.1.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" - integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== - dependencies: - graceful-fs "^4.2.0" - jsonfile "^4.0.0" - universalify "^0.1.0" - fs-extra@^9.0.1: version "9.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" @@ -3920,11 +3867,6 @@ function-bind@^1.1.1: resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== -fuzzysort@^1.1.4: - version "1.1.4" - resolved "https://registry.yarnpkg.com/fuzzysort/-/fuzzysort-1.1.4.tgz#a0510206ed44532cbb52cf797bf5a3cb12acd4ba" - integrity sha512-JzK/lHjVZ6joAg3OnCjylwYXYVjRiwTY6Yb25LvfpJHK8bjisfnZJ5bY8aVWwTwCXgxPNgLAtmHL+Hs5q1ddLQ== - gauge@~2.7.3: version "2.7.4" resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" @@ -4593,6 +4535,21 @@ ioredis@^4.28.5: redis-parser "^3.0.0" standard-as-callback "^2.1.0" +ioredis@^5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-5.2.2.tgz#212467e04f6779b4e0e800cece7bb7d3d7b546d2" + integrity sha512-wryKc1ur8PcCmNwfcGkw5evouzpbDXxxkMkzPK8wl4xQfQf7lHe11Jotell5ikMVAtikXJEu/OJVaoV51BggRQ== + dependencies: + "@ioredis/commands" "^1.1.1" + cluster-key-slot "^1.1.0" + debug "^4.3.4" + denque "^2.0.1" + lodash.defaults "^4.2.0" + lodash.isarguments "^3.1.0" + redis-errors "^1.2.0" + redis-parser "^3.0.0" + standard-as-callback "^2.1.0" + "ip-address@5.8.9 - 5.9.4": version "5.9.4" resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-5.9.4.tgz#4660ac261ad61bd397a860a007f7e98e4eaee386" @@ -5206,22 +5163,6 @@ jsonfile@^3.0.0: optionalDependencies: graceful-fs "^4.1.6" -jsonfile@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" - integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss= - optionalDependencies: - graceful-fs "^4.1.6" - -jsonfile@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-5.0.0.tgz#e6b718f73da420d612823996fdf14a03f6ff6922" - integrity sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w== - dependencies: - universalify "^0.1.2" - optionalDependencies: - graceful-fs "^4.1.6" - jsonfile@^6.0.1: version "6.1.0" resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" @@ -6492,6 +6433,13 @@ pend@~1.2.0: resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA= +picmo@^5.4.0: + version "5.4.0" + resolved "https://registry.yarnpkg.com/picmo/-/picmo-5.4.0.tgz#d51c9258031b351217e2d165ed3781f4a192c938" + integrity sha512-Rq9R7JOuT/Dzc0kVvYisO0MwMG8m22i+fE5nuVELXmn2aXDIjr5c29b4BFQuhbtzSx+l77uwp5/V4L2/KE477w== + dependencies: + emojibase "^6.1.0" + picocolors@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" @@ -8063,11 +8011,6 @@ systeminformation@^5.11.6: resolved "https://registry.yarnpkg.com/systeminformation/-/systeminformation-5.11.6.tgz#8624cbb2e95e6fa98a4ebb0d10759427c0e88144" integrity sha512-7KBXgdnIDxABQ93w+GrPSrK/pup73+fM09VGka4A/+FhgzdlRY0JNGGDFmV8BHnFuzP9zwlI3n64yDbp7emasQ== -tabbable@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-4.0.0.tgz#5bff1d1135df1482cf0f0206434f15eadbeb9261" - integrity sha512-H1XoH1URcBOa/rZZWxLxHCtOdVUEev+9vo5YdYhC9tCY4wnybX+VQrCYuy9ubkg69fCBxCONJOSLGfw0DWMffQ== - tapable@^2.1.1, tapable@^2.2.0: version "2.2.1" resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" @@ -8194,11 +8137,6 @@ time-stamp@^1.0.0: resolved "https://registry.yarnpkg.com/time-stamp/-/time-stamp-1.1.0.tgz#764a5a11af50561921b133f3b44e618687e0f5c3" integrity sha1-dkpaEa9QVhkhsTPztE5hhofg9cM= -tiny-emitter@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" - integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== - tiny-inflate@^1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4" @@ -8316,7 +8254,7 @@ tr46@~0.0.3: resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o= -tslib@^2.0.0, tslib@^2.3.0: +tslib@^2.3.0: version "2.3.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== @@ -8328,21 +8266,6 @@ tunnel-agent@^0.6.0: dependencies: safe-buffer "^5.0.1" -twemoji-parser@13.1.0: - version "13.1.0" - resolved "https://registry.yarnpkg.com/twemoji-parser/-/twemoji-parser-13.1.0.tgz#65e7e449c59258791b22ac0b37077349127e3ea4" - integrity sha512-AQOzLJpYlpWMy8n+0ATyKKZzWlZBJN+G0C+5lhX7Ftc2PeEVdUU/7ns2Pn2vVje26AIZ/OHwFoUbdv6YYD/wGg== - -twemoji@^13.0.0: - version "13.1.0" - resolved "https://registry.yarnpkg.com/twemoji/-/twemoji-13.1.0.tgz#65bb71e966dae56f0d42c30176f04cbdae109913" - integrity sha512-e3fZRl2S9UQQdBFLYXtTBT6o4vidJMnpWUAhJA+yLGR+kaUTZAt3PixC0cGvvxWSuq2MSz/o0rJraOXrWw/4Ew== - dependencies: - fs-extra "^8.0.1" - jsonfile "^5.0.0" - twemoji-parser "13.1.0" - universalify "^0.1.2" - type-check@~0.3.2: version "0.3.2" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" @@ -9201,7 +9124,7 @@ xmlhttprequest-ssl@~1.6.2: resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.6.3.tgz#03b713873b01659dfa2c1c5d056065b27ddc2de6" integrity sha512-3XfeQE/wNkvrIktn2Kf0869fC0BN6UpydVasGIeSm2B1Llihf7/0UfZM+eCkOw3P7bP4+qPgqhm7ZoxuJtFU0Q== -xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.0, xtend@~4.0.1: +xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== From 5fd0b22f3f0a12afafb60a7c3fe3a9b9427f04bc Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 4 Aug 2022 04:45:06 -0400 Subject: [PATCH 16/35] added chat worker; all workers start --- app/workers/chat.js | 66 ++++++++++++ app/workers/chat/job/chat-room-clear.js | 16 +-- app/workers/chat/job/chat-room-delete.js | 6 +- app/workers/newsletter.js | 97 +---------------- app/workers/newsletter/job/email-send.js | 63 +++++++++++ app/workers/newsletter/job/transmit.js | 100 ++++++++++++++++++ .../reeeper/cron/expire-crashed-hosts.js | 1 + config/job-queues.js | 4 + lib/site-common.js | 4 + lib/site-worker.js | 30 +++++- 10 files changed, 274 insertions(+), 113 deletions(-) create mode 100644 app/workers/chat.js create mode 100644 app/workers/newsletter/job/email-send.js create mode 100644 app/workers/newsletter/job/transmit.js diff --git a/app/workers/chat.js b/app/workers/chat.js new file mode 100644 index 0000000..0b5a2e7 --- /dev/null +++ b/app/workers/chat.js @@ -0,0 +1,66 @@ +// chat.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); +require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); + +const mongoose = require('mongoose'); + +const { SiteLog, SiteWorker, SiteAsync } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); + +module.rootPath = path.resolve(__dirname, '..', '..'); +module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); + +module.config = { + environment: process.env.NODE_ENV, + root: module.rootPath, + component: { name: 'chatWorker', slug: 'chat-worker' }, +}; + +module.config.site = require(path.join(module.rootPath, 'config', 'site')); + +class ChatWorker extends SiteWorker { + + constructor (dtp) { + super(dtp, dtp.config.component); + } + + async start ( ) { + await super.start(); + + await this.loadProcessor(path.join(__dirname, 'chat', 'job', 'chat-room-clear.js')); + await this.loadProcessor(path.join(__dirname, 'chat', 'job', 'chat-room-delete.js')); + + await this.startProcessors(); + } + + async stop ( ) { + await super.stop(); + } + + async deleteChatMessage (message) { + const { attachment: attachmentService } = this.dtp.services; + const ChatMessage = mongoose.model('ChatMessage'); + + await SiteAsync.each(message.attachments, attachmentService.remove.bind(attachmentService), 2); + await ChatMessage.deleteOne({ _id: message._id }); + } +} + +(async ( ) => { + try { + module.log = new SiteLog(module, module.config.component); + + module.worker = new ChatWorker(module); + await module.worker.start(); + + module.log.info(`${module.pkg.name} v${module.pkg.version} ${module.config.component.name} started`); + } catch (error) { + module.log.error('failed to start worker', { component: module.config.component, error }); + process.exit(-1); + } + +})(); \ No newline at end of file diff --git a/app/workers/chat/job/chat-room-clear.js b/app/workers/chat/job/chat-room-clear.js index d51b2a7..1598c4e 100644 --- a/app/workers/chat/job/chat-room-clear.js +++ b/app/workers/chat/job/chat-room-clear.js @@ -8,19 +8,10 @@ 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')); +const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); -/** - * DTP Core Chat sticker processor can receive requests to ingest and delete - * stickers to be executed as background jobs in a queue. This processor - * attaches to the `media` queue and registers processors for `sticker-ingest` - * and `sticker-delete`. - */ class ChatRoomClearJob extends SiteWorkerProcess { static get COMPONENT ( ) { @@ -37,10 +28,10 @@ class ChatRoomClearJob extends SiteWorkerProcess { async start ( ) { await super.start(); - const queue = this.getJobQueue('chat'); + this.queue = await this.getJobQueue('chat'); this.log.info('registering job processor', { queue: this.queue.name, name: 'chat-room-clear' }); - queue.process('chat-room-clear', this.processChatRoomClear.bind(this)); + this.queue.process('chat-room-clear', this.processChatRoomClear.bind(this)); } async stop ( ) { @@ -50,7 +41,6 @@ class ChatRoomClearJob extends SiteWorkerProcess { 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() diff --git a/app/workers/chat/job/chat-room-delete.js b/app/workers/chat/job/chat-room-delete.js index 586496e..7c06a00 100644 --- a/app/workers/chat/job/chat-room-delete.js +++ b/app/workers/chat/job/chat-room-delete.js @@ -13,7 +13,7 @@ const ChatRoomInvite = mongoose.model('ChatRoomInvite'); const ChatMessage = mongoose.model('ChatMessage'); const EmojiReaction = mongoose.model('EmojiReaction'); -const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); +const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); /** * DTP Core Chat sticker processor can receive requests to ingest and delete @@ -37,10 +37,10 @@ class ChatRoomDeleteJob extends SiteWorkerProcess { async start ( ) { await super.start(); - const queue = this.getJobQueue('chat'); + this.queue = await this.getJobQueue('chat'); this.log.info('registering job processor', { queue: this.queue.name, name: 'chat-room-delete' }); - queue.process('chat-room-delete', this.processChatRoomDelete.bind(this)); + this.queue.process('chat-room-delete', this.processChatRoomDelete.bind(this)); } async stop ( ) { diff --git a/app/workers/newsletter.js b/app/workers/newsletter.js index 9efb0a3..25b201a 100644 --- a/app/workers/newsletter.js +++ b/app/workers/newsletter.js @@ -7,8 +7,6 @@ const path = require('path'); require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); -const mongoose = require('mongoose'); - const { SiteWorker, SiteLog } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); @@ -21,18 +19,16 @@ class NewsletterWorker extends SiteWorker { constructor (dtp) { super(dtp, dtp.config.component); - this.newsletters = this.newsletters || { }; + this.newsletters = { }; } async start ( ) { await super.start(); - const { jobQueue: jobQueueService } = this.dtp.services; - this.jobQueue = await jobQueueService.getJobQueue('newsletter', { - attempts: 3, - }); - this.jobQueue.process('transmit', this.transmitNewsletter.bind(this)); - this.jobQueue.process('email-send', this.sendNewsletterEmail.bind(this)); + await this.loadProcessor(path.join(__dirname, 'newsletter', 'job', 'transmit.js')); + await this.loadProcessor(path.join(__dirname, 'newsletter', 'job', 'email-send.js')); + + await this.startProcessors(); } async stop ( ) { @@ -53,89 +49,6 @@ class NewsletterWorker extends SiteWorker { } return newsletter; } - - async transmitNewsletter (job) { - const User = mongoose.model('User'); - const NewsletterRecipient = mongoose.model('NewsletterRecipient'); - this.log.info('newsletter email job received', { data: job.data }); - try { - /* - * Transmit first to all local user accounts with verified email who've - * opted in for receiving marketing email. - */ - await User - .find({ - 'flags.isEmailVerified': true, - 'optIn.marketing': true, - }) - .select('email displayName username username_lc') - .lean() - .cursor() - .eachAsync(async (user) => { - try { - const jobData = { - newsletterId: job.data.newsletterId, - recipient: user.email, - recipientName: user.displayName || user.username, - }; - const jobOptions = { attempts: 3 }; - await this.jobQueue.add('email-send', jobData, jobOptions); - } catch (error) { - this.log.error('failed to create newsletter email job', { error }); - } - }, { parallel: 4 }); - - /* - * Transmit to all newsletter recipients on file who've joined through the - * widget on the site w/o signing up for an account. - */ - await NewsletterRecipient - .find({ 'flags.isVerified': true, 'flags.isOptIn': true, 'flags.isRejected': false }) - .lean() - .cursor() - .eachAsync(async (recipient) => { - try { - const jobData = { - newsletterId: job.data.newsletterId, - recipient: recipient.address, - }; - const jobOptions = { attempts: 3 }; - await this.jobQueue.add('email-send', jobData, jobOptions); - } catch (error) { - this.log.error('failed to create newsletter email job', { error }); - } - }, { parallel: 4 }); - } catch (error) { - this.log.error('failed to send newsletter', { newsletterId: job.data.newsletterId, error }); - throw error; - } - } - - async sendNewsletterEmail (job) { - const { email: emailService } = this.dtp.services; - const { newsletterId, recipient } = job.data; - - try { - let newsletter = await this.loadNewsletter(newsletterId); - if (!newsletter) { - throw new Error('newsletter not found'); - } - - const result = await emailService.send({ - from: process.env.DTP_EMAIL_SMTP_FROM || `noreply@${this.dtp.config.site.domainKey}`, - to: recipient, - subject: newsletter.title, - html: newsletter.content.html, - text: newsletter.content.text, - }); - - job.log(`newsletter email sent: ${result}`); - this.log.info('newsletter email sent', { recipient, result }); - } catch (error) { - this.log.error('failed to send newsletter email', { newsletterId, recipient, error }); - throw error; // throw error to Bull so it can report in job reports - } - } } (async ( ) => { diff --git a/app/workers/newsletter/job/email-send.js b/app/workers/newsletter/job/email-send.js new file mode 100644 index 0000000..e88343a --- /dev/null +++ b/app/workers/newsletter/job/email-send.js @@ -0,0 +1,63 @@ +// newsletter/job/email-send.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); + +const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); + +class NewsletterEmailSendJob extends SiteWorkerProcess { + + static get COMPONENT ( ) { + return { + name: 'newsletterEmailSendJob', + slug: 'newsletter-email-send-job', + }; + } + + constructor (worker) { + super(worker, NewsletterEmailSendJob.COMPONENT); + } + + async start ( ) { + await super.start(); + + this.queue = await this.getJobQueue('newsletter'); + + this.log.info('registering job processor', { queue: this.queue.name, name: 'email-send' }); + this.queue.process('email-send', this.processEmailSend.bind(this)); + } + + async stop ( ) { + await super.stop(); + } + + async processEmailSend (job) { + const { email: emailService } = this.dtp.services; + const { newsletterId, recipient } = job.data; + + try { + let newsletter = await this.worker.loadNewsletter(newsletterId); + if (!newsletter) { + throw new Error('newsletter not found'); + } + + const result = await emailService.send({ + from: process.env.DTP_EMAIL_SMTP_FROM || `noreply@${this.dtp.config.site.domainKey}`, + to: recipient, + subject: newsletter.title, + html: newsletter.content.html, + text: newsletter.content.text, + }); + + this.jobLog(job, 'newsletter email sent', { result }); + } catch (error) { + this.log.error('failed to send newsletter email', { newsletterId, recipient, error }); + throw error; // throw error to Bull so it can report in job reports + } + } +} + +module.exports = NewsletterEmailSendJob; \ No newline at end of file diff --git a/app/workers/newsletter/job/transmit.js b/app/workers/newsletter/job/transmit.js new file mode 100644 index 0000000..3b171d7 --- /dev/null +++ b/app/workers/newsletter/job/transmit.js @@ -0,0 +1,100 @@ +// newsletter/job/transmit.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); + +const mongoose = require('mongoose'); + +const User = mongoose.model('User'); +const NewsletterRecipient = mongoose.model('NewsletterRecipient'); + +const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); + +class NewsletterTransmitJob extends SiteWorkerProcess { + + static get COMPONENT ( ) { + return { + name: 'newsletterTransmitJob', + slug: 'newsletter-transmit-job', + }; + } + + constructor (worker) { + super(worker, NewsletterTransmitJob.COMPONENT); + } + + async start ( ) { + await super.start(); + + this.queue = await this.getJobQueue('newsletter'); + + this.log.info('registering job processor', { queue: this.queue.name, name: 'transmit' }); + this.queue.process('transmit', this.processTransmit.bind(this)); + } + + async stop ( ) { + await super.stop(); + } + + async processTransmit (job) { + const { newsletterId } = job.data; + this.log.info('newsletter email job received', { id: job.id, newsletterId }); + + try { + /* + * Transmit first to all local user accounts with verified email who've + * opted in for receiving marketing email. + */ + await User + .find({ + 'flags.isEmailVerified': true, + 'optIn.marketing': true, + }) + .select('email displayName username username_lc') + .lean() + .cursor() + .eachAsync(async (user) => { + try { + const jobData = { + newsletterId: newsletterId, + recipient: user.email, + recipientName: user.displayName || user.username, + }; + const jobOptions = { attempts: 3 }; + await this.queue.add('email-send', jobData, jobOptions); + } catch (error) { + this.log.error('failed to create newsletter email job', { error }); + } + }, { parallel: 4 }); + + /* + * Transmit to all newsletter recipients on file who've joined through the + * widget on the site w/o signing up for an account. + */ + await NewsletterRecipient + .find({ 'flags.isVerified': true, 'flags.isOptIn': true, 'flags.isRejected': false }) + .lean() + .cursor() + .eachAsync(async (recipient) => { + try { + const jobData = { + newsletterId: newsletterId, + recipient: recipient.address, + }; + const jobOptions = { attempts: 3 }; + await this.queue.add('email-send', jobData, jobOptions); + } catch (error) { + this.log.error('failed to create newsletter email job', { error }); + } + }, { parallel: 4 }); + } catch (error) { + this.log.error('failed to send newsletter', { newsletterId, error }); + throw error; + } + } +} + +module.exports = NewsletterTransmitJob; \ No newline at end of file diff --git a/app/workers/reeeper/cron/expire-crashed-hosts.js b/app/workers/reeeper/cron/expire-crashed-hosts.js index e85940f..793b0b5 100644 --- a/app/workers/reeeper/cron/expire-crashed-hosts.js +++ b/app/workers/reeeper/cron/expire-crashed-hosts.js @@ -57,6 +57,7 @@ class CrashedHostsCron extends SiteWorkerProcess { async expireCrashedHosts ( ) { try { + this.log.debug('expiring crashed hosts'); await NetHost .find({ status: 'crashed' }) .select('_id hostname') diff --git a/config/job-queues.js b/config/job-queues.js index 1fb3b31..0c55228 100644 --- a/config/job-queues.js +++ b/config/job-queues.js @@ -5,6 +5,10 @@ 'use strict'; module.exports = { + 'chat': { + attempts: 5, + removeOnComplete: true, + }, 'media': { attempts: 3, removeOnComplete: true, diff --git a/lib/site-common.js b/lib/site-common.js index fd363ff..c3bce95 100644 --- a/lib/site-common.js +++ b/lib/site-common.js @@ -42,6 +42,10 @@ class SiteCommon extends Events { } async getJobQueue (name) { + if (this.jobQueues[name]) { + return this.jobQueues[name]; + } + const { jobQueue: jobQueueService } = this.dtp.services; const config = this.dtp.config.jobQueues[name]; diff --git a/lib/site-worker.js b/lib/site-worker.js index 9b79951..3ad0cce 100644 --- a/lib/site-worker.js +++ b/lib/site-worker.js @@ -70,7 +70,7 @@ class SiteWorker extends SiteCommon { const processor = new ProcessorClass(this); const { COMPONENT } = ProcessorClass; - this.log.info('registering worker processor', { component: COMPONENT }); + this.log.info('loading worker processor', { component: COMPONENT.name }); this.processors[COMPONENT.name] = processor; return processor; @@ -84,8 +84,18 @@ class SiteWorker extends SiteCommon { async startProcessors ( ) { const slugs = Object.keys(this.processors); await SiteAsync.each(slugs, async (slug) => { - this.log.info('starting worker processor', { slug }); - await this.processors[slug].start(); + const processor = this.processors[slug]; + try { + this.log.info('starting worker processor', { + component: processor.component.name, + }); + await processor.start(); + } catch (error) { + this.log.error('failed to start processor', { + component: processor.component.name, + error, + }); + } }, 1); } @@ -95,8 +105,18 @@ class SiteWorker extends SiteCommon { async stop ( ) { const slugs = Object.keys(this.processors); await SiteAsync.each(slugs, async (slug) => { - this.log.info('stopping worker processor', { slug }); - await this.processors[slug].stop(); + const processor = this.processors[slug]; + try { + this.log.info('stopping worker processor', { + component: processor.component.name, + }); + await processor.stop(); + } catch (error) { + this.log.error('failed to stop processor', { + component: processor.component.name, + error, + }); + } }, 1); } } From 235d660940bb6f2abc5468205af99b8bdc0500dc Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 4 Aug 2022 04:49:57 -0400 Subject: [PATCH 17/35] remove unused things --- app/workers/media.js | 1 - app/workers/media/job/attachment-ingest.js | 1 - 2 files changed, 2 deletions(-) diff --git a/app/workers/media.js b/app/workers/media.js index 76706a7..a00e5d8 100644 --- a/app/workers/media.js +++ b/app/workers/media.js @@ -5,7 +5,6 @@ 'use strict'; const path = require('path'); -const fs = require('fs'); require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); const mongoose = require('mongoose'); diff --git a/app/workers/media/job/attachment-ingest.js b/app/workers/media/job/attachment-ingest.js index d5b9a18..4b619c9 100644 --- a/app/workers/media/job/attachment-ingest.js +++ b/app/workers/media/job/attachment-ingest.js @@ -11,7 +11,6 @@ const sharp = require('sharp'); const ATTACHMENT_IMAGE_HEIGHT = 540; const mongoose = require('mongoose'); -const { response } = require('express'); const Attachment = mongoose.model('Attachment'); const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); From 940f65f2066a7e964c253bb14ed323eb26f7e206 Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 4 Aug 2022 04:50:09 -0400 Subject: [PATCH 18/35] remove unnecessary log --- app/workers/reeeper/cron/expire-crashed-hosts.js | 1 - 1 file changed, 1 deletion(-) diff --git a/app/workers/reeeper/cron/expire-crashed-hosts.js b/app/workers/reeeper/cron/expire-crashed-hosts.js index 793b0b5..e85940f 100644 --- a/app/workers/reeeper/cron/expire-crashed-hosts.js +++ b/app/workers/reeeper/cron/expire-crashed-hosts.js @@ -57,7 +57,6 @@ class CrashedHostsCron extends SiteWorkerProcess { async expireCrashedHosts ( ) { try { - this.log.debug('expiring crashed hosts'); await NetHost .find({ status: 'crashed' }) .select('_id hostname') From 07541a93ce0ec5a9320dd7703d348792c121c4be Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 4 Aug 2022 04:50:23 -0400 Subject: [PATCH 19/35] adapted to these modern times we live in --- start-local | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/start-local b/start-local index 036979f..dde9911 100755 --- a/start-local +++ b/start-local @@ -9,12 +9,16 @@ export MINIO_ROOT_USER MINIO_ROOT_PASSWORD MINIO_CI_CD forever start --killSignal=SIGINT app/workers/host-services.js forever start --killSignal=SIGINT app/workers/reeeper.js + forever start --killSignal=SIGINT app/workers/newsletter.js -forever start --killSignal=SIGINT app/workers/stickers.js +forever start --killSignal=SIGINT app/workers/media.js +forever start --killSignal=SIGINT app/workers/chat.js minio server ./data/minio --address ":9010" --console-address ":9011" -forever stop app/workers/stickers.js +forever stop app/workers/chat.js +forever stop app/workers/media.js forever stop app/workers/newsletter.js + forever stop app/workers/reeeper.js forever stop app/workers/host-services.js \ No newline at end of file From 9e40eeb0b542cbfa9ddd627836c002d6c1521eee Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 4 Aug 2022 05:19:41 -0400 Subject: [PATCH 20/35] a bit of documentation; and SiteController.createMulter() --- app/workers/sample-worker.js | 91 --------------------------------- docs/samples/controller.js | 97 ++++++++++++++++++++++++++++++++++++ docs/samples/service.js | 68 +++++++++++++++++++++++++ docs/samples/worker.js | 56 +++++++++++++++++++++ lib/site-controller.js | 6 +++ 5 files changed, 227 insertions(+), 91 deletions(-) delete mode 100644 app/workers/sample-worker.js create mode 100644 docs/samples/controller.js create mode 100644 docs/samples/service.js create mode 100644 docs/samples/worker.js diff --git a/app/workers/sample-worker.js b/app/workers/sample-worker.js deleted file mode 100644 index c5298ec..0000000 --- a/app/workers/sample-worker.js +++ /dev/null @@ -1,91 +0,0 @@ -// sample-worker.js -// Copyright (C) 2022 DTP Technologies, LLC -// License: Apache-2.0 - -'use strict'; - -const path = require('path'); -require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); - -const { - SiteLog, - SiteWorker, -} = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); - -const { CronJob } = require('cron'); - -module.rootPath = path.resolve(__dirname, '..', '..'); -module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); - -module.config = { - environment: process.env.NODE_ENV, - root: module.rootPath, - component: { name: 'sampleWorker', slug: 'sample-worker' }, -}; - -module.config.site = require(path.join(module.rootPath, 'config', 'site')); -module.config.http = require(path.join(module.rootPath, 'config', 'http')); - -class SampleWorker extends SiteWorker { - - constructor (dtp) { - super(dtp, { }); - } - - async start ( ) { - const CRON_TIMEZONE = 'America/New_York'; - - await super.start(); - - this.log.info('starting worker job'); - this.job = new CronJob( - '*/5 * * * * *', - this.runJob.bind(this), - null, true, CRON_TIMEZONE, - ); - - const { jobQueue: jobQueueService } = this.dtp.services; - this.sampleJobQueue = jobQueueService.getJobQueue('dtp-sample', this.dtp.config.jobQueues['dtp-sample']); - this.sampleJobQueue.process('dtp-sample', 1, this.processDtpSample.bind(this)); - } - - async stop ( ) { - this.log.info('stopping sample worker job'); - this.job.stop(); - delete this.job; - - await super.stop(); - } - - async runJob ( ) { - this.log.alert('sample job starting'); - - /* - * Your worker will do interesting things here - */ - - this.log.alert('sample job ending'); - } - - async processDtpSample (job) { - this.log.info('received sample job', { id: job.id, data: job.data }); - } -} - -(async ( ) => { - try { - module.log = new SiteLog(module, module.config.component); - - module.worker = new SampleWorker(module); - await module.worker.start(); - - module.log.info(`${module.pkg.name} v${module.pkg.version} ${module.config.component.name} started`); - } catch (error) { - module.log.error('failed to start worker', { - component: module.config.component, - error, - }); - process.exit(-1); - } - -})(); \ No newline at end of file diff --git a/docs/samples/controller.js b/docs/samples/controller.js new file mode 100644 index 0000000..3a55f4d --- /dev/null +++ b/docs/samples/controller.js @@ -0,0 +1,97 @@ +// samples/controller.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const express = require('express'); + +const { SiteController, SiteError } = require('../../lib/site-lib'); + +class HomeController extends SiteController { + + constructor (dtp) { + super(dtp, module.exports); + } + + async start ( ) { + const { dtp } = this; + const { limiter: limiterService } = dtp.services; + + const upload = this.createMulter(); + + const router = express.Router(); + dtp.app.use('/your-route', router); + + router.use(async (req, res, next) => { + res.locals.currentView = 'your-view'; + return next(); + }); + + router.param('itemId', this.populateItemId.bind(this)); + + router.post( + '/item', + limiterService.createMiddleware(limiterService.config.home.postItemCreate), + upload.none(), + this.postItemCreate.bind(this), + ); + + router.get( + '/item/:itemId', + limiterService.createMiddleware(limiterService.config.sample.getItemView), + this.getItemView.bind(this), + ); + + router.get('/', + limiterService.createMiddleware(limiterService.config.sample.getHome), + this.getHome.bind(this), + ); + } + + async populateItemId (req, res, next, itemId) { + const { item: itemService } = this.dtp.services; + try { + res.locals.item = await itemService.getById(itemId); + if (!res.locals.item) { + throw new SiteError(404, 'Item not found'); + } + return next(); + } catch (error) { + return next(error); + } + } + + async postItemCreate (req, res, next) { + const { item: itemService } = this.dtp.services; + try { + const item = await itemService.create(req.user, req.body); + res.redirect(`/item/${item._id}`); + } catch (error) { + this.log.error('failed to create item', { error }); + return next(error); + } + } + + async getItemView (req, res) { + res.render('item/view'); + } + + async getHome (req, res, next) { + const { announcement: announcementService } = this.dtp.services; + try { + res.locals.announcements = await announcementService.getLatest(req.user); + res.render('index'); + } catch (error) { + this.log.error('failed to render home view', { error }); + return next(error); + } + } +} + +module.exports = { + slug: 'home', + name: 'home', + isHome: true, + create: async (dtp) => { return new HomeController(dtp); }, +}; \ No newline at end of file diff --git a/docs/samples/service.js b/docs/samples/service.js new file mode 100644 index 0000000..0126acd --- /dev/null +++ b/docs/samples/service.js @@ -0,0 +1,68 @@ +// samples/service.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); + +const Item = mongoose.model('Item'); + +const { SiteService } = require('../../lib/site-lib'); + +class SampleService extends SiteService { + + constructor (dtp) { + super(dtp, module.exports); + } + + async start ( ) { + await super.start(); + + this.queue = this.getJobQueue('sample'); + } + + async stop ( ) { + // do your shutdown here + + await super.stop(); + } + + async create (owner, itemDefinition) { + const NOW = new Date(); + const item = new Item(); + + item.created = NOW; + item.title = itemDefinition.title; + item.content = itemDefinition.content; + + await item.save(); + + return item.toObject(); + } + + async getItems (search, pagination) { + const items = await Item + .find(search) + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .lean(); + return items; + } + + async getById (itemId) { + const item = await Item.findById(itemId).lean(); + return item; + } + + async deleteItem (item) { + await Item.deleteOne({ _id: item._id }); + } +} + +module.exports = { + name: 'sample', + slug: 'sample', + create: (dtp) => { return new SampleService(dtp); }, +}; \ No newline at end of file diff --git a/docs/samples/worker.js b/docs/samples/worker.js new file mode 100644 index 0000000..d3c725c --- /dev/null +++ b/docs/samples/worker.js @@ -0,0 +1,56 @@ +// samples/worker.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); +require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); + +const { SiteLog, SiteWorker } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); + +class SampleWorker extends SiteWorker { + + constructor (dtp) { + super(dtp, dtp.config.component); + } + + async start ( ) { + await super.start(); + + await this.loadProcessor(path.join(__dirname, 'your-worker', 'cron', 'expire-things.js')); + await this.loadProcessor(path.join(__dirname, 'your-worker', 'job', 'process-things.js')); + + await this.startProcessors(); + } + + async stop ( ) { + await super.stop(); + } +} + +(async ( ) => { + try { + module.rootPath = path.resolve(__dirname, '..', '..'); + module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); + module.component = { name: 'theWorkerName', slug: 'the-worker-name' }; + + module.config = { + environment: process.env.NODE_ENV, + root: module.rootPath, + component: module.component, + }; + + module.config.site = require(path.join(module.rootPath, 'config', 'site')); + module.log = new SiteLog(module, module.component); + + module.worker = new SampleWorker(module); + await module.worker.start(); + + module.log.info(`${module.pkg.name} v${module.pkg.version} ${module.component.name} started`); + } catch (error) { + module.log.error('failed to start worker', { component: module.component.name, error }); + process.exit(-1); + } + +})(); \ No newline at end of file diff --git a/lib/site-controller.js b/lib/site-controller.js index 37e487e..28185f7 100644 --- a/lib/site-controller.js +++ b/lib/site-controller.js @@ -5,6 +5,7 @@ 'use strict'; const path = require('path'); +const multer = require('multer'); const { SiteCommon } = require(path.join(__dirname, 'site-common')); @@ -35,6 +36,11 @@ class SiteController extends SiteCommon { return pagination; } + createMulter (slug) { + slug = slug || 'uploads'; + return multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/${slug}/${this.component.slug}` }); + } + createDisplayList (name) { const { displayEngine: displayEngineService } = this.dtp.services; return displayEngineService.createDisplayList(name); From 7e1a2d9561d626beaab7b94f62d89257646f19b4 Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 4 Aug 2022 05:25:47 -0400 Subject: [PATCH 21/35] all controllers now use this.createMulter() --- app/controllers/admin/content-report.js | 5 ++--- app/controllers/admin/core-node.js | 2 -- app/controllers/admin/core-user.js | 2 -- app/controllers/auth.js | 3 ++- app/controllers/chat.js | 2 +- app/controllers/image.js | 3 +-- app/controllers/newsletter.js | 2 +- app/controllers/user.js | 3 ++- lib/site-controller.js | 9 +++++++-- 9 files changed, 16 insertions(+), 15 deletions(-) diff --git a/app/controllers/admin/content-report.js b/app/controllers/admin/content-report.js index cdf7b89..fe47479 100644 --- a/app/controllers/admin/content-report.js +++ b/app/controllers/admin/content-report.js @@ -5,9 +5,8 @@ 'use strict'; const express = require('express'); -const multer = require('multer'); -const { /*SiteError,*/ SiteController } = require('../../../lib/site-lib'); +const { SiteController } = require('../../../lib/site-lib'); class ContentReportController extends SiteController { @@ -16,7 +15,7 @@ class ContentReportController extends SiteController { } async start ( ) { - const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/upload` }); + const upload = this.createMulter(); const router = express.Router(); router.use(async (req, res, next) => { diff --git a/app/controllers/admin/core-node.js b/app/controllers/admin/core-node.js index 0d68cb5..2ae9816 100644 --- a/app/controllers/admin/core-node.js +++ b/app/controllers/admin/core-node.js @@ -16,8 +16,6 @@ class CoreNodeController extends SiteController { } async start ( ) { - // const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/upload` }); - const router = express.Router(); router.use(async (req, res, next) => { res.locals.currentView = 'admin'; diff --git a/app/controllers/admin/core-user.js b/app/controllers/admin/core-user.js index 1d72973..421c6fe 100644 --- a/app/controllers/admin/core-user.js +++ b/app/controllers/admin/core-user.js @@ -16,8 +16,6 @@ class CoreUserController extends SiteController { } async start ( ) { - // const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/upload` }); - const router = express.Router(); router.use(async (req, res, next) => { res.locals.currentView = 'admin'; diff --git a/app/controllers/auth.js b/app/controllers/auth.js index b78b458..2435b6d 100644 --- a/app/controllers/auth.js +++ b/app/controllers/auth.js @@ -25,7 +25,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); diff --git a/app/controllers/chat.js b/app/controllers/chat.js index fdf4ecb..e9e76b1 100644 --- a/app/controllers/chat.js +++ b/app/controllers/chat.js @@ -23,7 +23,7 @@ class ChatController extends SiteController { session: sessionService, } = 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('/chat', router); diff --git a/app/controllers/image.js b/app/controllers/image.js index 6c79620..0f5af9a 100644 --- a/app/controllers/image.js +++ b/app/controllers/image.js @@ -26,8 +26,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, }, diff --git a/app/controllers/newsletter.js b/app/controllers/newsletter.js index 2294e0e..92bc7b9 100644 --- a/app/controllers/newsletter.js +++ b/app/controllers/newsletter.js @@ -19,7 +19,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); diff --git a/app/controllers/user.js b/app/controllers/user.js index 9e2b8f9..2d8222b 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -24,7 +24,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); diff --git a/lib/site-controller.js b/lib/site-controller.js index 28185f7..872fab4 100644 --- a/lib/site-controller.js +++ b/lib/site-controller.js @@ -36,9 +36,14 @@ class SiteController extends SiteCommon { return pagination; } - createMulter (slug) { + createMulter (slug, options) { slug = slug || 'uploads'; - return multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/${slug}/${this.component.slug}` }); + + options = Object.assign({ + dest: `/tmp/${this.dtp.config.site.domainKey}/${slug}/${this.component.slug}` + }, options || { }); + + return multer(options); } createDisplayList (name) { From b163e5e39ed0dd13ac4d8a618cac21a259c93f7e Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 15 Aug 2022 18:28:29 -0400 Subject: [PATCH 22/35] cleanup --- app/controllers/auth.js | 1 - app/controllers/chat.js | 1 - app/controllers/image.js | 1 - app/controllers/newsletter.js | 1 - app/controllers/user.js | 1 - 5 files changed, 5 deletions(-) diff --git a/app/controllers/auth.js b/app/controllers/auth.js index 2435b6d..15caccb 100644 --- a/app/controllers/auth.js +++ b/app/controllers/auth.js @@ -6,7 +6,6 @@ const express = require('express'); const mongoose = require('mongoose'); -const multer = require('multer'); const passport = require('passport'); const uuidv4 = require('uuid').v4; diff --git a/app/controllers/chat.js b/app/controllers/chat.js index e9e76b1..e675b4a 100644 --- a/app/controllers/chat.js +++ b/app/controllers/chat.js @@ -5,7 +5,6 @@ 'use strict'; const express = require('express'); -const multer = require('multer'); const { SiteController,/*, SiteError*/ SiteError} = require('../../lib/site-lib'); diff --git a/app/controllers/image.js b/app/controllers/image.js index 0f5af9a..bc8bac5 100644 --- a/app/controllers/image.js +++ b/app/controllers/image.js @@ -8,7 +8,6 @@ const fs = require('fs'); const express = require('express'); const mongoose = require('mongoose'); -const multer = require('multer'); const { SiteController/*, SiteError*/ } = require('../../lib/site-lib'); diff --git a/app/controllers/newsletter.js b/app/controllers/newsletter.js index 92bc7b9..bfaed75 100644 --- a/app/controllers/newsletter.js +++ b/app/controllers/newsletter.js @@ -5,7 +5,6 @@ 'use strict'; const express = require('express'); -const multer = require('multer'); const { SiteController } = require('../../lib/site-lib'); diff --git a/app/controllers/user.js b/app/controllers/user.js index 2d8222b..c5cda71 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -6,7 +6,6 @@ const express = require('express'); const mongoose = require('mongoose'); -const multer = require('multer'); const { SiteController, SiteError } = require('../../lib/site-lib'); From 2c13b997bfe72e83f705a483aa68f7b81130ce16 Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 15 Aug 2022 18:28:50 -0400 Subject: [PATCH 23/35] added `pretty-checkbox` for, well, better checkboxes in forms --- app/views/layouts/main.pug | 1 + lib/site-platform.js | 9 ++++++--- package.json | 1 + yarn.lock | 5 +++++ 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/views/layouts/main.pug b/app/views/layouts/main.pug index fc94731..c22e0d1 100644 --- a/app/views/layouts/main.pug +++ b/app/views/layouts/main.pug @@ -19,6 +19,7 @@ html(lang='en') 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 diff --git a/lib/site-platform.js b/lib/site-platform.js index ed5b587..3ac27de 100644 --- a/lib/site-platform.js +++ b/lib/site-platform.js @@ -261,17 +261,20 @@ module.exports.startWebServer = async (dtp) => { module.app.use('/uikit/images', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'uikit', 'src', 'images'))); module.app.use('/uikit', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'uikit', 'dist'))); - module.app.use('/chart.js', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'chart.js', 'dist'))); module.app.use('/chartjs-adapter-moment', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'chartjs-adapter-moment', 'dist'))); - module.app.use('/cropperjs', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'cropperjs', 'dist'))); + module.app.use('/pretty-checkbox', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'pretty-checkbox', 'dist'))); + module.app.use('/fontawesome', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', '@fortawesome', 'fontawesome-free'))); module.app.use('/moment', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'moment', 'min'))); - module.app.use('/mpegts', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'mpegts.js', 'dist'))); module.app.use('/numeral', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'numeral', 'min'))); + + module.app.use('/cropperjs', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'cropperjs', 'dist'))); module.app.use('/tinymce', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'tinymce'))); module.app.use('/highlight.js', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'highlight.js'))); + module.app.use('/mpegts', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'mpegts.js', 'dist'))); + /* * ExpressJS middleware */ diff --git a/package.json b/package.json index c63e1f4..1459b82 100644 --- a/package.json +++ b/package.json @@ -63,6 +63,7 @@ "passport-oauth2-client-password": "^0.1.2", "password-generator": "^2.3.2", "picmo": "^5.4.0", + "pretty-checkbox": "^3.0.3", "pug": "^3.0.2", "qrcode": "^1.5.0", "rate-limiter-flexible": "^2.3.6", diff --git a/yarn.lock b/yarn.lock index 91ce3f5..654b517 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6545,6 +6545,11 @@ pretty-bytes@^5.3.0, pretty-bytes@^5.4.1: resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.6.0.tgz#356256f643804773c82f64723fe78c92c62beaeb" integrity sha512-FFw039TmrBqFK8ma/7OL3sDz/VytdtJr044/QUJtH0wK9lb9jLq9tJyIxUwtQJHwar2BqtiA4iCWSwo9JLkzFg== +pretty-checkbox@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/pretty-checkbox/-/pretty-checkbox-3.0.3.tgz#d49c8013a8fc08ee0c2d6ebde453464bfdbc428e" + integrity sha512-kCLsENsJ6h5Bcq106Q3YMSxuz2q3jtIXP7fgDB/+jZjUsZjRjAoL9Lr1TVwAEcugufVBhr5Mfd9L7P6d+SR+Yw== + pretty-hrtime@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz#b7e3ea42435a4c9b2759d99e0f201eb195802ee1" From 43b340790a147eb61c866847317bbb93245a3a01 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 16 Aug 2022 07:00:03 -0400 Subject: [PATCH 24/35] added `parseTagList` --- lib/site-common.js | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/lib/site-common.js b/lib/site-common.js index c3bce95..8c00f31 100644 --- a/lib/site-common.js +++ b/lib/site-common.js @@ -7,6 +7,8 @@ const path = require('path'); const pug = require('pug'); +const striptags = require('striptags'); + const { SiteLog } = require(path.join(__dirname, 'site-log')); const { SiteAsync } = require(path.join(__dirname, 'site-async')); @@ -102,6 +104,25 @@ class SiteCommon extends Events { }, options); return pug.compileFileClient(scriptFile, options); } + + parseTagList (tagList, options) { + options = Object.assign({ + lowercase: true, + filter: [ ], + }, options); + return tagList + .split(',') + .map((metric) => { + metric = striptags(metric.trim()); + return options.lowercase ? metric.toLowerCase() : metric; + }) + .filter((metric) => { + return (typeof metric === 'string') && + (metric.length > 0) && + !options.filter.includes(metric.toLowerCase()) + ; + }); + } } module.exports.SiteCommon = SiteCommon; \ No newline at end of file From 8e872cd688bce2924c1e8e59ffc4c63c75d6d4eb Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 19 Aug 2022 21:53:31 -0400 Subject: [PATCH 25/35] Comments update to make them more resource agnostic and usable --- app/controllers/announcement.js | 23 +- app/controllers/comment.js | 157 +++++++++++ app/models/comment.js | 7 +- app/models/lib/resource-stats.js | 5 +- app/models/resource-view.js | 6 +- app/services/comment.js | 46 ++++ .../announcement/components/announcement.pug | 16 +- app/views/announcement/view.pug | 11 + .../components/comment-list-standalone.pug | 2 +- app/views/comment/components/comment-list.pug | 9 +- .../comment/components/comment-standalone.pug | 2 +- app/views/comment/components/comment.pug | 25 +- app/views/comment/components/composer.pug | 21 +- .../components/reply-list-standalone.pug | 2 +- app/views/comment/components/section.pug | 38 +++ app/views/components/off-canvas.pug | 4 + client/js/site-app.js | 189 ++------------ client/js/site-comments.js | 244 ++++++++++++++++++ 18 files changed, 599 insertions(+), 208 deletions(-) create mode 100644 app/controllers/comment.js create mode 100644 app/views/announcement/view.pug create mode 100644 app/views/comment/components/section.pug create mode 100644 client/js/site-comments.js diff --git a/app/controllers/announcement.js b/app/controllers/announcement.js index 269291b..ea55350 100644 --- a/app/controllers/announcement.js +++ b/app/controllers/announcement.js @@ -15,11 +15,22 @@ class AnnouncementController extends SiteController { } async start ( ) { + const { comment: commentService } = this.dtp.services; + const router = express.Router(); this.dtp.app.use('/announcement', router); + const upload = this.createMulter(); + + router.use(async (req, res, next) => { + res.locals.currentView = 'announcement'; + return next(); + }); + router.param('announcementId', this.populateAnnouncementId.bind(this)); + router.post('/:announcementId/comment', upload.none(), commentService.commentCreateHandler('Announcement', 'announcement')); + router.get('/:announcementId', this.getAnnouncementView.bind(this)); router.get('/', this.getHome.bind(this)); @@ -40,8 +51,16 @@ class AnnouncementController extends SiteController { } } - async getAnnouncementView (req, res) { - res.render('announcement/view'); + async getAnnouncementView (req, res, next) { + const { comment: commentService } = this.dtp.services; + try { + res.locals.pagination = this.getPaginationParameters(req, 10); + res.locals.comments = await commentService.getForResource(res.locals.announcement, ['published'], res.locals.pagination); + res.render('announcement/view'); + } catch (error) { + this.log.error('failed to render announcement view', { error }); + return next(error); + } } async getHome (req, res, next) { diff --git a/app/controllers/comment.js b/app/controllers/comment.js new file mode 100644 index 0000000..a40cbaa --- /dev/null +++ b/app/controllers/comment.js @@ -0,0 +1,157 @@ +// comment.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const express = require('express'); +const numeral = require('numeral'); + +const { SiteController, SiteError } = require('../../lib/site-lib'); + +class CommentController extends SiteController { + + constructor (dtp) { + super(dtp, module.exports); + } + + async start ( ) { + const { dtp } = this; + const { limiter: limiterService, session: sessionService } = dtp.services; + + const authRequired = sessionService.authCheckMiddleware({ requiredLogin: true }); + + const router = express.Router(); + dtp.app.use('/comment', router); + + router.use(async (req, res, next) => { + res.locals.currentView = module.exports.slug; + return next(); + }); + + router.param('commentId', this.populateCommentId.bind(this)); + + router.post('/:commentId/vote', authRequired, this.postVote.bind(this)); + + router.get('/:commentId/replies', this.getCommentReplies.bind(this)); + + router.delete('/:commentId', + authRequired, + limiterService.createMiddleware(limiterService.config.comment.deleteComment), + this.deleteComment.bind(this), + ); + } + + async populateCommentId (req, res, next, commentId) { + const { comment: commentService } = this.dtp.services; + try { + res.locals.comment = await commentService.getById(commentId); + if (!res.locals.comment) { + return next(new SiteError(404, 'Comment not found')); + } + res.locals.post = res.locals.comment.resource; + return next(); + } catch (error) { + this.log.error('failed to populate commentId', { commentId, error }); + return next(error); + } + } + + async postVote (req, res) { + const { contentVote: contentVoteService } = this.dtp.services; + try { + const displayList = this.createDisplayList('comment-vote'); + const { message, stats } = 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(stats.upvoteCount).format(stats.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(stats.downvoteCount).format(stats.upvoteCount > 1000 ? '0,0.0a' : '0,0'), + ); + displayList.showNotification(message, 'success', 'bottom-center', 3000); + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to process comment vote', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + + async getCommentReplies (req, res) { + const { comment: commentService } = this.dtp.services; + try { + const displayList = this.createDisplayList('get-replies'); + + if (req.query.buttonId) { + displayList.removeElement(`li.dtp-load-more[data-button-id="${req.query.buttonId}"]`); + } + + Object.assign(res.locals, req.app.locals); + + res.locals.countPerPage = parseInt(req.query.cpp || "20", 10); + if (res.locals.countPerPage < 1) { + res.locals.countPerPage = 1; + } + if (res.locals.countPerPage > 20) { + res.locals.countPerPage = 20; + } + + res.locals.pagination = this.getPaginationParameters(req, res.locals.countPerPage); + res.locals.comments = await commentService.getReplies(res.locals.comment, res.locals.pagination); + + const html = await commentService.renderTemplate('replyList', res.locals); + + const replyList = `ul.dtp-reply-list[data-comment-id="${res.locals.comment._id}"]`; + displayList.addElement(replyList, 'beforeEnd', html); + + const replyListContainer = `.dtp-reply-list-container[data-comment-id="${res.locals.comment._id}"]`; + displayList.removeAttribute(replyListContainer, 'hidden'); + + if (Array.isArray(res.locals.comments) && (res.locals.comments.length > 0)) { + displayList.removeElement(`p#empty-comments-label[data-comment-id="${res.locals.comment._id}"]`); + } + + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to display comment replies', { error }); + res.status(error.statusCode || 500).json({ success: false, message: error.message }); + } + } + + async deleteComment (req, res) { + const { comment: commentService } = this.dtp.services; + try { + const displayList = this.createDisplayList('add-recipient'); + + await commentService.remove(res.locals.comment, 'removed'); + + let selector = `article[data-comment-id="${res.locals.comment._id}"] .comment-content`; + displayList.setTextContent(selector, 'Comment removed'); + + displayList.showNotification( + 'Comment removed successfully', + 'success', + 'bottom-center', + 5000, + ); + + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to remove comment', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message + }); + } + } +} + +module.exports = { + slug: 'comment', + name: 'comment', + create: async (dtp) => { return new CommentController(dtp); }, +}; \ No newline at end of file diff --git a/app/models/comment.js b/app/models/comment.js index 2ea385e..edc08e8 100644 --- a/app/models/comment.js +++ b/app/models/comment.js @@ -20,7 +20,12 @@ const { 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 }, diff --git a/app/models/lib/resource-stats.js b/app/models/lib/resource-stats.js index 016644d..1e4b401 100644 --- a/app/models/lib/resource-stats.js +++ b/app/models/lib/resource-stats.js @@ -8,7 +8,10 @@ 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 }, diff --git a/app/models/resource-view.js b/app/models/resource-view.js index ec00268..d59ad0a 100644 --- a/app/models/resource-view.js +++ b/app/models/resource-view.js @@ -4,11 +4,13 @@ 'use strict'; +const path = require('path'); + const mongoose = require('mongoose'); const Schema = mongoose.Schema; -const RESOURCE_TYPE_LIST = ['Page', 'Post', 'Newsletter']; +const { RESOURCE_TYPE_LIST } = require(path.join(__dirname, 'lib', 'resource-stats.js')); const ResourceViewSchema = new Schema({ created: { type: Date, default: Date.now, required: true, index: 1, expires: '30d' }, @@ -27,4 +29,4 @@ ResourceViewSchema.index({ name: 'res_view_daily_unique', }); -module.exports = mongoose.model('ResourceView', ResourceViewSchema); +module.exports = mongoose.model('ResourceView', ResourceViewSchema); \ No newline at end of file diff --git a/app/services/comment.js b/app/services/comment.js index 191483f..13976e3 100644 --- a/app/services/comment.js +++ b/app/services/comment.js @@ -75,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(); diff --git a/app/views/announcement/components/announcement.pug b/app/views/announcement/components/announcement.pug index 91a5625..2b169c5 100644 --- a/app/views/announcement/components/announcement.pug +++ b/app/views/announcement/components/announcement.pug @@ -6,7 +6,15 @@ mixin renderAnnouncement (announcement) i(class=`fas ${announcement.title.icon.class}`, style=`color: ${announcement.title.icon.color}`) span.uk-margin-small-left= announcement.title.content .uk-card-body!= marked.parse(announcement.content, { renderer: marked.Renderer() }) - .uk-card-footer - .uk-text-small.uk-text-muted.uk-flex.uk-flex-between - div= moment(announcement.created).format('MMM DD, YYYY') - div= moment(announcement.created).format('hh:mm a') \ No newline at end of file + .uk-card-footer.uk-text-small.uk-text-muted + div(uk-grid).uk-grid-small.uk-grid-divider + .uk-width-auto + div= moment(announcement.created).format('MMM DD, YYYY') + .uk-width-auto + div= moment(announcement.created).format('hh:mm a') + if currentView !== 'announcement' + .uk-width-auto + a(href=`/announcement/${announcement._id}`) + span + i.fas.fa-link + span.uk-margin-small-left Open Announcement \ No newline at end of file diff --git a/app/views/announcement/view.pug b/app/views/announcement/view.pug new file mode 100644 index 0000000..1ad8a26 --- /dev/null +++ b/app/views/announcement/view.pug @@ -0,0 +1,11 @@ +extends ../layouts/main +block content + + include ../comment/components/section + include components/announcement + + section.uk-section.uk-section-default.uk-section-small + .uk-container + +renderAnnouncement(announcement) + + +renderCommentSection({ name: `announcement-${announcement._id}`, rootUrl: `/announcement/${announcement._id}/comment` }) \ No newline at end of file diff --git a/app/views/comment/components/comment-list-standalone.pug b/app/views/comment/components/comment-list-standalone.pug index c6bff35..30cd9ee 100644 --- a/app/views/comment/components/comment-list-standalone.pug +++ b/app/views/comment/components/comment-list-standalone.pug @@ -1,4 +1,4 @@ include ../../components/library include comment-list include composer -+renderCommentList(comments, { rootUrl: `/post/${post.slug}/comment`, countPerPage }) \ No newline at end of file ++renderCommentList(comments, { name: appletName || 'comments', rootUrl: `/post/${post.slug}/comment`, countPerPage }) \ No newline at end of file diff --git a/app/views/comment/components/comment-list.pug b/app/views/comment/components/comment-list.pug index 585ff74..1da58ac 100644 --- a/app/views/comment/components/comment-list.pug +++ b/app/views/comment/components/comment-list.pug @@ -4,7 +4,10 @@ mixin renderCommentList (comments, options = { }) if Array.isArray(comments) && (comments.length > 0) each comment in comments li(data-comment-id= comment._id) - +renderComment(comment) + - + var commentOptions = Object.assign({ }, options); + commentOptions.name = `${options.name}-reply-${comment._id}`; + +renderComment(comment, commentOptions) if (comments.length >= options.countPerPage) - var buttonId = mongoose.Types.ObjectId(); @@ -13,7 +16,7 @@ mixin renderCommentList (comments, options = { }) type="button", data-button-id= buttonId, data-post-id= post._id, - data-next-page= pagination.p + 1, + data-next-page= options.pagination ? options.pagination.p + 1 : 2, data-root-url= options.rootUrl, - onclick= `return dtp.app.loadMoreComments(event);`, + onclick= `return dtp.app.comments['${options.name}'].loadMoreComments(event);`, ).uk-button.dtp-button-primary LOAD MORE \ No newline at end of file diff --git a/app/views/comment/components/comment-standalone.pug b/app/views/comment/components/comment-standalone.pug index 46bcb54..743fe79 100644 --- a/app/views/comment/components/comment-standalone.pug +++ b/app/views/comment/components/comment-standalone.pug @@ -1,4 +1,4 @@ include ../../components/library include comment include composer -+renderComment(comment) \ No newline at end of file ++renderComment(comment, appletOptions || { }) \ No newline at end of file diff --git a/app/views/comment/components/comment.pug b/app/views/comment/components/comment.pug index 5fc5e53..d85aeac 100644 --- a/app/views/comment/components/comment.pug +++ b/app/views/comment/components/comment.pug @@ -1,6 +1,6 @@ include composer -mixin renderComment (comment) +mixin renderComment (comment, options) - var resourceId = comment.resource._id || comment.resource; article(data-comment-id= comment._id).uk-comment.dtp-site-comment header.uk-comment-header @@ -28,7 +28,7 @@ mixin renderComment (comment) a( href="", data-comment-id= comment._id, - onclick="return dtp.app.deleteComment(event);", + onclick=`return dtp.app.comments['${options.name}'].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['${options.name}'].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['${options.name}'].blockCommentAuthor(event);`, ) Block author .uk-comment-body @@ -82,7 +82,7 @@ mixin renderComment (comment) type="button", data-comment-id= comment._id, data-vote="up", - onclick="return dtp.app.submitCommentVote(event);", + onclick=`return dtp.app.comments['${options.name}'].submitCommentVote(event);`, title="Upvote this comment", ).uk-button.uk-button-link +renderLabeledIcon('fa-chevron-up', formatCount(comment.stats.upvoteCount)) @@ -91,7 +91,7 @@ mixin renderComment (comment) type="button", data-comment-id= comment._id, data-vote="down", - onclick="return dtp.app.submitCommentVote(event);", + onclick=`return dtp.app.comments['${options.name}'].submitCommentVote(event);`, title="Downvote this comment", ).uk-button.uk-button-link +renderLabeledIcon('fa-chevron-down', formatCount(comment.stats.downvoteCount)) @@ -99,7 +99,7 @@ mixin renderComment (comment) button( type="button", data-comment-id= comment._id, - onclick="return dtp.app.openReplies(event);", + onclick=`return dtp.app.comments['${options.name}'].openReplies(event);`, title="Load replies to this comment", ).uk-button.uk-button-link +renderLabeledIcon('fa-comment', formatCount(comment.stats.replyCount)) @@ -107,16 +107,21 @@ mixin renderComment (comment) button( type="button", data-comment-id= comment._id, - onclick="return dtp.app.openReplyComposer(event);", + onclick=`return dtp.app.comments['${options.name}'].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(Object.assign({ showCancel: true, replyTo: comment._id }, options)) div(data-comment-id= comment._id, hidden).dtp-reply-list-container.uk-margin ul(data-comment-id= comment._id).dtp-reply-list.uk-list.uk-margin-medium-left \ No newline at end of file diff --git a/app/views/comment/components/composer.pug b/app/views/comment/components/composer.pug index 2513657..98dfeea 100644 --- a/app/views/comment/components/composer.pug +++ b/app/views/comment/components/composer.pug @@ -1,23 +1,23 @@ -mixin renderCommentComposer (actionUrl, options = { }) - form(method="POST", action= actionUrl, onsubmit="return dtp.app.submitForm(event, 'create-comment');").uk-form +mixin renderCommentComposer (options = { }) + form(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);", + oninput=`return dtp.app.comments['${options.name}'].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 @@ -25,12 +25,14 @@ mixin renderCommentComposer (actionUrl, options = { }) li 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(uk-drop={ mode: 'click' }) + .comment-emoji-picker + 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 +41,6 @@ mixin renderCommentComposer (actionUrl, options = { }) if options.showCancel .uk-width-auto button(type="submit").uk-button.dtp-button-secondary Cancel + .uk-width-auto button(type="submit").uk-button.dtp-button-primary Post \ No newline at end of file diff --git a/app/views/comment/components/reply-list-standalone.pug b/app/views/comment/components/reply-list-standalone.pug index a5e3fd8..b6eee33 100644 --- a/app/views/comment/components/reply-list-standalone.pug +++ b/app/views/comment/components/reply-list-standalone.pug @@ -1,4 +1,4 @@ include ../../components/library include comment-list include composer -+renderCommentList(comments, { rootUrl: `/comment/${comment._id}/replies`, countPerPage }) \ No newline at end of file ++renderCommentList(comments, { name: appletName || 'comments', rootUrl: `/comment/${comment._id}/replies`, countPerPage }) \ No newline at end of file diff --git a/app/views/comment/components/section.pug b/app/views/comment/components/section.pug new file mode 100644 index 0000000..bc3accf --- /dev/null +++ b/app/views/comment/components/section.pug @@ -0,0 +1,38 @@ +include composer +include comment-list + +mixin renderCommentSection (options = { }) + section + .uk-container + if user && user.permissions.canComment + - + const composerOptions = Object.assign({ }, options); + composerOptions.name = `${options.name}-composer`; + .content-block(dtp-comments= composerOptions.name, data-root-url= options.rootUrl) + .uk-margin + +renderSectionTitle('Add a comment') + .uk-margin-small + +renderCommentComposer(composerOptions) + + if featuredComment + .content-block(dtp-comments= `${options.name}-feature`, data-root-url= options.rootUrl) + #featured-comment.uk-margin-large + .uk-margin + +renderSectionTitle('Linked Comment') + - + const featureOptions = Object.assign({ }, options); + featureOptions.name = `${options.name}-feature`; + +renderComment(featuredComment, featureOptions) + + .content-block(dtp-comments= options.name, data-root-url= options.rootUrl) + +renderSectionTitle('Comments') + + if Array.isArray(comments) && (comments.length > 0) + ul#post-comment-list.uk-list.uk-list-divider.uk-list-large + +renderCommentList(comments, Object.assign({ + countPerPage: countPerPage || 10, + rootUrl: options.rootUrl, + }, options)) + else + ul#post-comment-list.uk-list.uk-list-divider.uk-list-large + div There are no comments at this time. Please check back later. \ No newline at end of file diff --git a/app/views/components/off-canvas.pug b/app/views/components/off-canvas.pug index fcc04e4..2296d56 100644 --- a/app/views/components/off-canvas.pug +++ b/app/views/components/off-canvas.pug @@ -20,6 +20,10 @@ 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 diff --git a/client/js/site-app.js b/client/js/site-app.js index e8a51d9..0763554 100644 --- a/client/js/site-app.js +++ b/client/js/site-app.js @@ -14,6 +14,7 @@ import QRCode from 'qrcode'; import Cropper from 'cropperjs'; import SiteChat from './site-chat'; +import SiteComments from './site-comments'; const GRID_COLOR = 'rgb(64, 64, 64)'; const GRID_TICK_COLOR = 'rgb(192,192,192)'; @@ -42,11 +43,26 @@ export default class DtpSiteApp extends DtpApp { }); this.chat = new SiteChat(this); + + this.initializeComments(); + this.charts = { /* will hold rendered charts */ }; this.scrollToHash(); } + initializeComments ( ) { + this.comments = { }; + + const containers = document.querySelectorAll('[dtp-comments]'); + containers.forEach((container) => { + const name = container.getAttribute('dtp-comments'); + const rootUrl = container.getAttribute('data-root-url'); + this.log.info('initializeComments', 'initializing commenting scope', { name, rootUrl }); + this.comments[name] = new SiteComments(this, container); + }); + } + async scrollToHash ( ) { const { hash } = window.location; if (hash === '') { @@ -411,125 +427,11 @@ export default class DtpSiteApp extends DtpApp { UIkit.modal.alert(`Failed to remove image: ${error.message}`); } } - async onCommentInput (event) { - const label = document.getElementById('comment-character-count'); - label.textContent = numeral(event.target.value.length).format('0,0'); - } - - async showReportCommentForm (event) { - event.preventDefault(); - event.stopPropagation(); - - const resourceType = event.currentTarget.getAttribute('data-resource-type'); - const resourceId = event.currentTarget.getAttribute('data-resource-id'); - const commentId = event.currentTarget.getAttribute('data-comment-id'); - - this.closeCommentDropdownMenu(commentId); - - try { - const response = await fetch('/content-report/comment/form', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - resourceType, resourceId, commentId - }), - }); - if (!response.ok) { - throw new Error('failed to load report form'); - } - const html = await response.text(); - this.currentDialog = UIkit.modal.dialog(html); - } catch (error) { - this.log.error('reportComment', 'failed to process comment request', { resourceType, resourceId, commentId, error }); - UIkit.modal.alert(`Failed to report comment: ${error.message}`); - } - - return true; - } - - async deleteComment (event) { - event.preventDefault(); - event.stopPropagation(); - const commentId = (event.currentTarget || event.target).getAttribute('data-comment-id'); - try { - const response = fetch(`/comment/${commentId}`, { method: 'DELETE' }); - if (!response.ok) { - throw new Error('Server error'); - } - this.processResponse(response); - } catch (error) { - UIkit.modal.alert(`Failed to delete comment: ${error.message}`); - } - } - async submitDialogForm (event, userAction) { await this.submitForm(event, userAction); await this.closeCurrentDialog(); } - async blockCommentAuthor (event) { - event.preventDefault(); - event.stopPropagation(); - - const resourceType = event.currentTarget.getAttribute('data-resource-type'); - const resourceId = event.currentTarget.getAttribute('data-resource-id'); - const commentId = event.currentTarget.getAttribute('data-comment-id'); - const actionUrl = this.getCommentActionUrl(resourceType, resourceId, commentId, 'block-author'); - - this.closeCommentDropdownMenu(commentId); - - try { - this.log.info('blockCommentAuthor', 'blocking comment author', { resourceType, resourceId, commentId }); - const response = await fetch(actionUrl, { method: 'POST'}); - await this.processResponse(response); - - } catch (error) { - this.log.error('reportComment', 'failed to process comment request', { resourceType, resourceId, commentId, error }); - UIkit.modal.alert(`Failed to block comment author: ${error.message}`); - } - - return true; - } - - closeCommentDropdownMenu (commentId) { - const dropdown = document.querySelector(`[data-comment-id="${commentId}"][uk-dropdown]`); - UIkit.dropdown(dropdown).hide(false); - } - - getCommentActionUrl (resourceType, resourceId, commentId, action) { - switch (resourceType) { - case 'Newsletter': - return `/newsletter/${resourceId}/comment/${commentId}/${action}`; - case 'Page': - return `/page/${resourceId}/comment/${commentId}/${action}`; - case 'Post': - return `/post/${resourceId}/comment/${commentId}/${action}`; - default: - break; - } - throw new Error('Invalid resource type for comment operation'); - } - - async submitCommentVote (event) { - const target = (event.currentTarget || event.target); - const commentId = target.getAttribute('data-comment-id'); - const vote = target.getAttribute('data-vote'); - try { - const response = await fetch(`/comment/${commentId}/vote`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ vote }), - }); - await this.processResponse(response); - } catch (error) { - UIkit.modal.alert(`Failed to submit vote: ${error.message}`); - } - } - async renderStatsGraph (selector, title, data) { try { const canvas = document.querySelector(selector); @@ -602,65 +504,6 @@ export default class DtpSiteApp extends DtpApp { UIkit.modal.alert(`Failed to render chart: ${error.message}`); } } - - async openReplies (event) { - event.preventDefault(); - event.stopPropagation(); - - const target = event.currentTarget || event.target; - const commentId = target.getAttribute('data-comment-id'); - - const container = document.querySelector(`.dtp-reply-list-container[data-comment-id="${commentId}"]`); - const replyList = document.querySelector(`ul.dtp-reply-list[data-comment-id="${commentId}"]`); - - const isOpen = !container.hasAttribute('hidden'); - if (isOpen) { - container.setAttribute('hidden', ''); - while (replyList.firstChild) { - replyList.removeChild(replyList.firstChild); - } - return; - } - - try { - const response = await fetch(`/comment/${commentId}/replies`); - this.processResponse(response); - } catch (error) { - UIkit.modal.alert(`Failed to load replies: ${error.message}`); - } - - return true; - } - - async openReplyComposer (event) { - event.preventDefault(); - event.stopPropagation(); - - const target = event.currentTarget || event.target; - const commentId = target.getAttribute('data-comment-id'); - const composer = document.querySelector(`.dtp-reply-composer[data-comment-id="${commentId}"]`); - composer.toggleAttribute('hidden'); - - return true; - } - - async loadMoreComments (event) { - event.preventDefault(); - event.stopPropagation(); - - const target = event.currentTarget || event.target; - - const buttonId = target.getAttribute('data-button-id'); - const rootUrl = target.getAttribute('data-root-url'); - const nextPage = target.getAttribute('data-next-page'); - - try { - const response = await fetch(`${rootUrl}?p=${nextPage}&buttonId=${buttonId}`); - await this.processResponse(response); - } catch (error) { - UIkit.modal.alert(`Failed to load more comments: ${error.message}`); - } - } } dtp.DtpSiteApp = DtpSiteApp; \ No newline at end of file diff --git a/client/js/site-comments.js b/client/js/site-comments.js new file mode 100644 index 0000000..3f020e7 --- /dev/null +++ b/client/js/site-comments.js @@ -0,0 +1,244 @@ +// site-comments.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +import DtpLog from 'dtp/dtp-log.js'; + +import UIkit from 'uikit'; + +import * as picmo from 'picmo'; + +export default class SiteComments { + + constructor (app, rootElement) { + this.app = app; + this.log = new DtpLog({ name: 'Site Comments', slug: 'comments' }); + + this.ui = { + input: rootElement.querySelector('#comment-content'), + emojiPicker: rootElement.querySelector('#comment-emoji-picker'), + characterCount: rootElement.querySelector('.comment-character-count'), + }; + + if (this.ui.emojiPicker) { + this.ui.emojiPickerUI = this.ui.emojiPicker.querySelector('.comment-emoji-picker'); + this.ui.picmo = picmo.createPicker({ + emojisPerRow: 7, + rootElement: this.ui.emojiPickerUI, + theme: picmo.darkTheme, + }); + + this.ui.picmo.addEventListener('emoji:select', this.onEmojiSelected.bind(this)); + + this.ui.emojiPickerDrop = UIkit.drop(this.ui.emojiPicker); + UIkit.util.on(this.ui.emojiPicker, 'show', ( ) => { + this.log.info('SiteComments', 'showing emoji picker'); + this.ui.picmo.reset(); + }); + } else { + UIkit.modal.alert('Comment section without an emoji picker defined'); + } + } + + async onCommentInput (event) { + this.ui.characterCount.textContent = numeral(event.target.value.length).format('0,0'); + } + + async showReportCommentForm (event) { + event.preventDefault(); + event.stopPropagation(); + + const resourceType = event.currentTarget.getAttribute('data-resource-type'); + const resourceId = event.currentTarget.getAttribute('data-resource-id'); + const commentId = event.currentTarget.getAttribute('data-comment-id'); + + this.closeCommentDropdownMenu(commentId); + + try { + const response = await fetch('/content-report/comment/form', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + resourceType, resourceId, commentId + }), + }); + if (!response.ok) { + throw new Error('failed to load report form'); + } + const html = await response.text(); + this.currentDialog = UIkit.modal.dialog(html); + } catch (error) { + this.log.error('reportComment', 'failed to process comment request', { resourceType, resourceId, commentId, error }); + UIkit.modal.alert(`Failed to report comment: ${error.message}`); + } + + return true; + } + + async deleteComment (event) { + event.preventDefault(); + event.stopPropagation(); + const commentId = (event.currentTarget || event.target).getAttribute('data-comment-id'); + try { + const response = fetch(`/comment/${commentId}`, { method: 'DELETE' }); + if (!response.ok) { + throw new Error('Server error'); + } + this.app.processResponse(response); + } catch (error) { + UIkit.modal.alert(`Failed to delete comment: ${error.message}`); + } + } + + async blockCommentAuthor (event) { + event.preventDefault(); + event.stopPropagation(); + + const resourceType = event.currentTarget.getAttribute('data-resource-type'); + const resourceId = event.currentTarget.getAttribute('data-resource-id'); + const commentId = event.currentTarget.getAttribute('data-comment-id'); + const actionUrl = this.getCommentActionUrl(resourceType, resourceId, commentId, 'block-author'); + + this.closeCommentDropdownMenu(commentId); + + try { + this.log.info('blockCommentAuthor', 'blocking comment author', { resourceType, resourceId, commentId }); + const response = await fetch(actionUrl, { method: 'POST'}); + await this.app.processResponse(response); + + } catch (error) { + this.log.error('reportComment', 'failed to process comment request', { resourceType, resourceId, commentId, error }); + UIkit.modal.alert(`Failed to block comment author: ${error.message}`); + } + + return true; + } + + closeCommentDropdownMenu (commentId) { + const dropdown = document.querySelector(`[data-comment-id="${commentId}"][uk-dropdown]`); + UIkit.dropdown(dropdown).hide(false); + } + + getCommentActionUrl (resourceType, resourceId, commentId, action) { + switch (resourceType) { + case 'Newsletter': + return `/newsletter/${resourceId}/comment/${commentId}/${action}`; + case 'Page': + return `/page/${resourceId}/comment/${commentId}/${action}`; + case 'Post': + return `/post/${resourceId}/comment/${commentId}/${action}`; + default: + break; + } + throw new Error('Invalid resource type for comment operation'); + } + + async submitCommentVote (event) { + const target = (event.currentTarget || event.target); + const commentId = target.getAttribute('data-comment-id'); + const vote = target.getAttribute('data-vote'); + try { + const response = await fetch(`/comment/${commentId}/vote`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ vote }), + }); + await this.app.processResponse(response); + } catch (error) { + UIkit.modal.alert(`Failed to submit vote: ${error.message}`); + } + } + + async openReplies (event) { + event.preventDefault(); + event.stopPropagation(); + + const target = event.currentTarget || event.target; + const commentId = target.getAttribute('data-comment-id'); + + const container = document.querySelector(`.dtp-reply-list-container[data-comment-id="${commentId}"]`); + const replyList = document.querySelector(`ul.dtp-reply-list[data-comment-id="${commentId}"]`); + + const isOpen = !container.hasAttribute('hidden'); + if (isOpen) { + container.setAttribute('hidden', ''); + while (replyList.firstChild) { + replyList.removeChild(replyList.firstChild); + } + return; + } + + try { + const response = await fetch(`/comment/${commentId}/replies`); + this.app.processResponse(response); + } catch (error) { + UIkit.modal.alert(`Failed to load replies: ${error.message}`); + } + + return true; + } + + async openReplyComposer (event) { + event.preventDefault(); + event.stopPropagation(); + + const target = event.currentTarget || event.target; + const commentId = target.getAttribute('data-comment-id'); + const composer = document.querySelector(`.dtp-reply-composer[data-comment-id="${commentId}"]`); + composer.toggleAttribute('hidden'); + + return true; + } + + async loadMoreComments (event) { + event.preventDefault(); + event.stopPropagation(); + + const target = event.currentTarget || event.target; + + const buttonId = target.getAttribute('data-button-id'); + const rootUrl = target.getAttribute('data-root-url'); + const nextPage = target.getAttribute('data-next-page'); + + try { + const response = await fetch(`${rootUrl}?p=${nextPage}&buttonId=${buttonId}`); + await this.app.processResponse(response); + } catch (error) { + UIkit.modal.alert(`Failed to load more comments: ${error.message}`); + } + } + + async onEmojiSelected (event) { + this.ui.emojiPickerDrop.hide(false); + return this.insertContentAtCursor(event.emoji); + } + + async insertContentAtCursor (content) { + this.ui.input.focus(); + + if (document.selection) { + let sel = document.selection.createRange(); + sel.text = content; + } else if (this.ui.input.selectionStart || (this.ui.input.selectionStart === 0)) { + let startPos = this.ui.input.selectionStart; + let endPos = this.ui.input.selectionEnd; + + let oldLength = this.ui.input.value.length; + this.ui.input.value = + this.ui.input.value.substring(0, startPos) + + content + + this.ui.input.value.substring(endPos, this.ui.input.value.length); + + this.ui.input.selectionStart = startPos + (this.ui.input.value.length - oldLength); + this.ui.input.selectionEnd = this.ui.input.selectionStart; + } else { + this.ui.input.value += content; + } + } +} \ No newline at end of file From 3e79acb1a8c358741b38ea8f59e70c7c4bf3451a Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 19 Aug 2022 22:45:50 -0400 Subject: [PATCH 26/35] small elaboration --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2e1d88d..cf9c353 100644 --- a/README.md +++ b/README.md @@ -113,4 +113,4 @@ Redis simply has many different documents to describe it's many different featur ## Software License -The DTP Base and the Phoenix Engine are licensed under the [Apache 2.0](https://spdx.org/licenses/Apache-2.0.html) open source software license. See [LICENSE](LICENSE) for more information. \ No newline at end of file +The DTP Base and the DTP Phoenix Engine and framework are licensed under the [Apache 2.0](https://spdx.org/licenses/Apache-2.0.html) open source software license. See [LICENSE](LICENSE) for more information. \ No newline at end of file From b7ff19dc5e2fce08618fa792f80639913b46e08c Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 22 Aug 2022 05:11:20 -0400 Subject: [PATCH 27/35] comments updates --- app/models/comment.js | 5 +- app/views/comment/components/comment.pug | 14 ++-- app/views/comment/components/composer.pug | 18 +++-- app/views/comment/components/section.pug | 2 +- client/js/index.js | 9 +++ client/js/site-app.js | 15 +--- client/js/site-comments.js | 85 ++++++++++++++--------- 7 files changed, 87 insertions(+), 61 deletions(-) diff --git a/app/models/comment.js b/app/models/comment.js index edc08e8..00b7dad 100644 --- a/app/models/comment.js +++ b/app/models/comment.js @@ -18,6 +18,8 @@ const { RESOURCE_TYPE_LIST, CommentStats, CommentStatsDefaults, + ResourceStats, + ResourceStatsDefaults, } = require(path.join(__dirname, 'lib', 'resource-stats.js')); const COMMENT_STATUS_LIST = [ @@ -40,7 +42,8 @@ const CommentSchema = new Schema({ flags: { isNSFW: { type: Boolean, default: false, required: true }, }, - stats: { type: CommentStats, default: CommentStatsDefaults, required: true }, + resourceStats: { type: ResourceStats, default: ResourceStatsDefaults, required: true }, + commentStats: { type: CommentStats, default: CommentStatsDefaults, required: true }, }); /* diff --git a/app/views/comment/components/comment.pug b/app/views/comment/components/comment.pug index d85aeac..a3db135 100644 --- a/app/views/comment/components/comment.pug +++ b/app/views/comment/components/comment.pug @@ -28,7 +28,7 @@ mixin renderComment (comment, options) a( href="", data-comment-id= comment._id, - onclick=`return dtp.app.comments['${options.name}'].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, options) data-resource-type= comment.resourceType, data-resource-id= resourceId, data-comment-id= comment._id, - onclick=`return dtp.app.comments['${options.name}'].showReportCommentForm(event);`, + onclick=`return dtp.app.comments.showReportCommentForm(event);`, ) Report li a( @@ -46,7 +46,7 @@ mixin renderComment (comment, options) data-resource-type= comment.resourceType, data-resource-id= resourceId, data-comment-id= comment._id, - onclick=`return dtp.app.comments['${options.name}'].blockCommentAuthor(event);`, + onclick=`return dtp.app.comments.blockCommentAuthor(event);`, ) Block author .uk-comment-body @@ -82,7 +82,7 @@ mixin renderComment (comment, options) type="button", data-comment-id= comment._id, data-vote="up", - onclick=`return dtp.app.comments['${options.name}'].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)) @@ -91,7 +91,7 @@ mixin renderComment (comment, options) type="button", data-comment-id= comment._id, data-vote="down", - onclick=`return dtp.app.comments['${options.name}'].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)) @@ -99,7 +99,7 @@ mixin renderComment (comment, options) button( type="button", data-comment-id= comment._id, - onclick=`return dtp.app.comments['${options.name}'].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)) @@ -107,7 +107,7 @@ mixin renderComment (comment, options) button( type="button", data-comment-id= comment._id, - onclick=`return dtp.app.comments['${options.name}'].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') diff --git a/app/views/comment/components/composer.pug b/app/views/comment/components/composer.pug index 98dfeea..d55e0b3 100644 --- a/app/views/comment/components/composer.pug +++ b/app/views/comment/components/composer.pug @@ -1,5 +1,10 @@ -mixin renderCommentComposer (options = { }) - form(method="POST", action= options.rootUrl, 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) @@ -11,7 +16,8 @@ mixin renderCommentComposer (options = { }) rows="4", maxlength="3000", placeholder="Enter comment", - oninput=`return dtp.app.comments['${options.name}'].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 @@ -22,15 +28,15 @@ mixin renderCommentComposer (options = { }) div(uk-grid).uk-flex-between.uk-grid-small .uk-width-expand ul.uk-subnav - li + li.comment-emoji-picker button( type="button", uk-tooltip="Add an emoji", ).uk-button.dtp-button-default span i.far.fa-smile - #comment-emoji-picker(uk-drop={ mode: 'click' }) - .comment-emoji-picker + .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") diff --git a/app/views/comment/components/section.pug b/app/views/comment/components/section.pug index bc3accf..b60e6db 100644 --- a/app/views/comment/components/section.pug +++ b/app/views/comment/components/section.pug @@ -12,7 +12,7 @@ mixin renderCommentSection (options = { }) .uk-margin +renderSectionTitle('Add a comment') .uk-margin-small - +renderCommentComposer(composerOptions) + +renderCommentComposer(composerOptions.name, composerOptions) if featuredComment .content-block(dtp-comments= `${options.name}-feature`, data-root-url= options.rootUrl) diff --git a/client/js/index.js b/client/js/index.js index fc6399b..0be660f 100644 --- a/client/js/index.js +++ b/client/js/index.js @@ -12,6 +12,15 @@ import DtpSiteApp from './site-app.js'; import DtpWebLog from 'dtp/dtp-log.js'; import UIkit from 'uikit'; +/** + * Monkeypatch to count characters instead of .length's code point count. + * @returns character count of string + */ +String.prototype.charCount = function () { + const splat = [...this]; + return splat.length; +}; + window.addEventListener('load', async ( ) => { // application console log dtp.log = new DtpWebLog(DTP_COMPONENT); diff --git a/client/js/site-app.js b/client/js/site-app.js index 0763554..506c688 100644 --- a/client/js/site-app.js +++ b/client/js/site-app.js @@ -43,26 +43,13 @@ export default class DtpSiteApp extends DtpApp { }); this.chat = new SiteChat(this); - - this.initializeComments(); + this.comments = new SiteComments(this); this.charts = { /* will hold rendered charts */ }; this.scrollToHash(); } - initializeComments ( ) { - this.comments = { }; - - const containers = document.querySelectorAll('[dtp-comments]'); - containers.forEach((container) => { - const name = container.getAttribute('dtp-comments'); - const rootUrl = container.getAttribute('data-root-url'); - this.log.info('initializeComments', 'initializing commenting scope', { name, rootUrl }); - this.comments[name] = new SiteComments(this, container); - }); - } - async scrollToHash ( ) { const { hash } = window.location; if (hash === '') { diff --git a/client/js/site-comments.js b/client/js/site-comments.js index 3f020e7..fe12dc3 100644 --- a/client/js/site-comments.js +++ b/client/js/site-comments.js @@ -12,38 +12,56 @@ import * as picmo from 'picmo'; export default class SiteComments { - constructor (app, rootElement) { + constructor (app) { this.app = app; this.log = new DtpLog({ name: 'Site Comments', slug: 'comments' }); + this.createEmojiPickers(); + } + + createEmojiPickers ( ) { + const pickerContainers = document.querySelectorAll('li.comment-emoji-picker:not([data-initialized])'); + for (const container of pickerContainers) { + const picker = { }; + + picker.drop = container.querySelector('.comment-emoji-picker-drop'); + picker.ui = picker.drop.querySelector('.comment-emoji-picker-ui'); - this.ui = { - input: rootElement.querySelector('#comment-content'), - emojiPicker: rootElement.querySelector('#comment-emoji-picker'), - characterCount: rootElement.querySelector('.comment-character-count'), - }; + const formId = picker.drop.getAttribute('data-form-id'); + picker.form = document.querySelector(`form#${formId}`); + picker.input = picker.form.querySelector(`textarea[data-form-id=${formId}]`); + picker.characterCount = picker.form.querySelector('span.comment-character-count'); - if (this.ui.emojiPicker) { - this.ui.emojiPickerUI = this.ui.emojiPicker.querySelector('.comment-emoji-picker'); - this.ui.picmo = picmo.createPicker({ + picker.picmo = picmo.createPicker({ emojisPerRow: 7, - rootElement: this.ui.emojiPickerUI, + rootElement: picker.ui, theme: picmo.darkTheme, }); - this.ui.picmo.addEventListener('emoji:select', this.onEmojiSelected.bind(this)); + picker.picmo.addEventListener('emoji:select', this.onEmojiSelected.bind(this, picker)); - this.ui.emojiPickerDrop = UIkit.drop(this.ui.emojiPicker); - UIkit.util.on(this.ui.emojiPicker, 'show', ( ) => { + picker.emojiPickerDrop = UIkit.drop(picker.drop); + UIkit.util.on(picker.drop, 'show', ( ) => { this.log.info('SiteComments', 'showing emoji picker'); - this.ui.picmo.reset(); + picker.picmo.reset(); }); - } else { - UIkit.modal.alert('Comment section without an emoji picker defined'); + + container.setAttribute('data-initialized', true); } } async onCommentInput (event) { - this.ui.characterCount.textContent = numeral(event.target.value.length).format('0,0'); + const target = event.currentTarget || event.target; + + const formId = target.getAttribute('data-form-id'); + if (!formId) { return; } + + const form = document.getElementById(formId); + if (!form) { return; } + + const label = form.querySelector('span.comment-character-count'); + if (!label) { return; } + + label.textContent = numeral(event.target.value.charCount()).format('0,0'); } async showReportCommentForm (event) { @@ -177,6 +195,7 @@ export default class SiteComments { try { const response = await fetch(`/comment/${commentId}/replies`); this.app.processResponse(response); + this.createEmojiPickers(); } catch (error) { UIkit.modal.alert(`Failed to load replies: ${error.message}`); } @@ -209,36 +228,38 @@ export default class SiteComments { try { const response = await fetch(`${rootUrl}?p=${nextPage}&buttonId=${buttonId}`); await this.app.processResponse(response); + this.createEmojiPickers(); } catch (error) { UIkit.modal.alert(`Failed to load more comments: ${error.message}`); } } - async onEmojiSelected (event) { - this.ui.emojiPickerDrop.hide(false); - return this.insertContentAtCursor(event.emoji); + async onEmojiSelected (picker, event) { + picker.emojiPickerDrop.hide(false); + await this.insertContentAtCursor(picker, event.emoji); + picker.characterCount.textContent = numeral(picker.input.value.charCount()).format('0,0'); } - async insertContentAtCursor (content) { - this.ui.input.focus(); + async insertContentAtCursor (picker, content) { + picker.input.focus(); if (document.selection) { let sel = document.selection.createRange(); sel.text = content; - } else if (this.ui.input.selectionStart || (this.ui.input.selectionStart === 0)) { - let startPos = this.ui.input.selectionStart; - let endPos = this.ui.input.selectionEnd; + } else if (picker.input.selectionStart || (picker.input.selectionStart === 0)) { + let startPos = picker.input.selectionStart; + let endPos = picker.input.selectionEnd; - let oldLength = this.ui.input.value.length; - this.ui.input.value = - this.ui.input.value.substring(0, startPos) + + let oldLength = picker.input.value.length; + picker.input.value = + picker.input.value.substring(0, startPos) + content + - this.ui.input.value.substring(endPos, this.ui.input.value.length); + picker.input.value.substring(endPos, picker.input.value.length); - this.ui.input.selectionStart = startPos + (this.ui.input.value.length - oldLength); - this.ui.input.selectionEnd = this.ui.input.selectionStart; + picker.input.selectionStart = startPos + (picker.input.value.length - oldLength); + picker.input.selectionEnd = picker.input.selectionStart; } else { - this.ui.input.value += content; + picker.input.value += content; } } } \ No newline at end of file From 70ab56988277886ea703a0644bf65d1d83659f78 Mon Sep 17 00:00:00 2001 From: rob Date: Mon, 22 Aug 2022 05:16:49 -0400 Subject: [PATCH 28/35] quick fix --- app/views/comment/components/comment.pug | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/views/comment/components/comment.pug b/app/views/comment/components/comment.pug index a3db135..a5769af 100644 --- a/app/views/comment/components/comment.pug +++ b/app/views/comment/components/comment.pug @@ -121,7 +121,7 @@ mixin renderComment (comment, options) ).dtp-reply-composer.uk-margin if user && user.permissions.canComment .uk-margin - +renderCommentComposer(Object.assign({ showCancel: true, replyTo: comment._id }, options)) + +renderCommentComposer(`composer-reply-${comment._id}`, Object.assign({ showCancel: true, replyTo: comment._id }, options)) div(data-comment-id= comment._id, hidden).dtp-reply-list-container.uk-margin ul(data-comment-id= comment._id).dtp-reply-list.uk-list.uk-margin-medium-left \ No newline at end of file From 0273e2d51b94b16310cee9ead471e9fc58b6921a Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 23 Aug 2022 11:58:19 -0400 Subject: [PATCH 29/35] removed 21d expire from Announcement model This requires an index re-build, and the responsibility is moving to Reeeper. --- app/models/announcement.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/models/announcement.js b/app/models/announcement.js index 6481078..88db819 100644 --- a/app/models/announcement.js +++ b/app/models/announcement.js @@ -9,7 +9,7 @@ const mongoose = require('mongoose'); const Schema = mongoose.Schema; 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 }, From 5e90fca35345632ab32ba49a492a80d0e45de981 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 23 Aug 2022 11:58:38 -0400 Subject: [PATCH 30/35] Add link to Announcement component to open the announcement --- app/views/announcement/components/announcement.pug | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/views/announcement/components/announcement.pug b/app/views/announcement/components/announcement.pug index 2b169c5..28ef603 100644 --- a/app/views/announcement/components/announcement.pug +++ b/app/views/announcement/components/announcement.pug @@ -9,9 +9,7 @@ mixin renderAnnouncement (announcement) .uk-card-footer.uk-text-small.uk-text-muted div(uk-grid).uk-grid-small.uk-grid-divider .uk-width-auto - div= moment(announcement.created).format('MMM DD, YYYY') - .uk-width-auto - div= moment(announcement.created).format('hh:mm a') + 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}`) From 91fe2ab01bfd4baaa87a72c214948ee3678e39e2 Mon Sep 17 00:00:00 2001 From: rob Date: Tue, 23 Aug 2022 18:31:50 -0400 Subject: [PATCH 31/35] more work on comments; reeeper updated - Moved the responsibility of expiring Announcements from MongoDB into the Reeeper - Added logic to clean up comments attached to an expiring Announcement - ResourceStats are now much more universal and common - CommentStats are for comments only - More routines to comment on and vote on "content resources" --- app/controllers/admin/announcement.js | 2 +- app/controllers/comment.js | 6 +- app/models/announcement.js | 9 ++ app/models/comment.js | 4 +- app/models/core-user.js | 2 +- app/models/lib/resource-stats.js | 8 +- app/models/user.js | 2 +- app/services/announcement.js | 3 + app/services/comment.js | 5 +- app/services/content-vote.js | 20 ++-- app/services/core-node.js | 6 +- app/views/comment/components/comment.pug | 6 +- app/workers/reeeper.js | 1 + .../reeeper/cron/expire-announcements.js | 94 +++++++++++++++++++ .../reeeper/cron/expire-crashed-hosts.js | 13 ++- client/js/site-admin-app.js | 22 ++--- 16 files changed, 155 insertions(+), 48 deletions(-) create mode 100644 app/workers/reeeper/cron/expire-announcements.js diff --git a/app/controllers/admin/announcement.js b/app/controllers/admin/announcement.js index ebdf4f1..cfcd3dc 100644 --- a/app/controllers/admin/announcement.js +++ b/app/controllers/admin/announcement.js @@ -87,7 +87,7 @@ class AnnouncementAdminController extends SiteController { try { const displayList = this.createDisplayList('delete-announcement'); await announcementService.remove(res.locals.announcement); - displayList.reloadView(); + displayList.reload(); res.status(200).json({ success: true, displayList }); } catch (error) { this.log.error('failed to delete announcement', { error }); diff --git a/app/controllers/comment.js b/app/controllers/comment.js index a40cbaa..7935572 100644 --- a/app/controllers/comment.js +++ b/app/controllers/comment.js @@ -61,14 +61,14 @@ class CommentController extends SiteController { const { contentVote: contentVoteService } = this.dtp.services; try { const displayList = this.createDisplayList('comment-vote'); - const { message, stats } = await contentVoteService.recordVote(req.user, 'Comment', res.locals.comment, req.body.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(stats.upvoteCount).format(stats.upvoteCount > 1000 ? '0,0.0a' : '0,0'), + 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(stats.downvoteCount).format(stats.upvoteCount > 1000 ? '0,0.0a' : '0,0'), + 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 }); diff --git a/app/models/announcement.js b/app/models/announcement.js index 88db819..d7d8b51 100644 --- a/app/models/announcement.js +++ b/app/models/announcement.js @@ -4,10 +4,18 @@ '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 }, title: { @@ -18,6 +26,7 @@ const AnnouncementSchema = new Schema({ content: { type: String, required: true }, }, content: { type: String, required: true }, + resourceStats: { type: ResourceStats, default: ResourceStatsDefaults, required: true }, }); module.exports = mongoose.model('Announcement', AnnouncementSchema); \ No newline at end of file diff --git a/app/models/comment.js b/app/models/comment.js index 00b7dad..2aaa3de 100644 --- a/app/models/comment.js +++ b/app/models/comment.js @@ -16,10 +16,10 @@ const CommentHistorySchema = new Schema({ const { RESOURCE_TYPE_LIST, - CommentStats, - CommentStatsDefaults, ResourceStats, ResourceStatsDefaults, + CommentStats, + CommentStatsDefaults, } = require(path.join(__dirname, 'lib', 'resource-stats.js')); const COMMENT_STATUS_LIST = [ diff --git a/app/models/core-user.js b/app/models/core-user.js index 812e826..04e9e46 100644 --- a/app/models/core-user.js +++ b/app/models/core-user.js @@ -33,7 +33,7 @@ const CoreUserSchema = new Schema({ permissions: { type: UserPermissionsSchema, select: false }, optIn: { type: UserOptInSchema, required: true, select: false }, theme: { type: String, enum: DTP_THEME_LIST, default: 'dtp-light', required: true }, - stats: { type: ResourceStats, default: ResourceStatsDefaults, required: true }, + resourceStats: { type: ResourceStats, default: ResourceStatsDefaults, required: true }, }); CoreUserSchema.index({ diff --git a/app/models/lib/resource-stats.js b/app/models/lib/resource-stats.js index 1e4b401..257bfc7 100644 --- a/app/models/lib/resource-stats.js +++ b/app/models/lib/resource-stats.js @@ -16,21 +16,21 @@ module.exports.RESOURCE_TYPE_LIST = [ module.exports.ResourceStats = new Schema({ uniqueVisitCount: { type: Number, default: 0, required: true, index: -1 }, totalVisitCount: { type: Number, default: 0, required: true }, + upvoteCount: { type: Number, default: 0, required: true }, + downvoteCount: { type: Number, default: 0, required: true }, }); module.exports.ResourceStatsDefaults = { uniqueVisitCount: 0, totalVisitCount: 0, + upvoteCount: 0, + downvoteCount: 0, }; module.exports.CommentStats = new Schema({ - upvoteCount: { type: Number, default: 0, required: true }, - downvoteCount: { type: Number, default: 0, required: true }, replyCount: { type: Number, default: 0, required: true }, }); module.exports.CommentStatsDefaults = { - upvoteCount: 0, - downvoteCount: 0, replyCount: 0, }; \ No newline at end of file diff --git a/app/models/user.js b/app/models/user.js index e71b1fc..adb39a9 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -38,7 +38,7 @@ const UserSchema = new Schema({ permissions: { type: UserPermissionsSchema, select: false }, optIn: { type: UserOptInSchema, required: true, select: false }, theme: { type: String, enum: DTP_THEME_LIST, default: 'dtp-light', required: true }, - stats: { type: ResourceStats, default: ResourceStatsDefaults, required: true }, + resourceStats: { type: ResourceStats, default: ResourceStatsDefaults, required: true }, lastAnnouncement: { type: Date }, }); diff --git a/app/services/announcement.js b/app/services/announcement.js index a5e812c..786b901 100644 --- a/app/services/announcement.js +++ b/app/services/announcement.js @@ -108,6 +108,9 @@ class AnnouncementService extends SiteService { } async remove (announcement) { + const { comment: commentService } = this.dtp.services; + await commentService.deleteForResource(announcement); + await Announcement.deleteOne({ _id: announcement._id }); } } diff --git a/app/services/comment.js b/app/services/comment.js index 13976e3..79bb539 100644 --- a/app/services/comment.js +++ b/app/services/comment.js @@ -161,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(); @@ -303,7 +303,8 @@ class CommentService extends SiteService { } /** - * Deletes all comments filed against a given resource. + * Deletes all comments filed against a given resource. Will also get their + * replies as those are also filed against a resource and will match. * @param {Resource} resource The resource for which all comments are to be * deleted (physically removed from database). */ diff --git a/app/services/content-vote.js b/app/services/content-vote.js index 66cef90..a611d98 100644 --- a/app/services/content-vote.js +++ b/app/services/content-vote.js @@ -47,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 { @@ -58,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 }; } /* @@ -74,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'; } } @@ -87,8 +87,8 @@ class ContentVoteService extends SiteService { this.log.info('updating resource stats', { resourceType, resource, updateOp }); await ResourceModel.updateOne({ _id: resource._id }, updateOp); - const updatedResource = await ResourceModel.findById(resource._id).select('stats'); - return { message, stats: updatedResource.stats }; + const updatedResource = await ResourceModel.findById(resource._id).select('resourceStats'); + return { message, resourceStats: updatedResource.resourceStats }; } } diff --git a/app/services/core-node.js b/app/services/core-node.js index a26990b..b8cc039 100644 --- a/app/services/core-node.js +++ b/app/services/core-node.js @@ -21,6 +21,7 @@ const OAuth2Strategy = require('passport-oauth2'); const striptags = require('striptags'); const { SiteService, SiteError, SiteAsync } = require('../../lib/site-lib'); +const { ResourceStatsDefaults } = require('../models/lib/resource-stats'); class CoreAddress { @@ -573,10 +574,7 @@ class CoreNodeService extends SiteService { marketing: false, }, theme: 'dtp-light', - stats: { - uniqueVisitCount: 0, - totalVisitCount: 0, - }, + resourceStats: ResourceStatsDefaults, }, $set: { updated: NOW, diff --git a/app/views/comment/components/comment.pug b/app/views/comment/components/comment.pug index a5769af..f2a6798 100644 --- a/app/views/comment/components/comment.pug +++ b/app/views/comment/components/comment.pug @@ -85,7 +85,7 @@ mixin renderComment (comment, options) 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", @@ -94,7 +94,7 @@ mixin renderComment (comment, options) 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", @@ -102,7 +102,7 @@ mixin renderComment (comment, options) 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", diff --git a/app/workers/reeeper.js b/app/workers/reeeper.js index 57d263d..6a0da5c 100644 --- a/app/workers/reeeper.js +++ b/app/workers/reeeper.js @@ -35,6 +35,7 @@ class ReeeperWorker extends SiteWorker { await super.start(); await this.loadProcessor(path.join(__dirname, 'reeeper', 'cron', 'expire-crashed-hosts.js')); + await this.loadProcessor(path.join(__dirname, 'reeeper', 'cron', 'expire-announcements.js')); await this.startProcessors(); } diff --git a/app/workers/reeeper/cron/expire-announcements.js b/app/workers/reeeper/cron/expire-announcements.js new file mode 100644 index 0000000..648dec2 --- /dev/null +++ b/app/workers/reeeper/cron/expire-announcements.js @@ -0,0 +1,94 @@ +// reeeper/cron/expire-announcements.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); +const moment = require('moment'); + +const mongoose = require('mongoose'); +const Announcement = mongoose.model('Announcement'); + +const { CronJob } = require('cron'); + +const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); + +/** + * Announcements used to auto-expire from the MongoDB database after 21 days, + * but then I added commenting. Now, an auto-expiring Announcement would orphan + * all those comments. That's bad. + * + * The solution, therefore, is to have a cron that wakes up daily and expires + * all Announcements older than 21 days. Same policy, it just also cleans up + * the comments and whatever else gets bolted onto an Announcement over time. + * + * This is how you do that. + */ +class ExpiredAnnouncementsCron extends SiteWorkerProcess { + + static get COMPONENT ( ) { + return { + name: 'expiredAnnouncementsCron', + slug: 'expired-announcements-cron', + }; + } + + constructor (worker) { + super(worker, ExpiredAnnouncementsCron.COMPONENT); + } + + async start ( ) { + await super.start(); + + this.log.info('performing startup expiration of announcements'); + await this.expireAnnouncements(); + + this.log.info('starting daily cron to expire announcements'); + this.job = new CronJob( + '0 0 0 * * *', // at midnight every day + this.expireAnnouncements.bind(this), + null, + true, + process.env.DTP_CRON_TIMEZONE || 'America/New_York', + ); + } + + async stop ( ) { + if (this.job) { + this.log.info('stopping announcement expire job'); + this.job.stop(); + delete this.job; + } + await super.stop(); + } + + async expireAnnouncements ( ) { + const { announcement: announcementService } = this.dtp.services; + + const NOW = new Date(); + const OLDEST_DATE = moment(NOW).subtract(21, 'days').toDate(); + + try { + await Announcement + .find({ created: { $lt: OLDEST_DATE } }) + .lean() + .cursor() + .eachAsync(async (announcement) => { + try { + await announcementService.remove(announcement); + } catch (error) { + this.log.error('failed to remove expired Announcement', { + announcementId: announcement._id, + error, + }); + // fall through, we'll get it in a future run + } + }); + } catch (error) { + this.log.error('failed to expire crashed hosts', { error }); + } + } +} + +module.exports = ExpiredAnnouncementsCron; \ No newline at end of file diff --git a/app/workers/reeeper/cron/expire-crashed-hosts.js b/app/workers/reeeper/cron/expire-crashed-hosts.js index e85940f..2662fb2 100644 --- a/app/workers/reeeper/cron/expire-crashed-hosts.js +++ b/app/workers/reeeper/cron/expire-crashed-hosts.js @@ -14,10 +14,15 @@ const { CronJob } = require('cron'); const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); /** - * DTP Core Chat sticker processor can receive requests to ingest and delete - * stickers to be executed as background jobs in a queue. This processor - * attaches to the `media` queue and registers processors for `sticker-ingest` - * and `sticker-delete`. + * Hosts on the DTP network register themselves and periodically report various + * metrics. They clean up after themselves when exiting gracefully. But, hosts + * lose power or get un-plugged or get caught in a sharknado or whatever. + * + * When that happens, the Reeeper ensures those hosts don't become Night of the + * Living Dead. + * + * That is the formal technical explanation of what's going on in here. We're + * preventing DTP host processes from becoming The Night of the Living Dead. */ class CrashedHostsCron extends SiteWorkerProcess { diff --git a/client/js/site-admin-app.js b/client/js/site-admin-app.js index 833f100..6786430 100644 --- a/client/js/site-admin-app.js +++ b/client/js/site-admin-app.js @@ -286,27 +286,23 @@ export default class DtpSiteAdminHostStatsApp extends DtpApp { } } - async deletePost (event) { - const postId = event.currentTarget.getAttribute('data-post-id'); - const postTitle = event.currentTarget.getAttribute('data-post-title'); - console.log(postId, postTitle); + async deleteAnnouncement (event) { + const target = event.currentTarget || event.target; + const announcementId = target.getAttribute('data-announcement-id'); try { - await UIkit.modal.confirm(`Are you sure you want to delete "${postTitle}"`); + await UIkit.modal.confirm('Are you sure you want to delete the announcement?'); } catch (error) { - this.log.info('deletePost', 'aborted'); return; } try { - const response = await fetch(`/admin/post/${postId}`, { - method: 'DELETE', - }); + const actionUrl = `/admin/announcement/${announcementId}`; + const response = await fetch(actionUrl, { method: 'DELETE' }); if (!response.ok) { - throw new Error('Failed to delete post'); + throw new Error('Server error'); } - await this.processResponse(response); + await this.processResponse(response); } catch (error) { - this.log.error('deletePost', 'failed to delete post', { postId, postTitle, error }); - UIkit.modal.alert(`Failed to delete post: ${error.message}`); + UIkit.modal.alert(`Failed to delete announcement: ${error.message}`); } } From 637d0fb03b07c20447fa3e5958951d2eee8702d9 Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 25 Aug 2022 00:41:32 -0400 Subject: [PATCH 32/35] Added ability to delete an OAuth2Client (Service Node) --- app/controllers/admin/service-node.js | 19 +++++++++++++++++++ app/services/oauth2.js | 9 +++++++++ app/views/admin/service-node/editor.pug | 19 +++++++++++++++++-- client/js/site-admin-app.js | 20 ++++++++++++++++++++ 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/app/controllers/admin/service-node.js b/app/controllers/admin/service-node.js index c644026..26f8c06 100644 --- a/app/controllers/admin/service-node.js +++ b/app/controllers/admin/service-node.js @@ -32,6 +32,8 @@ class ServiceNodeController extends SiteController { router.get('/:clientId', this.getClientView.bind(this)); router.get('/', this.getIndex.bind(this)); + router.delete('/:clientId', this.deleteClient.bind(this)); + return router; } @@ -106,6 +108,23 @@ class ServiceNodeController extends SiteController { return next(error); } } + + async deleteClient (req, res) { + const { oauth2: oauth2Service } = this.dtp.services; + try { + await oauth2Service.removeClient(res.locals.serviceNode); + + const displayList = this.createDisplayList('delete-newsletter'); + displayList.navigateTo('/admin/service-node'); + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to delete client', { clientId: res.locals.serviceNode._id, error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } } module.exports = { diff --git a/app/services/oauth2.js b/app/services/oauth2.js index ec8532f..2e886b8 100644 --- a/app/services/oauth2.js +++ b/app/services/oauth2.js @@ -458,6 +458,15 @@ class OAuth2Service extends SiteService { */ return done(null, client); } + + /** + * Removes and fully de-authorizes an OAuth2Client from the system. + * @param {OAuth2Client} client the client to be removed + */ + async removeClient (client) { + this.log.info('removing client', { clientId: client._id, }); + await OAuth2Client.deleteOne({ _id: client._id }); + } } module.exports = { diff --git a/app/views/admin/service-node/editor.pug b/app/views/admin/service-node/editor.pug index f309898..21a09d7 100644 --- a/app/views/admin/service-node/editor.pug +++ b/app/views/admin/service-node/editor.pug @@ -18,15 +18,30 @@ block content .uk-margin label(for="notes").uk-form-label Notes - textarea(id="notes", name="notes", rows="4", placeholder="Enter client notes").uk-textarea= serviceNode.admin.notes + textarea(id="notes", name="notes", rows="4", placeholder="Enter client notes").uk-textarea= (serviceNode.admin && serviceNode.admin.notes) ? serviceNode.admin.notes : undefined .uk-margin label(for="is-active") input(id="is-active", name="isActive", type="checkbox", checked= serviceNode.flags.isActive).uk-checkbox span.uk-margin-small-left Is Active + .uk-card-footer div(uk-grid).uk-grid-small .uk-width-expand +renderBackButton() + + .uk-width-auto + button( + type="button", + data-service-node-id= serviceNode._id, + onclick="return dtp.adminApp.deleteServiceNode(event);" + ).uk-button.uk-button-danger.uk-border-rounded + span + i.fas.fa-trash + span.uk-margin-small-left Delete + .uk-width-auto - button(type="submit").uk-button.uk-button-primary Save \ No newline at end of file + button(type="submit").uk-button.uk-button-primary.uk-border-rounded + span + i.fas.fa-save + span.uk-margin-small-left Save \ No newline at end of file diff --git a/client/js/site-admin-app.js b/client/js/site-admin-app.js index 6786430..57a1eb7 100644 --- a/client/js/site-admin-app.js +++ b/client/js/site-admin-app.js @@ -324,6 +324,26 @@ export default class DtpSiteAdminHostStatsApp extends DtpApp { UIkit.modal.alert(`Failed to send Core Connect response: ${error.message}`); } } + + async deleteServiceNode (event) { + const target = event.currentTarget || event.target; + const serviceNodeId = target.getAttribute('data-service-node-id'); + try { + await UIkit.modal.confirm('Are you sure you want to delete the Service Node?'); + } catch (error) { + return; // user cancel + } + try { + const actionUrl = `/admin/service-node/${serviceNodeId}`; + const response = await fetch(actionUrl, { method: 'DELETE' }); + if (!response.ok) { + throw new Error('Server error'); + } + await this.processResponse(response); + } catch (error) { + UIkit.modal.alert(`Failed to delete Service Node: ${error.message}`); + } + } } dtp.DtpSiteAdminHostStatsApp = DtpSiteAdminHostStatsApp; \ No newline at end of file From 40cae0d899f8e11096e8f1c70f0b8773765f94cc Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 25 Aug 2022 00:49:33 -0400 Subject: [PATCH 33/35] chat room management updates --- app/views/chat/components/room-list.pug | 21 +++++++++++++++++---- app/views/chat/layouts/room.pug | 19 ++----------------- 2 files changed, 19 insertions(+), 21 deletions(-) diff --git a/app/views/chat/components/room-list.pug b/app/views/chat/components/room-list.pug index 0c07821..6244561 100644 --- a/app/views/chat/components/room-list.pug +++ b/app/views/chat/components/room-list.pug @@ -1,4 +1,17 @@ -mixin renderRoomList (rooms) - each room in rooms - li.uk-active - a(href=`/chat/room/${room._id}`)= room.name \ No newline at end of file +mixin renderRoomList (rooms, options) + ul#room-list.uk-nav.uk-nav-default + + li.uk-nav-header + div(uk-grid).uk-grid-small + .uk-text-bold.uk-width-expand= options.title + if !options.hideCreate + .uk-width-auto + a(href='/chat/room/create', title= "Create new chat room...").uk-link-reset + i.fas.fa-plus + + if Array.isArray(rooms) && (rooms.length > 0) + each room in rooms + li.uk-active + a(href=`/chat/room/${room._id}`)= room.name + else + li= options.emptyPrompt \ No newline at end of file diff --git a/app/views/chat/layouts/room.pug b/app/views/chat/layouts/room.pug index 283389a..4da72bf 100644 --- a/app/views/chat/layouts/room.pug +++ b/app/views/chat/layouts/room.pug @@ -9,25 +9,10 @@ block content-container 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 - if Array.isArray(ownedChatRooms) && (ownedChatRooms.length > 0) - ul#room-list.uk-nav.uk-nav-default - li.uk-nav-header - div(uk-grid).uk-grid-small - .uk-text-bold.uk-width-expand Your Rooms - .uk-width-auto - a(href='/chat/room/create', title= "Create new chat room...").uk-link-reset - i.fas.fa-plus - +renderRoomList(ownedChatRooms) - else - div You don't own any chat rooms. + +renderRoomList(ownedChatRooms, { title: "Your Rooms", emptyPrompt: "You don't own any chat rooms" }) .site-chat-sidebar-widget.uk-border-rounded - if Array.isArray(joinedChatRooms) && (joinedChatRooms.length > 0) - ul#room-list.uk-nav.uk-nav-default - li.uk-nav-header Joined Rooms - +renderRoomList(joinedChatRooms) - else - div You haven't joined any chat rooms. + +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 From 4c817243c1500adee2c8d2da491f693632b44cc0 Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 25 Aug 2022 00:52:58 -0400 Subject: [PATCH 34/35] remove emoji explosion --- client/js/site-chat.js | 1 - 1 file changed, 1 deletion(-) diff --git a/client/js/site-chat.js b/client/js/site-chat.js index 22e38ce..56c7a8f 100644 --- a/client/js/site-chat.js +++ b/client/js/site-chat.js @@ -247,7 +247,6 @@ export default class SiteChat { createEmojiReact (message) { this.ui.reactions.create(message.reaction); - this.triggerEmojiExplosion(); } triggerEmojiExplosion ( ) { From 578aaffb1eee4cafa0b37d42240575e331633f28 Mon Sep 17 00:00:00 2001 From: rob Date: Thu, 25 Aug 2022 00:55:11 -0400 Subject: [PATCH 35/35] remove redundant send --- lib/site-ioserver.js | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/site-ioserver.js b/lib/site-ioserver.js index 49bfdec..eea3471 100644 --- a/lib/site-ioserver.js +++ b/lib/site-ioserver.js @@ -190,7 +190,6 @@ class SiteIoServer extends Events { const channelId = reaction.subject.toString(); await chatService.sendMessage(channelId, 'user-react', payload); - session.socket.emit('user-react', payload); } catch (error) { this.log.error('failed to process reaction', { message, error }); session.socket.emit('system-message', {