Browse Source

huge update

develop
Rob Colbert 1 year ago
parent
commit
b81533bc39
  1. 76
      README.md
  2. 66
      app/controllers/admin.js
  3. 70
      app/controllers/admin/user.js
  4. 39
      app/controllers/chat.js
  5. 4
      app/controllers/manifest.js
  6. 6
      app/models/chat-message.js
  7. 3
      app/models/chat-room.js
  8. 3
      app/models/video.js
  9. 168
      app/services/chat.js
  10. 13
      app/services/user.js
  11. 174
      app/services/video.js
  12. 25
      app/views/admin/dashboard.pug
  13. 28
      app/views/admin/layout/main.pug
  14. 19
      app/views/admin/user/components/list-table.pug
  15. 7
      app/views/admin/user/dashboard.pug
  16. 103
      app/views/admin/user/view.pug
  17. 74
      app/views/chat/components/message.pug
  18. 3
      app/views/chat/components/reaction-bar-standalone.pug
  19. 18
      app/views/chat/components/reaction-bar.pug
  20. 33
      app/views/chat/room/settings.pug
  21. 14
      app/views/chat/room/view.pug
  22. 19
      app/views/components/library.pug
  23. 6
      app/views/components/navbar.pug
  24. 4
      app/views/layout/main.pug
  25. 70
      app/workers/chat-processor.js
  26. 84
      app/workers/worker-template.js
  27. 4
      client/css/dtp-site.less
  28. 50
      client/css/site/drop-feedback.less
  29. 4
      client/css/site/main.less
  30. 4
      client/css/site/menu.less
  31. 57
      client/css/site/stage.less
  32. 18
      client/css/site/stats.less
  33. 8
      client/css/site/uikit-theme.dtp-dark.less
  34. 10
      client/css/site/uikit-theme.dtp-light.less
  35. 87
      client/js/chat-client.js
  36. BIN
      client/static/sfx/reaction-remove.mp3
  37. BIN
      client/static/sfx/reaction.mp3
  38. BIN
      client/static/sfx/room-connect.mp3
  39. 157
      dtp-chat-cli.js
  40. 16
      dtp-chat.js
  41. 1
      lib/client/js/dtp-app.js
  42. 1
      lib/client/js/dtp-display-engine.js
  43. 21
      lib/site-controller.js
  44. 11
      lib/site-runtime.js
  45. 2
      start-local

76
README.md

