From b81533bc390e1465f0c659853c05065dd0240df3 Mon Sep 17 00:00:00 2001 From: rob Date: Sun, 21 Apr 2024 19:22:03 -0400 Subject: [PATCH] huge update --- README.md | 76 +++++++- app/controllers/admin.js | 66 +++++++ app/controllers/admin/user.js | 70 +++++++ app/controllers/chat.js | 39 ++++ app/controllers/manifest.js | 4 +- app/models/chat-message.js | 6 + app/models/chat-room.js | 3 + app/models/video.js | 3 + app/services/chat.js | 172 ++++++++++++++++- app/services/user.js | 13 ++ app/services/video.js | 174 +++++++++++++++++- app/views/admin/dashboard.pug | 25 +++ app/views/admin/layout/main.pug | 28 +++ .../admin/user/components/list-table.pug | 19 ++ app/views/admin/user/dashboard.pug | 7 + app/views/admin/user/view.pug | 103 +++++++++++ app/views/chat/components/message.pug | 86 +++++++-- .../components/reaction-bar-standalone.pug | 3 + app/views/chat/components/reaction-bar.pug | 18 ++ app/views/chat/room/settings.pug | 45 ++++- app/views/chat/room/view.pug | 14 +- app/views/components/library.pug | 19 ++ app/views/components/navbar.pug | 6 +- app/views/layout/main.pug | 4 +- app/workers/chat-processor.js | 70 +++++++ app/workers/worker-template.js | 84 +++++++++ client/css/dtp-site.less | 6 +- client/css/site/drop-feedback.less | 50 +++++ client/css/site/main.less | 4 + client/css/site/menu.less | 4 + client/css/site/stage.less | 57 +++++- client/css/site/stats.less | 18 ++ client/css/site/uikit-theme.dtp-dark.less | 8 +- client/css/site/uikit-theme.dtp-light.less | 10 +- client/js/chat-client.js | 87 ++++++++- client/static/sfx/reaction-remove.mp3 | Bin 0 -> 1536 bytes client/static/sfx/reaction.mp3 | Bin 0 -> 2088 bytes client/static/sfx/room-connect.mp3 | Bin 0 -> 48900 bytes dtp-chat-cli.js | 157 ++++++++++++++++ dtp-chat.js | 18 +- lib/client/js/dtp-app.js | 1 - lib/client/js/dtp-display-engine.js | 1 - lib/site-controller.js | 21 ++- lib/site-runtime.js | 11 +- start-local | 2 + 45 files changed, 1538 insertions(+), 74 deletions(-) create mode 100644 app/controllers/admin.js create mode 100644 app/controllers/admin/user.js create mode 100644 app/views/admin/dashboard.pug create mode 100644 app/views/admin/layout/main.pug create mode 100644 app/views/admin/user/components/list-table.pug create mode 100644 app/views/admin/user/dashboard.pug create mode 100644 app/views/admin/user/view.pug create mode 100644 app/views/chat/components/reaction-bar-standalone.pug create mode 100644 app/views/chat/components/reaction-bar.pug create mode 100644 app/workers/chat-processor.js create mode 100644 app/workers/worker-template.js create mode 100644 client/css/site/drop-feedback.less create mode 100644 client/css/site/menu.less create mode 100644 client/css/site/stats.less create mode 100644 client/static/sfx/reaction-remove.mp3 create mode 100644 client/static/sfx/reaction.mp3 create mode 100644 client/static/sfx/room-connect.mp3 create mode 100644 dtp-chat-cli.js diff --git a/README.md b/README.md index e7c11a8..5d9870e 100644 --- a/README.md +++ b/README.md @@ -1,23 +1,85 @@ # DTP Chat + A no-nonsense/no-frills communications platform. -## Production Host Configuration +## System Requirements +Make sure you've got the latest/greatest for your Ubuntu. + +```sh +apt -y update && apt -y upgrade +apt -y install python3-pip build-essential ffmpeg supervisor ``` -adduser dtp +The Linux headers and image installs are optional. If you not using a GPU for video encoding, you won't need kernel sources to build your GPU's driver. + +```sh +apt -y install linux-headers-generic linux-headers-virtual linux-image-virtual linux-virtual ``` +The latest pnpm instructions can always be found here: +[pnpm installation](https://pnpm.io/installation) + +These are just convenience copies from that page. + +```sh +corepack enable pnpm +corepack use pnpm@latest ``` -curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash +```sh +pnpm install ``` -### Install Yarn v2 +## Development Host Management +For convenience, it's possible to open multiple terminals in VS Code using `Ctrl + Shift + ~` + +In one terminal, start the workers and MinIO. + +```sh +./start-local ``` -corepack enable -yarn set version stable -yarn install + +In a separate terminal, starts the dev application environment. + +```sh +pnpm dev ``` +## Production Host Configuration + +Configure the firewall: + +```sh +ufw allow ssh +ufw allow http +ufw allow https +ufw allow from [console_ip] proto tcp to [host_ip] port 8190 +ufw enable +``` +Create the DTP admin user on the host. All production hosts require this user. Do not set a login password for this user. We will be creating SSH keys for the user to access your git repo as a deployer, but you will never log into the host using the DTP user account. + +Instead, we always log in as root, and use `su - dtp` to become the DTP user. + +```sh +adduser dtp # just accept the defaults or enter whatever you want +su - dtp # this will put you in the DTP home directory as the DTP user +ssh-keygen # generate the user's SSH key to use for git deployments + +# print the DTP user's SSH public key to provide to git repo as deploy key +cat ~/.ssh/id_rsa.pub +``` +Add that SSH key to your git repo as a deploy key. + +In the DTP user's home directory: + +```sh +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash + +npm install --lts +``` + +Edit `.bashrc` and set `NODE_ENV=production` + ## Emoji Picker + Chat currently uses [emoji-picker-element](https://www.npmjs.com/package/emoji-picker-element) as the renderer of an emoji picker, and then it manages the presentation of the picker itself. \ No newline at end of file diff --git a/app/controllers/admin.js b/app/controllers/admin.js new file mode 100644 index 0000000..c50d7d2 --- /dev/null +++ b/app/controllers/admin.js @@ -0,0 +1,66 @@ +// admin.js +// Copyright (C) 2024 Digital Telepresence, LLC +// All Rights Reserved + +'use strict'; + +import path, { dirname } from 'path'; +import { fileURLToPath } from 'url'; +const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line + +import mongoose from 'mongoose'; +const User = mongoose.model('User'); +const ChatRoom = mongoose.model('ChatRoom'); +const ChatMessage = mongoose.model('ChatMessage'); +const ChatImage = mongoose.model('Image'); +const Video = mongoose.model('Video'); + +import express from 'express'; + +import { SiteController, SiteError } from '../../lib/site-lib.js'; + +export default class AdminController extends SiteController { + + static get name ( ) { return 'AdminController'; } + static get slug ( ) { return 'admin'; } + + constructor (dtp) { + super(dtp, AdminController); + } + + async start ( ) { + const router = express.Router(); + this.dtp.app.use('/admin', router); + + router.use('/user', await this.loadChild(path.join(__dirname, 'admin', 'user.js'))); + + router.get( + '/', + this.getDashboard.bind(this), + ); + + return router; + } + + async getDashboard (req, res, next) { + const { user: userService } = this.dtp.services; + try { + res.locals.currentView = 'admin'; + + res.locals.stats = { + userCount: await User.estimatedDocumentCount(), + chatRoomCount: await ChatRoom.estimatedDocumentCount(), + chatMessageCount: await ChatMessage.estimatedDocumentCount(), + imageCount: await ChatImage.estimatedDocumentCount(), + videoCount: await Video.estimatedDocumentCount(), + }; + + res.locals.latestSignups = await userService.getLatestSignups(10); + + res.render('admin/dashboard'); + } catch (error) { + this.error.log('failed to present the admin dashboard', { error }); + return next(error); + } + } +} \ No newline at end of file diff --git a/app/controllers/admin/user.js b/app/controllers/admin/user.js new file mode 100644 index 0000000..1c07b50 --- /dev/null +++ b/app/controllers/admin/user.js @@ -0,0 +1,70 @@ +// admin/user.js +// Copyright (C) 2024 Digital Telepresence, LLC +// All Rights Reserved + +'use strict'; + +import express from 'express'; + +import { SiteController, SiteError } from '../../../lib/site-lib.js'; + +export default class UserAdminController extends SiteController { + + static get name ( ) { return 'UserAdminController'; } + static get slug ( ) { return 'admin'; } + + constructor (dtp) { + super(dtp, UserAdminController); + } + + async start ( ) { + const router = express.Router(); + + router.param('userId', this.populateUserId.bind(this)); + + router.get( + '/:userId', + this.getUserView.bind(this), + ); + + router.get( + '/', + this.getDashboard.bind(this), + ); + + return router; + } + + async populateUserId (req, res, next, userId) { + const { user: userService } = this.dtp.services; + try { + res.locals.userAccount = await userService.getUserAccount(userId); + if (!res.locals.userAccount) { + throw new SiteError(404, 'User not found'); + } + return next(); + } catch (error) { + this.log.error('failed to populate user account', { userId, error }); + return next(error); + } + } + + async getUserView (req, res) { + res.render('admin/user/view'); + } + + async getDashboard (req, res, next) { + const { user: userService } = this.dtp.services; + try { + res.locals.currentView = 'admin'; + res.locals.adminView = 'user'; + + res.locals.latestSignups = await userService.getLatestSignups(10); + + res.render('admin/user/dashboard'); + } catch (error) { + this.error.log('failed to present the admin dashboard', { error }); + return next(error); + } + } +} \ No newline at end of file diff --git a/app/controllers/chat.js b/app/controllers/chat.js index 6a311d3..12eb89a 100644 --- a/app/controllers/chat.js +++ b/app/controllers/chat.js @@ -55,9 +55,11 @@ export default class ChatController extends SiteController { ); router.param('roomId', this.populateRoomId.bind(this)); + router.param('messageId', this.populateMessageId.bind(this)); router.post( '/room/:roomId/message', + // limiterService.create(limiterService.config.chat.postRoomMessage), multer.fields([{ name: 'imageFiles', maxCount: 6 }, { name: 'videoFiles', maxCount: 1 }]), this.postRoomMessage.bind(this), ); @@ -65,6 +67,7 @@ export default class ChatController extends SiteController { router.post( '/room/:roomId/settings', requireRoomOwner, + // limiterService.create(limiterService.config.chat.postRoomSettings), this.postRoomSettings.bind(this), ); @@ -74,8 +77,16 @@ export default class ChatController extends SiteController { this.postCreateRoom.bind(this), ); + router.post( + '/message/:messageId/reaction', + // limiterService.create(limiterService.config.chat.postMessageReaction), + multer.none(), + this.postMessageReaction.bind(this), + ); + router.get( '/room/create', + // limiterService.create(limiterService.config.chat.getRoomCreateView), this.getRoomCreateView.bind(this), ); @@ -106,6 +117,7 @@ export default class ChatController extends SiteController { router.delete( '/room/:roomId', + // limiterService.create(limiterService.config.chat.deleteRoom), this.deleteRoom.bind(this), ); @@ -125,6 +137,33 @@ export default class ChatController extends SiteController { } } + async populateMessageId (req, res, next, messageId) { + const { chat: chatService } = this.dtp.services; + try { + res.locals.message = await chatService.getMessageById(messageId); + if (!res.locals.message) { + throw new SiteError(404, "The chat message doesn't exist."); + } + return next(); + } catch (error) { + return next(error); + } + } + + async postMessageReaction (req, res) { + const { chat: chatService } = this.dtp.services; + try { + await chatService.toggleMessageReaction(req.user, res.locals.message, req.body); + return res.status(200).json({ success: true }); + } catch (error) { + this.log.error('failed to send chat room message', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + async postRoomMessage (req, res) { const { chat: chatService } = this.dtp.services; try { diff --git a/app/controllers/manifest.js b/app/controllers/manifest.js index cd14079..8e68048 100644 --- a/app/controllers/manifest.js +++ b/app/controllers/manifest.js @@ -52,8 +52,8 @@ export default class ManifestController extends SiteController { short_name: this.dtp.config.site.name, description: this.dtp.config.site.description, display: 'fullscreen', - theme_color: '#e8e8e8', - background_color: '#c32b2b', + theme_color: '#62767e', + background_color: '#62767e', icons: [ ], }; diff --git a/app/models/chat-message.js b/app/models/chat-message.js index 66f56a1..f6f59b2 100644 --- a/app/models/chat-message.js +++ b/app/models/chat-message.js @@ -9,6 +9,11 @@ const Schema = mongoose.Schema; const CHANNEL_TYPE_LIST = ['User', 'ChatRoom']; +const ReactionSchema = new Schema({ + emoji: { type: String }, + users: { type: [Schema.ObjectId], ref: 'User' }, +}); + const ChatMessageSchema = new Schema({ created: { type: Date, default: Date.now, required: true, index: -1, expires: '30d' }, expires: { type: Date, index: -1 }, @@ -23,6 +28,7 @@ const ChatMessageSchema = new Schema({ images: { type: [Schema.ObjectId], ref: 'Image' }, videos: { type: [Schema.ObjectId], ref: 'Video' }, }, + reactions: { type: [ReactionSchema], default: [ ] }, }); export default mongoose.model('ChatMessage', ChatMessageSchema); \ No newline at end of file diff --git a/app/models/chat-room.js b/app/models/chat-room.js index 88b8fcf..b5df023 100644 --- a/app/models/chat-room.js +++ b/app/models/chat-room.js @@ -20,6 +20,9 @@ const ChatRoomSchema = new Schema({ members: { type: [Schema.ObjectId], select: false }, present: { type: [Schema.ObjectId], select: false }, banned: { type: [Schema.ObjectId], select: false }, + settings: { + expireDays: { type: Number, default: 7, min: 1, max: 30, required: true }, + }, stats: { memberCount: { type: Number, default: 0, min: 0, max: MAX_ROOM_CAPACITY, required: true }, presentCount: { type: Number, default: 0, min: 0, max: MAX_ROOM_CAPACITY, required: true }, diff --git a/app/models/video.js b/app/models/video.js index 0543ecd..cf3f2d0 100644 --- a/app/models/video.js +++ b/app/models/video.js @@ -18,6 +18,9 @@ const VideoSchema = new Schema({ duration: { type: Number }, thumbnail: { type: Schema.ObjectId, ref: 'Image' }, status: { type: String, enum: VIDEO_STATUS_LIST, required: true, index: 1 }, + flags: { + fromGif: { type: Boolean, default: false, required: true }, + }, media: { bucket: { type: String, required: true }, key: { type: String, required: true }, diff --git a/app/services/chat.js b/app/services/chat.js index d028103..6d9245f 100644 --- a/app/services/chat.js +++ b/app/services/chat.js @@ -10,6 +10,7 @@ const ChatMessage = mongoose.model('ChatMessage'); const ChatRoomInvite = mongoose.model('ChatRoomInvite'); import numeral from 'numeral'; +import dayjs from 'dayjs'; import { SiteService, SiteError } from '../../lib/site-lib.js'; import { MAX_ROOM_CAPACITY } from '../models/lib/constants.js'; @@ -29,6 +30,7 @@ export default class ChatService extends SiteService { this.templates = { message: this.loadViewTemplate('chat/components/message-standalone.pug'), memberListItem: this.loadViewTemplate('chat/components/member-list-item-standalone.pug'), + reactionBar: this.loadViewTemplate('chat/components/reaction-bar-standalone.pug'), }; this.populateChatRoom = [ @@ -63,6 +65,15 @@ export default class ChatService extends SiteService { { path: 'attachments.videos', }, + { + path: 'reactions', + populate: [ + { + path: 'users', + select: 'username displayName', + }, + ], + }, ]; } @@ -86,6 +97,27 @@ export default class ChatService extends SiteService { return room.toObject(); } + async updateRoomSettings (room, settingsDefinition) { + const { text: textService } = this.dtp.services; + const update = { $set: { }, $unset: { } }; + + update.$set.name = textService.filter(settingsDefinition.name); + if (!update.$set.name) { + throw new SiteError(400, 'Room must have a name'); + } + + const topic = textService.filter(settingsDefinition.topic); + if (topic && (room.topic !== topic)) { + update.$set.topic = topic; + } else { + update.$unset.topic = 1; + } + + update.$set['settings.expireDays'] = parseInt(settingsDefinition.expireDays, 10); + + await ChatRoom.updateOne({ _id: room._id }, update); + } + async destroyRoom (user, room) { if (!user._id.equals(room.owner._id)) { throw new SiteError(401, 'This is not your chat room'); @@ -165,6 +197,7 @@ export default class ChatService extends SiteService { .to(room._id.toString()) .emit('chat-control', { displayList, + audio: { playSound: 'chat-room-connect' }, systemMessages: [systemMessage], }); } @@ -225,6 +258,7 @@ export default class ChatService extends SiteService { const message = new ChatMessage(); message.created = NOW; + message.expires = dayjs(NOW).add(room?.settings?.expireDays || 7, 'day'); message.channelType = 'ChatRoom'; message.channel = room._id; message.author = author._id; @@ -243,20 +277,37 @@ export default class ChatService extends SiteService { if (videoFiles) { for (const videoFile of videoFiles) { - const video = await videoService.createVideo(author, { }, videoFile); - message.attachments.videos.push(video._id); + switch (videoFile.mimetype) { + case 'video/mp4': + const video = await videoService.createVideo(author, { }, videoFile); + message.attachments.videos.push(video._id); + break; + + case 'video/quicktime': + await videoService.transcodeMov(videoFile); + const mov = await videoService.createVideo(author, { }, videoFile); + message.attachments.videos.push(mov._id); + break; + + case 'image/gif': + await videoService.transcodeGif(videoFile); + const gif = await videoService.createVideo(author, { fromGif: true }, videoFile); + message.attachments.videos.push(gif._id); + break; + } } } await message.save(); + await ChatMessage.populate(message, this.populateChatMessage); - const messageObj = message.toObject(); - let viewModel = Object.assign({ }, this.dtp.app.locals); + viewModel = Object.assign(viewModel, { user: author, message }); + const html = this.templates.message(viewModel); + + const messageObj = message.toObject(); messageObj.author = userService.filterUserObject(author); - viewModel = Object.assign(viewModel, { message: messageObj }); - const html = this.templates.message(viewModel); this.dtp.emitter .to(room._id.toString()) .emit('chat-message', { message: messageObj, html }); @@ -275,6 +326,97 @@ export default class ChatService extends SiteService { return messages.reverse(); } + async toggleMessageReaction (sender, message, reactionDefinition) { + const reaction = message.reactions ? message.reactions.find((r) => r.emoji === reactionDefinition.emoji) : undefined; + if (reaction) { + const currentReact = reaction.users.find((user) => user._id.equals(sender._id)); + if (currentReact) { + if (reaction.users.length === 1) { + // last user to react, remove the whole reaction for this emoji + await ChatMessage.updateOne( + { + _id: message._id, + 'reactions.emoji': reactionDefinition.emoji, + }, + { + $pull: { + 'reactions': { emoji: reactionDefinition.emoji }, + }, + }, + ); + return this.updateMessageReactionBar(message, { playSound: 'reaction-remove' }); + } + + // just pull the user from the emoji's users array + await ChatMessage.updateOne( + { + _id: message._id, + 'reactions.emoji': reactionDefinition.emoji, + }, + { + $pull: { + 'reactions': { user: sender._id }, + }, + }, + ); + return this.updateMessageReactionBar(message, { playSound: 'reaction-remove' }); + } else { + // add sender to emoji's users array + await ChatMessage.updateOne( + { + _id: message._id, + 'reactions.emoji': reactionDefinition.emoji, + }, + { + $push: { + 'reactions.$.users': sender._id, + } + }, + ); + return this.updateMessageReactionBar(message, { playSound: 'reaction' }); + } + } + + // create a reaction for the emoji + await ChatMessage.updateOne( + { _id: message._id }, + { + $push: { + reactions: { + emoji: reactionDefinition.emoji, + users: [sender._id], + }, + }, + }, + ); + return this.updateMessageReactionBar(message, { playSound: 'reaction' }); + } + + async updateMessageReactionBar (message, audio) { + message = await ChatMessage + .findOne({ _id: message._id }) + .populate(this.populateChatMessage) + .lean(); + + let viewModel = Object.assign({ }, this.dtp.app.locals); + viewModel = Object.assign(viewModel, { message }); + + const displayList = this.createDisplayList('reaction-bar-update'); + displayList.replaceElement( + `.chat-message[data-message-id="${message._id}"] .message-reaction-bar`, + this.templates.reactionBar(viewModel), + ); + + const payload = { displayList }; + if (audio) { + payload.audio = audio; + } + + this.dtp.emitter + .to(message.channel._id.toString()) + .emit('chat-control', payload); + } + async checkRoomMember (room, member) { if (room.owner._id.equals(member._id)) { return true; @@ -353,6 +495,14 @@ export default class ChatService extends SiteService { await ChatRoomInvite.deleteMany({ room: room._id }); } + async getMessageById (messageId) { + const message = await ChatMessage + .findOne({ _id: messageId }) + .populate(this.populateChatMessage) + .lean(); + return message; + } + async removeMessagesForChannel (channel) { this.log.alert('removing all messages for channel', { channelId: channel._id }); await ChatMessage @@ -365,8 +515,16 @@ export default class ChatService extends SiteService { async expireMessages ( ) { const NOW = new Date(); + + this.log.info('expiring chat messages'); + await ChatMessage - .find({ expires: { $lt: NOW } }) + .find({ + $or: [ + { expires: { $lt: NOW } }, + { expires: { $exists: false } }, + ], + }) .cursor() .eachAsync(async (message) => { await this.removeMessage(message); diff --git a/app/services/user.js b/app/services/user.js index a04737b..b521c10 100644 --- a/app/services/user.js +++ b/app/services/user.js @@ -15,6 +15,7 @@ import PassportLocal from 'passport-local'; import { v4 as uuidv4 } from 'uuid'; import { SiteService, SiteError } from '../../lib/site-lib.js'; +import { users } from 'systeminformation'; export default class UserService extends SiteService { @@ -25,6 +26,7 @@ export default class UserService extends SiteService { super(dtp, UserService); this.USER_SELECT = '_id created username username_lc displayName picture flags permissions'; + this.ADMIN_SELECT = '_id created email username username_lc displayName picture flags permissions'; this.populateUser = [ { path: 'picture.large', @@ -240,6 +242,17 @@ export default class UserService extends SiteService { return users; } + async getLatestSignups (count = 5) { + const users = await User + .find() + .sort({ created: -1}) + .select(this.ADMIN_SELECT) + .limit(count) + .populate(this.populateUser) + .lean(); + return users; + } + async isUsernameReserved (username) { if (this.reservedNames.includes(username)) { this.log.alert('prohibiting use of reserved username', { username }); diff --git a/app/services/video.js b/app/services/video.js index b49b053..494e2c3 100644 --- a/app/services/video.js +++ b/app/services/video.js @@ -13,9 +13,17 @@ const Video = mongoose.model('Video'); import mime from 'mime'; import numeral from 'numeral'; +import { v4 as uuidv4 } from 'uuid'; + import { SiteService, SiteError } from '../../lib/site-lib.js'; import user from '../models/user.js'; +const ONE_MEGABYTE = 1024 * 1024; +const SIZE_100MB = ONE_MEGABYTE * 25; +const SIZE_25MB = ONE_MEGABYTE * 25; + +const MAX_VIDEO_BITRATE = 4000; + export default class VideoService extends SiteService { static get name ( ) { return 'VideoService'; } @@ -42,20 +50,22 @@ export default class VideoService extends SiteService { ]; } - async createVideo (owner, attachmentDefinition, file) { + async createVideo (owner, videoDefinition, file) { const NOW = new Date(); const { media: mediaService, minio: minioService } = this.dtp.services; + this.log.debug('video definition in createVideo', { videoDefinition }); + this.log.debug('running ffprobe on uploaded library media', { path: file.path }); const metadata = await mediaService.ffprobe(file.path); this.log.debug('video file probed', { path: file.path, metadata }); if (user.membership) { - if (file.size > (100 * 1024 * 1024)) { + if (file.size > SIZE_100MB) { throw new SiteError(403, 'Video attachments are limited to 100MB file size.'); } } else { - if (file.size > (25 * 1024 * 1024)) { + if (file.size > SIZE_25MB) { throw new SiteError(403, 'Video attachments are limited to 25MB file size.'); } } @@ -66,6 +76,13 @@ export default class VideoService extends SiteService { video.duration = metadata.duration; video.status = 'new'; + if (!videoDefinition.fromGif) { + videoDefinition.fromGif = false; + } + video.flags = { + fromGif: videoDefinition.fromGif, + }; + const ownerId = owner._id.toString(); const videoId = video._id.toString(); const fileBucket = process.env.MINIO_VIDEO_BUCKET || 'videos'; @@ -192,6 +209,157 @@ export default class VideoService extends SiteService { await this.removeVideoThumbnailImage(video); } + if (!video.media || !video.media.bucket || !video.media.key) { + return; + } + await minioService.removeObject(video.media.bucket, video.media.key); } + + async transcodeMov (file) { + const { media: mediaService } = this.dtp.services; + + const probe = await mediaService.ffprobe(file.path); + const videoStream = probe.streams.find((stream) => stream.codec_type === 'video'); + const audioStream = probe.streams.find((stream) => stream.codec_type === 'audio'); + this.log.info('Quicktime MOV probe result', { probe }); + + const transcodeArgs = ['-y', '-i', file.path]; + + if (videoStream && (videoStream.codec_name === 'h264')) { + transcodeArgs.push('-c:v', 'copy'); + } else { + let needScale = false; + + /* + * If the width isn't divisible by 2, adapt. + */ + if (probe.width && ((probe.width % 2) !== 0)) { + probe.width = Math.floor(probe.width / 2) * 2; + needScale = true; + } + + /* + * If the height isn't divisible by 2, that needs to be fixed. + */ + if ((probe.height % 2) !== 0) { + probe.height = Math.floor(probe.height / 2) * 2; + needScale = true; + } else if (probe.height && (probe.height > 540)) { + probe.height = 540; + needScale = true; + } + + if (needScale) { + transcodeArgs.push('-vf', `scale=${probe.width}:${probe.height}`); + } + + /* + * Software: libx264 + * GPU: h264_nvenc + */ + transcodeArgs.push('-pix_fmt', 'yuv420p'); + transcodeArgs.push('-c:v', 'libx264'); + + /* + * If bit rate is too high, correct it. + */ + probe.format.bit_rate = parseInt(probe.format.bit_rate, 10); + probe.format.bit_rate = Math.round(probe.format.bit_rate / 1024); + this.log.info('detected bit rate', { bitrate: probe.format.bit_rate }); + if (probe.format.bit_rate > MAX_VIDEO_BITRATE) { + transcodeArgs.push('-b:v', `${MAX_VIDEO_BITRATE}k`); + } else { + transcodeArgs.push('-b:v', `${probe.format.bit_rate}k`); + } + + transcodeArgs.push('-profile:v', 'high'); + } + + if (audioStream && (audioStream.codec_name === 'aac')) { + transcodeArgs.push('-c:a', 'copy'); + } else { + transcodeArgs.push( + '-c:a', 'aac', + '-b:a', '160k', + ); + } + + file.uuid = uuidv4(); + const outFile = path.join(process.env.VIDEO_WORK_PATH, `${file.uuid}.mp4`); + transcodeArgs.push( + '-movflags', '+faststart', + outFile, + ); + + this.log.info('transcoding Quicktime MOV video to MP4'); + await mediaService.ffmpeg(transcodeArgs); + + await fs.promises.rm(file.path, { force: true }); + file.path = outFile; + } + + async transcodeGif (file) { + const { media: mediaService } = this.dtp.services; + + const probe = await mediaService.ffprobe(file.path); + this.log.info('GIF probe result', { probe }); + + const transcodeArgs = ['-y', '-i', file.path]; + + let needScale = false; + + /* + * If the width isn't divisible by 2, adapt. + */ + if (probe.width && ((probe.width % 2) !== 0)) { + probe.width = Math.floor(probe.width / 2) * 2; + needScale = true; + } + + /* + * If the height isn't divisible by 2, adapt. + */ + if (probe.height && ((probe.height % 2) !== 0)) { + probe.height = Math.floor(probe.height / 2) * 2; + if (probe.height > 540) { + probe.height = 540; + } + needScale = true; + } else if (probe.height && (probe.height > 540)) { + probe.height = 540; + needScale = true; + } + + if (needScale) { + transcodeArgs.push('-vf', `scale=${probe.width}:${probe.height}`); + } + + transcodeArgs.push('-pix_fmt', 'yuv420p'); + transcodeArgs.push('-c:v', 'libx264'); + + /* + * If bit rate is too high, correct it. + */ + probe.format.bit_rate = Math.round(parseInt(probe.format.bit_rate, 10) / 1024); + this.log.info('detected bit rate', { bitrate: probe.format.bit_rate }); + if (probe.format.bit_rate > MAX_VIDEO_BITRATE) { + transcodeArgs.push('-b:v', `${MAX_VIDEO_BITRATE}k`); + } else { + transcodeArgs.push('-b:v', `${probe.format.bit_rate}k`); + } + + transcodeArgs.push('-profile:v', 'high'); + transcodeArgs.push('-an'); + + file.uuid = uuidv4(); + const outFile = path.join(process.env.VIDEO_WORK_PATH, `${file.uuid}.mp4`); + transcodeArgs.push('-movflags', '+faststart', outFile); + + this.log.info('transcoding GIF video to MP4'); + await mediaService.ffmpeg(transcodeArgs); + + await fs.promises.rm(file.path, { force: true }); + file.path = outFile; + } } \ No newline at end of file diff --git a/app/views/admin/dashboard.pug b/app/views/admin/dashboard.pug new file mode 100644 index 0000000..f570bd8 --- /dev/null +++ b/app/views/admin/dashboard.pug @@ -0,0 +1,25 @@ +extends layout/main +block admin-content + + include user/components/list-table + + mixin renderStatsBlock (label, value) + .stats-block.uk-text-center + .stat-label= label + .stat-value= value + + .uk-margin-medium + div(uk-grid) + .uk-width-1-5 + +renderStatsBlock('Users', formatCount(stats.userCount)) + .uk-width-1-5 + +renderStatsBlock('Chat Rooms', formatCount(stats.chatRoomCount)) + .uk-width-1-5 + +renderStatsBlock('Chat Messages', formatCount(stats.chatMessageCount)) + .uk-width-1-5 + +renderStatsBlock('Images', formatCount(stats.imageCount)) + .uk-width-1-5 + +renderStatsBlock('Videos', formatCount(stats.videoCount)) + + h2.uk-margin-small Latest Signups + +renderAdminUserTable(latestSignups) \ No newline at end of file diff --git a/app/views/admin/layout/main.pug b/app/views/admin/layout/main.pug new file mode 100644 index 0000000..cf84543 --- /dev/null +++ b/app/views/admin/layout/main.pug @@ -0,0 +1,28 @@ +extends ../../layout/main +block view-content + + .uk-margin + .uk-container.uk-container-expand + div(uk-grid) + div(style="width: 150px;") + ul.uk-nav.uk-nav-default + li.uk-nav-header Admin Menu + li + a(href="/admin").uk-display-block + .uk-flex + .uk-width-auto + .menu-icon + i.fa-solid.fa-home + .uk-width-expand + span Home + li + a(href="/admin/user").uk-display-block + .uk-flex + .uk-width-auto + .menu-icon + i.fa-solid.fa-user + .uk-width-expand + span Users + + .uk-width-expand + block admin-content \ No newline at end of file diff --git a/app/views/admin/user/components/list-table.pug b/app/views/admin/user/components/list-table.pug new file mode 100644 index 0000000..75d4dc2 --- /dev/null +++ b/app/views/admin/user/components/list-table.pug @@ -0,0 +1,19 @@ +mixin renderAdminUserTable (users) + .uk-overflow-auto + table.uk-table.uk-table-small.uk-table-justify + thead + tr + th Username + th Display Name + th Email + th(uk-tooltip="(A)dmin (M)oderator (E)mailVerify (G)Cloaked Can(L)ogin Can(C)hat CanC(O)mment Can(R)eport ") Flags + th Created + tbody + each user in users + tr + td + a(href=`/admin/user/${user._id}`)= user.username + td= user.displayName || '---' + td= user.email + td.uk-text-fixed= getUserFlags(user) + td= dayjs(user.created).fromNow() \ No newline at end of file diff --git a/app/views/admin/user/dashboard.pug b/app/views/admin/user/dashboard.pug new file mode 100644 index 0000000..c7bb42e --- /dev/null +++ b/app/views/admin/user/dashboard.pug @@ -0,0 +1,7 @@ +extends ../layout/main +block admin-content + + include components/list-table + + h1 Latest Signups + +renderAdminUserTable(latestSignups) \ No newline at end of file diff --git a/app/views/admin/user/view.pug b/app/views/admin/user/view.pug new file mode 100644 index 0000000..afec61e --- /dev/null +++ b/app/views/admin/user/view.pug @@ -0,0 +1,103 @@ +extends ../layout/main +block admin-content + + include ../../user/components/profile-picture + + .uk-card.uk-card-default.uk-card-small.uk-border-rounded + .uk-card-header + div(uk-grid).uk-grid-small + .uk-width-auto + +renderProfilePicture(userAccount) + + .uk-width-expand + .uk-margin + .uk-text-lead= user.displayName + div(uk-grid).uk-grid-small.uk-grid-divider + .uk-width-auto @#{user.username} + .uk-width-auto= user.email + .uk-width-auto Created #{dayjs(userAccount.created).fromNow()} on #{dayjs(userAccount.created).format('MMMM D, YYYY')} + + .uk-card-body + .uk-margin + label(for="profile-bio").uk-form-label bio + #profile-bio.markdown-block!= marked.parse(userAccount.bio || '(no bio provided)', { renderer: fullMarkdownRenderer }) + + .uk-margin + label(for="profile-badges").uk-form-label Profile Badges + input(id="profile-badges", type= "text", placeholder= "Comma-separated list of badge names", value= userAccount.badges.join(',')).uk-input + + .uk-margin + form(method="POST", action=`/admin/user/${userAccount._id}`).uk-form + .uk-margin + label.uk-form-label Flags + .uk-margin-small + div(uk-grid).uk-grid-small + .uk-width-auto + .pretty.p-default + input(type="checkbox", name="isAdmin", checked= userAccount.flags.isAdmin) + .state.p-success + label Admin + .uk-width-auto + .pretty.p-default + input(type="checkbox", name="isModerator", checked= userAccount.flags.isModerator) + .state.p-success + label Moderator + .uk-width-auto + .pretty.p-default + input(type="checkbox", name="isEmailVerified", checked= userAccount.flags.isEmailVerified) + .state.p-success + label Email Verified + + .uk-margin + label.uk-form-label Permissions + .uk-margin-small + div(uk-grid).uk-grid-small + .uk-width-auto + .pretty.p-default + input(type="checkbox", name="canLogin", checked= userAccount.permissions.canLogin) + .state.p-success + label Can Login + .uk-width-auto + .pretty.p-default + input(type="checkbox", name="canChat", checked= userAccount.permissions.canChat) + .state.p-success + label Can Chat + .uk-width-auto + .pretty.p-default + input(type="checkbox", name="canReport", checked= userAccount.permissions.canReport) + .state.p-success + label Can Report + .uk-width-auto + .pretty.p-default + input(type="checkbox", name="canShareLinks", checked= userAccount.permissions.canShareLinks) + .state.p-success + label Can Share Links + + .uk-margin + label.uk-form-label Email Opt-In + .uk-margin-small + div(uk-grid).uk-grid-small + .uk-width-auto + .pretty.p-default + input(type="checkbox", name="optIn.system", checked= userAccount.optIn.system) + .state.p-success + label System Messages + .uk-width-auto + .pretty.p-default + input(type="checkbox", name="optIn.marketing", checked= userAccount.optIn.marketing) + .state.p-success + label Marketing + + .uk-card-footer + div(uk-grid).uk-grid-medium + .uk-width-auto + button(type="submit", name="action", value="update").uk-button.uk-button-primary.uk-border-rounded + span + i.fa-solid.fa-save + span.uk-margin-small-left Update User + + .uk-width-auto + button(type="submit", name="action", value="ban").uk-button.uk-button-danger.uk-border-rounded + span + i.fa-solid.fa-cancel + span.uk-margin-small-left Ban User diff --git a/app/views/chat/components/message.pug b/app/views/chat/components/message.pug index 12c3836..65176eb 100644 --- a/app/views/chat/components/message.pug +++ b/app/views/chat/components/message.pug @@ -1,7 +1,9 @@ include ../../link/components/preview include ../../user/components/profile-picture +include ./reaction-bar + mixin renderChatMessage (message) - .chat-message + div(data-message-id= message._id).chat-message .uk-flex .uk-width-auto.no-select +renderProfilePicture(message.author, { iconClass: 'member-profile-icon' }) @@ -31,21 +33,83 @@ mixin renderChatMessage (message) if Array.isArray(message.attachments.videos) && (message.attachments.videos.length > 0) each video of message.attachments.videos - video( - data-video-id= video._id, - poster= video.thumbnail ? `/image/${video.thumbnail._id}` : false, - controls, disablepictureinpicture, disableremoteplayback, playsinline, - ).video-attachment - source(src=`/video/${video._id}/media`) + if video.flags && video.flags.fromGif + video( + data-video-id= video._id, + data-from-gif, + poster= video.thumbnail ? `/image/${video.thumbnail._id}` : false, + disablepictureinpicture, disableremoteplayback, playsinline, muted, autoplay, loop, + ).video-attachment + source(src=`/video/${video._id}/media`) + else + video( + data-video-id= video._id, + poster= video.thumbnail ? `/image/${video.thumbnail._id}` : false, + controls, disablepictureinpicture, disableremoteplayback, playsinline, + ).video-attachment + source(src=`/video/${video._id}/media`) if Array.isArray(message.links) && (message.links.length > 0) each link in message.links div(class="uk-width-large").uk-margin-small +renderLinkPreview(link, { layout: 'responsive' }) - .uk-width-auto - .uk-text-bold ! + + +renderReactionBar(message) .message-menu - div(uk-grid).uk-grid-small + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-auto + button( + type="button", + data-message-id= message._id, + data-emoji="👍️", + uk-tooltip="React with thumbs-up" + onclick="return dtp.app.toggleMessageReaction(event);", + ).message-menu-button 👍️ + .uk-width-auto + button( + type="button", + data-message-id= message._id, + data-emoji="👎️", + uk-tooltip="React with thumbs-down" + onclick="return dtp.app.toggleMessageReaction(event);", + ).message-menu-button 👎️ + .uk-width-auto + button( + type="button", + data-message-id= message._id, + data-emoji="😃", + uk-tooltip="React with smiley" + onclick="return dtp.app.toggleMessageReaction(event);", + ).message-menu-button 😃 + .uk-width-auto + button( + type="button", + data-message-id= message._id, + data-emoji="🫡", + uk-tooltip="React with salute" + onclick="return dtp.app.toggleMessageReaction(event);", + ).message-menu-button 🫡 + .uk-width-auto + button( + type="button", + data-message-id= message._id, + data-emoji="🎉", + uk-tooltip="React with a tada!" + onclick="return dtp.app.toggleMessageReaction(event);", + ).message-menu-button 🎉 + .uk-width-auto - div Emoji reacts & shit + button(type="button").dropdown-menu + span + i.fa-solid.fa-ellipsis-vertical + div(uk-dropdown="mode: click") + ul.uk-nav.uk-dropdown-nav + if !user._id.equals(message.author._id) + li + a(href="") Reply + if user._id.equals(message.author._id) + li + a(href="") Edit + li + a(href="") Delete \ No newline at end of file diff --git a/app/views/chat/components/reaction-bar-standalone.pug b/app/views/chat/components/reaction-bar-standalone.pug new file mode 100644 index 0000000..95990df --- /dev/null +++ b/app/views/chat/components/reaction-bar-standalone.pug @@ -0,0 +1,3 @@ +include ../../components/library +include reaction-bar ++renderReactionBar(message) \ No newline at end of file diff --git a/app/views/chat/components/reaction-bar.pug b/app/views/chat/components/reaction-bar.pug new file mode 100644 index 0000000..679e01f --- /dev/null +++ b/app/views/chat/components/reaction-bar.pug @@ -0,0 +1,18 @@ +mixin renderReactionBar (message) + .message-reaction-bar + if Array.isArray(message.reactions) && (message.reactions.length > 0) + .uk-flex.uk-flex-middle + each reaction of message.reactions + .uk-width-auto + button( + type="button", + data-message-id= message._id, + data-emoji= reaction.emoji, + onclick="return dtp.app.toggleMessageReaction(event);", + ).message-react-button + .uk-flex.uk-flex-middle + span.reaction-emoji= reaction.emoji + span= formatCount(reaction.users.length) + div(uk-dropdown="mode: hover") + span.uk-margin-small-right= reaction.emoji + span= reaction.users.map((user) => user.username).join(',') \ No newline at end of file diff --git a/app/views/chat/room/settings.pug b/app/views/chat/room/settings.pug index f861b6a..4f31411 100644 --- a/app/views/chat/room/settings.pug +++ b/app/views/chat/room/settings.pug @@ -16,12 +16,41 @@ block view-content .uk-margin label(for="topic") Topic input(id="topic", name="topic", type="text", placeholder="Enter room topic or leave blank", value= room.topic).uk-input + .uk-margin + label(for="expireDays") Message expiration + div(uk-grid).uk-grid-small + .uk-width-large + input( + id="expire-days", + name="expireDays", + type="range", + min= 1, + max= 30, + step= 1, + value= room.settings.expireDays, + oninput= "return updateExpireDays(event);", + ).uk-range + .uk-width-auto + div(id="expire-days-display") #{room.settings.expireDays} days - .uk-card-footer.uk-flex.uk-flex-right - button( - type="button", - data-room-id= room._id, - data-room-name= room.name, - onclick="dtp.app.confirmRoomDelete(event);", - ).uk-button.uk-button-danger.uk-border-rounded.uk-margin-right Delete Room - button(type="submit").uk-button.uk-button-primary.uk-border-rounded Save Settings \ No newline at end of file + .uk-card-footer + div(uk-grid).uk-grid-small + .uk-width-expand + a(href=`/chat/room/${room._id}`).uk-button.uk-button-defalt.uk-border-rounded Back to room + .uk-width-auto + button( + type="button", + data-room-id= room._id, + data-room-name= room.name, + onclick="dtp.app.confirmRoomDelete(event);", + ).uk-button.uk-button-danger.uk-border-rounded Delete Room + .uk-width-auto + button(type="submit").uk-button.uk-button-primary.uk-border-rounded Save Settings +block viewjs + script. + const expireDaysDisplay = document.querySelector('#expire-days-display'); + function updateExpireDays (event) { + const range = event.currentTarget || event.target; + dtp.app.log.info('ChatSettingsView', 'expiration days is changing', { range }); + expireDaysDisplay.textContent = `${range.value} days`; + } \ No newline at end of file diff --git a/app/views/chat/room/view.pug b/app/views/chat/room/view.pug index 6c9c54a..70b380f 100644 --- a/app/views/chat/room/view.pug +++ b/app/views/chat/room/view.pug @@ -22,7 +22,12 @@ block view-content block view-navbar - .dtp-chat-stage + div( + ondragenter="return dtp.app.onDragEnter(event);", + ondragleave="return dtp.app.onDragLeave(event);", + ondragover="return dtp.app.onDragOver(event);", + ondrop="return dtp.app.onDrop(event);", + ).dtp-chat-stage #room-member-panel.chat-sidebar .chat-stage-header div(uk-grid).uk-grid-small.uk-grid-middle @@ -109,6 +114,13 @@ block view-content button(id="chat-send-btn", type="submit", uk-tooltip={ title: 'Send message' }).uk-button.uk-button-default.uk-button-small.uk-border-rounded i.fa-regular.fa-paper-plane + .dtp-drop-feedback + .drop-feedback-container + .uk-text-center + .feedback-icon + i.fa-solid.fa-cloud-arrow-up + .drop-feedback-prompt Drop items to attach them to your message. + include ../../components/emoji-picker block viewjs diff --git a/app/views/components/library.pug b/app/views/components/library.pug index f4d0708..00c3f72 100644 --- a/app/views/components/library.pug +++ b/app/views/components/library.pug @@ -1,8 +1,27 @@ //- Common routines for all views - + function formatCount (count) { + return numeral(count).format((count > 1000) ? '0,0.0a' : '0,0'); + } + function getUserPictureUrl (userProfile, which) { if (!userProfile || !userProfile.picture || !userProfile.picture[which]) { return `https://${site.domain}/img/default-member.png`; } return `https://${site.domain}/image/${userProfile.picture[which]._id}`; + } + + function getUserFlags (user) { + const fA = `${user.flags.isAdmin ? 'A' : '-'}`; + const fM = `${user.flags.isModerator ? 'M' : '-'}`; + const fE = `${user.flags.isEmailVerified ? 'E' : '-'}`; + const fG = `${user.flags.isCloaked ? 'G' : '-'}`; + + const pL = `${user.permissions.canLogin ? 'L' : '-'}`; + const pC = `${user.permissions.canChat ? 'C' : '-'}`; + const pO = `${user.permissions.canComment ? 'O' : '-'}`; + const pR = `${user.permissions.canReport ? 'R' : '-'}`; + const pI = `${user.permissions.canShareLinks ? 'I' : '-'}`; + + return `${fA}${fM}${fE}${fG} ${pL}${pC}${pO}${pR}${pI}`; } \ No newline at end of file diff --git a/app/views/components/navbar.pug b/app/views/components/navbar.pug index 1547807..e845b32 100644 --- a/app/views/components/navbar.pug +++ b/app/views/components/navbar.pug @@ -35,7 +35,7 @@ nav(style="background: #000000;").uk-navbar-container.uk-light li a(href=`/user/${user._id}/settings`) - span.nav-item-icon + span.menu-icon i.fas.fa-cog span Settings @@ -43,7 +43,7 @@ nav(style="background: #000000;").uk-navbar-container.uk-light li.uk-nav-divider li a(href='/admin') - span.nav-item-icon + span.menu-icon i.fas.fa-user-lock span Admin @@ -51,6 +51,6 @@ nav(style="background: #000000;").uk-navbar-container.uk-light li a(href=`/auth/logout`) - span.nav-item-icon + span.menu-icon i.fas.fa-right-from-bracket span Sign Out \ No newline at end of file diff --git a/app/views/layout/main.pug b/app/views/layout/main.pug index ca9bcca..b0361f4 100644 --- a/app/views/layout/main.pug +++ b/app/views/layout/main.pug @@ -71,8 +71,8 @@ html(lang='en', data-obs-widget= obsWidget) block view-content - script(src='/dayjs/dayjs.min.js') - script(src='/numeral/numeral.min.js') + script(src=`/dayjs/dayjs.min.js?v=${pkg.version}`) + script(src=`/numeral/numeral.min.js?v=${pkg.version}`) script(src=`/socket.io/socket.io.js?v=${pkg.version}`) block clientjs diff --git a/app/workers/chat-processor.js b/app/workers/chat-processor.js new file mode 100644 index 0000000..edc7dfb --- /dev/null +++ b/app/workers/chat-processor.js @@ -0,0 +1,70 @@ +// chat-processor.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import 'dotenv/config'; + +import path, { dirname } from 'path'; + +import { SiteRuntime } from '../../lib/site-lib.js'; + +import { CronJob } from 'cron'; +const CRON_TIMEZONE = 'America/New_York'; + +class ChatProcessorService extends SiteRuntime { + + static get name ( ) { return 'ChatProcessorService'; } + static get slug ( ) { return 'chatProcessor'; } + + constructor (rootPath) { + super(ChatProcessorService, rootPath); + } + + async start ( ) { + await super.start(); + + const mongoose = await import('mongoose'); + this.ChatMessage = mongoose.model('ChatMessage'); + + /* + * Cron jobs + */ + + const messageExpireSchedule = '0 0 * * * *'; // Every hour + this.cronJob = new CronJob( + messageExpireSchedule, + this.expireChatMessages.bind(this), + null, + true, + CRON_TIMEZONE, + ); + } + + async shutdown ( ) { + this.log.alert('ChatLinksWorker shutting down'); + await super.shutdown(); + } + + async expireChatMessages ( ) { + const { chat: chatService } = this.services; + await chatService.expireMessages(); + } +} + +(async ( ) => { + + try { + const { fileURLToPath } = await import('node:url'); + const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line + + const worker = new ChatProcessorService(path.resolve(__dirname, '..', '..')); + await worker.start(); + + } catch (error) { + console.error('failed to start chat processing worker', { error }); + process.exit(-1); + } + +})(); \ No newline at end of file diff --git a/app/workers/worker-template.js b/app/workers/worker-template.js new file mode 100644 index 0000000..abed791 --- /dev/null +++ b/app/workers/worker-template.js @@ -0,0 +1,84 @@ +// worker-template.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import 'dotenv/config'; + +import path, { dirname } from 'path'; + +import { SiteRuntime } from '../../lib/site-lib.js'; + +import { CronJob } from 'cron'; +const CRON_TIMEZONE = 'America/New_York'; + +class TemplateService extends SiteRuntime { + + static get name ( ) { return 'TemplateService'; } + static get slug ( ) { return 'template'; } + + constructor (rootPath) { + super(TemplateService, rootPath); + } + + async start ( ) { + await super.start(); + + const mongoose = await import('mongoose'); + this.Link = mongoose.model('Link'); + + this.viewModel = { }; + await this.populateViewModel(this.viewModel); + + /* + * Cron jobs + */ + + const cronJobSchedule = '*/5 * * * * *'; // Every 5 seconds + this.cronJob = new CronJob( + cronJobSchedule, + this.cronJobProcessor.bind(this), + null, + true, + CRON_TIMEZONE, + ); + + /* + * Bull Queue job processors + */ + + this.log.info('registering queue job processor', { config: this.config.jobQueues.links }); + this.linksProcessingQueue = this.services.jobQueue.getJobQueue('links', this.config.jobQueues.links); + this.linksProcessingQueue.process('link-ingest', 1, this.ingestLink.bind(this)); + } + + async shutdown ( ) { + this.log.alert('ChatLinksWorker shutting down'); + await super.shutdown(); + } + + async cronJobProcessor ( ) { + this.log.info('your cron job is running now'); + } + + async ingestLink (job) { + this.log.info('processing queue job', { id: job.id }); + } +} + +(async ( ) => { + + try { + const { fileURLToPath } = await import('node:url'); + const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line + + const worker = new TemplateService(path.resolve(__dirname, '..', '..')); + await worker.start(); + + } catch (error) { + console.error('failed to start template worker', { error }); + process.exit(-1); + } + +})(); \ No newline at end of file diff --git a/client/css/dtp-site.less b/client/css/dtp-site.less index c0700cf..4396695 100644 --- a/client/css/dtp-site.less +++ b/client/css/dtp-site.less @@ -1,9 +1,13 @@ @import "site/uk-lightbox.less"; @import "site/main.less"; + @import "site/button.less"; +@import "site/drop-feedback.less"; @import "site/emoji-picker.less"; @import "site/image.less"; @import "site/link-preview.less"; +@import "site/menu.less"; @import "site/navbar.less"; -@import "site/stage.less"; \ No newline at end of file +@import "site/stage.less"; +@import "site/stats.less"; \ No newline at end of file diff --git a/client/css/site/drop-feedback.less b/client/css/site/drop-feedback.less new file mode 100644 index 0000000..6398c64 --- /dev/null +++ b/client/css/site/drop-feedback.less @@ -0,0 +1,50 @@ +.dtp-drop-feedback { + position: fixed; + top: 0; right: 0; bottom: 0; left: 0; + box-sizing: border-box; + + display: flex; + align-items: center; + justify-content: center; + + background-color: rgba(0,0,0, 0.8); + color: #e8e8e8; + + opacity: 0; + transition: opacity 0.2s; + + pointer-events: none; + + &.feedback-active { + opacity: 1; + } + + .drop-feedback-container { + box-sizing: border-box; + padding: 20px; + + border: dotted 3px #e8e8e8; + border-radius: 10px; + + background-color: #48a8d4; + color: #e8e8e8; + + pointer-events: none; + + .feedback-icon { + margin-bottom: 10px; + + font-size: 2.5em; + line-height: 1; + + color: #ffffff; + + pointer-events: none; + } + + .drop-feedback-prompt { + font-size: 1.2em; + pointer-events: none; + } + } +} \ No newline at end of file diff --git a/client/css/site/main.less b/client/css/site/main.less index 0ad75c5..d7302df 100644 --- a/client/css/site/main.less +++ b/client/css/site/main.less @@ -15,4 +15,8 @@ html, body { .uk-resize-none { resize: none; +} + +.uk-text-fixed { + font-family: 'Courier New'; } \ No newline at end of file diff --git a/client/css/site/menu.less b/client/css/site/menu.less new file mode 100644 index 0000000..1c8bab8 --- /dev/null +++ b/client/css/site/menu.less @@ -0,0 +1,4 @@ +.menu-icon { + width: 2em; + text-align: center; +} \ No newline at end of file diff --git a/client/css/site/stage.less b/client/css/site/stage.less index 2c6d369..dd3bf0f 100644 --- a/client/css/site/stage.less +++ b/client/css/site/stage.less @@ -158,18 +158,45 @@ } } + &:last-child { + margin-bottom: 0; + } + .message-menu { display: none; box-sizing: border-box; position: absolute; - top: 0; right: 10px; + top: 5px; right: 5px; padding: 5px 10px; - background-color: white; - color: #1a1a1a; + background-color: @chat-message-menu-bgcolor; + color: @chat-message-menu-color; border-radius: 16px; + + button.message-menu-button { + background: none; + border: none; + outline: none; + + color: inherit; + font-size: 1.2em; + cursor: pointer; + } + button.dropdown-menu { + position: relative; + top: 2px; + padding: 0 5px; + + color: inherit; + background: none; + border: none; + outline: none; + + cursor: pointer; + font-size: 1.2em; + } } &.system-message { @@ -242,9 +269,28 @@ } } } + + .message-reaction-bar { + margin-top: 5px; + + button.message-react-button { + padding: 4px 6px; + margin-right: 5px; + + border: none; + outline: none; + border-radius: 6px; + + background: @message-react-button-bgcolor; + color: @message-react-button-color; + + span.reaction-emoji { + margin-right: 3px; + } + } + } } } - } .chat-input-panel { @@ -274,7 +320,8 @@ } .input-button-bar { - margin-top: 5px; + margin-top: 8px; + margin-bottom: 3px; } } } diff --git a/client/css/site/stats.less b/client/css/site/stats.less new file mode 100644 index 0000000..e9b2801 --- /dev/null +++ b/client/css/site/stats.less @@ -0,0 +1,18 @@ +.stats-block { + padding: 10px 20px; + + border: solid 1px red; + border-radius: 10px; + + background-color: #fff0f0; + color: #2a2a2a; + + .stat-label { + font-size: 0.8em; + } + + .stat-value { + font-size: 1.2em; + font-weight: bold; + } +} \ No newline at end of file diff --git a/client/css/site/uikit-theme.dtp-dark.less b/client/css/site/uikit-theme.dtp-dark.less index 4b73268..8382a5b 100644 --- a/client/css/site/uikit-theme.dtp-dark.less +++ b/client/css/site/uikit-theme.dtp-dark.less @@ -143,11 +143,17 @@ a.uk-button.uk-button-default { @chat-message-color: #e8e8e8; @chat-message-timestamp-color: #808080; +@message-react-button-bgcolor: @chat-sidebar-bgcolor; +@message-react-button-color: @chat-sidebar-color; + +@chat-message-menu-bgcolor: @chat-sidebar-bgcolor; +@chat-message-menu-color: @chat-sidebar-color; + @system-message-bgcolor: #3a3a3a; @system-message-color: #a8a8a8; @system-message-timestamp-color: #a8a8a8; -@chat-input-panel-bgcolor: #1a1a1a; +@chat-input-panel-bgcolor: #3f5768; @chat-input-panel-color: #e8e8e8; @link-container-bgcolor: rgba(0,0,0, 0.3); diff --git a/client/css/site/uikit-theme.dtp-light.less b/client/css/site/uikit-theme.dtp-light.less index abcbd59..a0eca75 100644 --- a/client/css/site/uikit-theme.dtp-light.less +++ b/client/css/site/uikit-theme.dtp-light.less @@ -49,7 +49,7 @@ @stage-live-member-bgcolor: #1a1a1a; @stage-live-member-color: #8a8a8a; -@chat-sidebar-bgcolor: #7e7b62; +@chat-sidebar-bgcolor: #62767e; @chat-sidebar-color: #FCF1E8; @chat-container-bgcolor: #e8e8e8; @@ -62,11 +62,17 @@ @chat-message-color: #071E22; @chat-message-timestamp-color: #679289; +@message-react-button-bgcolor: @chat-sidebar-bgcolor; +@message-react-button-color: @chat-sidebar-color; + +@chat-message-menu-bgcolor: @chat-sidebar-bgcolor; +@chat-message-menu-color: @chat-sidebar-color; + @system-message-bgcolor: #EE2E31; @system-message-color: #FCF1E8; @system-message-timestamp-color: #FCF1E8; -@chat-input-panel-bgcolor: #a5a17c; +@chat-input-panel-bgcolor: #7794a0; @chat-input-panel-color: #1a1a1a; @link-container-bgcolor: rgba(0, 0, 0, 0.1); diff --git a/client/js/chat-client.js b/client/js/chat-client.js index 6ffc788..ac72634 100644 --- a/client/js/chat-client.js +++ b/client/js/chat-client.js @@ -21,12 +21,15 @@ import hljs from 'highlight.js'; export class ChatApp extends DtpApp { + static get SFX_CHAT_ROOM_CONNECT ( ) { return 'chat-room-connect'; } static get SFX_CHAT_MESSAGE ( ) { return 'chat-message'; } + static get SFX_CHAT_REACTION ( ) { return 'reaction'; } + static get SFX_CHAT_REACTION_REMOVE ( ) { return 'reaction-remove'; } constructor (user) { super(DTP_COMPONENT_NAME, user); this.loadSettings(); - this.log.info('DTP app client online'); + this.log.info('constructor', 'DTP app client online'); this.chat = { form: document.querySelector('#chat-input-form'), @@ -45,11 +48,6 @@ export class ChatApp extends DtpApp { hljs.highlightAll(); } - window.addEventListener('dtp-load', this.onDtpLoad.bind(this)); - window.addEventListener('unload', this.onDtpUnload.bind(this)); - - this.updateTimestamps(); - this.emojiPickerDisplay = document.querySelector('.emoji-picker-display'); if (this.emojiPickerDisplay) { this.emojiPickerDisplay.addEventListener('click', this.onEmojiPickerClose.bind(this)); @@ -58,15 +56,25 @@ export class ChatApp extends DtpApp { if (this.emojiPicker) { this.emojiPicker.addEventListener('emoji-click', this.onEmojiPicked.bind(this)); } + + this.dragFeedback = document.querySelector('.dtp-drop-feedback'); + + window.addEventListener('dtp-load', this.onDtpLoad.bind(this)); + window.addEventListener('unload', this.onDtpUnload.bind(this)); + + this.updateTimestamps(); } async startAudio ( ) { - this.log.info('startAudio', 'starting ChtaAudio'); + this.log.info('startAudio', 'starting ChatAudio'); this.audio = new ChatAudio(); this.audio.start(); try { await Promise.all([ + this.audio.loadSound(ChatApp.SFX_CHAT_ROOM_CONNECT, '/static/sfx/room-connect.mp3'), this.audio.loadSound(ChatApp.SFX_CHAT_MESSAGE, '/static/sfx/chat-message.mp3'), + this.audio.loadSound(ChatApp.SFX_CHAT_REACTION, '/static/sfx/reaction.mp3'), + this.audio.loadSound(ChatApp.SFX_CHAT_REACTION_REMOVE, '/static/sfx/reaction-remove.mp3'), ]); } catch (error) { this.log.error('startAudio', 'failed to load sound', { error }); @@ -86,6 +94,47 @@ export class ChatApp extends DtpApp { } } + async onDragEnter (event) { + event.preventDefault(); + event.stopPropagation(); + + this.log.info('onDragEnter', 'something being dragged has entered the stage', { event }); + this.dragFeedback.classList.add('feedback-active'); + } + + async onDragLeave (event) { + event.preventDefault(); + event.stopPropagation(); + + this.log.info('onDragLeave', 'something being dragged has left the stage', { event }); + this.dragFeedback.classList.remove('feedback-active'); + } + + async onDragOver (event) { + /* + * Inform that we want "copy" as a drop effect and prevent all default + * processing so we'll actually get the files in the drop event. If this + * isn't done, you simply won't get the files in the drop. + */ + event.preventDefault(); + event.stopPropagation(); // this ends now! + event.dataTransfer.dropEffect = 'copy'; + + // this.log.info('onDragOver', 'something was dragged over the stage', { event }); + this.dragFeedback.classList.add('feedback-active'); + } + + async onDrop (event) { + event.preventDefault(); + event.stopPropagation(); + + for (const file of event.dataTransfer.files) { + this.log.info('onDrop', 'a file has been dropped', { file }); + } + this.log.info('onFileDrop', 'something was dropped on the stage', { event, files: event.files }); + this.dragFeedback.classList.remove('feedback-active'); + } + async onChatMessageListChanged (mutationList) { this.log.info('onMutation', 'DOM mutation received', { mutationList }); if (!Array.isArray(mutationList) || (mutationList.length === 0)) { @@ -213,6 +262,25 @@ export class ChatApp extends DtpApp { return true; } + async toggleMessageReaction (event) { + const target = event.currentTarget || event.target; + const messageId = target.getAttribute('data-message-id'); + const emoji = target.getAttribute('data-emoji'); + try { + const response = await fetch(`/chat/message/${messageId}/reaction`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ emoji }), + }); + await this.processResponse(response); + } catch (error) { + this.log.error('sendEmojiReact', 'failed to send emoji react', { error }); + UIkit.modal.alert(`Failed to send emoji react: ${error.message}`); + } + } + async onDtpLoad ( ) { this.log.info('dtp-load event received. Connecting to platform.'); await this.connect({ @@ -255,6 +323,11 @@ export class ChatApp extends DtpApp { async onChatControl (message) { const isAtBottom = this.chat.isAtBottom; + if (message.audio) { + if (message.audio.playSound) { + this.audio.playSound(message.audio.playSound); + } + } if (message.displayList) { this.displayEngine.executeDisplayList(message.displayList); } diff --git a/client/static/sfx/reaction-remove.mp3 b/client/static/sfx/reaction-remove.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..bf4a5618566c82917729e2fec40cee090766be60 GIT binary patch literal 1536 zcmezWdrAre0pOXJmJd|I55&w23~USxp@hIHLf}6k0JO`}$I;i-SkKVFfN>Sfg(3|q zOs;(l3=JT6L@d3L1Cto4^nY;pGab_NJ8P4`V8kOnVSxxkkg9X#&W;9wEe94kGbjMP zrog~_fVVx;fysfL@x)_^JO($;&u8w)H9dG(yZil%j(6|gA7QTO2tEJ*&f|R@3@s0s zIPZLMum6Ad?%g{VczEn9dgT94XMgZ#N6n7fAIa0%?*$%c*?qbl5eI)coT>y`6m44a zodkMM+`NC~UyJ3r;yM5SKes&RB+#PJP+-B>c=*xdQwiMjZb$yR?jbGJ&9=ztxx>Y4 zw*syU$8{*2(TG&|Xs!Na^|I!gG`1R(#2(4Q5CusC1%>AwKV`OQI@~y{$Qx9``E~jN zP3Gl_Pm?m-m#JP|GO1%zsnj&DmydbQ1BpAS42%jZr3)4=d{k&G${Mrwf)s}#DEv7)KF@UsxL&w`L+|vD zfCn550j|typ*I^Cc5Qg*)S$p<5pn2IvBTN^w%Hs?&5mrmOlq4i?Ml4Tx$ujys@Sg3 z8QlS^g^u0Q4%N!gto-pK*fl)$qM2*Duh{Rf*&>tndg}UI33LrLIw|zZB%mx>H@Y@- z-R(^mix;TND!kvn^8fdmevlJE;LH7Mzixh=81ObM?!}AJD#6m&K)Ls|U*GC zPvL_5xofspKFpTcRFK%Pi6Ko`?Qq5v?In7Qjvac9jdCe2$2uPKPjF;bdRf^g#@lmV zqNicX(P)$#~Hva$LXGe^Q{`9k_=l@8MH--A=|MK7A zr;|go*3FbUJL}D_kew z@gGde5+YY$d89C_1T`C~?Rm0O|1m|1TUf?ESYNm@>@&|9_DzVd&`S`~N?e^&b%tjrS+s Z|8R6z00aje9bpXrx9~!fP#F!82LLhHeC7ZE literal 0 HcmV?d00001 diff --git a/client/static/sfx/reaction.mp3 b/client/static/sfx/reaction.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..a1d8a50c201616bc3ba52570e20c2a5754b76319 GIT binary patch literal 2088 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 { + + let app; + try { + app = new SiteTerminalApp(); + await app.start(); + await app.run(process.argv.slice(2)); + } catch (error) { + console.error('failed to start Chat terminal application', error); + } finally { + await app.shutdown(); + process.nextTick(( ) => { + process.exit(0); + }); + } + +})(); \ No newline at end of file diff --git a/dtp-chat.js b/dtp-chat.js index 8cc7691..c95893f 100644 --- a/dtp-chat.js +++ b/dtp-chat.js @@ -11,9 +11,6 @@ import fs from 'fs'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line -import { createRequire } from 'module'; -const require = createRequire(import.meta.url); // jshint ignore:line - import * as rfs from 'rotating-file-stream'; import webpack from 'webpack'; @@ -244,6 +241,11 @@ class SiteWebApp extends SiteRuntime { if (!entry.isFile()) { continue; } + if (!entry.name.endsWith("js")) { + this.log.alert('skipping invalid file in controllers directory', { entry }); + continue; + } + try { const ControllerClass = (await import(path.join(basePath, entry.name))).default; if (!ControllerClass) { @@ -266,7 +268,7 @@ class SiteWebApp extends SiteRuntime { } for await (const controller of inits) { - if (controller.name === 'HomeController') { + if (controller.slug === 'home') { continue; } await controller.start(); @@ -283,7 +285,13 @@ class SiteWebApp extends SiteRuntime { try { const app = new SiteWebApp(); - await app.start(); + try { + await app.start(); + } catch (error) { + await app.shutdown(); + await app.terminate(); + throw new Error('failed to start web app', { cause: error }); + } } catch (error) { console.error('failed to start application harness', error); } diff --git a/lib/client/js/dtp-app.js b/lib/client/js/dtp-app.js index 285b51d..4840345 100644 --- a/lib/client/js/dtp-app.js +++ b/lib/client/js/dtp-app.js @@ -18,7 +18,6 @@ export default class DtpApp { this.name = appName; this.log = new DtpLog(appName); - this.log.debug('constructor', 'creating DisplayEngine instance'); this.displayEngine = new DtpDisplayEngine(); this.domParser = new DOMParser(); diff --git a/lib/client/js/dtp-display-engine.js b/lib/client/js/dtp-display-engine.js index 0e22ef9..ed3b888 100644 --- a/lib/client/js/dtp-display-engine.js +++ b/lib/client/js/dtp-display-engine.js @@ -13,7 +13,6 @@ export default class DtpDisplayEngine { constructor ( ) { this.processors = { }; this.log = new DtpLog(DTP_COMPONENT_NAME); - this.log.debug('constructor', 'DTP Display Engine instance created'); } /** diff --git a/lib/site-controller.js b/lib/site-controller.js index 4b8d82c..1a4cf72 100644 --- a/lib/site-controller.js +++ b/lib/site-controller.js @@ -4,6 +4,8 @@ 'use strict'; +import path from 'node:path'; + import multer from 'multer'; import slugTool from 'slug'; @@ -21,12 +23,23 @@ export class SiteController extends SiteCommon { } async loadChild (filename) { - let child = await require(filename); - this.children[child.slug] = child; + const pathObj = path.parse(filename); + + const ControllerClass = (await import(filename)).default; + if (!ControllerClass) { + this.log.error('failed to receive a default export class from child controller', { script: pathObj.name }); + throw new Error('Child controller failed to provide a default export'); + } - let instance = child.create(this.dtp); + this.log.info('loading child controller', { + script: pathObj.name, + name: ControllerClass.name, + slug: ControllerClass.slug, + }); + const controller = new ControllerClass(this.dtp); - return await instance.start(); + this.children[ControllerClass.slug] = controller; + return controller.start(); } getPaginationParameters (req, maxPerPage, pageParamName = 'p', cppParamName = 'cpp') { diff --git a/lib/site-runtime.js b/lib/site-runtime.js index 664b87f..7d6d88b 100644 --- a/lib/site-runtime.js +++ b/lib/site-runtime.js @@ -15,7 +15,11 @@ import { createRequire } from 'module'; const require = createRequire(import.meta.url); // jshint ignore:line import numeral from 'numeral'; + import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime.js'; +dayjs.extend(relativeTime); + import * as Marked from 'marked'; import hljs from 'highlight.js'; @@ -51,12 +55,14 @@ export class SiteRuntime { await this.loadModels(); await this.loadServices(); - process.on('unhandledRejection', (error, p) => { + process.on('unhandledRejection', async (error, p) => { this.log.error('Unhandled rejection', { error: error, promise: p, stack: error.stack }); + const exitCode = await this.shutdown(); + await this.terminate(exitCode); }); process.on('warning', (error) => { @@ -250,9 +256,8 @@ export class SiteRuntime { viewModel.pkg = this.config.pkg; viewModel.dayjs = dayjs; viewModel.numeral = numeral; - // viewModel.phoneNumberJS = require('libphonenumber-js'); + viewModel.hljs = hljs; // viewModel.anchorme = require('anchorme').default; - // viewModel.hljs = hljs; // viewModel.Color = require('color'); // viewModel.numberToWords = require('number-to-words'); viewModel.uuidv4 = (await import('uuid')).v4; diff --git a/start-local b/start-local index 5b9a182..cc6ff5a 100755 --- a/start-local +++ b/start-local @@ -10,8 +10,10 @@ export MINIO_ROOT_USER MINIO_ROOT_PASSWORD forever start --killSignal=SIGINT app/workers/host-services.js forever start --killSignal=SIGINT app/workers/chat-links.js +forever start --killSignal=SIGINT app/workers/chat-processor.js minio server ./data/minio --address ":9080" --console-address ":9081" +forever stop app/workers/chat-processor.js forever stop app/workers/chat-links.js forever stop app/workers/host-services.js \ No newline at end of file