diff --git a/app/controllers/auth.js b/app/controllers/auth.js index ea4e446..8bc42c5 100644 --- a/app/controllers/auth.js +++ b/app/controllers/auth.js @@ -207,7 +207,7 @@ export default class AuthController extends SiteController { res.locals.authToken = await authTokenService.claim(req.body.authToken); - res.locals.accountId = mongoose.Types.ObjectId(req.body.accountId); + res.locals.accountId = mongoose.Types.ObjectId.createFromHexString(req.body.accountId); res.locals.account = await userService.getUserAccount(res.locals.accountId); this.log.alert('updating user password', { userId: res.locals.account._id, diff --git a/app/controllers/chat.js b/app/controllers/chat.js index 7f0c5bd..2cc181c 100644 --- a/app/controllers/chat.js +++ b/app/controllers/chat.js @@ -13,6 +13,8 @@ export default class ChatController extends SiteController { static get name ( ) { return 'ChatController'; } static get slug ( ) { return 'chat'; } + static get MESSAGES_PER_PAGE ( ) { return 20; } + constructor (dtp) { super(dtp, ChatController); } @@ -63,6 +65,12 @@ export default class ChatController extends SiteController { this.getRoomJoin.bind(this), ); + router.get( + '/room/:roomId/messages', + // limiterService.create(limiterService.config.chat.getRoomMessages), + this.getRoomMessages.bind(this), + ); + router.get( '/room/:roomId', // limiterService.create(limiterService.config.chat.getRoomView), @@ -124,12 +132,25 @@ export default class ChatController extends SiteController { } } + async getRoomMessages (req, res, next) { + const { chat: chatService } = this.dtp.services; + try { + res.locals.pagination = this.getPaginationParameters(req, ChatController.MESSAGES_PER_PAGE); + res.locals.messages = await chatService.getRoomMessages(res.locals.room, res.locals.pagination); + + res.render('chat/room/view'); + } catch (error) { + this.log.error('failed to present the chat room view', { error }); + return next(error); + } + } + async getRoomView (req, res, next) { const { chat: chatService } = this.dtp.services; try { res.locals.currentView = 'chat-room'; - res.locals.pagination = this.getPaginationParameters(req, 20); + res.locals.pagination = this.getPaginationParameters(req, ChatController.MESSAGES_PER_PAGE); res.locals.messages = await chatService.getRoomMessages(res.locals.room, res.locals.pagination); res.render('chat/room/view'); diff --git a/app/controllers/image.js b/app/controllers/image.js index 1c1c2ed..9b6594b 100644 --- a/app/controllers/image.js +++ b/app/controllers/image.js @@ -65,7 +65,7 @@ export default class ImageController extends SiteController { async populateImage (req, res, next, imageId) { const { image: imageService } = this.dtp.services; try { - res.locals.imageId = mongoose.Types.ObjectId(imageId); + res.locals.imageId = mongoose.Types.ObjectId.createFromHexString(imageId); res.locals.image = await imageService.getImageById(res.locals.imageId); if (!res.locals.image) { throw new SiteError(404, 'Image not found'); diff --git a/app/services/chat.js b/app/services/chat.js index 39c7554..f048d56 100644 --- a/app/services/chat.js +++ b/app/services/chat.js @@ -24,7 +24,7 @@ export default class ChatService extends SiteService { } async start ( ) { - const { user: userService } = this.dtp.services; + const { link: linkService, user: userService } = this.dtp.services; this.templates = { message: this.loadViewTemplate('chat/components/message-standalone.pug'), @@ -36,6 +36,10 @@ export default class ChatService extends SiteService { path: 'owner', select: userService.USER_SELECT, }, + { + path: 'present', + select: userService.USER_SELECT, + }, ]; this.populateChatMessage = [ { @@ -49,6 +53,11 @@ export default class ChatService extends SiteService { path: 'mentions', select: userService.USER_SELECT, }, + { + path: 'links', + populate: linkService.populateLink, + + }, ]; } @@ -212,6 +221,7 @@ export default class ChatService extends SiteService { message.mentions = await textService.findMentions(message.content); message.hashtags = await textService.findHashtags(message.content); + message.links = await textService.findLinks(author, message.content, { channelId: room._id }); await message.save(); @@ -232,12 +242,12 @@ export default class ChatService extends SiteService { async getRoomMessages (room, pagination) { const messages = await ChatMessage .find({ channel: room._id }) - .sort({ created: 1 }) + .sort({ created: -1 }) .skip(pagination.skip) .limit(pagination.cpp) .populate(this.populateChatMessage) .lean(); - return messages; + return messages.reverse(); } async checkRoomMember (room, member) { diff --git a/app/services/image.js b/app/services/image.js new file mode 100644 index 0000000..7b6611d --- /dev/null +++ b/app/services/image.js @@ -0,0 +1,264 @@ +// image.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import path from 'node:path'; +import fs from 'node:fs'; + +import mongoose from 'mongoose'; +const StreamRayImage = mongoose.model('Image'); + +import sharp from 'sharp'; + +import { SiteService, SiteAsync } from '../../lib/site-lib.js'; + +export default class ImageService extends SiteService { + + static get name ( ) { return 'ImageService'; } + static get slug ( ) { return 'image'; } + + constructor (dtp) { + super(dtp, ImageService); + } + + async start ( ) { + await super.start(); + + const { user: userService } = this.dtp.services; + this.populateImage = [ + { + path: 'owner', + select: userService.USER_SELECT, + }, + ]; + + await fs.promises.mkdir(process.env.IMAGE_WORK_PATH, { recursive: true }); + } + + async create (owner, imageDefinition, file) { + const NOW = new Date(); + const { chat: chatService, minio: minioService } = this.dtp.services; + + this.log.debug('processing uploaded image', { imageDefinition, file }); + + const sharpImage = await sharp(file.path); + const metadata = await sharpImage.metadata(); + + // create an Image model instance, but leave it here in application memory. + // we don't persist it to the db until MinIO accepts the binary data. + const image = new StreamRayImage(); + image.created = NOW; + image.owner = owner._id; + if (imageDefinition.caption) { + image.caption = chatService.filterText(imageDefinition.caption); + } + image.flags.isSensitive = imageDefinition['flags.isSensitive'] === 'on'; + image.flags.isPendingAttachment = imageDefinition['flags.isPendingAttachment'] === 'on'; + + image.type = file.mimetype; + image.size = file.size; + + image.file.bucket = process.env.MINIO_IMAGE_BUCKET; + image.metadata = this.makeImageMetadata(metadata); + + const imageId = image._id.toString(); + const ownerId = owner._id.toString(); + const fileKey = `/${ownerId.slice(0, 3)}/${ownerId}/${imageId.slice(0, 3)}/${imageId}`; + image.file.key = fileKey; + + // upload the image file to MinIO + const response = await minioService.uploadFile({ + bucket: image.file.bucket, + key: image.file.key, + filePath: file.path, + metadata: { + 'Content-Type': file.mimetype, + 'Content-Length': file.size, + }, + }); + + // store the eTag from MinIO in the Image model + image.file.etag = response.etag; + + // save the Image model to the db + await image.save(); + + this.log.info('processed uploaded image', { ownerId, imageId, fileKey }); + return image.toObject(); + } + + async getImageById (imageId) { + const image = await StreamRayImage + .findById(imageId) + .populate(this.populateImage); + return image; + } + + async getRecentImagesForOwner (owner) { + const images = await StreamRayImage + .find({ owner: owner._id }) + .sort({ created: -1 }) + .limit(10) + .populate(this.populateImage) + .lean(); + return images; + } + + async deleteImage (image) { + const { minio: minioService } = this.dtp.services; + try { + if (image.file && image.file.bucket && image.file.key) { + // this.log.debug('removing image from storage', { file: image.file }); + await minioService.removeObject(image.file.bucket, image.file.key); + } + } catch (error) { + this.log.error('failed to remove image from storage', { error }); + // fall through + } + await StreamRayImage.deleteOne({ _id: image._id }); + } + + async processImageFile (owner, file, outputs, options) { + this.log.debug('processing image file', { owner, file, outputs }); + const sharpImage = sharp(file.path); + return this.processImage(owner, sharpImage, outputs, options); + } + + async processImage (owner, sharpImage, outputs, options) { + const NOW = new Date(); + const service = this; + const { minio: minioService } = this.dtp.services; + + options = Object.assign({ + removeWorkFiles: true, + }, options); + + const imageWorkPath = process.env.IMAGE_WORK_PATH || '/tmp'; + const metadata = await sharpImage.metadata(); + + async function processOutputImage (output) { + const outputMetadata = service.makeImageMetadata(metadata); + outputMetadata.width = output.width; + outputMetadata.height = output.height; + + service.log.debug('processing image', { output, outputMetadata }); + + const image = new StreamRayImage(); + image.created = NOW; + image.owner = owner._id; + image.type = `image/${output.format}`; + image.metadata = outputMetadata; + + try { + let chain = sharpImage.clone().resize({ width: output.width, height: output.height }); + chain = chain[output.format](output.formatParameters); + + output.filePath = path.join(imageWorkPath, `${image._id}.${output.width}x${output.height}.${output.format}`); + output.mimetype = `image/${output.format}`; + await chain.toFile(output.filePath); + output.stat = await fs.promises.stat(output.filePath); + } catch (error) { + service.log.error('failed to process output image', { output, error }); + throw error; + } + + try { + const imageId = image._id.toString(); + const ownerId = owner._id.toString(); + const fileKey = `/${ownerId.slice(0, 3)}/${ownerId}/images/${imageId.slice(0, 3)}/${imageId}.${output.format}`; + + image.file.bucket = process.env.MINIO_IMAGE_BUCKET; + image.file.key = fileKey; + image.size = output.stat.size; + + // upload the image file to MinIO + const response = await minioService.uploadFile({ + bucket: image.file.bucket, + key: image.file.key, + filePath: output.filePath, + metadata: { + 'Content-Type': output.mimetype, + 'Content-Length': output.stat.size, + }, + }); + + // store the eTag from MinIO in the Image model + image.file.etag = response.etag; + + // save the Image model to the db + await image.save(); + + service.log.info('processed uploaded image', { ownerId, imageId, fileKey }); + + if (options.removeWorkFiles) { + service.log.debug('removing work file', { path: output.filePath }); + await fs.promises.unlink(output.filePath); + delete output.filePath; + } + + output.image = { + _id: image._id, + bucket: image.file.bucket, + key: image.file.key, + }; + } catch (error) { + service.log.error('failed to persist output image', { output, error }); + if (options.removeWorkFiles) { + service.log.debug('removing work file', { path: output.filePath }); + await SiteAsync.each(outputs, async (output) => { + await fs.promises.unlink(output.filePath); + delete output.filePath; + }, 4); + } + throw error; + } + } + + await SiteAsync.each(outputs, processOutputImage, 4); + } + + makeImageMetadata (metadata) { + return { + format: metadata.format, + size: metadata.size, + width: metadata.width, + height: metadata.height, + space: metadata.space, + channels: metadata.channels, + depth: metadata.depth, + density: metadata.density, + hasAlpha: metadata.hasAlpha, + orientation: metadata.orientation, + }; + } + + async reportStats ( ) { + const chart = await StreamRayImage.aggregate([ + { + $match: { }, + }, + { + $group: { + _id: { $dateToString: { format: '%Y-%m', date: '$created' } }, + count: { $sum: 1 }, + bytes: { $sum: '$size' }, + }, + }, + { + $sort: { _id: 1 }, + }, + ]); + + const stats = { }; + stats.count = chart.reduce((prev, value) => { + return prev + value.count; + }, 0); + stats.bytes = chart.reduce((prev, value) => { + return prev + value.bytes; + }, 0); + + return { chart, stats }; + } +} \ No newline at end of file diff --git a/app/services/link.js b/app/services/link.js index 4d8dd95..b884c5d 100644 --- a/app/services/link.js +++ b/app/services/link.js @@ -7,7 +7,6 @@ import mongoose from 'mongoose'; const Link = mongoose.model('Link'); -import UserAgent from 'user-agents'; import { JSDOM } from 'jsdom'; import { SiteService, SiteError } from '../../lib/site-lib.js'; @@ -16,6 +15,9 @@ export default class LinkService extends SiteService { static get name ( ) { return 'LinkService'; } static get slug ( ) { return 'link'; } + + static get DOMAIN_BLACKLIST_KEY ( ) { return 'dblklist'; } + constructor (dtp) { super(dtp, LinkService); } @@ -23,14 +25,15 @@ export default class LinkService extends SiteService { async start ( ) { await super.start(); - const userAgent = new UserAgent(); - this.userAgent = userAgent.toString(); + const { jobQueue: jobQueueService, user: userService } = this.dtp.services; + this.linksQueue = jobQueueService.getJobQueue('links', this.dtp.config.jobQueues.links); + + this.userAgent = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"; this.templates = { linkPreview: this.loadViewTemplate('link/components/preview-standalone.pug'), }; - const { user: userService } = this.dtp.services; this.populateLink = [ { path: 'submittedBy', @@ -69,8 +72,8 @@ export default class LinkService extends SiteService { } async isDomainBlocked (domain) { - const { domain: domainService } = this.dtp.services; - return domainService.isDomainBlacklisted(domain); + const isBlocked = await this.dtp.redis.sismember(LinkService.DOMAIN_BLACKLIST_KEY, domain); + return (isBlocked !== 0); } async ingest (author, url) { @@ -82,6 +85,9 @@ export default class LinkService extends SiteService { if (domain.endsWith('.il')) { throw new SiteError(403, 'Linking to websites in Israel is prohibited.'); } + if (domain.includes('tiktok.com')) { + throw new SiteError(403, 'Linking to TikTok is prohibited.'); + } if (await this.isDomainBlocked(domain)) { this.log.alert('detected blocked domain in shared link', { @@ -113,18 +119,10 @@ export default class LinkService extends SiteService { { upsert: true, new: true }, ); - /* - * link is now the document from MongoDB and will contain additional - * information about the link, or not. If not, create a job to fetch link - * preview data, and to scan the link for malicious intent (unless we know - * the link has been administratively blocked). - */ - // if (!link.flags || (!link.flags.isBlocked && !link.flags.havePreview)) { - this.socialQueue.add('link-ingest', { + this.linksQueue.add('link-ingest', { submitterId: author._id, linkId: link._id, }); - // } return link; } diff --git a/app/services/text.js b/app/services/text.js index 51843a6..42eec86 100644 --- a/app/services/text.js +++ b/app/services/text.js @@ -29,6 +29,9 @@ export default class TextService extends SiteService { async start ( ) { await this.loadChatFilters(); + + const { jobQueue: jobQueueService } = this.dtp.services; + this.linksQueue = jobQueueService.getJobQueue('links', this.dtp.config.jobQueues.links); } /** @@ -135,7 +138,7 @@ export default class TextService extends SiteService { * @param {*} content the content of the status being scanned * @returns array of links detected or an empty array */ - async findLinks (author, content) { + async findLinks (author, content, options) { const NOW = new Date(); const { link: linkService } = this.dtp.services; @@ -196,12 +199,11 @@ export default class TextService extends SiteService { * preview data, and to scan the link for malicious intent (unless we know * the link has been administratively blocked). */ - // if (!link.flags || (!link.flags.isBlocked && !link.flags.havePreview)) { - this.socialQueue.add('link-ingest', { - submitterId: author._id, - linkId: link._id, - }); - // } + this.linksQueue.add('link-ingest', { + submitterId: author._id, + linkId: link._id, + options, + }); this.log.debug('adding detected link', { domain, url, link: link._id }); links.push(link._id); diff --git a/app/services/user.js b/app/services/user.js index 59ac230..a04737b 100644 --- a/app/services/user.js +++ b/app/services/user.js @@ -438,6 +438,55 @@ export default class UserService extends SiteService { } } + async updatePhoto (user, file) { + const { image: imageService } = this.dtp.services; + const images = [ + { + width: 512, + height: 512, + format: 'jpeg', + formatParameters: { + quality: 80, + }, + }, + { + width: 64, + height: 64, + format: 'jpeg', + formatParameters: { + conpressionLevel: 9, + }, + }, + ]; + await imageService.processImageFile(user, file, images); + await User.updateOne( + { _id: user._id }, + { + $set: { + 'picture.large': images[0].image._id, + 'picture.small': images[1].image._id, + }, + }, + ); + return images[0].image; + } + + async removePhoto (user) { + const { image: imageService } = this.dtp.services; + + this.log.info('remove profile photo', { user: user._id }); + user = await this.getUserAccount(user._id); + if (user.picture) { + if (user.picture.large) { + await imageService.deleteImage(user.picture.large); + } + if (user.picture.small) { + await imageService.deleteImage(user.picture.small); + } + } + await User.updateOne({ _id: user._id }, { $unset: { 'picture': '' } }); + } + async authenticate (account, options) { const { crypto } = this.dtp.services; @@ -634,7 +683,7 @@ export default class UserService extends SiteService { } async block (user, blockedUserId) { - blockedUserId = mongoose.Types.ObjectId(blockedUserId); + blockedUserId = mongoose.Types.ObjectId.createFromHexString(blockedUserId); this.log.info('blocking user', { user: user._id, blockedUserId }); await User.updateOne( { _id: user._id }, diff --git a/app/views/chat/components/member-list-item.pug b/app/views/chat/components/member-list-item.pug index 76c3cdc..3a0dc15 100644 --- a/app/views/chat/components/member-list-item.pug +++ b/app/views/chat/components/member-list-item.pug @@ -13,9 +13,9 @@ mixin renderChatMemberListItem (room, member, options) // var isChannelMod = user && Array.isArray(message.channel.moderators) && !!message.channel.moderators.find((moderator) => moderator._id.equals(user._id)); li(data-member-id= member._id, data-member-username= member.username) - div(uk-grid).uk-grid-collapse.uk-flex-middle.member-list-item + .uk-flex.uk-flex-middle.member-list-item .uk-width-auto - img(src='/img/default-member.png').member-profile-icon + +renderProfilePicture(member, { iconClass: 'member-profile-icon' }) .uk-width-expand.uk-text-small a(href=`/member/${member.username}`, uk-tooltip={ title: `Visit ${member.username}`}).uk-link-reset .member-display-name= member.displayName || member.username diff --git a/app/views/chat/components/message.pug b/app/views/chat/components/message.pug index 302ccd6..2af43af 100644 --- a/app/views/chat/components/message.pug +++ b/app/views/chat/components/message.pug @@ -1,8 +1,10 @@ +include ../../link/components/preview +include ../../user/components/profile-picture mixin renderChatMessage (message) .chat-message .uk-flex .uk-width-auto.no-select - img(src="/img/default-member.png").member-profile-icon + +renderProfilePicture(message.author, { iconClass: 'member-profile-icon' }) .uk-width-expand .message-attribution.uk-margin-small.no-select .uk-flex.uk-flex-top @@ -19,4 +21,9 @@ mixin renderChatMessage (message) if message.content && (message.content.length > 0) .message-content - div!= marked.parse(message.content, { renderer: fullMarkedRenderer }) \ No newline at end of file + div!= marked.parse(message.content, { renderer: fullMarkedRenderer }) + + if Array.isArray(message.links) && (message.links.length > 0) + each link in message.links + div(class="uk-width-large").uk-margin-small + +renderLinkPreview(link, { layout: 'responsive' }) \ No newline at end of file diff --git a/app/views/chat/room/view.pug b/app/views/chat/room/view.pug index bb22e64..91bb679 100644 --- a/app/views/chat/room/view.pug +++ b/app/views/chat/room/view.pug @@ -8,28 +8,6 @@ block view-content include ../components/message - mixin renderMemberListEntry (member, options) - - - options = Object.assign({ - active: false, - idle: false, - audioActive: false, - audioIndicatorActive: false, - }, options); - - li( - data-user-id= member._id, - data-username= member.username, - class={ 'entry-active': options.active, 'entry-idle': options.idle, 'entry-audio-active': options.audioActive }, - ).member-list-entry - div(uk-grid).uk-grid-collapse.uk-flex-middle - .uk-width-auto - img(src="/img/default-member.png").member-profile-icon - .uk-width-expand= member.username - .uk-width-auto - div(data-member-id= member._id, class={ 'indicator-active': options.audioIndicatorActive }).member-audio-indicator - i.fas.fa-volume-off - mixin renderLiveMember (member) div(data-user-id= member._id, data-username= member.username).stage-live-member video(poster="/img/default-poster.png", disablepictureinpicture, disableremoteplayback) @@ -57,7 +35,6 @@ block view-content .chat-stage-header Idle Members .sidebar-panel - .uk-text-italic There are no idle members. ul(id="chat-idle-members", data-room-id= room._id, hidden).uk-list.uk-list-collapse .chat-container diff --git a/app/views/link/components/preview.pug b/app/views/link/components/preview.pug index 344293c..0659764 100644 --- a/app/views/link/components/preview.pug +++ b/app/views/link/components/preview.pug @@ -22,12 +22,12 @@ mixin renderLinkPreview (link, options) .uk-text-lead.uk-text-truncate a(href= link.url, target="_blank", uk-tooltip={ title: `Watch ${link.title} on ${link.oembed.provider_name}` }).uk-link-reset= link.title .uk-text-small author: #[a(href= link.oembed.author_url, target="_blank", uk-tooltip={ title: `Visit ${link.oembed.author_name} on ${link.oembed.provider_name}` })= link.oembed.author_name] - .markdown-block!= marked.parse(dtpparse(link.oembed.description || link.description)) + .markdown-block.link-description.uk-text-break!= marked.parse(link.oembed.description || link.description || 'No description provided', { renderer: fullMarkedRenderer }) default div(uk-grid).uk-grid-small.link-preview if Array.isArray(link.images) && (link.images.length > 0) - div(class= (options.layout === 'responsive') ? "uk-width-1-1 uk-width-auto@m" : "uk-width-1-1", data-layout= options.layout) + div(data-layout= options.layout).uk-width-auto a(href= link.url, data-link-id= link._id, onclick= "return dtp.app.visitLink(event);", @@ -36,13 +36,13 @@ mixin renderLinkPreview (link, options) div(class="uk-width-1-1 uk-width-expand@s") .uk-margin-small - .uk-text-bold + .uk-text-bold.uk-margin-small a(ref= link.url, data-link-id= link._id, onclick= "return dtp.app.visitLink(event);", ).uk-link-reset= link.title - .link-description.uk-text-small!= marked.parse(dtpparse(link.description)) + .markdown-block.link-description.uk-text-break!= marked.parse(link.description || 'No description provided', { renderer: fullMarkedRenderer }) .uk-flex.uk-flex-middle.uk-text-small if Array.isArray(link.favicons) && (link.favicons.length > 0) @@ -54,7 +54,4 @@ mixin renderLinkPreview (link, options) ) .uk-width-expand .uk-margin-small-left - a(href=`//${link.domain}`, target="_blank", uk-tooltip={ title: link.mediaType })= link.siteName || link.domain - .uk-width-auto - .uk-margin-small-left - a(href=`/link/${link._id}/feed`, uk-tooltip={ title: 'Visit link' }) link feed \ No newline at end of file + a(href=`//${link.domain}`, target="_blank", uk-tooltip={ title: link.mediaType })= link.siteName || link.domain \ No newline at end of file diff --git a/app/views/user/block-list.pug b/app/views/user/block-list.pug index cdf4e82..b0f39f5 100644 --- a/app/views/user/block-list.pug +++ b/app/views/user/block-list.pug @@ -1,21 +1,7 @@ extends ../layouts/main +include components/profile-picture block content - mixin renderProfilePicture (user, options) - - - var iconImageUrl = (user.picture && user.picture.small) ? `/image/${user.picture.small._id}` : '/img/default-member.png'; - options = Object.assign({ - title: user.displayName || user.username, - iconClass: 'sb-xxsmall', - }, options); - - a(href=`/member/${user.username}`, uk-tooltip={ title: `Visit ${user.displayName || user.username}` }).uk-link-reset - img( - src= iconImageUrl, - class= `streamray-profile-picture ${options.iconClass}`, - style= "margin: 0;", - ) - section.uk-section.uk-section-default .uk-container h1 Block List diff --git a/app/views/user/components/profile-picture.pug b/app/views/user/components/profile-picture.pug index 2b20395..57636b5 100644 --- a/app/views/user/components/profile-picture.pug +++ b/app/views/user/components/profile-picture.pug @@ -1,6 +1,11 @@ mixin renderProfilePicture (user, options) - - var iconImageUrl = (user.picture && user.picture.large) ? `/image/${user.picture.large._id}` : '/img/default-member.png'; + var iconImageUrl = '/img/default-member.png'; + if (user?.picture?.large) { + iconImageUrl = `/image/${user.picture.large._id}`; + } else if (user?.picture?.small) { + iconImageUrl = `/image/${user.picture.small._id}`; + } options = Object.assign({ title: user.displayName || user.username, iconClass: 'sb-xxsmall', @@ -8,5 +13,5 @@ mixin renderProfilePicture (user, options) img( src= iconImageUrl, - class= `streamray-profile-picture ${options.iconClass}`, + class= `profile-picture ${options.iconClass}`, ) \ No newline at end of file diff --git a/app/workers/chat-links.js b/app/workers/chat-links.js new file mode 100644 index 0000000..0898237 --- /dev/null +++ b/app/workers/chat-links.js @@ -0,0 +1,280 @@ +// host-services.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import 'dotenv/config'; + +import path, { dirname } from 'path'; +import fs from 'node:fs'; +import readline from 'node:readline'; + +import { SiteRuntime } from '../../lib/site-lib.js'; + +import { CronJob } from 'cron'; +const CRON_TIMEZONE = 'America/New_York'; + +import { Readable, pipeline } from 'node:stream'; +import { promisify } from 'node:util'; +const streamPipeline = promisify(pipeline); + +class ChatLinksService extends SiteRuntime { + + static get name ( ) { return 'ChatLinksWorker'; } + static get slug ( ) { return 'chatLinks'; } + + static get BLOCKLIST_URL ( ) { return 'https://raw.githubusercontent.com/StevenBlack/hosts/master/alternates/porn/hosts'; } + + constructor (rootPath) { + super(ChatLinksService, rootPath); + } + + async start ( ) { + await super.start(); + + const mongoose = await import('mongoose'); + this.Link = mongoose.model('Link'); + + this.viewModel = { }; + await this.populateViewModel(this.viewModel); + + this.blacklist = { + porn: path.join(this.config.root, 'data', 'blacklist', 'porn'), + }; + + /* + * Bull Queue job processors + */ + + this.log.info('registering link-ingest 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)); + + /* + * Cron jobs + */ + + const cronBlacklistUpdate = '0 0 3 * * *'; // Ever day at 3:00 a.m. + this.log.info('created URL blacklist update cron', { cronBlacklistUpdate }); + this.updateBlacklistJob = new CronJob( + cronBlacklistUpdate, + this.updateUrlBlacklist.bind(this), + null, + true, + CRON_TIMEZONE, + ); + } + + async shutdown ( ) { + this.log.alert('ChatLinksWorker shutting down'); + await super.shutdown(); + } + + async ingestLink (job) { + const { link: linkService, user: userService } = this.services; + this.log.info('received link ingest job', { data: job.data }); + + try { + if (!job.data.submitterId) { + this.log.error('link ingest submitted without submitterId'); + return; + } + + job.data.submitter = await userService.getUserAccount(job.data.submitterId); + if (!job.data.submitter) { + this.log.error('link submitted with invalid User', { submitterId: job.data.submitterId }); + return; + } + + /* + * Is the submitter blocked from sharing links? + */ + if (!job.data.submitter.permissions.canShareLinks) { + this.log.alert('Submitter is not permitted to share links', { + submitter: { + _id: job.data.submitter._id, + username: job.data.submitter.username, + }, + }); + return; + } + + this.log.info('fetching link from database'); + job.data.link = await linkService.getById(job.data.linkId); + if (!job.data.link) { + this.log.error('link not found in database', { linkId: job.data.linkId }); + return; + } + + /* + * Is the domain or URL already known to be blocked? + */ + if (job.data.link.flags && job.data.link.flags.isBlocked) { + this.log.alert('aborting ingest of blocked link', { + submitter: { + _id: job.data.submitter._id, + username: job.data.submitter.username, + }, + domain: job.data.link.domain, + url: job.data.link.url, + }); + return; + } + + /* + * Is the domain currently blocked? + */ + const isDomainBlocked = await linkService.isDomainBlocked(job.data.link.domain); + if (isDomainBlocked) { + /* + * Make sure the flag is set on the Link + */ + await this.Link.updateOne( + { _id: job.data.link._id }, + { + $set: { + 'flags.isBlocked': true, + }, + }, + ); + /* + * Log the rejection + */ + this.log.alert('prohibiting link from blocked domain', { + submitter: { + _id: job.data.submitter._id, + username: job.data.submitter.username, + }, + domain: job.data.link.domain, + url: job.data.link.url, + }); + + return; // bye! + } + + this.log.info('fetching link preview', { + domain: job.data.link.domain, + url: job.data.link.url, + }); + + job.data.preview = await linkService.generatePagePreview(job.data.link.url); + if (!job.data.preview) { + throw new Error('failed to load link preview'); + } + + this.log.info('updating link record in Mongo', { + link: job.data.link._id, + preview: job.data.preview, + }); + job.data.link = await this.Link.findOneAndUpdate( + { _id: job.data.link._id }, + { + $set: { + lastPreviewFetched: job.data.preview.fetched, + title: job.data.preview.title, + siteName: job.data.preview.siteName, + description: job.data.preview.description, + tags: job.data.preview.tags, + mediaType: job.data.preview.mediaType, + contentType: job.data.preview.contentType, + images: job.data.preview.images, + videos: job.data.preview.videos, + audios: job.data.preview.audios, + favicons: job.data.preview.favicons, + oembed: job.data.preview.oembed, + 'flags.havePreview': true, + }, + }, + { new: true }, + ); + job.data.link = await this.Link.populate(job.data.link, linkService.populateLink); + + this.log.info('link ingest complete', { + submitter: { + _id: job.data.submitter._id, + username: job.data.submitter.username, + }, + link: job.data.link, + }); + + if (job.data?.options?.channelId) { + const viewModel = Object.assign({ link: job.data.link }, this.viewModel); + const displayList = linkService.createDisplayList('replace-preview'); + displayList.replaceElement( + `.link-container[data-link-id="${job.data.link._id}"]`, + await linkService.renderPreview(viewModel), + ); + this.emitter.to(job.data.options.channelId).emit('chat-control', { displayList }); + } + } catch (error) { + await this.log.error('failed to ingest link', { + domain: job.data.link.domain, + url: job.data.link.url, + error + }); + throw error; + } + } + + async updateUrlBlacklist ( ) { + try { + /* + * Fetch latest to local file + */ + this.log.info('fetching updated domain blacklist'); + const response = await fetch(ChatLinksService.BLOCKLIST_URL); + if (!response.ok) { + throw new Error(`unexpected response ${response.statusText}`); + } + + await streamPipeline(Readable.fromWeb(response.body), fs.createWriteStream(this.blacklist.porn)); + + /* + * Read local file line-by-line with filtering and comment removal to insert + * to Redis set of blocked domains + */ + const fileStream = fs.createReadStream(this.blacklist.porn); + const rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); + for await (let line of rl) { + line = line.trim(); + if (line[0] === '#') { + continue; + } + const tokens = line.split(' '); + if (tokens[0] !== '0.0.0.0' || tokens[1] === '0.0.0.0') { + continue; + } + + const r = await this.redis.sadd(ChatLinksService.DOMAIN_BLACKLIST_KEY, tokens[1]); + if (r > 0) { + this.log.info('added domain to Redis blocklist', { domain: tokens[1] }); + } + } + } catch (error) { + this.log.error('failed to update domain blacklist', { error }); + // fall through + } finally { + this.log.info('domain block list updated'); + } + } +} + +(async ( ) => { + + try { + const { fileURLToPath } = await import('node:url'); + const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line + + const worker = new ChatLinksService(path.resolve(__dirname, '..', '..')); + await worker.start(); + + } catch (error) { + console.error('failed to start Host Cache worker', { error }); + process.exit(-1); + } + +})(); \ No newline at end of file diff --git a/app/workers/host-services.js b/app/workers/host-services.js index f015ca3..3125307 100644 --- a/app/workers/host-services.js +++ b/app/workers/host-services.js @@ -24,6 +24,8 @@ import { CronJob } from 'cron'; import { createRequire } from 'module'; const require = createRequire(import.meta.url); // jshint ignore:line +import { Readable } from 'node:stream'; + const CRON_TIMEZONE = 'America/New_York'; class CacheStats { @@ -308,7 +310,6 @@ class HostCacheTransaction { this.dtp.manager.resolveTransaction(this, res); }); - const { Readable } = await import('stream'); Readable.fromWeb(response.body).pipe(writeStream); } catch (error) { this.error = error; @@ -443,13 +444,13 @@ class TransactionManager { } } -class SiteHostServices extends SiteRuntime { +class HostServicesWorker extends SiteRuntime { - static get name ( ) { return 'SiteHostServices'; } + static get name ( ) { return 'HostServicesWorker'; } static get slug ( ) { return 'hostServices'; } constructor (rootPath) { - super(SiteHostServices, rootPath); + super(HostServicesWorker, rootPath); } async start (basePath) { @@ -462,12 +463,12 @@ class SiteHostServices extends SiteRuntime { basePath = basePath || process.env.HOST_CACHE_PATH; this.log.info('ensuring host-services path exists', { basePath }); await fs.promises.mkdir(basePath, { recursive: true }); - await this.cleanHostCache(basePath); - - this.networkStats = await si.networkStats('*'); this.log.info('starting cache service', { basePath }); this.cacheStats = new CacheStats(); + await this.cleanHostCache(basePath); + + this.networkStats = await si.networkStats('*'); /* * Host Cache server socket setup @@ -521,11 +522,14 @@ class SiteHostServices extends SiteRuntime { await this.registerHost(); await this.setHostStatus('active'); - this.log.info(`${this.config.pkg.name} v${this.config.pkg.version} ${SiteHostServices.name} started`); + this.log.info(`${this.config.pkg.name} v${this.config.pkg.version} ${HostServicesWorker.name} started`); } async shutdown ( ) { + this.log.alert('HostServicesWorker shutting down'); await this.setHostStatus('shutdown'); + + await super.shutdown(); } async onHostCacheMessage (message, rinfo) { @@ -797,7 +801,7 @@ class SiteHostServices extends SiteRuntime { const { fileURLToPath } = await import('node:url'); const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line - const worker = new SiteHostServices(path.resolve(__dirname, '..', '..')); + const worker = new HostServicesWorker(path.resolve(__dirname, '..', '..')); await worker.start(); } catch (error) { diff --git a/client/css/dtp-site.less b/client/css/dtp-site.less index f70619b..f51fbae 100644 --- a/client/css/dtp-site.less +++ b/client/css/dtp-site.less @@ -2,4 +2,5 @@ @import "site/button.less"; @import "site/navbar.less"; @import "site/stage.less"; +@import "site/link-preview.less"; @import "site/image.less"; \ No newline at end of file diff --git a/client/css/site/image.less b/client/css/site/image.less index 1e0488f..379cc1b 100644 --- a/client/css/site/image.less +++ b/client/css/site/image.less @@ -7,4 +7,10 @@ img.profile-navbar { width: auto; height: 48px; border-radius: 5px; +} + +img.profile-picture { + width: 64px; + height: 64px; + border-radius: 5px; } \ No newline at end of file diff --git a/client/css/site/link-preview.less b/client/css/site/link-preview.less new file mode 100644 index 0000000..33b9575 --- /dev/null +++ b/client/css/site/link-preview.less @@ -0,0 +1,36 @@ + + +.link-container { + box-sizing: border-box; + + padding: 5px; + background-color: @link-container-bgcolor; + + // border-top: solid 1px red; + // border-right: solid 1px red; + // border-bottom: solid 1px red; + border-left: solid 4px @link-container-border-color; + + border-radius: 5px; + + .link-preview { + + img.link-thumbnail { + width: 180px; + height: auto; + } + + .link-description { + line-height: 1.15em; + max-height: 4.65em; + overflow: hidden; + } + + iframe { + aspect-ratio: 16 / 9; + height: auto; + width: 100%; + max-height: 540px; + } + } +} \ No newline at end of file diff --git a/client/css/site/stage.less b/client/css/site/stage.less index 6ac0435..ee5c3f2 100644 --- a/client/css/site/stage.less +++ b/client/css/site/stage.less @@ -26,8 +26,11 @@ color: @chat-sidebar-color; border-right: solid 1px @stage-border-color; + overflow-y: auto; + overflow-x: hidden; .sidebar-panel { + box-sizing: border-box; padding: @stage-panel-padding; margin-bottom: 10px; color: inherit; diff --git a/client/css/site/uikit-theme.dtp-dark.less b/client/css/site/uikit-theme.dtp-dark.less index cc98512..cefcdea 100644 --- a/client/css/site/uikit-theme.dtp-dark.less +++ b/client/css/site/uikit-theme.dtp-dark.less @@ -147,4 +147,7 @@ a.uk-button.uk-button-default { @system-message-color: #a8a8a8; @chat-input-panel-bgcolor: #1a1a1a; -@chat-input-panel-color: #e8e8e8; \ No newline at end of file +@chat-input-panel-color: #e8e8e8; + +@link-container-bgcolor: rgba(0,0,0, 0.3); +@link-container-border-color: darkred; \ No newline at end of file diff --git a/client/css/site/uikit-theme.dtp-light.less b/client/css/site/uikit-theme.dtp-light.less index e3fbb44..3f3d1e0 100644 --- a/client/css/site/uikit-theme.dtp-light.less +++ b/client/css/site/uikit-theme.dtp-light.less @@ -66,4 +66,7 @@ @system-message-color: #c8c8c8; @chat-input-panel-bgcolor: #e8e8e8; -@chat-input-panel-color: #1a1a1a; \ No newline at end of file +@chat-input-panel-color: #1a1a1a; + +@link-container-bgcolor: rgba(0, 0, 0, 0.1); +@link-container-border-color: red; \ No newline at end of file diff --git a/client/img/icon/globe-icon.svg b/client/img/icon/globe-icon.svg new file mode 100644 index 0000000..0716d90 --- /dev/null +++ b/client/img/icon/globe-icon.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/client/js/chat-client.js b/client/js/chat-client.js index 228c0a4..aea2590 100644 --- a/client/js/chat-client.js +++ b/client/js/chat-client.js @@ -14,6 +14,9 @@ import QRCode from 'qrcode'; import Cropper from 'cropperjs'; import dayjs from 'dayjs'; +import dayjsRelativeTime from 'dayjs/plugin/relativeTime.js'; +dayjs.extend(dayjsRelativeTime); + import hljs from 'highlight.js'; export class ChatApp extends DtpApp { @@ -504,6 +507,32 @@ export class ChatApp extends DtpApp { window.localStorage.settings = JSON.stringify(this.settings); } + async submitImageForm (event) { + event.preventDefault(); + event.stopPropagation(); + + const formElement = event.currentTarget || event.target; + const form = new FormData(formElement); + + this.cropper.getCroppedCanvas().toBlob(async (imageData) => { + try { + const imageId = formElement.getAttribute('data-image-id'); + form.append('imageFile', imageData, imageId); + + this.log.info('submitImageForm', 'uploading image', { event, action: formElement.action }); + const response = await fetch(formElement.action, { + method: formElement.method, + body: form, + }); + await this.processResponse(response); + } catch (error) { + UIkit.modal.alert(`Failed to upload image: ${error.message}`); + } + }); + + return; + } + async selectImageFile (event) { event.preventDefault(); diff --git a/config/job-queues.js b/config/job-queues.js index 3a990b6..74a386f 100644 --- a/config/job-queues.js +++ b/config/job-queues.js @@ -11,4 +11,7 @@ export default { 'email': { attempts: 3, }, + 'links': { + attempts: 2, + }, }; \ No newline at end of file diff --git a/lib/client/js/dtp-display-engine.js b/lib/client/js/dtp-display-engine.js index 061fc38..0e22ef9 100644 --- a/lib/client/js/dtp-display-engine.js +++ b/lib/client/js/dtp-display-engine.js @@ -104,12 +104,14 @@ export default class DtpDisplayEngine { * html: replaces the whole specified element */ async replaceElement (displayList, command) { - const element = document.querySelector(command.selector); - if (!element) { + const elements = document.querySelectorAll(command.selector); + if (!elements || (elements.length === 0)) { this.log.debug('replaceElement', 'displayList.replaceElement has failed to find requested element', { command }); return; } - element.outerHTML = command.params.html; + for (const element of elements) { + element.outerHTML = command.params.html; + } } /* diff --git a/lib/site-runtime.js b/lib/site-runtime.js index 15d1f08..a2daef3 100644 --- a/lib/site-runtime.js +++ b/lib/site-runtime.js @@ -173,10 +173,10 @@ export class SiteRuntime { async connectRedis ( ) { try { const options = { - host: process.env.DTP_REDIS_HOST, - port: parseInt(process.env.DTP_REDIS_PORT || '6379', 10), - password: process.env.DTP_REDIS_PASSWORD, - keyPrefix: process.env.DTP_REDIS_KEY_PREFIX, + host: process.env.REDIS_HOST, + port: parseInt(process.env.REDIS_PORT || '6379', 10), + password: process.env.REDIS_PASSWORD, + keyPrefix: process.env.REDIS_KEY_PREFIX, lazyConnect: false, }; this.log.info('connecting to Redis', { diff --git a/nodemon.json b/nodemon.json index 27b9a3b..25a1602 100644 --- a/nodemon.json +++ b/nodemon.json @@ -1,4 +1,5 @@ { + "signal": "SIGINT", "verbose": true, "ignore": [ "dist", diff --git a/package.json b/package.json index ef99919..9251c3e 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "qrcode": "^1.5.3", "rate-limiter-flexible": "^5.0.0", "rotating-file-stream": "^3.2.1", + "sharp": "^0.33.3", "shoetest": "^1.2.2", "slug": "^9.0.0", "socket.io": "^4.7.5", diff --git a/start-local b/start-local index e4ab081..5b9a182 100755 --- a/start-local +++ b/start-local @@ -9,7 +9,9 @@ MINIO_ROOT_PASSWORD="dd039ca4-1bab-4a6c-809b-0bbb43c46def" export MINIO_ROOT_USER MINIO_ROOT_PASSWORD forever start --killSignal=SIGINT app/workers/host-services.js +forever start --killSignal=SIGINT app/workers/chat-links.js minio server ./data/minio --address ":9080" --console-address ":9081" +forever stop app/workers/chat-links.js forever stop app/workers/host-services.js \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 4bccce7..da7883d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -956,11 +956,131 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== +"@emnapi/runtime@^1.1.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.1.1.tgz#697d02276ca6f49bafe6fd01c9df0034818afa98" + integrity sha512-3bfqkzuR1KLx57nZfjr2NLnFOobvyS0aTszaEGCGqmYMVDRaGvgIZbjGSV/MHSSmLgQ/b9JFHQ5xm5WRZYd+XQ== + dependencies: + tslib "^2.4.0" + "@fortawesome/fontawesome-free@^6.5.1": version "6.5.1" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.1.tgz#55cc8410abf1003b726324661ce5b0d1c10de258" integrity sha512-CNy5vSwN3fsUStPRLX7fUYojyuzoEMSXPl7zSLJ8TgtRfjv24LOnOWKT2zYwaHZCJGkdyRnTmstR0P+Ah503Gw== +"@img/sharp-darwin-arm64@0.33.3": + version "0.33.3" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.3.tgz#2bbf676be830c5a9ae7d9294f201c9151535badd" + integrity sha512-FaNiGX1MrOuJ3hxuNzWgsT/mg5OHG/Izh59WW2mk1UwYHUwtfbhk5QNKYZgxf0pLOhx9ctGiGa2OykD71vOnSw== + optionalDependencies: + "@img/sharp-libvips-darwin-arm64" "1.0.2" + +"@img/sharp-darwin-x64@0.33.3": + version "0.33.3" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.3.tgz#c59567b141eb676e884066f76091a2673120c3f5" + integrity sha512-2QeSl7QDK9ru//YBT4sQkoq7L0EAJZA3rtV+v9p8xTKl4U1bUqTIaCnoC7Ctx2kCjQgwFXDasOtPTCT8eCTXvw== + optionalDependencies: + "@img/sharp-libvips-darwin-x64" "1.0.2" + +"@img/sharp-libvips-darwin-arm64@1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.2.tgz#b69f49fecbe9572378675769b189410721b0fa53" + integrity sha512-tcK/41Rq8IKlSaKRCCAuuY3lDJjQnYIW1UXU1kxcEKrfL8WR7N6+rzNoOxoQRJWTAECuKwgAHnPvqXGN8XfkHA== + +"@img/sharp-libvips-darwin-x64@1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.2.tgz#5665da7360d8e5ed7bee314491c8fe736b6a3c39" + integrity sha512-Ofw+7oaWa0HiiMiKWqqaZbaYV3/UGL2wAPeLuJTx+9cXpCRdvQhCLG0IH8YGwM0yGWGLpsF4Su9vM1o6aer+Fw== + +"@img/sharp-libvips-linux-arm64@1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.2.tgz#8a05e5e9e9b760ff46561e32f19bd5e035fa881c" + integrity sha512-x7kCt3N00ofFmmkkdshwj3vGPCnmiDh7Gwnd4nUwZln2YjqPxV1NlTyZOvoDWdKQVDL911487HOueBvrpflagw== + +"@img/sharp-libvips-linux-arm@1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.2.tgz#0fd33b9bf3221948ce0ca7a5a725942626577a03" + integrity sha512-iLWCvrKgeFoglQxdEwzu1eQV04o8YeYGFXtfWU26Zr2wWT3q3MTzC+QTCO3ZQfWd3doKHT4Pm2kRmLbupT+sZw== + +"@img/sharp-libvips-linux-s390x@1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.2.tgz#4b89150ec91b256ee2cbb5bb125321bf029a4770" + integrity sha512-cmhQ1J4qVhfmS6szYW7RT+gLJq9dH2i4maq+qyXayUSn9/3iY2ZeWpbAgSpSVbV2E1JUL2Gg7pwnYQ1h8rQIog== + +"@img/sharp-libvips-linux-x64@1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.2.tgz#947ccc22ca5bc8c8cfe921b39a5fdaebc5e39f3f" + integrity sha512-E441q4Qdb+7yuyiADVi5J+44x8ctlrqn8XgkDTwr4qPJzWkaHwD489iZ4nGDgcuya4iMN3ULV6NwbhRZJ9Z7SQ== + +"@img/sharp-libvips-linuxmusl-arm64@1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.2.tgz#821d58ce774f0f8bed065b69913a62f65d512f2f" + integrity sha512-3CAkndNpYUrlDqkCM5qhksfE+qSIREVpyoeHIU6jd48SJZViAmznoQQLAv4hVXF7xyUB9zf+G++e2v1ABjCbEQ== + +"@img/sharp-libvips-linuxmusl-x64@1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.2.tgz#4309474bd8b728a61af0b3b4fad0c476b5f3ccbe" + integrity sha512-VI94Q6khIHqHWNOh6LLdm9s2Ry4zdjWJwH56WoiJU7NTeDwyApdZZ8c+SADC8OH98KWNQXnE01UdJ9CSfZvwZw== + +"@img/sharp-linux-arm64@0.33.3": + version "0.33.3" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.3.tgz#a1f788ddf49ed63509dd37d4b01e571fe7f189d5" + integrity sha512-Zf+sF1jHZJKA6Gor9hoYG2ljr4wo9cY4twaxgFDvlG0Xz9V7sinsPp8pFd1XtlhTzYo0IhDbl3rK7P6MzHpnYA== + optionalDependencies: + "@img/sharp-libvips-linux-arm64" "1.0.2" + +"@img/sharp-linux-arm@0.33.3": + version "0.33.3" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.3.tgz#661b0671ed7f740fd06821ce15050ba23f1d0523" + integrity sha512-Q7Ee3fFSC9P7vUSqVEF0zccJsZ8GiiCJYGWDdhEjdlOeS9/jdkyJ6sUSPj+bL8VuOYFSbofrW0t/86ceVhx32w== + optionalDependencies: + "@img/sharp-libvips-linux-arm" "1.0.2" + +"@img/sharp-linux-s390x@0.33.3": + version "0.33.3" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.3.tgz#8719341d3931a297df1a956c02ee003736fa8fac" + integrity sha512-vFk441DKRFepjhTEH20oBlFrHcLjPfI8B0pMIxGm3+yilKyYeHEVvrZhYFdqIseSclIqbQ3SnZMwEMWonY5XFA== + optionalDependencies: + "@img/sharp-libvips-linux-s390x" "1.0.2" + +"@img/sharp-linux-x64@0.33.3": + version "0.33.3" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.3.tgz#dbd860b4aa16e7e25727c7e05b411132b58d017d" + integrity sha512-Q4I++herIJxJi+qmbySd072oDPRkCg/SClLEIDh5IL9h1zjhqjv82H0Seupd+q2m0yOfD+/fJnjSoDFtKiHu2g== + optionalDependencies: + "@img/sharp-libvips-linux-x64" "1.0.2" + +"@img/sharp-linuxmusl-arm64@0.33.3": + version "0.33.3" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.3.tgz#25b3fbfe9b6fa32d773422d878d8d84f3f6afceb" + integrity sha512-qnDccehRDXadhM9PM5hLvcPRYqyFCBN31kq+ErBSZtZlsAc1U4Z85xf/RXv1qolkdu+ibw64fUDaRdktxTNP9A== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-arm64" "1.0.2" + +"@img/sharp-linuxmusl-x64@0.33.3": + version "0.33.3" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.3.tgz#1e533e44abf2e2d427428ed49294ddba4eb11456" + integrity sha512-Jhchim8kHWIU/GZ+9poHMWRcefeaxFIs9EBqf9KtcC14Ojk6qua7ghKiPs0sbeLbLj/2IGBtDcxHyjCdYWkk2w== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-x64" "1.0.2" + +"@img/sharp-wasm32@0.33.3": + version "0.33.3" + resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.33.3.tgz#340006047a77df0744db84477768bbca6327b4b4" + integrity sha512-68zivsdJ0koE96stdUfM+gmyaK/NcoSZK5dV5CAjES0FUXS9lchYt8LAB5rTbM7nlWtxaU/2GON0HVN6/ZYJAQ== + dependencies: + "@emnapi/runtime" "^1.1.0" + +"@img/sharp-win32-ia32@0.33.3": + version "0.33.3" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.3.tgz#0fdc49ab094ed0151ec8347afac7917aa5fc5145" + integrity sha512-CyimAduT2whQD8ER4Ux7exKrtfoaUiVr7HG0zZvO0XTFn2idUWljjxv58GxNTkFb8/J9Ub9AqITGkJD6ZginxQ== + +"@img/sharp-win32-x64@0.33.3": + version "0.33.3" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.3.tgz#a94e1028f180666f97fd51e35c4ad092d7704ef0" + integrity sha512-viT4fUIDKnli3IfOephGnolMzhz5VaTvDRkYqtZxOMIoMQ4MrAziO7pT1nVnOt2FAm7qW5aa+CCc13aEY6Le0g== + "@ioredis/commands@^1.1.1": version "1.2.0" resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" @@ -2024,16 +2144,32 @@ color-name@1.1.3: resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== -color-name@~1.1.4: +color-name@^1.0.0, color-name@~1.1.4: version "1.1.4" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== +color-string@^1.9.0: + version "1.9.1" + resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.9.1.tgz#4467f9146f036f855b764dfb5bf8582bf342c7a4" + integrity sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg== + dependencies: + color-name "^1.0.0" + simple-swizzle "^0.2.2" + color-support@^1.1.3: version "1.1.3" resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== +color@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/color/-/color-4.2.3.tgz#d781ecb5e57224ee43ea9627560107c0e0c6463a" + integrity sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A== + dependencies: + color-convert "^2.0.1" + color-string "^1.9.0" + colorette@^2.0.10, colorette@^2.0.14: version "2.0.20" resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" @@ -2387,6 +2523,11 @@ destroy@~1.0.4: resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80" integrity sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg== +detect-libc@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" + integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== + dev-ip@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/dev-ip/-/dev-ip-1.0.1.tgz#a76a3ed1855be7a012bb8ac16cb80f3c00dc28f0" @@ -3355,6 +3496,11 @@ is-array-buffer@^3.0.4: call-bind "^1.0.2" get-intrinsic "^1.2.1" +is-arrayish@^0.3.1: + version "0.3.2" + resolved "https://registry.yarnpkg.com/is-arrayish/-/is-arrayish-0.3.2.tgz#4574a2ae56f7ab206896fb431eaeed066fdf8f03" + integrity sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ== + is-bigint@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" @@ -5019,7 +5165,7 @@ semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.5.2, semver@^7.5.3, semver@^7.5.4: +semver@^7.5.2, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0: version "7.6.0" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== @@ -5160,6 +5306,35 @@ shallow-clone@^3.0.0: dependencies: kind-of "^6.0.2" +sharp@^0.33.3: + version "0.33.3" + resolved "https://registry.yarnpkg.com/sharp/-/sharp-0.33.3.tgz#3342fe0aa5ed45a363e6578fa575c7af366216c2" + integrity sha512-vHUeXJU1UvlO/BNwTpT0x/r53WkLUVxrmb5JTgW92fdFCFk0ispLMAeu/jPO2vjkXM1fYUi3K7/qcLF47pwM1A== + dependencies: + color "^4.2.3" + detect-libc "^2.0.3" + semver "^7.6.0" + optionalDependencies: + "@img/sharp-darwin-arm64" "0.33.3" + "@img/sharp-darwin-x64" "0.33.3" + "@img/sharp-libvips-darwin-arm64" "1.0.2" + "@img/sharp-libvips-darwin-x64" "1.0.2" + "@img/sharp-libvips-linux-arm" "1.0.2" + "@img/sharp-libvips-linux-arm64" "1.0.2" + "@img/sharp-libvips-linux-s390x" "1.0.2" + "@img/sharp-libvips-linux-x64" "1.0.2" + "@img/sharp-libvips-linuxmusl-arm64" "1.0.2" + "@img/sharp-libvips-linuxmusl-x64" "1.0.2" + "@img/sharp-linux-arm" "0.33.3" + "@img/sharp-linux-arm64" "0.33.3" + "@img/sharp-linux-s390x" "0.33.3" + "@img/sharp-linux-x64" "0.33.3" + "@img/sharp-linuxmusl-arm64" "0.33.3" + "@img/sharp-linuxmusl-x64" "0.33.3" + "@img/sharp-wasm32" "0.33.3" + "@img/sharp-win32-ia32" "0.33.3" + "@img/sharp-win32-x64" "0.33.3" + shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -5194,6 +5369,13 @@ sift@16.0.1: resolved "https://registry.yarnpkg.com/sift/-/sift-16.0.1.tgz#e9c2ccc72191585008cf3e36fc447b2d2633a053" integrity sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ== +simple-swizzle@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/simple-swizzle/-/simple-swizzle-0.2.2.tgz#a4da6b635ffcccca33f70d17cb92592de95e557a" + integrity sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg== + dependencies: + is-arrayish "^0.3.1" + simple-update-notifier@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz#d70b92bdab7d6d90dfd73931195a30b6e3d7cebb" @@ -5647,7 +5829,7 @@ tr46@^5.0.0: dependencies: punycode "^2.3.1" -tslib@^2.0.0, tslib@^2.3.0: +tslib@^2.0.0, tslib@^2.3.0, tslib@^2.4.0: version "2.6.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==