@ -1,23 +1,85 @@
# DTP Chat # DTP Chat
A no-nonsense/no-frills communications platform. 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 In a separate terminal, starts the dev application environment.
yarn install
```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 ## 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. 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.

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

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

39
app/controllers/chat.js

@ -55,9 +55,11 @@ export default class ChatController extends SiteController {
); );
router.param('roomId', this.populateRoomId.bind(this)); router.param('roomId', this.populateRoomId.bind(this));
router.param('messageId', this.populateMessageId.bind(this));
router.post( router.post(
'/room/:roomId/message', '/room/:roomId/message',
// limiterService.create(limiterService.config.chat.postRoomMessage),
multer.fields([{ name: 'imageFiles', maxCount: 6 }, { name: 'videoFiles', maxCount: 1 }]), multer.fields([{ name: 'imageFiles', maxCount: 6 }, { name: 'videoFiles', maxCount: 1 }]),
this.postRoomMessage.bind(this), this.postRoomMessage.bind(this),
); );
@ -65,6 +67,7 @@ export default class ChatController extends SiteController {
router.post( router.post(
'/room/:roomId/settings', '/room/:roomId/settings',
requireRoomOwner, requireRoomOwner,
// limiterService.create(limiterService.config.chat.postRoomSettings),
this.postRoomSettings.bind(this), this.postRoomSettings.bind(this),
); );
@ -74,8 +77,16 @@ export default class ChatController extends SiteController {
this.postCreateRoom.bind(this), this.postCreateRoom.bind(this),
); );
router.post(
'/message/:messageId/reaction',
// limiterService.create(limiterService.config.chat.postMessageReaction),
multer.none(),
this.postMessageReaction.bind(this),
);
router.get( router.get(
'/room/create', '/room/create',
// limiterService.create(limiterService.config.chat.getRoomCreateView),
this.getRoomCreateView.bind(this), this.getRoomCreateView.bind(this),
); );
@ -106,6 +117,7 @@ export default class ChatController extends SiteController {
router.delete( router.delete(
'/room/:roomId', '/room/:roomId',
// limiterService.create(limiterService.config.chat.deleteRoom),
this.deleteRoom.bind(this), 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) { async postRoomMessage (req, res) {
const { chat: chatService } = this.dtp.services; const { chat: chatService } = this.dtp.services;
try { try {

4
app/controllers/manifest.js

@ -52,8 +52,8 @@ export default class ManifestController extends SiteController {
short_name: this.dtp.config.site.name, short_name: this.dtp.config.site.name,
description: this.dtp.config.site.description, description: this.dtp.config.site.description,
display: 'fullscreen', display: 'fullscreen',
theme_color: '#e8e8e8', theme_color: '#62767e',
background_color: '#c32b2b', background_color: '#62767e',
icons: [ ], icons: [ ],
}; };

6
app/models/chat-message.js

@ -9,6 +9,11 @@ const Schema = mongoose.Schema;
const CHANNEL_TYPE_LIST = ['User', 'ChatRoom']; const CHANNEL_TYPE_LIST = ['User', 'ChatRoom'];
const ReactionSchema = new Schema({
emoji: { type: String },
users: { type: [Schema.ObjectId], ref: 'User' },
});
const ChatMessageSchema = new Schema({ const ChatMessageSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1, expires: '30d' }, created: { type: Date, default: Date.now, required: true, index: -1, expires: '30d' },
expires: { type: Date, index: -1 }, expires: { type: Date, index: -1 },
@ -23,6 +28,7 @@ const ChatMessageSchema = new Schema({
images: { type: [Schema.ObjectId], ref: 'Image' }, images: { type: [Schema.ObjectId], ref: 'Image' },
videos: { type: [Schema.ObjectId], ref: 'Video' }, videos: { type: [Schema.ObjectId], ref: 'Video' },
}, },
reactions: { type: [ReactionSchema], default: [ ] },
}); });
export default mongoose.model('ChatMessage', ChatMessageSchema); export default mongoose.model('ChatMessage', ChatMessageSchema);

3
app/models/chat-room.js

@ -20,6 +20,9 @@ const ChatRoomSchema = new Schema({
members: { type: [Schema.ObjectId], select: false }, members: { type: [Schema.ObjectId], select: false },
present: { type: [Schema.ObjectId], select: false }, present: { type: [Schema.ObjectId], select: false },
banned: { type: [Schema.ObjectId], select: false }, banned: { type: [Schema.ObjectId], select: false },
settings: {
expireDays: { type: Number, default: 7, min: 1, max: 30, required: true },
},
stats: { stats: {
memberCount: { type: Number, default: 0, min: 0, max: MAX_ROOM_CAPACITY, required: true }, 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 }, presentCount: { type: Number, default: 0, min: 0, max: MAX_ROOM_CAPACITY, required: true },

3
app/models/video.js

@ -18,6 +18,9 @@ const VideoSchema = new Schema({
duration: { type: Number }, duration: { type: Number },
thumbnail: { type: Schema.ObjectId, ref: 'Image' }, thumbnail: { type: Schema.ObjectId, ref: 'Image' },
status: { type: String, enum: VIDEO_STATUS_LIST, required: true, index: 1 }, status: { type: String, enum: VIDEO_STATUS_LIST, required: true, index: 1 },
flags: {
fromGif: { type: Boolean, default: false, required: true },
},
media: { media: {
bucket: { type: String, required: true }, bucket: { type: String, required: true },
key: { type: String, required: true }, key: { type: String, required: true },

168
app/services/chat.js

@ -10,6 +10,7 @@ const ChatMessage = mongoose.model('ChatMessage');
const ChatRoomInvite = mongoose.model('ChatRoomInvite'); const ChatRoomInvite = mongoose.model('ChatRoomInvite');
import numeral from 'numeral'; import numeral from 'numeral';
import dayjs from 'dayjs';
import { SiteService, SiteError } from '../../lib/site-lib.js'; import { SiteService, SiteError } from '../../lib/site-lib.js';
import { MAX_ROOM_CAPACITY } from '../models/lib/constants.js'; import { MAX_ROOM_CAPACITY } from '../models/lib/constants.js';
@ -29,6 +30,7 @@ export default class ChatService extends SiteService {
this.templates = { this.templates = {
message: this.loadViewTemplate('chat/components/message-standalone.pug'), message: this.loadViewTemplate('chat/components/message-standalone.pug'),
memberListItem: this.loadViewTemplate('chat/components/member-list-item-standalone.pug'), memberListItem: this.loadViewTemplate('chat/components/member-list-item-standalone.pug'),
reactionBar: this.loadViewTemplate('chat/components/reaction-bar-standalone.pug'),
}; };
this.populateChatRoom = [ this.populateChatRoom = [
@ -63,6 +65,15 @@ export default class ChatService extends SiteService {
{ {
path: 'attachments.videos', path: 'attachments.videos',
}, },
{
path: 'reactions',
populate: [
{
path: 'users',
select: 'username displayName',
},
],
},
]; ];
} }
@ -86,6 +97,27 @@ export default class ChatService extends SiteService {
return room.toObject(); 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) { async destroyRoom (user, room) {
if (!user._id.equals(room.owner._id)) { if (!user._id.equals(room.owner._id)) {
throw new SiteError(401, 'This is not your chat room'); throw new SiteError(401, 'This is not your chat room');
@ -165,6 +197,7 @@ export default class ChatService extends SiteService {
.to(room._id.toString()) .to(room._id.toString())
.emit('chat-control', { .emit('chat-control', {
displayList, displayList,
audio: { playSound: 'chat-room-connect' },
systemMessages: [systemMessage], systemMessages: [systemMessage],
}); });
} }
@ -225,6 +258,7 @@ export default class ChatService extends SiteService {
const message = new ChatMessage(); const message = new ChatMessage();
message.created = NOW; message.created = NOW;
message.expires = dayjs(NOW).add(room?.settings?.expireDays || 7, 'day');
message.channelType = 'ChatRoom'; message.channelType = 'ChatRoom';
message.channel = room._id; message.channel = room._id;
message.author = author._id; message.author = author._id;
@ -243,20 +277,37 @@ export default class ChatService extends SiteService {
if (videoFiles) { if (videoFiles) {
for (const videoFile of videoFiles) { for (const videoFile of videoFiles) {
switch (videoFile.mimetype) {
case 'video/mp4':
const video = await videoService.createVideo(author, { }, videoFile); const video = await videoService.createVideo(author, { }, videoFile);
message.attachments.videos.push(video._id); 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 message.save();
await ChatMessage.populate(message, this.populateChatMessage);
const messageObj = message.toObject();
let viewModel = Object.assign({ }, this.dtp.app.locals); 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); messageObj.author = userService.filterUserObject(author);
viewModel = Object.assign(viewModel, { message: messageObj });
const html = this.templates.message(viewModel);
this.dtp.emitter this.dtp.emitter
.to(room._id.toString()) .to(room._id.toString())
.emit('chat-message', { message: messageObj, html }); .emit('chat-message', { message: messageObj, html });
@ -275,6 +326,97 @@ export default class ChatService extends SiteService {
return messages.reverse(); 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) { async checkRoomMember (room, member) {
if (room.owner._id.equals(member._id)) { if (room.owner._id.equals(member._id)) {
return true; return true;
@ -353,6 +495,14 @@ export default class ChatService extends SiteService {
await ChatRoomInvite.deleteMany({ room: room._id }); 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) { async removeMessagesForChannel (channel) {
this.log.alert('removing all messages for channel', { channelId: channel._id }); this.log.alert('removing all messages for channel', { channelId: channel._id });
await ChatMessage await ChatMessage
@ -365,8 +515,16 @@ export default class ChatService extends SiteService {
async expireMessages ( ) { async expireMessages ( ) {
const NOW = new Date(); const NOW = new Date();
this.log.info('expiring chat messages');
await ChatMessage await ChatMessage
.find({ expires: { $lt: NOW } }) .find({
$or: [
{ expires: { $lt: NOW } },
{ expires: { $exists: false } },
],
})
.cursor() .cursor()
.eachAsync(async (message) => { .eachAsync(async (message) => {
await this.removeMessage(message); await this.removeMessage(message);

13
app/services/user.js

@ -15,6 +15,7 @@ import PassportLocal from 'passport-local';
import { v4 as uuidv4 } from 'uuid'; import { v4 as uuidv4 } from 'uuid';
import { SiteService, SiteError } from '../../lib/site-lib.js'; import { SiteService, SiteError } from '../../lib/site-lib.js';
import { users } from 'systeminformation';
export default class UserService extends SiteService { export default class UserService extends SiteService {
@ -25,6 +26,7 @@ export default class UserService extends SiteService {
super(dtp, UserService); super(dtp, UserService);
this.USER_SELECT = '_id created username username_lc displayName picture flags permissions'; 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 = [ this.populateUser = [
{ {
path: 'picture.large', path: 'picture.large',
@ -240,6 +242,17 @@ export default class UserService extends SiteService {
return users; 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) { async isUsernameReserved (username) {
if (this.reservedNames.includes(username)) { if (this.reservedNames.includes(username)) {
this.log.alert('prohibiting use of reserved username', { username }); this.log.alert('prohibiting use of reserved username', { username });

174
app/services/video.js

@ -13,9 +13,17 @@ const Video = mongoose.model('Video');
import mime from 'mime'; import mime from 'mime';
import numeral from 'numeral'; import numeral from 'numeral';
import { v4 as uuidv4 } from 'uuid';
import { SiteService, SiteError } from '../../lib/site-lib.js'; import { SiteService, SiteError } from '../../lib/site-lib.js';
import user from '../models/user.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 { export default class VideoService extends SiteService {
static get name ( ) { return 'VideoService'; } 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 NOW = new Date();
const { media: mediaService, minio: minioService } = this.dtp.services; 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 }); this.log.debug('running ffprobe on uploaded library media', { path: file.path });
const metadata = await mediaService.ffprobe(file.path); const metadata = await mediaService.ffprobe(file.path);
this.log.debug('video file probed', { path: file.path, metadata }); this.log.debug('video file probed', { path: file.path, metadata });
if (user.membership) { 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.'); throw new SiteError(403, 'Video attachments are limited to 100MB file size.');
} }
} else { } else {
if (file.size > (25 * 1024 * 1024)) { if (file.size > SIZE_25MB) {
throw new SiteError(403, 'Video attachments are limited to 25MB file size.'); 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.duration = metadata.duration;
video.status = 'new'; video.status = 'new';
if (!videoDefinition.fromGif) {
videoDefinition.fromGif = false;
}
video.flags = {
fromGif: videoDefinition.fromGif,
};
const ownerId = owner._id.toString(); const ownerId = owner._id.toString();
const videoId = video._id.toString(); const videoId = video._id.toString();
const fileBucket = process.env.MINIO_VIDEO_BUCKET || 'videos'; const fileBucket = process.env.MINIO_VIDEO_BUCKET || 'videos';
@ -192,6 +209,157 @@ export default class VideoService extends SiteService {
await this.removeVideoThumbnailImage(video); await this.removeVideoThumbnailImage(video);
} }
if (!video.media || !video.media.bucket || !video.media.key) {
return;
}
await minioService.removeObject(video.media.bucket, video.media.key); 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;
}
} }

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

28
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

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

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

103
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

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

@ -1,7 +1,9 @@
include ../../link/components/preview include ../../link/components/preview
include ../../user/components/profile-picture include ../../user/components/profile-picture
include ./reaction-bar
mixin renderChatMessage (message) mixin renderChatMessage (message)
.chat-message div(data-message-id= message._id).chat-message
.uk-flex .uk-flex
.uk-width-auto.no-select .uk-width-auto.no-select
+renderProfilePicture(message.author, { iconClass: 'member-profile-icon' }) +renderProfilePicture(message.author, { iconClass: 'member-profile-icon' })
@ -31,6 +33,15 @@ mixin renderChatMessage (message)
if Array.isArray(message.attachments.videos) && (message.attachments.videos.length > 0) if Array.isArray(message.attachments.videos) && (message.attachments.videos.length > 0)
each video of message.attachments.videos each video of message.attachments.videos
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( video(
data-video-id= video._id, data-video-id= video._id,
poster= video.thumbnail ? `/image/${video.thumbnail._id}` : false, poster= video.thumbnail ? `/image/${video.thumbnail._id}` : false,
@ -42,10 +53,63 @@ mixin renderChatMessage (message)
each link in message.links each link in message.links
div(class="uk-width-large").uk-margin-small div(class="uk-width-large").uk-margin-small
+renderLinkPreview(link, { layout: 'responsive' }) +renderLinkPreview(link, { layout: 'responsive' })
.uk-width-auto
.uk-text-bold ! +renderReactionBar(message)
.message-menu .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 .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

3
app/views/chat/components/reaction-bar-standalone.pug

@ -0,0 +1,3 @@
include ../../components/library
include reaction-bar
+renderReactionBar(message)

18
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(',')

33
app/views/chat/room/settings.pug

@ -16,12 +16,41 @@ block view-content
.uk-margin .uk-margin
label(for="topic") Topic label(for="topic") Topic
input(id="topic", name="topic", type="text", placeholder="Enter room topic or leave blank", value= room.topic).uk-input 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 .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( button(
type="button", type="button",
data-room-id= room._id, data-room-id= room._id,
data-room-name= room.name, data-room-name= room.name,
onclick="dtp.app.confirmRoomDelete(event);", onclick="dtp.app.confirmRoomDelete(event);",
).uk-button.uk-button-danger.uk-border-rounded.uk-margin-right Delete Room ).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 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`;
}

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

