Browse Source
- Integrated all chat updates from Soapbox/Shing with heavy mods to lib/site-ioserver.js and the creation of the chat service, worker, and jobs - Added ability to create ChatRoom instances, invite people to them, join them and delete them - Refactored the shit out of SiteWorker - Created SiteWorkerProcess - Created the chat worker and the chat-room-clear and chat-room-delete job processors - Created the media worker - Refactored Stickers from Soapbox/Shing into the media system - Created the Attachment model, service, and media worker jobs - Upgraded the emoji picker from emoji-button to picmo because the author depreacted emoji-button and released picmo. - Made a custom presentation for the emoji picker - Created the SiteChat client-side object for managing Core Chat within the browser - Brought the Kaleidoscope Event UI down from DTP Social so anything can render a timeline of them - Added configurations for the media and reeeper job queues - Added the basics of a Notifications view - Added the concept of Forms - upgraded ioredis to 5.2.2pull/2/head
96 changed files with 3217 additions and 1135 deletions
@ -0,0 +1,70 @@ |
|||
// email.js
|
|||
// Copyright (C) 2021 Digital Telepresence, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const path = require('path'); |
|||
const glob = require('glob'); |
|||
|
|||
const express = require('express'); |
|||
|
|||
const { SiteController,/*, SiteError*/ |
|||
SiteError} = require('../../lib/site-lib'); |
|||
|
|||
class FormController extends SiteController { |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, module.exports); |
|||
} |
|||
|
|||
async start ( ) { |
|||
const { |
|||
chat: chatService, |
|||
limiter: limiterService, |
|||
session: sessionService, |
|||
} = this.dtp.services; |
|||
|
|||
try { |
|||
this.forms = glob.sync(path.join(this.dtp.config.root, 'app', 'views', 'form', '*pug')) || [ ]; |
|||
this.forms = this.forms.map((filename) => path.parse(filename)); |
|||
} catch (error) { |
|||
this.log.error('failed to detect requestable forms', { error }); |
|||
this.forms = [ ]; |
|||
// fall through
|
|||
} |
|||
|
|||
const router = express.Router(); |
|||
this.dtp.app.use('/form', router); |
|||
|
|||
router.use( |
|||
sessionService.authCheckMiddleware({ requireLogin: true }), |
|||
chatService.middleware({ maxOwnedRooms: 25, maxJoinedRooms: 50 }), |
|||
async (req, res, next) => { |
|||
res.locals.currentView = module.exports.slug; |
|||
return next(); |
|||
}, |
|||
); |
|||
|
|||
router.get( |
|||
'/:formSlug', |
|||
limiterService.createMiddleware(limiterService.config.form.getForm), |
|||
this.getForm.bind(this), |
|||
); |
|||
} |
|||
|
|||
async getForm (req, res, next) { |
|||
const { formSlug } = req.params; |
|||
const form = this.forms.find((form) => form.name === formSlug); |
|||
if (!form) { |
|||
return next(new SiteError(400, 'Invalid form')); |
|||
} |
|||
res.render(`form/${form.name}`); |
|||
} |
|||
} |
|||
|
|||
module.exports = { |
|||
slug: 'form', |
|||
name: 'form', |
|||
create: async (dtp) => { return new FormController(dtp); }, |
|||
}; |
@ -0,0 +1,78 @@ |
|||
// notification.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const express = require('express'); |
|||
|
|||
const { SiteController, SiteError } = require('../../lib/site-lib'); |
|||
|
|||
class NotificationController extends SiteController { |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, module.exports); |
|||
} |
|||
|
|||
async start ( ) { |
|||
const { dtp } = this; |
|||
const { limiter: limiterService } = dtp.services; |
|||
|
|||
const router = express.Router(); |
|||
dtp.app.use('/notification', router); |
|||
|
|||
router.use(async (req, res, next) => { |
|||
res.locals.currentView = 'notification'; |
|||
return next(); |
|||
}); |
|||
|
|||
router.param('notificationId', this.populateNotificationId.bind(this)); |
|||
|
|||
router.get( |
|||
'/:notificationId', |
|||
limiterService.createMiddleware(limiterService.config.notification.getNotificationView), |
|||
this.getNotificationView.bind(this), |
|||
); |
|||
|
|||
router.get('/', |
|||
limiterService.createMiddleware(limiterService.config.notification.getNotificationHome), |
|||
this.getNotificationHome.bind(this), |
|||
); |
|||
} |
|||
|
|||
async populateNotificationId (req, res, next, notificationId) { |
|||
const { userNotification: userNotificationService } = this.dtp.services; |
|||
try { |
|||
res.locals.notification = await userNotificationService.getById(notificationId); |
|||
if (!res.locals.notification) { |
|||
throw new SiteError(404, 'Notification not found'); |
|||
} |
|||
return next(); |
|||
} catch (error) { |
|||
this.log.error('failed to populate notificationId', { notificationId, error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
|
|||
async getNotificationView (req, res) { |
|||
res.render('notification/view'); |
|||
} |
|||
|
|||
async getNotificationHome (req, res, next) { |
|||
const { userNotification: userNotificationService } = this.dtp.services; |
|||
try { |
|||
res.locals.pagination = this.getPaginationParameters(req, 20); |
|||
res.locals.notifications = await userNotificationService.getForUser(req.user, res.locals.pagination); |
|||
res.render('notification/index'); |
|||
} catch (error) { |
|||
this.log.error('failed to render notification home view', { error }); |
|||
return next(error); |
|||
} |
|||
} |
|||
} |
|||
|
|||
module.exports = { |
|||
slug: 'notification', |
|||
name: 'notification', |
|||
create: async (dtp) => { return new NotificationController(dtp); }, |
|||
}; |
@ -0,0 +1,64 @@ |
|||
// attachment.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const mongoose = require('mongoose'); |
|||
|
|||
const Schema = mongoose.Schema; |
|||
|
|||
const ATTACHMENT_STATUS_LIST = [ |
|||
'processing', // the attachment is in the processing queue
|
|||
'live', // the attachment is available for use
|
|||
'rejected', // the attachment was rejected (by proccessing queue)
|
|||
'retired', // the attachment has been retired
|
|||
]; |
|||
|
|||
const AttachmentFileSchema = new Schema({ |
|||
bucket: { type: String, required: true }, |
|||
key: { type: String, required: true }, |
|||
mime: { type: String, required: true }, |
|||
size: { type: Number, required: true }, |
|||
etag: { type: String, required: true }, |
|||
}, { |
|||
_id: false, |
|||
}); |
|||
|
|||
/* |
|||
* Attachments are simply files. They can really be any kind of file, but will |
|||
* mostly be image, video, and audio files. |
|||
* |
|||
* owner is the User or CoreUser that uploaded the attachment. |
|||
* |
|||
* item is the item to which the attachment is attached. |
|||
*/ |
|||
const AttachmentSchema = new Schema({ |
|||
created: { type: Date, default: Date.now, required: true, index: 1 }, |
|||
status: { type: String, enum: ATTACHMENT_STATUS_LIST, default: 'processing', required: true }, |
|||
ownerType: { type: String, required: true }, |
|||
owner: { type: Schema.ObjectId, required: true, index: 1, refPath: 'ownerType' }, |
|||
itemType: { type: String, required: true }, |
|||
item: { type: Schema.ObjectId, required: true, index: 1, refPath: 'itemType' }, |
|||
original: { type: AttachmentFileSchema, required: true, select: false }, |
|||
encoded: { type: AttachmentFileSchema, required: true }, |
|||
flags: { |
|||
isSensitive: { type: Boolean, default: false, required: true }, |
|||
}, |
|||
}); |
|||
|
|||
AttachmentSchema.index({ |
|||
ownerType: 1, |
|||
owner: 1, |
|||
}, { |
|||
name: 'attachment_owner_idx', |
|||
}); |
|||
|
|||
AttachmentSchema.index({ |
|||
itemType: 1, |
|||
item: 1, |
|||
}, { |
|||
name: 'attachment_item_idx', |
|||
}); |
|||
|
|||
module.exports = mongoose.model('Attachment', AttachmentSchema); |
@ -0,0 +1,186 @@ |
|||
// cache.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const mongoose = require('mongoose'); |
|||
const Attachment = mongoose.model('Attachment'); |
|||
|
|||
const { SiteService } = require('../../lib/site-lib'); |
|||
|
|||
class AttachmentService extends SiteService { |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, module.exports); |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
const { user: userService } = this.dtp.services; |
|||
this.populateAttachment = [ |
|||
{ |
|||
path: 'item', |
|||
}, |
|||
{ |
|||
path: 'owner', |
|||
select: userService.USER_SELECT, |
|||
}, |
|||
]; |
|||
|
|||
this.queue = this.getJobQueue('media'); |
|||
// this.template = this.loadViewTemplate('attachment/components/attachment-standalone.pug');
|
|||
} |
|||
|
|||
async create (owner, attachmentDefinition, file) { |
|||
const { minio: minioService } = this.dtp.services; |
|||
const NOW = new Date(); |
|||
|
|||
/* |
|||
* Fill in as much of the attachment as we can prior to uploading |
|||
*/ |
|||
|
|||
let attachment = new Attachment(); |
|||
attachment.created = NOW; |
|||
|
|||
attachment.ownerType = owner.type; |
|||
attachment.owner = owner._id; |
|||
|
|||
attachment.itemType = attachmentDefinition.itemType; |
|||
attachment.item = mongoose.Types.ObjectId(attachmentDefinition.item._id || attachmentDefinition.item); |
|||
|
|||
attachment.flags.isSensitive = attachmentDefinition.isSensitive === 'on'; |
|||
|
|||
/* |
|||
* Upload the original file to storage |
|||
*/ |
|||
|
|||
const attachmentId = attachment._id.toString(); |
|||
|
|||
attachment.original.bucket = process.env.MINIO_ATTACHMENT_BUCKET || 'dtp-attachments'; |
|||
attachment.original.key = this.getAttachmentKey(attachment, 'original'); |
|||
attachment.original.mime = file.mimetype; |
|||
attachment.original.size = file.size; |
|||
|
|||
const response = await minioService.uploadFile({ |
|||
bucket: attachment.file.bucket, |
|||
key: attachment.file.key, |
|||
filePath: file.path, |
|||
metadata: { |
|||
'X-DTP-Attachment-ID': attachmentId, |
|||
'Content-Type': attachment.metadata.mime, |
|||
'Content-Length': file.size, |
|||
}, |
|||
}); |
|||
|
|||
/* |
|||
* Complete the attachment definition, and save it. |
|||
*/ |
|||
|
|||
attachment.original.etag = response.etag; |
|||
await attachment.save(); |
|||
|
|||
attachment = await this.getById(attachment._id); |
|||
|
|||
await this.queue.add('attachment-ingest', { attachmentId: attachment._id }); |
|||
|
|||
return attachment; |
|||
} |
|||
|
|||
getAttachmentKey (attachment, slug) { |
|||
const attachmentId = attachment._id.toString(); |
|||
const prefix = attachmentId.slice(-4); // last 4 for best entropy
|
|||
return `/attachment/${prefix}/${attachmentId}/${attachmentId}-${slug}}`; |
|||
} |
|||
|
|||
/** |
|||
* Retrieves populated Attachment documents attached to an item. |
|||
* @param {String} itemType The type of item (ex: 'ChatMessage') |
|||
* @param {*} itemId The _id of the item (ex: message._id) |
|||
* @returns Array of attachments associated with the item. |
|||
*/ |
|||
async getForItem (itemType, itemId) { |
|||
const attachments = await Attachment |
|||
.find({ itemType, item: itemId }) |
|||
.sort({ order: 1, created: 1 }) |
|||
.populate(this.populateAttachment) |
|||
.lean(); |
|||
return attachments; |
|||
} |
|||
|
|||
/** |
|||
* Retrieves populated Attachment documents created by a specific owner. |
|||
* @param {User} owner The owner for which Attachments are being fetched. |
|||
* @param {*} pagination Optional pagination of data set |
|||
* @returns Array of attachments owned by the specified owner. |
|||
*/ |
|||
async getForOwner (owner, pagination) { |
|||
const attachments = await Attachment |
|||
.find({ ownerType: owner.type, owner: owner._id }) |
|||
.sort({ order: 1, created: 1 }) |
|||
.skip(pagination.skip) |
|||
.limit(pagination.cpp) |
|||
.populate(this.populateAttachment) |
|||
.lean(); |
|||
return attachments; |
|||
} |
|||
|
|||
/** |
|||
* |
|||
* @param {mongoose.Types.ObjectId} attachmentId The ID of the attachment |
|||
* @param {Object} options `withOriginal` true|false |
|||
* @returns A populated Attachment document configured per options. |
|||
*/ |
|||
async getById (attachmentId, options) { |
|||
options = Object.assign({ |
|||
withOriginal: false, |
|||
}, options || { }); |
|||
|
|||
let q = Attachment.findById(attachmentId); |
|||
if (options.withOriginal) { |
|||
q = q.select('+original'); |
|||
} |
|||
|
|||
const attachment = await q.populate(this.populateAttachment).lean(); |
|||
return attachment; |
|||
} |
|||
|
|||
/** |
|||
* Updates the status of an Attachment. |
|||
* @param {Attachment} attachment The attachment being modified. |
|||
* @param {*} status The new status of the attachment |
|||
*/ |
|||
async setStatus (attachment, status) { |
|||
await Attachment.updateOne({ _id: attachment._id }, { $set: { status } }); |
|||
} |
|||
|
|||
/** |
|||
* Passes an attachment and options through a Pug template to generate HTML |
|||
* output ready to be inserted into a DOM to present the attachment in the UI. |
|||
* @param {Attachment} attachment |
|||
* @param {Object} attachmentOptions Additional options passed to the template |
|||
* @returns HTML output of the template |
|||
*/ |
|||
async render (attachment, attachmentOptions) { |
|||
return this.attachmentTemplate({ attachment, attachmentOptions }); |
|||
} |
|||
|
|||
/** |
|||
* Creates a Bull Queue job to delete an Attachment including it's processed |
|||
* and original media files. |
|||
* @param {Attachment} attachment The attachment to be deleted. |
|||
* @returns Bull Queue job handle for the newly created job to delete the |
|||
* attachment. |
|||
*/ |
|||
async remove (attachment) { |
|||
this.log.info('creating job to delete attachment', { attachmentId: attachment._id }); |
|||
return await this.queue.add('attachment-delete', { attachmentId: attachment._id }); |
|||
} |
|||
} |
|||
|
|||
module.exports = { |
|||
slug: 'attachment', |
|||
name: 'attachment', |
|||
create: (dtp) => { return new AttachmentService(dtp); }, |
|||
}; |
@ -0,0 +1,4 @@ |
|||
include ../../user/components/profile-icon |
|||
include message |
|||
|
|||
+renderChatMessage(message) |
@ -1,32 +1,40 @@ |
|||
include ../../sticker/components/sticker |
|||
mixin renderChatMessage (message, options = { }) |
|||
- var authorName = message.author.displayName || message.author.username; |
|||
div(data-message-id= message._id, data-author-id= message.author._id).chat-message |
|||
div(uk-grid).uk-grid-small.uk-flex-bottom |
|||
.uk-width-expand |
|||
.uk-text-small.chat-username.uk-text-truncate= authorName |
|||
|
|||
if message.author.picture && message.author.picture.small |
|||
div( |
|||
data-message-id= message._id, data-author-id= message.author._id |
|||
).chat-message |
|||
.uk-margin-small |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
img(src=`/image/${message.author.picture.small._id}`, alt= `${authorName}'s profile picture`).chat-author-image |
|||
+renderProfileIcon(message.author, message.author.displayName || message.author.username, 'xsmall') |
|||
|
|||
.uk-width-expand |
|||
.chat-username.uk-text-truncate= message.author.displayName || message.author.username |
|||
.uk-text-small.uk-text-muted.uk-text-truncate= message.author.username |
|||
|
|||
if !options.hideMenu && !message.author._id.equals(user._id) |
|||
.uk-width-auto.chat-user-menu |
|||
button(type="button").uk-button.uk-button-link.chat-menu-button |
|||
i.fas.fa-ellipsis-h |
|||
div(data-message-id= message._id, uk-dropdown="mode: click").dtp-chatmsg-menu |
|||
ul.uk-nav.uk-dropdown-nav |
|||
li |
|||
a( |
|||
href="", |
|||
data-message-id= message._id, |
|||
data-user-id= message.author._id, |
|||
data-username= message.author.username, |
|||
onclick="return dtp.app.muteChatUser(event);", |
|||
) Mute #{authorName} |
|||
if !options.hideMenu && (user && !message.author._id.equals(user._id)) |
|||
.uk-width-auto.chat-user-menu |
|||
button(type="button").uk-button.uk-button-link.chat-menu-button |
|||
i.fas.fa-ellipsis-h |
|||
div(data-message-id= message._id, uk-dropdown="mode: click").dtp-chatmsg-menu |
|||
ul.uk-nav.uk-dropdown-nav |
|||
li |
|||
a( |
|||
href="", |
|||
data-message-id= message._id, |
|||
data-user-id= message.author._id, |
|||
data-username= message.author.username, |
|||
onclick="return dtp.app.muteChatUser(event);", |
|||
) Mute #{authorName} |
|||
|
|||
//- we're gonna go ahead and force long lines to break, and the content was |
|||
//- filtered at ingest for zalgo and HTML/XSS |
|||
.chat-content.uk-text-break!= marked.parse(message.content) |
|||
.chat-timestamp(data-created= message.created).uk-text-small |
|||
|
|||
//- "time" is filled in by the JavaScript client using the browser's locale |
|||
//- information so that "time" is always in the user's display timezone. |
|||
.chat-timestamp(data-dtp-timestamp= message.created).uk-text-small |
|||
|
|||
if Array.isArray(message.stickers) && (message.stickers.length > 0) |
|||
each sticker in message.stickers |
|||
|
@ -0,0 +1,4 @@ |
|||
mixin renderRoomList (rooms) |
|||
each room in rooms |
|||
li.uk-active |
|||
a(href=`/chat/room/${room._id}`)= room.name |
@ -0,0 +1,12 @@ |
|||
mixin renderUserListEntry (user, label) |
|||
div(uk-grid).uk-grid-small |
|||
div(class="uk-width-1-1 uk-width-auto@m") |
|||
a(href= getUserProfileUrl(user)) |
|||
+renderProfileIcon(user, user.displayName || user.username, 'small') |
|||
|
|||
div(class="uk-width-1-1 uk-width-expand@m").no-select |
|||
.uk-margin-small |
|||
.uk-text-bold.dtp-text-tight= user.displayName || user.username |
|||
.uk-text-small.dtp-text-tight @#{user.username} |
|||
if label |
|||
.uk-label= label |
@ -1,12 +1,22 @@ |
|||
extends layouts/room |
|||
block content |
|||
|
|||
.content-block.uk-height-1-1.uk-overflow-auto |
|||
include components/message |
|||
|
|||
h1 #{site.name} Chat |
|||
#site-chat-container.uk-flex.uk-flex-column.uk-height-1-1 |
|||
.chat-menubar.uk-padding-small |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
img(src=`/img/icon/${site.domainKey}/icon-48x48.png`, alt=`${site.name} icon`) |
|||
.uk-width-expand |
|||
h1.uk-margin-remove #{site.name} Chat Timeline |
|||
|
|||
p You can #[a(href='/chat/room/create') create] public and private chat rooms. A public room is listed in the public room directory, shown below. Private rooms are not listed in the directory and are unable to be found in search. |
|||
.chat-content-wrapper |
|||
#chat-message-list-wrapper.uk-height-1-1 |
|||
#chat-message-list |
|||
each message in timeline |
|||
+renderChatMessage(message, { includeRoomInfo: true }) |
|||
.chat-message-menu |
|||
button(type="button", onclick="return dtp.app.chat.resumeChatScroll(event);").chat-scroll-return Resume scrolling |
|||
|
|||
p Rooms can be open, which means anyone can join them if they have them link. Rooms can also be closed, which means the room owner must invite people to join (and they have to accept). |
|||
|
|||
h2 Public Rooms |
|||
//- pre= JSON.stringify(userTimeline, null, 2) |
@ -0,0 +1,22 @@ |
|||
button(type="button", uk-close).uk-modal-close-default |
|||
form( |
|||
method="POST", |
|||
action=`/chat/room/${room._id}/invite`, |
|||
onsubmit="return dtp.app.submitForm(event, 'invite chat member');" |
|||
).uk-form |
|||
.uk-card.uk-card-default.uk-card-small |
|||
.uk-card-header |
|||
h1.uk-card-title Invite Member |
|||
|
|||
.uk-card-body |
|||
p You are inviting a new member to #{room.name} |
|||
.uk-margin |
|||
label(for="username").uk-form-label Username |
|||
input(id="username", name="username", type="text", maxlength="100", required).uk-input |
|||
.uk-margin |
|||
label(for="message").uk-form-label Message |
|||
textarea(id="message", name="message", rows="2", placeholder="Enter message for recipient").uk-textarea |
|||
|
|||
.uk-card-footer.uk-flex.uk-flex-right |
|||
.uk-width-auto |
|||
button(type="submit").uk-button.uk-button-primary.uk-border-rounded Send |
@ -0,0 +1,29 @@ |
|||
extends ../layouts/room |
|||
block content |
|||
|
|||
mixin renderRoomTile (room) |
|||
div(data-room-id= room._id, data-room-name= room.name).uk-tile.uk-tile-default.uk-tile-small |
|||
.uk-tile-body |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
.uk-width-expand |
|||
.uk-margin-small |
|||
div(title= room.name).uk-text-bold.uk-text-truncate= room.name |
|||
.uk-text-small.uk-text-truncate= room.description |
|||
div(uk-grid).uk-grid-small.uk-text-small.uk-text-muted.no-select |
|||
.uk-width-expand |
|||
a(href= getUserProfileUrl(room.owner))= room.owner.username |
|||
.uk-width-auto |
|||
span |
|||
i.fas.fa-users |
|||
span.uk-margin-small-left= formatCount(room.members.length) |
|||
|
|||
.uk-height-1-1.uk-overflow-auto |
|||
|
|||
h1 Public Rooms |
|||
div(uk-grid) |
|||
each room in publicRooms |
|||
.uk-width-1-3 |
|||
+renderRoomTile(room) |
|||
|
|||
pre= JSON.stringify(publicRooms, null, 2) |
@ -0,0 +1,25 @@ |
|||
mixin renderInviteListItem (invite) |
|||
.uk-margin-small |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
+renderProfileIcon(invite.member, 'Invited member') |
|||
.uk-width-expand |
|||
.uk-text-bold.uk-text-truncate= invite.member.displayName || invite.member.username |
|||
.uk-text-small.uk-text-muted |
|||
.uk-text-truncate= invite.member.username |
|||
.uk-text-truncate= moment(invite.created).fromNow() |
|||
if invite.status === 'new' |
|||
.uk-width-auto |
|||
button( |
|||
type="button", |
|||
data-room-id= invite.room._id, |
|||
data-invite-id= invite._id, |
|||
onclick='return dtp.app.chat.deleteInvite(event);', |
|||
).uk-button.uk-button-danger.uk-button-small.uk-border-rounded |
|||
span |
|||
i.fas.fa-trash |
|||
span.uk-margin-small-left DELETE |
|||
|
|||
if invite.message |
|||
label.uk-form-label Message to @#{invite.member.username}: |
|||
div= invite.message |
@ -0,0 +1,6 @@ |
|||
include invite-list-item |
|||
mixin renderInviteList (invites) |
|||
ul.uk-list |
|||
each invite in invites |
|||
li(data-invite-id= invite._id) |
|||
+renderInviteListItem(invite) |
@ -0,0 +1,34 @@ |
|||
extends ../../layouts/room |
|||
block content |
|||
|
|||
include components/invite-list |
|||
|
|||
.uk-card.uk-card-default.uk-card-small.uk-flex.uk-flex-column.uk-height-1-1 |
|||
.uk-card-header |
|||
h1.uk-card-title.uk-margin-remove #{room.name} |
|||
div Membership invitation manager |
|||
|
|||
.uk-card-body.uk-flex-1.uk-overflow-auto |
|||
+renderSectionTitle('Sent') |
|||
.uk-margin |
|||
if (Array.isArray(invites.new) && (invites.new.length > 0)) |
|||
+renderInviteList(invites.new) |
|||
else |
|||
div No unresolved invitations. |
|||
|
|||
+renderSectionTitle('Accepted') |
|||
.uk-margin |
|||
if (Array.isArray(invites.accepted) && (invites.accepted.length > 0)) |
|||
+renderInviteList(invites.accepted) |
|||
else |
|||
div No accepted invitations. |
|||
|
|||
+renderSectionTitle('Rejected') |
|||
.uk-margin |
|||
if (Array.isArray(invites.rejected) && (invites.rejected.length > 0)) |
|||
+renderInviteList(invites.rejected) |
|||
else |
|||
div No outstanding rejected invitations. |
|||
|
|||
.uk-card-footer |
|||
+renderBackButton() |
@ -0,0 +1,59 @@ |
|||
extends ../../layouts/room |
|||
block content |
|||
|
|||
include ../../../kaleidoscope/components/event |
|||
include ../../../user/components/attribution-header |
|||
|
|||
form( |
|||
method="POST", |
|||
action=`/chat/room/${invite.room._id}/invite/${invite._id}/action`, |
|||
onsubmit='return dtp.app.submitForm(event, "chat-invite-action");' |
|||
).uk-height-1-1 |
|||
.uk-card.uk-card-default.uk-card-small.uk-flex.uk-flex-column.uk-height-1-1 |
|||
.uk-card-header |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
a(href= getUserProfileUrl(invite.room.owner)) |
|||
+renderProfileIcon(invite.room.owner, invite.room.owner.displayName || invite.room.owner.username, 'small') |
|||
.uk-width-expand |
|||
h1.uk-card-title.uk-margin-remove.dtp-text-tight #{invite.room.name} |
|||
if invite.room.description && (invite.room.description.length > 0) |
|||
.uk-text-small.dtp-text-tight |
|||
div!= marked.parse(invite.room.description) |
|||
div(uk-grid).uk-text-small.uk-text-muted |
|||
.uk-width-auto |
|||
div(title= "Member count").no-select |
|||
span |
|||
i.fas.fa-users |
|||
span.uk-margin-small-left= formatCount(invite.room.members.length) |
|||
|
|||
.uk-card-body.uk-flex-1.uk-overflow-auto |
|||
.uk-margin |
|||
.uk-text-bold Status |
|||
div(class={ |
|||
'uk-text-info': (invite.status === 'new'), |
|||
'uk-text-success': (invite.status === 'accepted'), |
|||
'uk-text-error': (invite.status === 'rejected'), |
|||
'uk-text-muted': (invite.status === 'deleted'), |
|||
})= invite.status |
|||
|
|||
.uk-margin |
|||
.uk-text-bold Invite message |
|||
div!= marked.parse(invite.message) |
|||
|
|||
if invite.room.policy && (invite.room.policy.length > 0) |
|||
.uk-margin |
|||
.uk-text-bold Room policy |
|||
div!= marked.parse(invite.room.policy) |
|||
|
|||
.uk-card-footer |
|||
div(uk-grid) |
|||
.uk-width-expand |
|||
+renderBackButton() |
|||
|
|||
.uk-width-auto(hidden= invite.status !== 'new') |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
button(type="submit", name="response", value="reject").uk-button.uk-button-danger.uk-border-rounded Reject |
|||
.uk-width-auto |
|||
button(type="submit", name="response", value="accept").uk-button.uk-button-primary.uk-border-rounded Accept |
@ -1,55 +1,93 @@ |
|||
extends ../layouts/room |
|||
block content |
|||
|
|||
include ../../user/components/profile-icon |
|||
|
|||
include ../components/input-form |
|||
include ../components/message |
|||
|
|||
#site-chat-container.uk-flex.uk-flex-column.uk-height-1-1 |
|||
div(uk-grid).uk-flex-middle.chat-menubar |
|||
div(uk-tooltip="Room details").uk-width-expand |
|||
h1.uk-card-title.uk-margin-remove= room.name |
|||
div= room.description |
|||
|
|||
div(uk-tooltip="Active Members").uk-width-auto.no-select |
|||
span |
|||
i.fas.fa-user |
|||
span(data-room-id= room._id).uk-margin-small-left.active-member-count= numeral(room.members.length).format('0,0') |
|||
|
|||
div(uk-tooltip="Total Members", class="uk-hidden@m").uk-width-auto.no-select |
|||
span |
|||
i.fas.fa-user |
|||
span.uk-margin-small-left= formatCount(room.members.length) |
|||
|
|||
|
|||
.uk-width-auto |
|||
button( |
|||
type="button", |
|||
data-room-id= room._id, |
|||
onclick="return dtp.app.chat.leaveRoom(event);", |
|||
).uk-button.dtp-button-default.uk-button-small.uk-border-pill.uk-text-bold |
|||
.chat-menubar.uk-padding-small |
|||
div(uk-grid).uk-grid-small.uk-flex-middle |
|||
div(class="uk-width-expand").no-select |
|||
div(title= room.name).chat-room-name.uk-margin-remove.uk-text-truncate= room.name |
|||
.uk-text-small.uk-text-muted.uk-text-truncate= room.description |
|||
|
|||
div(title="Total Members", class="uk-visible@m").uk-width-auto.no-select |
|||
span |
|||
i.fas.fa-sign-out-alt |
|||
span.uk-margin-small-left Leave Room |
|||
|
|||
.uk-width-auto |
|||
.uk-inline |
|||
button(type="button").uk-button.uk-button-link.uk-button-small |
|||
i.fas.fa-ellipsis-h |
|||
div(uk-dropdown={ pos: 'bottom-right', mode: 'click' }) |
|||
ul.uk-nav.uk-dropdown-nav |
|||
li.uk-nav-heading= room.name |
|||
li.uk-nav-divider |
|||
li |
|||
a(href=`/chat/${room._id}/pop-out`, target="_blank") Pop-Out Chat |
|||
|
|||
#chat-message-list-wrapper |
|||
#chat-reactions |
|||
#chat-message-list |
|||
each message in chatMessages || [ ] |
|||
+renderChatMessage(message) |
|||
|
|||
.chat-message-menu |
|||
button(type="button", onclick="return dtp.app.chat.resumeChatScroll(event);").chat-scroll-return Resume scrolling |
|||
|
|||
div |
|||
+renderChatInputForm(room) |
|||
i.fas.fa-users |
|||
span.uk-margin-small-left= formatCount(room.members.length) |
|||
|
|||
if !user || !room.owner._id.equals(user._id) |
|||
|
|||
.uk-width-auto |
|||
.uk-inline |
|||
button(type="button").uk-button.uk-button-link.uk-button-small |
|||
i.fas.fa-ellipsis-h |
|||
#chat-room-menu(uk-dropdown={ pos: 'bottom-right', mode: 'click' }) |
|||
ul.uk-nav.uk-nav-default.uk-dropdown-nav |
|||
li |
|||
a(href=`/chat/room/${room._id}/widget`, target="_blank") |
|||
span.nav-item-icon |
|||
i.fas.fa-comment-dots |
|||
span Pop-Out Chat |
|||
|
|||
|
|||
if user && !room.owner._id.equals(user._id) |
|||
a( |
|||
href="" |
|||
data-room-id= room._id, |
|||
onclick="return dtp.app.chat.leaveRoom(event);", |
|||
) |
|||
span.nav-item-icon |
|||
i.fas.fa-sign-out-alt |
|||
span Leave Room |
|||
|
|||
if user && room.owner._id.equals(user._id) |
|||
li.uk-nav-divider |
|||
|
|||
li |
|||
a( |
|||
href="", |
|||
data-room-id= room._id, |
|||
onclick=`return dtp.app.chat.showForm(event, '${room._id}', 'invite-member');` |
|||
) |
|||
span.nav-item-icon |
|||
i.fas.fa-user-plus |
|||
span Invite New Member |
|||
|
|||
li |
|||
a( |
|||
href=`/chat/room/${room._id}/invite`, |
|||
data-room-id= room._id, |
|||
) |
|||
span.nav-item-icon |
|||
i.fas.fa-mail-bulk |
|||
span Manage Invites |
|||
|
|||
li.uk-nav-divider |
|||
|
|||
li |
|||
a(href=`/chat/room/${room._id}/settings`) |
|||
span.nav-item-icon |
|||
i.fas.fa-cog |
|||
span Settings |
|||
|
|||
|
|||
.chat-content-wrapper |
|||
div(uk-grid).uk-grid-small.uk-height-1-1 |
|||
#chat-webcam-container(hidden).uk-width-auto |
|||
ul#chat-webcam-list.uk-list |
|||
|
|||
.uk-width-expand |
|||
#chat-message-list-wrapper.uk-height-1-1 |
|||
#chat-message-list |
|||
each message in chatMessages || [ ] |
|||
+renderChatMessage(message) |
|||
|
|||
#chat-reactions.no-select |
|||
|
|||
.chat-message-menu |
|||
button(type="button", onclick="return dtp.app.chat.resumeChatScroll(event);").chat-scroll-return Resume scrolling |
|||
|
|||
+renderChatInputForm(room) |
@ -1,4 +1,5 @@ |
|||
mixin renderButtonIcon (buttonClass, buttonLabel) |
|||
span |
|||
i(class=`fas ${buttonClass}`) |
|||
span(class="uk-visible@m").uk-margin-small-left= buttonLabel |
|||
if buttonLabel |
|||
span(class="uk-visible@m").uk-margin-small-left= buttonLabel |
@ -0,0 +1,43 @@ |
|||
mixin renderKaleidoscopeEvent (event) |
|||
div( |
|||
data-event-id= event._id, |
|||
data-event-source= event.source.pkg.name, |
|||
data-event-action= event.action, |
|||
).kaleidoscope-event |
|||
if event.thumbnail |
|||
img(src= event.thumbnail).event-feature-img |
|||
|
|||
header.event-header |
|||
if event.label |
|||
h4.uk-comment-title.uk-margin-small= event.label |
|||
|
|||
div(uk-grid).uk-grid-small.uk-flex-middle |
|||
if event.source.emitter |
|||
.uk-width-auto |
|||
a(href= event.source.emitter.href, uk-title= `Visit ${event.source.emitter.displayName || event.source.emitter.username } at ${event.source.site.name}`) |
|||
img(src=`//${event.source.site.domain}/hive/user/${event.source.emitter.emitterId}/picture`).site-profile-picture.sb-xsmall |
|||
.uk-width-expand |
|||
if event.source.emitter |
|||
.uk-text-bold= event.source.emitter.displayName |
|||
.uk-text-small |
|||
a( |
|||
href= event.source.emitter.href, |
|||
title= `Visit ${event.source.emitter.displayName || event.source.emitter.username } at ${event.source.site.name}`, |
|||
) #{event.source.emitter.username}@#{event.source.site.domainKey} |
|||
|
|||
.event-content!= marked.parse(event.content) |
|||
|
|||
.event-footer |
|||
div(uk-grid).uk-grid-small.uk-flex-middle |
|||
.uk-width-auto |
|||
.uk-text-small.uk-text-muted |
|||
a(href= event.href, title= "Open destination")= moment(event.created).fromNow() |
|||
.uk-width-auto |
|||
.uk-text-small.uk-text-muted #[span= event.source.pkg.name] |
|||
.uk-width-expand |
|||
.uk-text-small.uk-text-muted= event.action |
|||
.uk-width-auto |
|||
a(href=`//${event.source.site.domain}`, title= event.source.site.name) |
|||
img( |
|||
src=`//${event.source.site.domain}/img/icon/${event.source.site.domainKey}/icon-16x16.png`, |
|||
).site-favicon |
@ -0,0 +1,14 @@ |
|||
extends ../layouts/main-sidebar |
|||
block content |
|||
|
|||
include ../kaleidoscope/components/event |
|||
|
|||
+renderSectionTitle('Notifications') |
|||
|
|||
if Array.isArray(notifications) && (notifications.length > 0) |
|||
ul.uk-list |
|||
each notification in notifications |
|||
li |
|||
+renderKaleidoscopeEvent(notification.event) |
|||
else |
|||
div No notifications |
@ -0,0 +1,8 @@ |
|||
mixin renderUserAttributionHeader (user) |
|||
div(uk-grid).uk-grid-small |
|||
.uk-width-auto |
|||
+renderProfileIcon(user) |
|||
.uk-width-expand |
|||
.uk-text-bold(style="line-height: 1;")= user.displayName || user.username |
|||
.uk-text-small.uk-text-muted |
|||
a(href= getUserProfileUrl(user))= user.username |
@ -1,17 +1,35 @@ |
|||
mixin renderProfileIcon (user, title, size) |
|||
- |
|||
var sizeMap = { |
|||
"xxsmall": "small", |
|||
"xsmall": "small", |
|||
"list-item": "small", |
|||
"navbar": "small", |
|||
"small": "small", |
|||
"medium": "large", |
|||
"large": "large", |
|||
"full": "full", |
|||
}; |
|||
|
|||
mixin renderProfileIcon (user, title, size = "small") |
|||
if user.coreUserId |
|||
img( |
|||
src=`http://${user.core.meta.domain}/core/user/${user.coreUserId}/picture?s=${size || 'small'}`, |
|||
src=`http://${user.core.meta.domain}/core/user/${user.coreUserId}/picture?s=${sizeMap[size]}`, |
|||
class= "site-profile-picture", |
|||
class= `sb-${size}`, |
|||
title= title, |
|||
).site-profile-picture.sb-navbar |
|||
) |
|||
else |
|||
if user.picture && user.picture.small |
|||
img( |
|||
src= `/image/${user.picture.small._id}`, |
|||
src= `/image/${user.picture[sizeMap[size]]._id}`, |
|||
class= "site-profile-picture", |
|||
class= `sb-${size}`, |
|||
title= title, |
|||
).site-profile-picture.sb-navbar |
|||
) |
|||
else |
|||
img( |
|||
src= "/img/default-member.png", |
|||
class= "site-profile-picture", |
|||
class= `sb-${size}`, |
|||
title= title, |
|||
).site-profile-picture.sb-navbar |
|||
) |
|||
|
@ -0,0 +1,61 @@ |
|||
// chat/job/chat-room-clear.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const path = require('path'); |
|||
|
|||
const mongoose = require('mongoose'); |
|||
|
|||
const ChatRoom = mongoose.model('ChatRoom'); |
|||
const ChatRoomInvite = mongoose.model('ChatRoomInvite'); |
|||
const ChatMessage = mongoose.model('ChatMessage'); |
|||
const EmojiReaction = mongoose.model('EmojiReaction'); |
|||
|
|||
const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); |
|||
|
|||
/** |
|||
* DTP Core Chat sticker processor can receive requests to ingest and delete |
|||
* stickers to be executed as background jobs in a queue. This processor |
|||
* attaches to the `media` queue and registers processors for `sticker-ingest` |
|||
* and `sticker-delete`. |
|||
*/ |
|||
class ChatRoomClearJob extends SiteWorkerProcess { |
|||
|
|||
static get COMPONENT ( ) { |
|||
return { |
|||
name: 'charRoomClearJob', |
|||
slug: 'chat-room-clear-job', |
|||
}; |
|||
} |
|||
|
|||
constructor (worker) { |
|||
super(worker, ChatRoomClearJob.COMPONENT); |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
const queue = this.getJobQueue('chat'); |
|||
|
|||
this.log.info('registering job processor', { queue: this.queue.name, name: 'chat-room-clear' }); |
|||
queue.process('chat-room-clear', this.processChatRoomClear.bind(this)); |
|||
} |
|||
|
|||
async stop ( ) { |
|||
await super.stop(); |
|||
} |
|||
|
|||
async processChatRoomClear (job) { |
|||
const { roomId } = job.data; |
|||
this.log.info('received chat room clear job', { id: job.id, roomId }); |
|||
|
|||
await ChatMessage |
|||
.find({ room: roomId }) |
|||
.cursor() |
|||
.eachAsync(this.worker.deleteChatMessage.bind(this), 4); |
|||
} |
|||
} |
|||
|
|||
module.exports = ChatRoomClearJob; |
@ -0,0 +1,102 @@ |
|||
// chat/job/chat-room-delete.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const path = require('path'); |
|||
|
|||
const mongoose = require('mongoose'); |
|||
|
|||
const ChatRoom = mongoose.model('ChatRoom'); |
|||
const ChatRoomInvite = mongoose.model('ChatRoomInvite'); |
|||
const ChatMessage = mongoose.model('ChatMessage'); |
|||
const EmojiReaction = mongoose.model('EmojiReaction'); |
|||
|
|||
const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); |
|||
|
|||
/** |
|||
* DTP Core Chat sticker processor can receive requests to ingest and delete |
|||
* stickers to be executed as background jobs in a queue. This processor |
|||
* attaches to the `media` queue and registers processors for `sticker-ingest` |
|||
* and `sticker-delete`. |
|||
*/ |
|||
class ChatRoomDeleteJob extends SiteWorkerProcess { |
|||
|
|||
static get COMPONENT ( ) { |
|||
return { |
|||
name: 'chatRoomProcessor', |
|||
slug: 'chat-room-processor', |
|||
}; |
|||
} |
|||
|
|||
constructor (worker) { |
|||
super(worker, ChatRoomDeleteJob.COMPONENT); |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
const queue = this.getJobQueue('chat'); |
|||
|
|||
this.log.info('registering job processor', { queue: this.queue.name, name: 'chat-room-delete' }); |
|||
queue.process('chat-room-delete', this.processChatRoomDelete.bind(this)); |
|||
} |
|||
|
|||
async stop ( ) { |
|||
await super.stop(); |
|||
} |
|||
|
|||
async processChatRoomDelete (job) { |
|||
const { roomId } = job.data; |
|||
this.log.info('received chat room delete job', { id: job.id, roomId }); |
|||
|
|||
await EmojiReaction |
|||
.find({ subject: roomId }) |
|||
.cursor() |
|||
.eachAsync(this.deleteEmojiReaction.bind(this)); |
|||
|
|||
await ChatMessage |
|||
.find({ room: roomId }) |
|||
.cursor() |
|||
.eachAsync(this.worker.deleteChatMessage.bind(this), 4); |
|||
|
|||
await ChatRoomInvite |
|||
.find({ room: roomId }) |
|||
.cursor() |
|||
.eachAsync(this.deleteChatRoomInvite.bind(this), 4); |
|||
|
|||
await ChatRoom.deleteOne({ _id: roomId }); |
|||
} |
|||
|
|||
async deleteEmojiReaction (reaction) { |
|||
if (!reaction || !reaction._id) { |
|||
this.log.error('skipping invalid emoji reaction for delete'); |
|||
return; |
|||
} |
|||
|
|||
const EmojiReaction = mongoose.model('EmojiReaction'); |
|||
try { |
|||
await EmojiReaction.deleteOne({ _id: reaction._id }); |
|||
} catch (error) { |
|||
this.log.error('failed to delete chat message', { reactionId: reaction._id, error }); |
|||
} |
|||
} |
|||
|
|||
async deleteChatRoomInvite (invite) { |
|||
if (!invite || !invite._id) { |
|||
this.log.error('skipping invalid invite for delete'); |
|||
return; |
|||
} |
|||
|
|||
const ChatRoomInvite = mongoose.model('ChatRoomInvite'); |
|||
try { |
|||
await ChatRoomInvite.deleteOne({ _id: invite._id }); |
|||
} catch (error) { |
|||
this.log.error('failed to delete chat room invite', { inviteId: invite._id, error }); |
|||
} |
|||
} |
|||
|
|||
} |
|||
|
|||
module.exports = ChatRoomDeleteJob; |
@ -0,0 +1,86 @@ |
|||
// media.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const path = require('path'); |
|||
const fs = require('fs'); |
|||
require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); |
|||
|
|||
const mongoose = require('mongoose'); |
|||
|
|||
const { SitePlatform, SiteLog, SiteWorker } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); |
|||
|
|||
module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); |
|||
module.config = { |
|||
environment: process.env.NODE_ENV, |
|||
root: path.resolve(__dirname, '..', '..'), |
|||
component: { name: 'mediaWorker', slug: 'media-worker' }, |
|||
}; |
|||
|
|||
/** |
|||
* Provides background media processing for the DTP ecosystem. |
|||
* |
|||
* Background media processing is simply a way of life for scalable Web |
|||
* architectures. You don't want to force your site member to sit there and |
|||
* watch a process run. You want to accept their file, toss it to storage, and |
|||
* create a job to have whatever work needs done performed. |
|||
* |
|||
* This obviously induces a variable amount of time from when the site member |
|||
* uploads the file until it's ready for online distribution. The system |
|||
* therefore facilitates ways to query the status of the job and to receive a |
|||
* notification when the work is complete. |
|||
* |
|||
* This worker serves as a starting point or demonstration of how to do |
|||
* background media processing at scale and in production. This is the exact |
|||
* code we use to run the Digital Telepresence Platform every day. |
|||
*/ |
|||
class MediaWorker extends SiteWorker { |
|||
|
|||
constructor (dtp) { |
|||
super(dtp, dtp.config.component); |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
if (process.argv[2]) { |
|||
const stickerId = mongoose.Types.ObjectId(process.argv[2]); |
|||
this.log.info('creating sticker processing job', { stickerId }); |
|||
|
|||
const queue = this.getJobQueue('media'); |
|||
await queue.add('sticker-ingest', { stickerId }); |
|||
} |
|||
|
|||
await this.loadProcessor(path.join(__dirname, 'media', 'job', 'sticker-ingest.js')); |
|||
await this.loadProcessor(path.join(__dirname, 'media', 'job', 'sticker-delete.js')); |
|||
|
|||
await this.loadProcessor(path.join(__dirname, 'media', 'job', 'attachment-ingest.js')); |
|||
await this.loadProcessor(path.join(__dirname, 'media', 'job', 'attachment-delete.js')); |
|||
|
|||
await this.startProcessors(); |
|||
} |
|||
|
|||
async stop ( ) { |
|||
await super.stop(); |
|||
} |
|||
} |
|||
|
|||
(async ( ) => { |
|||
try { |
|||
module.log = new SiteLog(module, module.config.component); |
|||
await SitePlatform.startPlatform(module, module.config.component); |
|||
|
|||
module.worker = new MediaWorker(module); |
|||
await module.worker.start(); |
|||
|
|||
module.log.info(`${module.pkg.name} v${module.pkg.version} ${module.config.component.name} started`); |
|||
} catch (error) { |
|||
module.log.error('failed to start worker', { |
|||
component: module.config.component, |
|||
error, |
|||
}); |
|||
process.exit(-1); |
|||
} |
|||
})(); |
@ -0,0 +1,72 @@ |
|||
// media/job/attachment-delete.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const path = require('path'); |
|||
|
|||
const mongoose = require('mongoose'); |
|||
const Attachment = mongoose.model('Attachment'); |
|||
|
|||
const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); |
|||
|
|||
class AttachmentDeleteJob extends SiteWorkerProcess { |
|||
|
|||
static get COMPONENT ( ) { |
|||
return { |
|||
name: 'attachmentDeleteJob', |
|||
slug: 'attachment-delete-job', |
|||
}; |
|||
} |
|||
|
|||
constructor (worker) { |
|||
super(worker, AttachmentDeleteJob.COMPONENT); |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
this.queue = await this.getJobQueue('media'); |
|||
|
|||
this.log.info('registering job processor', { queue: this.queue.name, name: 'attachment-delete' }); |
|||
this.queue.process('attachment-delete', 1, this.processAttachmentDelete.bind(this)); |
|||
} |
|||
|
|||
async stop ( ) { |
|||
await super.stop(); |
|||
} |
|||
|
|||
async processAttachmentDelete (job) { |
|||
try { |
|||
const { attachment: attachmentService } = this.dtp.services; |
|||
const attachment = job.data.attachment = await attachmentService.getById( |
|||
job.data.attachmentId, |
|||
{ withOriginal: true }, |
|||
); |
|||
|
|||
await this.deleteAttachmentFile(attachment, 'processed'); |
|||
await this.deleteAttachmentFile(attachment, 'original'); |
|||
|
|||
this.log.info('deleting attachment', { _id: attachment._id }); |
|||
await Attachment.deleteOne({ _id: attachment._id }); |
|||
} catch (error) { |
|||
this.log.error('failed to delete attachment', { attachmentId: job.data.attachmentId, error }); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async deleteAttachmentFile (attachment, which) { |
|||
this.log.info('removing attachment file', { _id: attachment._id, which }); |
|||
|
|||
const file = attachment[which]; |
|||
if (!file || !file.bucket || !file.key) { |
|||
return; |
|||
} |
|||
|
|||
const { minio: minioService } = this.dtp.services; |
|||
await minioService.removeObject(file.bucket, file.key); |
|||
} |
|||
} |
|||
|
|||
module.exports = AttachmentDeleteJob; |
@ -0,0 +1,270 @@ |
|||
// media/job/attachment-ingest.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const path = require('path'); |
|||
const fs = require('fs'); |
|||
const sharp = require('sharp'); |
|||
|
|||
const ATTACHMENT_IMAGE_HEIGHT = 540; |
|||
|
|||
const mongoose = require('mongoose'); |
|||
const { response } = require('express'); |
|||
const Attachment = mongoose.model('Attachment'); |
|||
|
|||
const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); |
|||
|
|||
class AttachmentIngestJob extends SiteWorkerProcess { |
|||
|
|||
static get COMPONENT ( ) { |
|||
return { |
|||
name: 'attachmentIngestJob', |
|||
slug: 'attachment-ingest-job', |
|||
}; |
|||
} |
|||
|
|||
constructor (worker) { |
|||
super(worker, AttachmentIngestJob.COMPONENT); |
|||
this.processors = { |
|||
processAttachmentSharp: this.processAttachmentSharp.bind(this), |
|||
processAttachmentFFMPEG: this.processAttachmentFFMPEG.bind(this), |
|||
}; |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
this.queue = await this.getJobQueue('media'); |
|||
|
|||
this.log.info('registering job processor', { queue: this.queue.name, name: 'attachment-ingest' }); |
|||
this.queue.process('attachment-ingest', 1, this.processAttachmentIngest.bind(this)); |
|||
} |
|||
|
|||
async stop ( ) { |
|||
await super.stop(); |
|||
} |
|||
|
|||
async processAttachmentIngest (job) { |
|||
const { attachment: attachmentService } = this.dtp.services; |
|||
|
|||
const { attachmentId } = job.data; |
|||
this.log.info('received attachment-ingest job', { id: job.id, attachmentId }); |
|||
|
|||
try { |
|||
job.data.attachment = await attachmentService.getById(attachmentId, { withOriginal: true }); |
|||
|
|||
await this.resetAttachment(job); |
|||
await this.fetchAttachmentFile(job); |
|||
await this.processors[job.data.processor](job); |
|||
|
|||
//TODO: emit a completion event which should cause a refresh of the
|
|||
// creator's view to display the processed attachment
|
|||
|
|||
} catch (error) { |
|||
this.log.error('failed to process attachment for ingest', { attachmentId: job.data.attachmentId, error }); |
|||
throw error; |
|||
} finally { |
|||
if (job.data.workPath) { |
|||
this.log.info('removing attachment work path'); |
|||
await fs.promises.rmdir(job.data.workPath, { recursive: true, force: true }); |
|||
delete job.data.workPath; |
|||
} |
|||
} |
|||
} |
|||
|
|||
async fetchAttachmentFile (job) { |
|||
const { minio: minioService } = this.dtp.services; |
|||
try { |
|||
const { attachment } = job.data; |
|||
|
|||
job.data.workPath = path.join( |
|||
process.env.DTP_ATTACHMENT_WORK_PATH, |
|||
AttachmentIngestJob.COMPONENT.slug, |
|||
attachment._id.toString(), |
|||
); |
|||
|
|||
this.jobLog(job, 'creating work directory', { worthPath: job.data.workPath }); |
|||
await fs.promises.mkdir(job.data.workPath, { recursive: true }); |
|||
|
|||
switch (attachment.original.mime) { |
|||
case 'image/jpeg': |
|||
job.data.origFilePath = path.join(job.data.workPath, `${attachment._id}.jpg`); |
|||
job.data.procFilePath = path.join(job.data.workPath, `${attachment._id}.proc.jpg`); |
|||
job.data.processor = 'processAttachmentSharp'; |
|||
job.data.sharpFormat = 'jpeg'; |
|||
job.data.sharpFormatParameters = { quality: 85 }; |
|||
break; |
|||
|
|||
case 'image/png': |
|||
job.data.origFilePath = path.join(job.data.workPath, `${attachment._id}.png`); |
|||
job.data.procFilePath = path.join(job.data.workPath, `${attachment._id}.proc.png`); |
|||
job.data.processor = 'processAttachmentSharp'; |
|||
job.data.sharpFormat = 'png'; |
|||
job.data.sharpFormatParameters = { compression: 9 }; |
|||
break; |
|||
|
|||
case 'image/gif': |
|||
job.data.origFilePath = path.join(job.data.workPath, `${attachment._id}.gif`); |
|||
job.data.procFilePath = path.join(job.data.workPath, `${attachment._id}.proc.mp4`); |
|||
job.data.processor = 'processAttachmentFFMPEG'; |
|||
break; |
|||
|
|||
case 'image/webp': // process as PNG
|
|||
job.data.origFilePath = path.join(job.data.workPath, `${attachment._id}.webp`); |
|||
job.data.procFilePath = path.join(job.data.workPath, `${attachment._id}.proc.png`); |
|||
job.data.processor = 'processAttachmentSharp'; |
|||
job.data.sharpFormat = 'png'; |
|||
job.data.sharpFormatParameters = { compression: 9 }; |
|||
break; |
|||
|
|||
default: |
|||
throw new Error(`unsupported attachment type: ${attachment.original.mime}`); |
|||
} |
|||
|
|||
this.jobLog(job, 'fetching attachment original file', { |
|||
attachmentId: attachment._id, |
|||
mime: attachment.original.mime, |
|||
size: attachment.original.size, |
|||
worthPath: job.data.origFilePath, |
|||
}); |
|||
await minioService.downloadFile({ |
|||
bucket: attachment.original.bucket, |
|||
key: attachment.original.key, |
|||
filePath: job.data.origFilePath, |
|||
}); |
|||
} catch (error) { |
|||
this.log.error('failed to fetch attachment file', { attachmentId: job.data.attachmentId, error }); |
|||
throw error; |
|||
} |
|||
} |
|||
|
|||
async resetAttachment (job) { |
|||
const { minio: minioService } = this.dtp.services; |
|||
const { attachment } = job.data; |
|||
|
|||
const updateOp = { $set: { status: 'processing' } }; |
|||
|
|||
if (attachment.encoded) { |
|||
this.log.info('removing existing encoded attachment file', { file: attachment.encoded }); |
|||
await minioService.removeObject(attachment.encoded.bucket, attachment.encoded.key); |
|||
delete attachment.encoded; |
|||
updateOp.$unset = { encoded: '' }; |
|||
} |
|||
|
|||
await Attachment.updateOne({ _id: attachment._id }, updateOp); |
|||
} |
|||
|
|||
async processAttachmentSharp (job) { |
|||
const { attachment: attachmentService, minio: minioService } = this.dtp.services; |
|||
const { attachment } = job.data; |
|||
const attachmentId = attachment._id; |
|||
|
|||
const sharpImage = sharp(job.data.origFilePath); |
|||
const metadata = await sharpImage.metadata(); |
|||
this.log.info('attachment metadata from Sharp', { attachmentId, metadata }); |
|||
|
|||
let chain = sharpImage |
|||
.clone() |
|||
.toColorspace('srgb') |
|||
.resize({ height: ATTACHMENT_IMAGE_HEIGHT }); |
|||
chain = chain[job.data.sharpFormat](job.data.sharpFormatParameters); |
|||
await chain.toFile(job.data.procFilePath); |
|||
|
|||
job.data.outFileStat = await fs.promises.stat(job.data.procFilePath); |
|||
|
|||
const bucket = process.env.MINIO_ATTACHMENT_BUCKET; |
|||
const key = attachmentService.getAttachmentKey(attachment, 'processed'); |
|||
|
|||
const response = await minioService.uploadFile({ |
|||
bucket, |
|||
key, |
|||
filePath: job.data.procFilePath, |
|||
metadata: { |
|||
'Content-Type': `image/${job.data.sharpFormat}`, |
|||
'Content-Length': job.data.outFileStat.size, |
|||
}, |
|||
}); |
|||
|
|||
await Attachment.updateOne( |
|||
{ _id: job.data.attachment._id }, |
|||
{ |
|||
$set: { |
|||
status: 'live', |
|||
encoded: { |
|||
bucket, |
|||
key, |
|||
mime: `image/${job.data.sharpFormat}`, |
|||
size: job.data.outFileStat.size, |
|||
etag: response.etag, |
|||
}, |
|||
}, |
|||
}, |
|||
); |
|||
} |
|||
|
|||
async processAttachmentFFMPEG (job) { |
|||
const { |
|||
attachment: attachmentService, |
|||
media: mediaService, |
|||
minio: minioService, |
|||
} = this.dtp.services; |
|||
|
|||
const { attachment } = job.data; |
|||
const codecVideo = (process.env.DTP_GPU_ACCELERATION === 'enabled') ? 'h264_nvenc' : 'libx264'; |
|||
|
|||
// generate the encoded attachment
|
|||
// Output height is 100 lines by [aspect] width with width and height being
|
|||
// padded to be divisible by 2. The video stream is given a bit rate of
|
|||
// 128Kbps, and the media is flagged for +faststart. Audio is stripped if
|
|||
// present.
|
|||
|
|||
const ffmpegArgs = [ |
|||
'-y', '-i', job.data.origFilePath, |
|||
'-vf', `scale=-1:${ATTACHMENT_IMAGE_HEIGHT},pad=ceil(iw/2)*2:ceil(ih/2)*2`, |
|||
'-pix_fmt', 'yuv420p', |
|||
'-c:v', codecVideo, |
|||
'-b:v', '128k', |
|||
'-movflags', '+faststart', |
|||
'-an', |
|||
job.data.procFilePath, |
|||
]; |
|||
|
|||
this.log.debug('transcoding attachment', { ffmpegArgs }); |
|||
await mediaService.ffmpeg(ffmpegArgs); |
|||
|
|||
job.data.outFileStat = await fs.promises.stat(job.data.procFilePath); |
|||
|
|||
const bucket = process.env.MINIO_VIDEO_BUCKET; |
|||
const key = attachmentService.getAttachmentKey(attachment, 'processed'); |
|||
|
|||
this.jobLog(job, 'uploading processed media file'); |
|||
const response = await minioService.uploadFile({ |
|||
bucket, key, |
|||
filePath: job.data.procFilePath, |
|||
metadata: { |
|||
'Content-Type': 'video/mp4', |
|||
'Content-Length': job.data.outFileStat.size, |
|||
}, |
|||
}); |
|||
|
|||
await Attachment.updateOne( |
|||
{ _id: attachment._id }, |
|||
{ |
|||
$set: { |
|||
status: 'live', |
|||
encoded: { |
|||
bucket, |
|||
key, |
|||
mime: 'video/mp4', |
|||
size: job.data.outFileStat.size, |
|||
etag: response.etag, |
|||
}, |
|||
}, |
|||
}, |
|||
); |
|||
} |
|||
} |
|||
|
|||
module.exports = AttachmentIngestJob; |
@ -0,0 +1,62 @@ |
|||
// media/job/sticker-delete.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const path = require('path'); |
|||
|
|||
const mongoose = require('mongoose'); |
|||
const Sticker = mongoose.model('Sticker'); |
|||
|
|||
const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); |
|||
|
|||
class StickerDeleteJob extends SiteWorkerProcess { |
|||
|
|||
static get COMPONENT ( ) { |
|||
return { |
|||
name: 'stickerDeleteJob', |
|||
slug: 'sticker-delete-job', |
|||
}; |
|||
} |
|||
|
|||
constructor (worker) { |
|||
super(worker, StickerDeleteJob.COMPONENT); |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
this.queue = await this.getJobQueue('media'); |
|||
|
|||
this.log.info('registering job processor', { queue: this.queue.name, name: 'sticker-ingest' }); |
|||
this.queue.process('sticker-delete', 1, this.processStickerDelete.bind(this)); |
|||
} |
|||
|
|||
async stop ( ) { |
|||
await super.stop(); |
|||
} |
|||
|
|||
async processStickerDelete (job) { |
|||
const { minio: minioService, sticker: stickerService } = this.dtp.services; |
|||
try { |
|||
const sticker = await stickerService.getById(job.data.stickerId, true); |
|||
|
|||
this.log.info('removing original media', { stickerId: sticker._id, slug: sticker.slug }); |
|||
await minioService.removeObject(sticker.original.bucket, sticker.original.key); |
|||
|
|||
if (sticker.encoded) { |
|||
this.log.info('removing encoded media', { stickerId: sticker._id, slug: sticker.slug }); |
|||
await minioService.removeObject(sticker.encoded.bucket, sticker.encoded.key); |
|||
} |
|||
|
|||
this.log.info('removing sticker', { stickerId: sticker._id, slug: sticker.slug }); |
|||
await Sticker.deleteOne({ _id: sticker._id }); |
|||
} catch (error) { |
|||
this.log.error('failed to delete sticker', { stickerId: job.data.stickerId, error }); |
|||
throw error; // for job report
|
|||
} |
|||
} |
|||
} |
|||
|
|||
module.exports = StickerDeleteJob; |
@ -0,0 +1,75 @@ |
|||
// reeeper/cron/expire-crashed-hosts.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const path = require('path'); |
|||
|
|||
const mongoose = require('mongoose'); |
|||
const NetHost = mongoose.model('NetHost'); |
|||
|
|||
const { CronJob } = require('cron'); |
|||
|
|||
const { SiteWorkerProcess } = require(path.join(__dirname, '..', '..', '..', '..', 'lib', 'site-lib')); |
|||
|
|||
/** |
|||
* DTP Core Chat sticker processor can receive requests to ingest and delete |
|||
* stickers to be executed as background jobs in a queue. This processor |
|||
* attaches to the `media` queue and registers processors for `sticker-ingest` |
|||
* and `sticker-delete`. |
|||
*/ |
|||
class CrashedHostsCron extends SiteWorkerProcess { |
|||
|
|||
static get COMPONENT ( ) { |
|||
return { |
|||
name: 'crashedHostsCron', |
|||
slug: 'crashed-hosts-cron', |
|||
}; |
|||
} |
|||
|
|||
constructor (worker) { |
|||
super(worker, CrashedHostsCron.COMPONENT); |
|||
} |
|||
|
|||
async start ( ) { |
|||
await super.start(); |
|||
|
|||
await this.expireCrashedHosts(); // first-run the expirations
|
|||
|
|||
this.job = new CronJob( |
|||
'*/5 * * * * *', |
|||
this.expireCrashedHosts.bind(this), |
|||
null, |
|||
true, |
|||
process.env.DTP_CRON_TIMEZONE || 'America/New_York', |
|||
); |
|||
} |
|||
|
|||
async stop ( ) { |
|||
if (this.job) { |
|||
this.log.info('stopping host expire job'); |
|||
this.job.stop(); |
|||
delete this.job; |
|||
} |
|||
await super.stop(); |
|||
} |
|||
|
|||
async expireCrashedHosts ( ) { |
|||
try { |
|||
await NetHost |
|||
.find({ status: 'crashed' }) |
|||
.select('_id hostname') |
|||
.lean() |
|||
.cursor() |
|||
.eachAsync(async (host) => { |
|||
this.log.info('deactivating crashed host', { hostname: host.hostname }); |
|||
await NetHost.updateOne({ _id: host._id }, { $set: { status: 'inactive' } }); |
|||
}); |
|||
} catch (error) { |
|||
this.log.error('failed to expire crashed hosts', { error }); |
|||
} |
|||
} |
|||
} |
|||
|
|||
module.exports = CrashedHostsCron; |
@ -0,0 +1,48 @@ |
|||
.kaleidoscope-event { |
|||
box-sizing: border-box; |
|||
position: relative; |
|||
border: solid 2px @content-border-color; |
|||
border-radius: 6px; |
|||
overflow: hidden; |
|||
margin-bottom: @global-margin; |
|||
background-color: @content-background-color; |
|||
|
|||
box-shadow: 0 1px 6px rgba(0,0,0, 0.3); |
|||
|
|||
&[data-event-source="dtp-social"] { |
|||
border-color: #da0012; |
|||
} |
|||
|
|||
&[data-event-source="dtp-sites"] { |
|||
border-color: #0eaa00; |
|||
} |
|||
|
|||
&[data-event-action="room-invite-create"] { |
|||
border-color: #0082aa; |
|||
} |
|||
|
|||
.event-feature-img { |
|||
display: block; |
|||
line-height: 1; |
|||
width: 100%; |
|||
height: auto; |
|||
padding: 0; |
|||
margin: 0; |
|||
} |
|||
|
|||
.event-header { |
|||
padding: 4px @global-small-gutter; |
|||
} |
|||
|
|||
.event-content { |
|||
padding: 4px @global-small-gutter; |
|||
|
|||
p:last-child { |
|||
margin-bottom: 0; |
|||
} |
|||
} |
|||
|
|||
.event-footer { |
|||
padding: 4px @global-small-gutter; |
|||
} |
|||
} |
@ -0,0 +1,47 @@ |
|||
// site-worker-process.js
|
|||
// Copyright (C) 2022 DTP Technologies, LLC
|
|||
// License: Apache-2.0
|
|||
|
|||
'use strict'; |
|||
|
|||
const path = require('path'); |
|||
|
|||
const { SiteCommon } = require(path.join(__dirname, 'site-common')); |
|||
|
|||
/** |
|||
* Your actual worker processor will extend SiteWorkerProcess and implement the |
|||
* expected interface including the. |
|||
* |
|||
* Your derived class must implement a static getter for COMPONENT as follows: |
|||
* |
|||
* ``` |
|||
* static get COMPONENT ( ) { return { name: '', slug: '' }; } |
|||
* ``` |
|||
* |
|||
* It must pass that object to this constructor (super) along with the worker |
|||
* reference you are given at your constructor. |
|||
* |
|||
* Your worker logic script can then be a fully-managed process within the DTP |
|||
* ecosystem. |
|||
*/ |
|||
class SiteWorkerProcess extends SiteCommon { |
|||
|
|||
constructor (worker, component) { |
|||
super(worker.dtp, component); |
|||
this.worker = worker; |
|||
} |
|||
|
|||
/** |
|||
* Utility/convenience method that logs a message to both a Bull Queue job log |
|||
* and also DTP's logging infrastructure for the worker process. |
|||
* @param {Job} job Bull queue job for which a log is written |
|||
* @param {*} message The message to be written |
|||
* @param {*} data An object containing any data to be logged |
|||
*/ |
|||
async jobLog (job, message, data = { }) { |
|||
job.log(message); |
|||
this.log.info(message, { jobId: job.id, ...data }); |
|||
} |
|||
} |
|||
|
|||
module.exports.SiteWorkerProcess = SiteWorkerProcess; |
@ -885,53 +885,15 @@ |
|||
"@babel/helper-validator-identifier" "^7.15.7" |
|||
to-fast-properties "^2.0.0" |
|||
|
|||
"@fortawesome/fontawesome-common-types@^0.2.36": |
|||
version "0.2.36" |
|||
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz#b44e52db3b6b20523e0c57ef8c42d315532cb903" |
|||
integrity sha512-a/7BiSgobHAgBWeN7N0w+lAhInrGxksn13uK7231n2m8EDPE3BMCl9NZLTGrj9ZXfCmC6LM0QLqXidIizVQ6yg== |
|||
|
|||
"@fortawesome/fontawesome-free@^5.15.4": |
|||
version "5.15.4" |
|||
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-5.15.4.tgz#ecda5712b61ac852c760d8b3c79c96adca5554e5" |
|||
integrity sha512-eYm8vijH/hpzr/6/1CJ/V/Eb1xQFW2nnUKArb3z+yUWv7HTwj6M7SP957oMjfZjAHU6qpoNc2wQvIxBLWYa/Jg== |
|||
|
|||
"@fortawesome/fontawesome-svg-core@^1.2.28": |
|||
version "1.2.36" |
|||
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-svg-core/-/fontawesome-svg-core-1.2.36.tgz#4f2ea6f778298e0c47c6524ce2e7fd58eb6930e3" |
|||
integrity sha512-YUcsLQKYb6DmaJjIHdDWpBIGCcyE/W+p/LMGvjQem55Mm2XWVAP5kWTMKWLv9lwpCVjpLxPyOMOyUocP1GxrtA== |
|||
dependencies: |
|||
"@fortawesome/fontawesome-common-types" "^0.2.36" |
|||
|
|||
"@fortawesome/free-regular-svg-icons@^5.13.0": |
|||
version "5.15.4" |
|||
resolved "https://registry.yarnpkg.com/@fortawesome/free-regular-svg-icons/-/free-regular-svg-icons-5.15.4.tgz#b97edab436954333bbeac09cfc40c6a951081a02" |
|||
integrity sha512-9VNNnU3CXHy9XednJ3wzQp6SwNwT3XaM26oS4Rp391GsxVYA+0oDR2J194YCIWf7jNRCYKjUCOduxdceLrx+xw== |
|||
dependencies: |
|||
"@fortawesome/fontawesome-common-types" "^0.2.36" |
|||
|
|||
"@fortawesome/free-solid-svg-icons@^5.13.0": |
|||
version "5.15.4" |
|||
resolved "https://registry.yarnpkg.com/@fortawesome/free-solid-svg-icons/-/free-solid-svg-icons-5.15.4.tgz#2a68f3fc3ddda12e52645654142b9e4e8fbb6cc5" |
|||
integrity sha512-JLmQfz6tdtwxoihXLg6lT78BorrFyCf59SAwBM6qV/0zXyVeDygJVb3fk+j5Qat+Yvcxp1buLTY5iDh1ZSAQ8w== |
|||
dependencies: |
|||
"@fortawesome/fontawesome-common-types" "^0.2.36" |
|||
|
|||
"@joeattardi/emoji-button@^4.6.2": |
|||
version "4.6.2" |
|||
resolved "https://registry.yarnpkg.com/@joeattardi/emoji-button/-/emoji-button-4.6.2.tgz#75baf4ce27324e4d6fb90292f8b248235f638ad0" |
|||
integrity sha512-FhuzTmW3nVHLVp2BJfNX17CYV77fqtKZlx328D4h6Dw3cPTT1gJRNXN0jV7BvHgsl6Q/tN8DIQQxTUIO4jW3gQ== |
|||
dependencies: |
|||
"@fortawesome/fontawesome-svg-core" "^1.2.28" |
|||
"@fortawesome/free-regular-svg-icons" "^5.13.0" |
|||
"@fortawesome/free-solid-svg-icons" "^5.13.0" |
|||
"@popperjs/core" "^2.4.0" |
|||
"@types/twemoji" "^12.1.1" |
|||
escape-html "^1.0.3" |
|||
focus-trap "^5.1.0" |
|||
fuzzysort "^1.1.4" |
|||
tiny-emitter "^2.1.0" |
|||
tslib "^2.0.0" |
|||
twemoji "^13.0.0" |
|||
"@ioredis/commands@^1.1.1": |
|||
version "1.2.0" |
|||
resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" |
|||
integrity sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg== |
|||
|
|||
"@otplib/core@^12.0.1": |
|||
version "12.0.1" |
|||
@ -971,11 +933,6 @@ |
|||
"@otplib/plugin-crypto" "^12.0.1" |
|||
"@otplib/plugin-thirty-two" "^12.0.1" |
|||
|
|||
"@popperjs/core@^2.4.0": |
|||
version "2.11.0" |
|||
resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.0.tgz#6734f8ebc106a0860dff7f92bf90df193f0935d7" |
|||
integrity sha512-zrsUxjLOKAzdewIDRWy9nsV1GQsKBCWaGwsZQlCgr6/q+vjyZhFgqedLfFBuI9anTPEUT4APq9Mu0SZBTzIcGQ== |
|||
|
|||
"@rollup/plugin-babel@^5.2.0": |
|||
version "5.3.0" |
|||
resolved "https://registry.yarnpkg.com/@rollup/plugin-babel/-/plugin-babel-5.3.0.tgz#9cb1c5146ddd6a4968ad96f209c50c62f92f9879" |
|||
@ -1118,11 +1075,6 @@ |
|||
resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.2.tgz#fc25ad9943bcac11cceb8168db4f275e0e72e756" |
|||
integrity sha512-F5DIZ36YVLE+PN+Zwws4kJogq47hNgX3Nx6WyDJ3kcplxyke3XIzB8uK5n/Lpm1HBsbGzd6nmGehL8cPekP+Tg== |
|||
|
|||
"@types/twemoji@^12.1.1": |
|||
version "12.1.2" |
|||
resolved "https://registry.yarnpkg.com/@types/twemoji/-/twemoji-12.1.2.tgz#52578fd22665311e6a78d04f800275449d51c97e" |
|||
integrity sha512-3eMyKenMi0R1CeKzBYtk/Z2JIHsTMQrIrTah0q54o45pHTpWVNofU2oHx0jS8tqsDRhis2TbB6238WP9oh2l2w== |
|||
|
|||
"@types/webidl-conversions@*": |
|||
version "6.1.1" |
|||
resolved "https://registry.yarnpkg.com/@types/webidl-conversions/-/webidl-conversions-6.1.1.tgz#e33bc8ea812a01f63f90481c666334844b12a09e" |
|||
@ -2835,6 +2787,13 @@ debug@^3.2.6, debug@^3.2.7: |
|||
dependencies: |
|||
ms "^2.1.1" |
|||
|
|||
debug@^4.3.4: |
|||
version "4.3.4" |
|||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" |
|||
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== |
|||
dependencies: |
|||
ms "2.1.2" |
|||
|
|||
debug@~4.1.0: |
|||
version "4.1.1" |
|||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.1.1.tgz#3b72260255109c6b589cee050f1d516139664791" |
|||
@ -3198,6 +3157,11 @@ emoji-regex@^8.0.0: |
|||
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37" |
|||
integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== |
|||
|
|||
emojibase@^6.1.0: |
|||
version "6.1.0" |
|||
resolved "https://registry.yarnpkg.com/emojibase/-/emojibase-6.1.0.tgz#c3bc281e998a0e06398416090c23bac8c5ed3ee8" |
|||
integrity sha512-1GkKJPXP6tVkYJHOBSJHoGOr/6uaDxZ9xJ6H7m6PfdGXTmQgbALHLWaVRY4Gi/qf5x/gT/NUXLPuSHYLqtLtrQ== |
|||
|
|||
encode-utf8@^1.0.3: |
|||
version "1.0.3" |
|||
resolved "https://registry.yarnpkg.com/encode-utf8/-/encode-utf8-1.0.3.tgz#f30fdd31da07fb596f281beb2f6b027851994cda" |
|||
@ -3401,7 +3365,7 @@ escape-goat@^2.0.0: |
|||
resolved "https://registry.yarnpkg.com/escape-goat/-/escape-goat-2.1.1.tgz#1b2dc77003676c457ec760b2dc68edb648188675" |
|||
integrity sha512-8/uIhbG12Csjy2JEW7D9pHbreaVaS/OpN3ycnyvElTdwM5n6GY6W6e2IPemfvGZeUMqZ9A/3GqIZMgKnBhAw/Q== |
|||
|
|||
escape-html@^1.0.3, escape-html@~1.0.3: |
|||
escape-html@~1.0.3: |
|||
version "1.0.3" |
|||
resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" |
|||
integrity sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg= |
|||
@ -3800,14 +3764,6 @@ flush-write-stream@^1.0.2: |
|||
inherits "^2.0.3" |
|||
readable-stream "^2.3.6" |
|||
|
|||
focus-trap@^5.1.0: |
|||
version "5.1.0" |
|||
resolved "https://registry.yarnpkg.com/focus-trap/-/focus-trap-5.1.0.tgz#64a0bfabd95c382103397dbc96bfef3a3cf8e5ad" |
|||
integrity sha512-CkB/nrO55069QAUjWFBpX6oc+9V90Qhgpe6fBWApzruMq5gnlh90Oo7iSSDK7pKiV5ugG6OY2AXM5mxcmL3lwQ== |
|||
dependencies: |
|||
tabbable "^4.0.0" |
|||
xtend "^4.0.1" |
|||
|
|||
follow-redirects@^1.0.0, follow-redirects@^1.14.0: |
|||
version "1.14.5" |
|||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.5.tgz#f09a5848981d3c772b5392309778523f8d85c381" |
|||
@ -3870,15 +3826,6 @@ [email protected]: |
|||
jsonfile "^3.0.0" |
|||
universalify "^0.1.0" |
|||
|
|||
fs-extra@^8.0.1: |
|||
version "8.1.0" |
|||
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" |
|||
integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== |
|||
dependencies: |
|||
graceful-fs "^4.2.0" |
|||
jsonfile "^4.0.0" |
|||
universalify "^0.1.0" |
|||
|
|||
fs-extra@^9.0.1: |
|||
version "9.1.0" |
|||
resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" |
|||
@ -3920,11 +3867,6 @@ function-bind@^1.1.1: |
|||
resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" |
|||
integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== |
|||
|
|||
fuzzysort@^1.1.4: |
|||
version "1.1.4" |
|||
resolved "https://registry.yarnpkg.com/fuzzysort/-/fuzzysort-1.1.4.tgz#a0510206ed44532cbb52cf797bf5a3cb12acd4ba" |
|||
integrity sha512-JzK/lHjVZ6joAg3OnCjylwYXYVjRiwTY6Yb25LvfpJHK8bjisfnZJ5bY8aVWwTwCXgxPNgLAtmHL+Hs5q1ddLQ== |
|||
|
|||
gauge@~2.7.3: |
|||
version "2.7.4" |
|||
resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7" |
|||
@ -4593,6 +4535,21 @@ ioredis@^4.28.5: |
|||
redis-parser "^3.0.0" |
|||
standard-as-callback "^2.1.0" |
|||
|
|||
ioredis@^5.2.2: |
|||
version "5.2.2" |
|||
resolved "https://registry.yarnpkg.com/ioredis/-/ioredis-5.2.2.tgz#212467e04f6779b4e0e800cece7bb7d3d7b546d2" |
|||
integrity sha512-wryKc1ur8PcCmNwfcGkw5evouzpbDXxxkMkzPK8wl4xQfQf7lHe11Jotell5ikMVAtikXJEu/OJVaoV51BggRQ== |
|||
dependencies: |
|||
"@ioredis/commands" "^1.1.1" |
|||
cluster-key-slot "^1.1.0" |
|||
debug "^4.3.4" |
|||
denque "^2.0.1" |
|||
lodash.defaults "^4.2.0" |
|||
lodash.isarguments "^3.1.0" |
|||
redis-errors "^1.2.0" |
|||
redis-parser "^3.0.0" |
|||
standard-as-callback "^2.1.0" |
|||
|
|||
"[email protected] - 5.9.4": |
|||
version "5.9.4" |
|||
resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-5.9.4.tgz#4660ac261ad61bd397a860a007f7e98e4eaee386" |
|||
@ -5206,22 +5163,6 @@ jsonfile@^3.0.0: |
|||
optionalDependencies: |
|||
graceful-fs "^4.1.6" |
|||
|
|||
jsonfile@^4.0.0: |
|||
version "4.0.0" |
|||
resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" |
|||
integrity sha1-h3Gq4HmbZAdrdmQPygWPnBDjPss= |
|||
optionalDependencies: |
|||
graceful-fs "^4.1.6" |
|||
|
|||
jsonfile@^5.0.0: |
|||
version "5.0.0" |
|||
resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-5.0.0.tgz#e6b718f73da420d612823996fdf14a03f6ff6922" |
|||
integrity sha512-NQRZ5CRo74MhMMC3/3r5g2k4fjodJ/wh8MxjFbCViWKFjxrnudWSY5vomh+23ZaXzAS7J3fBZIR2dV6WbmfM0w== |
|||
dependencies: |
|||
universalify "^0.1.2" |
|||
optionalDependencies: |
|||
graceful-fs "^4.1.6" |
|||
|
|||
jsonfile@^6.0.1: |
|||
version "6.1.0" |
|||
resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" |
|||
@ -6492,6 +6433,13 @@ pend@~1.2.0: |
|||
resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" |
|||
integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA= |
|||
|
|||
picmo@^5.4.0: |
|||
version "5.4.0" |
|||
resolved "https://registry.yarnpkg.com/picmo/-/picmo-5.4.0.tgz#d51c9258031b351217e2d165ed3781f4a192c938" |
|||
integrity sha512-Rq9R7JOuT/Dzc0kVvYisO0MwMG8m22i+fE5nuVELXmn2aXDIjr5c29b4BFQuhbtzSx+l77uwp5/V4L2/KE477w== |
|||
dependencies: |
|||
emojibase "^6.1.0" |
|||
|
|||
picocolors@^1.0.0: |
|||
version "1.0.0" |
|||
resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" |
|||
@ -8063,11 +8011,6 @@ systeminformation@^5.11.6: |
|||
resolved "https://registry.yarnpkg.com/systeminformation/-/systeminformation-5.11.6.tgz#8624cbb2e95e6fa98a4ebb0d10759427c0e88144" |
|||
integrity sha512-7KBXgdnIDxABQ93w+GrPSrK/pup73+fM09VGka4A/+FhgzdlRY0JNGGDFmV8BHnFuzP9zwlI3n64yDbp7emasQ== |
|||
|
|||
tabbable@^4.0.0: |
|||
version "4.0.0" |
|||
resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-4.0.0.tgz#5bff1d1135df1482cf0f0206434f15eadbeb9261" |
|||
integrity sha512-H1XoH1URcBOa/rZZWxLxHCtOdVUEev+9vo5YdYhC9tCY4wnybX+VQrCYuy9ubkg69fCBxCONJOSLGfw0DWMffQ== |
|||
|
|||
tapable@^2.1.1, tapable@^2.2.0: |
|||
version "2.2.1" |
|||
resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" |
|||
@ -8194,11 +8137,6 @@ time-stamp@^1.0.0: |
|||
resolved "https://registry.yarnpkg.com/time-stamp/-/time-stamp-1.1.0.tgz#764a5a11af50561921b133f3b44e618687e0f5c3" |
|||
integrity sha1-dkpaEa9QVhkhsTPztE5hhofg9cM= |
|||
|
|||
tiny-emitter@^2.1.0: |
|||
version "2.1.0" |
|||
resolved "https://registry.yarnpkg.com/tiny-emitter/-/tiny-emitter-2.1.0.tgz#1d1a56edfc51c43e863cbb5382a72330e3555423" |
|||
integrity sha512-NB6Dk1A9xgQPMoGqC5CVXn123gWyte215ONT5Pp5a0yt4nlEoO1ZWeCwpncaekPHXO60i47ihFnZPiRPjRMq4Q== |
|||
|
|||
tiny-inflate@^1.0.2: |
|||
version "1.0.3" |
|||
resolved "https://registry.yarnpkg.com/tiny-inflate/-/tiny-inflate-1.0.3.tgz#122715494913a1805166aaf7c93467933eea26c4" |
|||
@ -8316,7 +8254,7 @@ tr46@~0.0.3: |
|||
resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" |
|||
integrity sha1-gYT9NH2snNwYWZLzpmIuFLnZq2o= |
|||
|
|||
tslib@^2.0.0, tslib@^2.3.0: |
|||
tslib@^2.3.0: |
|||
version "2.3.1" |
|||
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01" |
|||
integrity sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw== |
|||
@ -8328,21 +8266,6 @@ tunnel-agent@^0.6.0: |
|||
dependencies: |
|||
safe-buffer "^5.0.1" |
|||
|
|||
[email protected]: |
|||
version "13.1.0" |
|||
resolved "https://registry.yarnpkg.com/twemoji-parser/-/twemoji-parser-13.1.0.tgz#65e7e449c59258791b22ac0b37077349127e3ea4" |
|||
integrity sha512-AQOzLJpYlpWMy8n+0ATyKKZzWlZBJN+G0C+5lhX7Ftc2PeEVdUU/7ns2Pn2vVje26AIZ/OHwFoUbdv6YYD/wGg== |
|||
|
|||
twemoji@^13.0.0: |
|||
version "13.1.0" |
|||
resolved "https://registry.yarnpkg.com/twemoji/-/twemoji-13.1.0.tgz#65bb71e966dae56f0d42c30176f04cbdae109913" |
|||
integrity sha512-e3fZRl2S9UQQdBFLYXtTBT6o4vidJMnpWUAhJA+yLGR+kaUTZAt3PixC0cGvvxWSuq2MSz/o0rJraOXrWw/4Ew== |
|||
dependencies: |
|||
fs-extra "^8.0.1" |
|||
jsonfile "^5.0.0" |
|||
twemoji-parser "13.1.0" |
|||
universalify "^0.1.2" |
|||
|
|||
type-check@~0.3.2: |
|||
version "0.3.2" |
|||
resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.3.2.tgz#5884cab512cf1d355e3fb784f30804b2b520db72" |
|||
@ -9201,7 +9124,7 @@ xmlhttprequest-ssl@~1.6.2: |
|||
resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-1.6.3.tgz#03b713873b01659dfa2c1c5d056065b27ddc2de6" |
|||
integrity sha512-3XfeQE/wNkvrIktn2Kf0869fC0BN6UpydVasGIeSm2B1Llihf7/0UfZM+eCkOw3P7bP4+qPgqhm7ZoxuJtFU0Q== |
|||
|
|||
xtend@^4.0.0, xtend@^4.0.1, xtend@~4.0.0, xtend@~4.0.1: |
|||
xtend@^4.0.0, xtend@~4.0.0, xtend@~4.0.1: |
|||
version "4.0.2" |
|||
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" |
|||
integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== |
|||
|
Loading…
Reference in new issue