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 0000000..bf4a561 Binary files /dev/null and b/client/static/sfx/reaction-remove.mp3 differ diff --git a/client/static/sfx/reaction.mp3 b/client/static/sfx/reaction.mp3 new file mode 100644 index 0000000..a1d8a50 Binary files /dev/null and b/client/static/sfx/reaction.mp3 differ diff --git a/client/static/sfx/room-connect.mp3 b/client/static/sfx/room-connect.mp3 new file mode 100644 index 0000000..bdd6c6b Binary files /dev/null and b/client/static/sfx/room-connect.mp3 differ diff --git a/dtp-chat-cli.js b/dtp-chat-cli.js new file mode 100644 index 0000000..42da353 --- /dev/null +++ b/dtp-chat-cli.js @@ -0,0 +1,157 @@ +// dtp-chat-cli.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import 'dotenv/config'; + +import { dirname } from 'path'; +import fs from 'fs'; + +import { fileURLToPath } from 'url'; +const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line + +import mongoose from 'mongoose'; + +import { SiteRuntime } from './lib/site-runtime.js'; + +class SiteTerminalApp extends SiteRuntime { + + static get name ( ) { return 'SiteTerminalApp'; } + static get slug ( ) { return 'cliApp'; } + + constructor ( ) { + super(SiteTerminalApp, __dirname); + } + + async start ( ) { + await super.start(); + + this.processors = { + 'grant': this.grant.bind(this), + 'revoke': this.revoke.bind(this), + 'probe': this.probeMediaFile.bind(this), + 'transcodeMov': this.transcodeMov.bind(this), + 'transcodeGif': this.transcodeGif.bind(this), + }; + } + + async shutdown ( ) { + await super.shutdown(); + return 0; // exitCode + } + + async run (args) { + this.log.debug('running', { args }); + const command = args.shift(); + const processor = this.processors[command]; + if (!processor) { + this.log.error('Unknown command', { command }); + return; + } + return processor(args); + } + + async grant (args) { + const User = mongoose.model('User'); + + const privilege = args.shift(); + const username = args.shift(); + + const user = await User.findOne({ username_lc: username.toLowerCase().trim() }).select('_id'); + if (!user) { + throw new Error('User not found'); + } + + switch (privilege) { + case 'admin': + this.log.info('granting admin privileges'); + await User.updateOne({ _id: user._id }, { $set: { 'flags.isAdmin': true } }); + break; + case 'moderator': + this.log.info('granting moderator privileges'); + await User.updateOne({ _id: user._id }, { $set: { 'flags.isAdmin': true } }); + break; + } + } + + async revoke (args) { + const User = mongoose.model('User'); + + const username_lc = args.shift(); + const user = await User.findOne({ username_lc }).select('_id'); + if (!user) { + throw new Error('User not found'); + } + + const privilege = args.shift(); + switch (privilege) { + case 'admin': + this.log.info('revoking admin privileges'); + await User.updateOne({ _id: user._id }, { $set: { 'flags.isAdmin': false } }); + break; + case 'moderator': + this.log.info('revoking moderator privileges'); + await User.updateOne({ _id: user._id }, { $set: { 'flags.isAdmin': false } }); + break; + } + } + + async probeMediaFile (args) { + const { media: mediaService } = this.services; + const filename = args.shift(); + const probe = await mediaService.ffprobe(filename); + this.log.info('FFPROBE result', { probe }); + } + + async transcodeMov (args) { + const { video: videoService } = this.services; + + const filename = args.shift(); + const stat = await fs.promises.stat(filename); + const file = { + path: filename, + size: stat.size, + type: 'video/quicktime', + }; + + await videoService.transcodeMov(file); + + this.log.info('transcode output ready', { filename: file.path }); + } + + async transcodeGif (args) { + const { video: videoService } = this.services; + + const filename = args.shift(); + const stat = await fs.promises.stat(filename); + const file = { + path: filename, + size: stat.size, + type: 'image/gif', + }; + + await videoService.transcodeGif(file); + + this.log.info('transcode output ready', { filename: file.path }); + } +} + +(async ( ) => { + + 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