@ -22,7 +22,12 @@ block view-content
block view-navbar 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 #room-member-panel.chat-sidebar
.chat-stage-header .chat-stage-header
div(uk-grid).uk-grid-small.uk-grid-middle 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 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 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 include ../../components/emoji-picker
block viewjs block viewjs

19
app/views/components/library.pug

@ -1,8 +1,27 @@
//- Common routines for all views //- Common routines for all views
- -
function formatCount (count) {
return numeral(count).format((count > 1000) ? '0,0.0a' : '0,0');
}
function getUserPictureUrl (userProfile, which) { function getUserPictureUrl (userProfile, which) {
if (!userProfile || !userProfile.picture || !userProfile.picture[which]) { if (!userProfile || !userProfile.picture || !userProfile.picture[which]) {
return `https://${site.domain}/img/default-member.png`; return `https://${site.domain}/img/default-member.png`;
} }
return `https://${site.domain}/image/${userProfile.picture[which]._id}`; 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}`;
}

6
app/views/components/navbar.pug

@ -35,7 +35,7 @@ nav(style="background: #000000;").uk-navbar-container.uk-light
li li
a(href=`/user/${user._id}/settings`) a(href=`/user/${user._id}/settings`)
span.nav-item-icon span.menu-icon
i.fas.fa-cog i.fas.fa-cog
span Settings span Settings
@ -43,7 +43,7 @@ nav(style="background: #000000;").uk-navbar-container.uk-light
li.uk-nav-divider li.uk-nav-divider
li li
a(href='/admin') a(href='/admin')
span.nav-item-icon span.menu-icon
i.fas.fa-user-lock i.fas.fa-user-lock
span Admin span Admin
@ -51,6 +51,6 @@ nav(style="background: #000000;").uk-navbar-container.uk-light
li li
a(href=`/auth/logout`) a(href=`/auth/logout`)
span.nav-item-icon span.menu-icon
i.fas.fa-right-from-bracket i.fas.fa-right-from-bracket
span Sign Out span Sign Out

4
app/views/layout/main.pug

@ -71,8 +71,8 @@ html(lang='en', data-obs-widget= obsWidget)
block view-content block view-content
script(src='/dayjs/dayjs.min.js') script(src=`/dayjs/dayjs.min.js?v=${pkg.version}`)
script(src='/numeral/numeral.min.js') script(src=`/numeral/numeral.min.js?v=${pkg.version}`)
script(src=`/socket.io/socket.io.js?v=${pkg.version}`) script(src=`/socket.io/socket.io.js?v=${pkg.version}`)
block clientjs block clientjs

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

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

4
client/css/dtp-site.less

@ -1,9 +1,13 @@
@import "site/uk-lightbox.less"; @import "site/uk-lightbox.less";
@import "site/main.less"; @import "site/main.less";
@import "site/button.less"; @import "site/button.less";
@import "site/drop-feedback.less";
@import "site/emoji-picker.less"; @import "site/emoji-picker.less";
@import "site/image.less"; @import "site/image.less";
@import "site/link-preview.less"; @import "site/link-preview.less";
@import "site/menu.less";
@import "site/navbar.less"; @import "site/navbar.less";
@import "site/stage.less"; @import "site/stage.less";
@import "site/stats.less";

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

4
client/css/site/main.less

@ -16,3 +16,7 @@ html, body {
.uk-resize-none { .uk-resize-none {
resize: none; resize: none;
} }
.uk-text-fixed {
font-family: 'Courier New';
}

4
client/css/site/menu.less

@ -0,0 +1,4 @@
.menu-icon {
width: 2em;
text-align: center;
}

57
client/css/site/stage.less

@ -158,18 +158,45 @@
} }
} }
&:last-child {
margin-bottom: 0;
}
.message-menu { .message-menu {
display: none; display: none;
box-sizing: border-box; box-sizing: border-box;
position: absolute; position: absolute;
top: 0; right: 10px; top: 5px; right: 5px;
padding: 5px 10px; padding: 5px 10px;
background-color: white; background-color: @chat-message-menu-bgcolor;
color: #1a1a1a; color: @chat-message-menu-color;
border-radius: 16px; 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 { &.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 { .chat-input-panel {
@ -274,7 +320,8 @@
} }
.input-button-bar { .input-button-bar {
margin-top: 5px; margin-top: 8px;
margin-bottom: 3px;
} }
} }
} }

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

8
client/css/site/uikit-theme.dtp-dark.less

@ -143,11 +143,17 @@ a.uk-button.uk-button-default {
@chat-message-color: #e8e8e8; @chat-message-color: #e8e8e8;
@chat-message-timestamp-color: #808080; @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-bgcolor: #3a3a3a;
@system-message-color: #a8a8a8; @system-message-color: #a8a8a8;
@system-message-timestamp-color: #a8a8a8; @system-message-timestamp-color: #a8a8a8;
@chat-input-panel-bgcolor: #1a1a1a; @chat-input-panel-bgcolor: #3f5768;
@chat-input-panel-color: #e8e8e8; @chat-input-panel-color: #e8e8e8;
@link-container-bgcolor: rgba(0,0,0, 0.3); @link-container-bgcolor: rgba(0,0,0, 0.3);

10
client/css/site/uikit-theme.dtp-light.less

@ -49,7 +49,7 @@
@stage-live-member-bgcolor: #1a1a1a; @stage-live-member-bgcolor: #1a1a1a;
@stage-live-member-color: #8a8a8a; @stage-live-member-color: #8a8a8a;
@chat-sidebar-bgcolor: #7e7b62; @chat-sidebar-bgcolor: #62767e;
@chat-sidebar-color: #FCF1E8; @chat-sidebar-color: #FCF1E8;
@chat-container-bgcolor: #e8e8e8; @chat-container-bgcolor: #e8e8e8;
@ -62,11 +62,17 @@
@chat-message-color: #071E22; @chat-message-color: #071E22;
@chat-message-timestamp-color: #679289; @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-bgcolor: #EE2E31;
@system-message-color: #FCF1E8; @system-message-color: #FCF1E8;
@system-message-timestamp-color: #FCF1E8; @system-message-timestamp-color: #FCF1E8;
@chat-input-panel-bgcolor: #a5a17c; @chat-input-panel-bgcolor: #7794a0;
@chat-input-panel-color: #1a1a1a; @chat-input-panel-color: #1a1a1a;
@link-container-bgcolor: rgba(0, 0, 0, 0.1); @link-container-bgcolor: rgba(0, 0, 0, 0.1);

87
client/js/chat-client.js

@ -21,12 +21,15 @@ import hljs from 'highlight.js';
export class ChatApp extends DtpApp { 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_MESSAGE ( ) { return 'chat-message'; }
static get SFX_CHAT_REACTION ( ) { return 'reaction'; }
static get SFX_CHAT_REACTION_REMOVE ( ) { return 'reaction-remove'; }
constructor (user) { constructor (user) {
super(DTP_COMPONENT_NAME, user); super(DTP_COMPONENT_NAME, user);
this.loadSettings(); this.loadSettings();
this.log.info('DTP app client online'); this.log.info('constructor', 'DTP app client online');
this.chat = { this.chat = {
form: document.querySelector('#chat-input-form'), form: document.querySelector('#chat-input-form'),
@ -45,11 +48,6 @@ export class ChatApp extends DtpApp {
hljs.highlightAll(); 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'); this.emojiPickerDisplay = document.querySelector('.emoji-picker-display');
if (this.emojiPickerDisplay) { if (this.emojiPickerDisplay) {
this.emojiPickerDisplay.addEventListener('click', this.onEmojiPickerClose.bind(this)); this.emojiPickerDisplay.addEventListener('click', this.onEmojiPickerClose.bind(this));
@ -58,15 +56,25 @@ export class ChatApp extends DtpApp {
if (this.emojiPicker) { if (this.emojiPicker) {
this.emojiPicker.addEventListener('emoji-click', this.onEmojiPicked.bind(this)); 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 ( ) { async startAudio ( ) {
this.log.info('startAudio', 'starting ChtaAudio'); this.log.info('startAudio', 'starting ChatAudio');
this.audio = new ChatAudio(); this.audio = new ChatAudio();
this.audio.start(); this.audio.start();
try { try {
await Promise.all([ 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_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) { } catch (error) {
this.log.error('startAudio', 'failed to load sound', { 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) { async onChatMessageListChanged (mutationList) {
this.log.info('onMutation', 'DOM mutation received', { mutationList }); this.log.info('onMutation', 'DOM mutation received', { mutationList });
if (!Array.isArray(mutationList) || (mutationList.length === 0)) { if (!Array.isArray(mutationList) || (mutationList.length === 0)) {
@ -213,6 +262,25 @@ export class ChatApp extends DtpApp {
return true; 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 ( ) { async onDtpLoad ( ) {
this.log.info('dtp-load event received. Connecting to platform.'); this.log.info('dtp-load event received. Connecting to platform.');
await this.connect({ await this.connect({
@ -255,6 +323,11 @@ export class ChatApp extends DtpApp {
async onChatControl (message) { async onChatControl (message) {
const isAtBottom = this.chat.isAtBottom; const isAtBottom = this.chat.isAtBottom;
if (message.audio) {
if (message.audio.playSound) {
this.audio.playSound(message.audio.playSound);
}
}
if (message.displayList) { if (message.displayList) {
this.displayEngine.executeDisplayList(message.displayList); this.displayEngine.executeDisplayList(message.displayList);
} }

BIN
client/static/sfx/reaction-remove.mp3

Binary file not shown.

BIN
client/static/sfx/reaction.mp3

Binary file not shown.

BIN
client/static/sfx/room-connect.mp3

Binary file not shown.

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

16
dtp-chat.js

@ -11,9 +11,6 @@ import fs from 'fs';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line 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 * as rfs from 'rotating-file-stream';
import webpack from 'webpack'; import webpack from 'webpack';
@ -244,6 +241,11 @@ class SiteWebApp extends SiteRuntime {
if (!entry.isFile()) { if (!entry.isFile()) {
continue; continue;
} }
if (!entry.name.endsWith("js")) {
this.log.alert('skipping invalid file in controllers directory', { entry });
continue;
}
try { try {
const ControllerClass = (await import(path.join(basePath, entry.name))).default; const ControllerClass = (await import(path.join(basePath, entry.name))).default;
if (!ControllerClass) { if (!ControllerClass) {
@ -266,7 +268,7 @@ class SiteWebApp extends SiteRuntime {
} }
for await (const controller of inits) { for await (const controller of inits) {
if (controller.name === 'HomeController') { if (controller.slug === 'home') {
continue; continue;
} }
await controller.start(); await controller.start();
@ -283,7 +285,13 @@ class SiteWebApp extends SiteRuntime {
try { try {
const app = new SiteWebApp(); const app = new SiteWebApp();
try {
await app.start(); await app.start();
} catch (error) {
await app.shutdown();
await app.terminate();
throw new Error('failed to start web app', { cause: error });
}
} catch (error) { } catch (error) {
console.error('failed to start application harness', error); console.error('failed to start application harness', error);
} }

1
lib/client/js/dtp-app.js

@ -18,7 +18,6 @@ export default class DtpApp {
this.name = appName; this.name = appName;
this.log = new DtpLog(appName); this.log = new DtpLog(appName);
this.log.debug('constructor', 'creating DisplayEngine instance');
this.displayEngine = new DtpDisplayEngine(); this.displayEngine = new DtpDisplayEngine();
this.domParser = new DOMParser(); this.domParser = new DOMParser();

1
lib/client/js/dtp-display-engine.js

@ -13,7 +13,6 @@ export default class DtpDisplayEngine {
constructor ( ) { constructor ( ) {
this.processors = { }; this.processors = { };
this.log = new DtpLog(DTP_COMPONENT_NAME); this.log = new DtpLog(DTP_COMPONENT_NAME);
this.log.debug('constructor', 'DTP Display Engine instance created');
} }
/** /**

21
lib/site-controller.js

@ -4,6 +4,8 @@
'use strict'; 'use strict';
import path from 'node:path';
import multer from 'multer'; import multer from 'multer';
import slugTool from 'slug'; import slugTool from 'slug';
@ -21,12 +23,23 @@ export class SiteController extends SiteCommon {
} }
async loadChild (filename) { async loadChild (filename) {
let child = await require(filename); const pathObj = path.parse(filename);
this.children[child.slug] = child;
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') { getPaginationParameters (req, maxPerPage, pageParamName = 'p', cppParamName = 'cpp') {

11
lib/site-runtime.js

@ -15,7 +15,11 @@ import { createRequire } from 'module';
const require = createRequire(import.meta.url); // jshint ignore:line const require = createRequire(import.meta.url); // jshint ignore:line
import numeral from 'numeral'; import numeral from 'numeral';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime.js';
dayjs.extend(relativeTime);
import * as Marked from 'marked'; import * as Marked from 'marked';
import hljs from 'highlight.js'; import hljs from 'highlight.js';
@ -51,12 +55,14 @@ export class SiteRuntime {
await this.loadModels(); await this.loadModels();
await this.loadServices(); await this.loadServices();
process.on('unhandledRejection', (error, p) => { process.on('unhandledRejection', async (error, p) => {
this.log.error('Unhandled rejection', { this.log.error('Unhandled rejection', {
error: error, error: error,
promise: p, promise: p,
stack: error.stack stack: error.stack
}); });
const exitCode = await this.shutdown();
await this.terminate(exitCode);
}); });
process.on('warning', (error) => { process.on('warning', (error) => {
@ -250,9 +256,8 @@ export class SiteRuntime {
viewModel.pkg = this.config.pkg; viewModel.pkg = this.config.pkg;
viewModel.dayjs = dayjs; viewModel.dayjs = dayjs;
viewModel.numeral = numeral; viewModel.numeral = numeral;
// viewModel.phoneNumberJS = require('libphonenumber-js'); viewModel.hljs = hljs;
// viewModel.anchorme = require('anchorme').default; // viewModel.anchorme = require('anchorme').default;
// viewModel.hljs = hljs;
// viewModel.Color = require('color'); // viewModel.Color = require('color');
// viewModel.numberToWords = require('number-to-words'); // viewModel.numberToWords = require('number-to-words');
viewModel.uuidv4 = (await import('uuid')).v4; viewModel.uuidv4 = (await import('uuid')).v4;

2
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/host-services.js
forever start --killSignal=SIGINT app/workers/chat-links.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" 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/chat-links.js
forever stop app/workers/host-services.js forever stop app/workers/host-services.js
Loading…
Cancel
Save