Browse Source

link previews in chat

- Worker to process link-ingest jobs
- Link preview template w/oembed support
- CSS for typical link presentation in light & dark themes
develop
Rob Colbert 1 year ago
parent
commit
ec1f51c27e
  1. 2
      app/controllers/auth.js
  2. 23
      app/controllers/chat.js
  3. 2
      app/controllers/image.js
  4. 16
      app/services/chat.js
  5. 264
      app/services/image.js
  6. 28
      app/services/link.js
  7. 16
      app/services/text.js
  8. 51
      app/services/user.js
  9. 4
      app/views/chat/components/member-list-item.pug
  10. 9
      app/views/chat/components/message.pug
  11. 23
      app/views/chat/room/view.pug
  12. 11
      app/views/link/components/preview.pug
  13. 16
      app/views/user/block-list.pug
  14. 9
      app/views/user/components/profile-picture.pug
  15. 280
      app/workers/chat-links.js
  16. 22
      app/workers/host-services.js
  17. 1
      client/css/dtp-site.less
  18. 6
      client/css/site/image.less
  19. 36
      client/css/site/link-preview.less
  20. 3
      client/css/site/stage.less
  21. 3
      client/css/site/uikit-theme.dtp-dark.less
  22. 3
      client/css/site/uikit-theme.dtp-light.less
  23. 5
      client/img/icon/globe-icon.svg
  24. 29
      client/js/chat-client.js
  25. 3
      config/job-queues.js
  26. 8
      lib/client/js/dtp-display-engine.js
  27. 8
      lib/site-runtime.js
  28. 1
      nodemon.json
  29. 1
      package.json
  30. 2
      start-local
  31. 188
      yarn.lock

2
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.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); res.locals.account = await userService.getUserAccount(res.locals.accountId);
this.log.alert('updating user password', { this.log.alert('updating user password', {
userId: res.locals.account._id, userId: res.locals.account._id,

23
app/controllers/chat.js

@ -13,6 +13,8 @@ export default class ChatController extends SiteController {
static get name ( ) { return 'ChatController'; } static get name ( ) { return 'ChatController'; }
static get slug ( ) { return 'chat'; } static get slug ( ) { return 'chat'; }
static get MESSAGES_PER_PAGE ( ) { return 20; }
constructor (dtp) { constructor (dtp) {
super(dtp, ChatController); super(dtp, ChatController);
} }
@ -63,6 +65,12 @@ export default class ChatController extends SiteController {
this.getRoomJoin.bind(this), this.getRoomJoin.bind(this),
); );
router.get(
'/room/:roomId/messages',
// limiterService.create(limiterService.config.chat.getRoomMessages),
this.getRoomMessages.bind(this),
);
router.get( router.get(
'/room/:roomId', '/room/:roomId',
// limiterService.create(limiterService.config.chat.getRoomView), // 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) { async getRoomView (req, res, next) {
const { chat: chatService } = this.dtp.services; const { chat: chatService } = this.dtp.services;
try { try {
res.locals.currentView = 'chat-room'; 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.locals.messages = await chatService.getRoomMessages(res.locals.room, res.locals.pagination);
res.render('chat/room/view'); res.render('chat/room/view');

2
app/controllers/image.js

@ -65,7 +65,7 @@ export default class ImageController extends SiteController {
async populateImage (req, res, next, imageId) { async populateImage (req, res, next, imageId) {
const { image: imageService } = this.dtp.services; const { image: imageService } = this.dtp.services;
try { 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); res.locals.image = await imageService.getImageById(res.locals.imageId);
if (!res.locals.image) { if (!res.locals.image) {
throw new SiteError(404, 'Image not found'); throw new SiteError(404, 'Image not found');

16
app/services/chat.js

@ -24,7 +24,7 @@ export default class ChatService extends SiteService {
} }
async start ( ) { async start ( ) {
const { user: userService } = this.dtp.services; const { link: linkService, user: userService } = this.dtp.services;
this.templates = { this.templates = {
message: this.loadViewTemplate('chat/components/message-standalone.pug'), message: this.loadViewTemplate('chat/components/message-standalone.pug'),
@ -36,6 +36,10 @@ export default class ChatService extends SiteService {
path: 'owner', path: 'owner',
select: userService.USER_SELECT, select: userService.USER_SELECT,
}, },
{
path: 'present',
select: userService.USER_SELECT,
},
]; ];
this.populateChatMessage = [ this.populateChatMessage = [
{ {
@ -49,6 +53,11 @@ export default class ChatService extends SiteService {
path: 'mentions', path: 'mentions',
select: userService.USER_SELECT, 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.mentions = await textService.findMentions(message.content);
message.hashtags = await textService.findHashtags(message.content); message.hashtags = await textService.findHashtags(message.content);
message.links = await textService.findLinks(author, message.content, { channelId: room._id });
await message.save(); await message.save();
@ -232,12 +242,12 @@ export default class ChatService extends SiteService {
async getRoomMessages (room, pagination) { async getRoomMessages (room, pagination) {
const messages = await ChatMessage const messages = await ChatMessage
.find({ channel: room._id }) .find({ channel: room._id })
.sort({ created: 1 }) .sort({ created: -1 })
.skip(pagination.skip) .skip(pagination.skip)
.limit(pagination.cpp) .limit(pagination.cpp)
.populate(this.populateChatMessage) .populate(this.populateChatMessage)
.lean(); .lean();
return messages; return messages.reverse();
} }
async checkRoomMember (room, member) { async checkRoomMember (room, member) {

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

28
app/services/link.js

@ -7,7 +7,6 @@
import mongoose from 'mongoose'; import mongoose from 'mongoose';
const Link = mongoose.model('Link'); const Link = mongoose.model('Link');
import UserAgent from 'user-agents';
import { JSDOM } from 'jsdom'; import { JSDOM } from 'jsdom';
import { SiteService, SiteError } from '../../lib/site-lib.js'; 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 name ( ) { return 'LinkService'; }
static get slug ( ) { return 'link'; } static get slug ( ) { return 'link'; }
static get DOMAIN_BLACKLIST_KEY ( ) { return 'dblklist'; }
constructor (dtp) { constructor (dtp) {
super(dtp, LinkService); super(dtp, LinkService);
} }
@ -23,14 +25,15 @@ export default class LinkService extends SiteService {
async start ( ) { async start ( ) {
await super.start(); await super.start();
const userAgent = new UserAgent(); const { jobQueue: jobQueueService, user: userService } = this.dtp.services;
this.userAgent = userAgent.toString(); 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 = { this.templates = {
linkPreview: this.loadViewTemplate('link/components/preview-standalone.pug'), linkPreview: this.loadViewTemplate('link/components/preview-standalone.pug'),
}; };
const { user: userService } = this.dtp.services;
this.populateLink = [ this.populateLink = [
{ {
path: 'submittedBy', path: 'submittedBy',
@ -69,8 +72,8 @@ export default class LinkService extends SiteService {
} }
async isDomainBlocked (domain) { async isDomainBlocked (domain) {
const { domain: domainService } = this.dtp.services; const isBlocked = await this.dtp.redis.sismember(LinkService.DOMAIN_BLACKLIST_KEY, domain);
return domainService.isDomainBlacklisted(domain); return (isBlocked !== 0);
} }
async ingest (author, url) { async ingest (author, url) {
@ -82,6 +85,9 @@ export default class LinkService extends SiteService {
if (domain.endsWith('.il')) { if (domain.endsWith('.il')) {
throw new SiteError(403, 'Linking to websites in Israel is prohibited.'); 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)) { if (await this.isDomainBlocked(domain)) {
this.log.alert('detected blocked domain in shared link', { this.log.alert('detected blocked domain in shared link', {
@ -113,18 +119,10 @@ export default class LinkService extends SiteService {
{ upsert: true, new: true }, { upsert: true, new: true },
); );
/* this.linksQueue.add('link-ingest', {
* 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', {
submitterId: author._id, submitterId: author._id,
linkId: link._id, linkId: link._id,
}); });
// }
return link; return link;
} }

16
app/services/text.js

@ -29,6 +29,9 @@ export default class TextService extends SiteService {
async start ( ) { async start ( ) {
await this.loadChatFilters(); 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 * @param {*} content the content of the status being scanned
* @returns array of links detected or an empty array * @returns array of links detected or an empty array
*/ */
async findLinks (author, content) { async findLinks (author, content, options) {
const NOW = new Date(); const NOW = new Date();
const { link: linkService } = this.dtp.services; 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 * preview data, and to scan the link for malicious intent (unless we know
* the link has been administratively blocked). * the link has been administratively blocked).
*/ */
// if (!link.flags || (!link.flags.isBlocked && !link.flags.havePreview)) { this.linksQueue.add('link-ingest', {
this.socialQueue.add('link-ingest', { submitterId: author._id,
submitterId: author._id, linkId: link._id,
linkId: link._id, options,
}); });
// }
this.log.debug('adding detected link', { domain, url, link: link._id }); this.log.debug('adding detected link', { domain, url, link: link._id });
links.push(link._id); links.push(link._id);

51
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) { async authenticate (account, options) {
const { crypto } = this.dtp.services; const { crypto } = this.dtp.services;
@ -634,7 +683,7 @@ export default class UserService extends SiteService {
} }
async block (user, blockedUserId) { async block (user, blockedUserId) {
blockedUserId = mongoose.Types.ObjectId(blockedUserId); blockedUserId = mongoose.Types.ObjectId.createFromHexString(blockedUserId);
this.log.info('blocking user', { user: user._id, blockedUserId }); this.log.info('blocking user', { user: user._id, blockedUserId });
await User.updateOne( await User.updateOne(
{ _id: user._id }, { _id: user._id },

4
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)); // 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) 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 .uk-width-auto
img(src='/img/default-member.png').member-profile-icon +renderProfilePicture(member, { iconClass: 'member-profile-icon' })
.uk-width-expand.uk-text-small .uk-width-expand.uk-text-small
a(href=`/member/${member.username}`, uk-tooltip={ title: `Visit ${member.username}`}).uk-link-reset a(href=`/member/${member.username}`, uk-tooltip={ title: `Visit ${member.username}`}).uk-link-reset
.member-display-name= member.displayName || member.username .member-display-name= member.displayName || member.username

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

@ -1,8 +1,10 @@
include ../../link/components/preview
include ../../user/components/profile-picture
mixin renderChatMessage (message) mixin renderChatMessage (message)
.chat-message .chat-message
.uk-flex .uk-flex
.uk-width-auto.no-select .uk-width-auto.no-select
img(src="/img/default-member.png").member-profile-icon +renderProfilePicture(message.author, { iconClass: 'member-profile-icon' })
.uk-width-expand .uk-width-expand
.message-attribution.uk-margin-small.no-select .message-attribution.uk-margin-small.no-select
.uk-flex.uk-flex-top .uk-flex.uk-flex-top
@ -20,3 +22,8 @@ mixin renderChatMessage (message)
if message.content && (message.content.length > 0) if message.content && (message.content.length > 0)
.message-content .message-content
div!= marked.parse(message.content, { renderer: fullMarkedRenderer }) 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' })

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

@ -8,28 +8,6 @@ block view-content
include ../components/message 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) mixin renderLiveMember (member)
div(data-user-id= member._id, data-username= member.username).stage-live-member div(data-user-id= member._id, data-username= member.username).stage-live-member
video(poster="/img/default-poster.png", disablepictureinpicture, disableremoteplayback) video(poster="/img/default-poster.png", disablepictureinpicture, disableremoteplayback)
@ -57,7 +35,6 @@ block view-content
.chat-stage-header Idle Members .chat-stage-header Idle Members
.sidebar-panel .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 ul(id="chat-idle-members", data-room-id= room._id, hidden).uk-list.uk-list-collapse
.chat-container .chat-container

11
app/views/link/components/preview.pug

@ -22,12 +22,12 @@ mixin renderLinkPreview (link, options)
.uk-text-lead.uk-text-truncate .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 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] .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 default
div(uk-grid).uk-grid-small.link-preview div(uk-grid).uk-grid-small.link-preview
if Array.isArray(link.images) && (link.images.length > 0) 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, a(href= link.url,
data-link-id= link._id, data-link-id= link._id,
onclick= "return dtp.app.visitLink(event);", onclick= "return dtp.app.visitLink(event);",
@ -36,13 +36,13 @@ mixin renderLinkPreview (link, options)
div(class="uk-width-1-1 uk-width-expand@s") div(class="uk-width-1-1 uk-width-expand@s")
.uk-margin-small .uk-margin-small
.uk-text-bold .uk-text-bold.uk-margin-small
a(ref= link.url, a(ref= link.url,
data-link-id= link._id, data-link-id= link._id,
onclick= "return dtp.app.visitLink(event);", onclick= "return dtp.app.visitLink(event);",
).uk-link-reset= link.title ).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 .uk-flex.uk-flex-middle.uk-text-small
if Array.isArray(link.favicons) && (link.favicons.length > 0) if Array.isArray(link.favicons) && (link.favicons.length > 0)
@ -55,6 +55,3 @@ mixin renderLinkPreview (link, options)
.uk-width-expand .uk-width-expand
.uk-margin-small-left .uk-margin-small-left
a(href=`//${link.domain}`, target="_blank", uk-tooltip={ title: link.mediaType })= link.siteName || link.domain 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

16
app/views/user/block-list.pug

@ -1,21 +1,7 @@
extends ../layouts/main extends ../layouts/main
include components/profile-picture
block content 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 section.uk-section.uk-section-default
.uk-container .uk-container
h1 Block List h1 Block List

9
app/views/user/components/profile-picture.pug

@ -1,6 +1,11 @@
mixin renderProfilePicture (user, options) 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({ options = Object.assign({
title: user.displayName || user.username, title: user.displayName || user.username,
iconClass: 'sb-xxsmall', iconClass: 'sb-xxsmall',
@ -8,5 +13,5 @@ mixin renderProfilePicture (user, options)
img( img(
src= iconImageUrl, src= iconImageUrl,
class= `streamray-profile-picture ${options.iconClass}`, class= `profile-picture ${options.iconClass}`,
) )

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

22
app/workers/host-services.js

@ -24,6 +24,8 @@ import { CronJob } from 'cron';
import { createRequire } from 'module'; import { createRequire } from 'module';
const require = createRequire(import.meta.url); // jshint ignore:line const require = createRequire(import.meta.url); // jshint ignore:line
import { Readable } from 'node:stream';
const CRON_TIMEZONE = 'America/New_York'; const CRON_TIMEZONE = 'America/New_York';
class CacheStats { class CacheStats {
@ -308,7 +310,6 @@ class HostCacheTransaction {
this.dtp.manager.resolveTransaction(this, res); this.dtp.manager.resolveTransaction(this, res);
}); });
const { Readable } = await import('stream');
Readable.fromWeb(response.body).pipe(writeStream); Readable.fromWeb(response.body).pipe(writeStream);
} catch (error) { } catch (error) {
this.error = 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'; } static get slug ( ) { return 'hostServices'; }
constructor (rootPath) { constructor (rootPath) {
super(SiteHostServices, rootPath); super(HostServicesWorker, rootPath);
} }
async start (basePath) { async start (basePath) {
@ -462,12 +463,12 @@ class SiteHostServices extends SiteRuntime {
basePath = basePath || process.env.HOST_CACHE_PATH; basePath = basePath || process.env.HOST_CACHE_PATH;
this.log.info('ensuring host-services path exists', { basePath }); this.log.info('ensuring host-services path exists', { basePath });
await fs.promises.mkdir(basePath, { recursive: true }); await fs.promises.mkdir(basePath, { recursive: true });
await this.cleanHostCache(basePath);
this.networkStats = await si.networkStats('*');
this.log.info('starting cache service', { basePath }); this.log.info('starting cache service', { basePath });
this.cacheStats = new CacheStats(); this.cacheStats = new CacheStats();
await this.cleanHostCache(basePath);
this.networkStats = await si.networkStats('*');
/* /*
* Host Cache server socket setup * Host Cache server socket setup
@ -521,11 +522,14 @@ class SiteHostServices extends SiteRuntime {
await this.registerHost(); await this.registerHost();
await this.setHostStatus('active'); 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 ( ) { async shutdown ( ) {
this.log.alert('HostServicesWorker shutting down');
await this.setHostStatus('shutdown'); await this.setHostStatus('shutdown');
await super.shutdown();
} }
async onHostCacheMessage (message, rinfo) { async onHostCacheMessage (message, rinfo) {
@ -797,7 +801,7 @@ class SiteHostServices extends SiteRuntime {
const { fileURLToPath } = await import('node:url'); const { fileURLToPath } = await import('node:url');
const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line 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(); await worker.start();
} catch (error) { } catch (error) {

1
client/css/dtp-site.less

@ -2,4 +2,5 @@
@import "site/button.less"; @import "site/button.less";
@import "site/navbar.less"; @import "site/navbar.less";
@import "site/stage.less"; @import "site/stage.less";
@import "site/link-preview.less";
@import "site/image.less"; @import "site/image.less";

6
client/css/site/image.less

@ -8,3 +8,9 @@ img.profile-navbar {
height: 48px; height: 48px;
border-radius: 5px; border-radius: 5px;
} }
img.profile-picture {
width: 64px;
height: 64px;
border-radius: 5px;
}

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

3
client/css/site/stage.less

@ -26,8 +26,11 @@
color: @chat-sidebar-color; color: @chat-sidebar-color;
border-right: solid 1px @stage-border-color; border-right: solid 1px @stage-border-color;
overflow-y: auto;
overflow-x: hidden;
.sidebar-panel { .sidebar-panel {
box-sizing: border-box;
padding: @stage-panel-padding; padding: @stage-panel-padding;
margin-bottom: 10px; margin-bottom: 10px;
color: inherit; color: inherit;

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

@ -148,3 +148,6 @@ a.uk-button.uk-button-default {
@chat-input-panel-bgcolor: #1a1a1a; @chat-input-panel-bgcolor: #1a1a1a;
@chat-input-panel-color: #e8e8e8; @chat-input-panel-color: #e8e8e8;
@link-container-bgcolor: rgba(0,0,0, 0.3);
@link-container-border-color: darkred;

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

@ -67,3 +67,6 @@
@chat-input-panel-bgcolor: #e8e8e8; @chat-input-panel-bgcolor: #e8e8e8;
@chat-input-panel-color: #1a1a1a; @chat-input-panel-color: #1a1a1a;
@link-container-bgcolor: rgba(0, 0, 0, 0.1);
@link-container-border-color: red;

5
client/img/icon/globe-icon.svg

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="420" stroke="#e8e8e8" height="420" fill="none">
<path stroke-width="18" d="M209,15a195,195 0 1,0 2,0z"/>
<path stroke-width="12" d="m210,15v390m195-195H15M59,90a260,260 0 0,0 302,0 m0,240 a260,260 0 0,0-302,0M195,20a250,250 0 0,0 0,382 m30,0 a250,250 0 0,0 0-382"/>
</svg>

After

Width:  |  Height:  |  Size: 362 B

29
client/js/chat-client.js

@ -14,6 +14,9 @@ import QRCode from 'qrcode';
import Cropper from 'cropperjs'; import Cropper from 'cropperjs';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import dayjsRelativeTime from 'dayjs/plugin/relativeTime.js';
dayjs.extend(dayjsRelativeTime);
import hljs from 'highlight.js'; import hljs from 'highlight.js';
export class ChatApp extends DtpApp { export class ChatApp extends DtpApp {
@ -504,6 +507,32 @@ export class ChatApp extends DtpApp {
window.localStorage.settings = JSON.stringify(this.settings); 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) { async selectImageFile (event) {
event.preventDefault(); event.preventDefault();

3
config/job-queues.js

@ -11,4 +11,7 @@ export default {
'email': { 'email': {
attempts: 3, attempts: 3,
}, },
'links': {
attempts: 2,
},
}; };

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

@ -104,12 +104,14 @@ export default class DtpDisplayEngine {
* html: replaces the whole specified element * html: replaces the whole specified element
*/ */
async replaceElement (displayList, command) { async replaceElement (displayList, command) {
const element = document.querySelector(command.selector); const elements = document.querySelectorAll(command.selector);
if (!element) { if (!elements || (elements.length === 0)) {
this.log.debug('replaceElement', 'displayList.replaceElement has failed to find requested element', { command }); this.log.debug('replaceElement', 'displayList.replaceElement has failed to find requested element', { command });
return; return;
} }
element.outerHTML = command.params.html; for (const element of elements) {
element.outerHTML = command.params.html;
}
} }
/* /*

8
lib/site-runtime.js

@ -173,10 +173,10 @@ export class SiteRuntime {
async connectRedis ( ) { async connectRedis ( ) {
try { try {
const options = { const options = {
host: process.env.DTP_REDIS_HOST, host: process.env.REDIS_HOST,
port: parseInt(process.env.DTP_REDIS_PORT || '6379', 10), port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.DTP_REDIS_PASSWORD, password: process.env.REDIS_PASSWORD,
keyPrefix: process.env.DTP_REDIS_KEY_PREFIX, keyPrefix: process.env.REDIS_KEY_PREFIX,
lazyConnect: false, lazyConnect: false,
}; };
this.log.info('connecting to Redis', { this.log.info('connecting to Redis', {

1
nodemon.json

@ -1,4 +1,5 @@
{ {
"signal": "SIGINT",
"verbose": true, "verbose": true,
"ignore": [ "ignore": [
"dist", "dist",

1
package.json

@ -56,6 +56,7 @@
"qrcode": "^1.5.3", "qrcode": "^1.5.3",
"rate-limiter-flexible": "^5.0.0", "rate-limiter-flexible": "^5.0.0",
"rotating-file-stream": "^3.2.1", "rotating-file-stream": "^3.2.1",
"sharp": "^0.33.3",
"shoetest": "^1.2.2", "shoetest": "^1.2.2",
"slug": "^9.0.0", "slug": "^9.0.0",
"socket.io": "^4.7.5", "socket.io": "^4.7.5",

2
start-local

@ -9,7 +9,9 @@ MINIO_ROOT_PASSWORD="dd039ca4-1bab-4a6c-809b-0bbb43c46def"
export MINIO_ROOT_USER MINIO_ROOT_PASSWORD export MINIO_ROOT_USER MINIO_ROOT_PASSWORD
forever start --killSignal=SIGINT app/workers/host-services.js forever start --killSignal=SIGINT app/workers/host-services.js
forever start --killSignal=SIGINT app/workers/chat-links.js
minio server ./data/minio --address ":9080" --console-address ":9081" minio server ./data/minio --address ":9080" --console-address ":9081"
forever stop app/workers/chat-links.js
forever stop app/workers/host-services.js forever stop app/workers/host-services.js

188
yarn.lock

@ -956,11 +956,131 @@
resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70"
integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== 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": "@fortawesome/fontawesome-free@^6.5.1":
version "6.5.1" version "6.5.1"
resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.1.tgz#55cc8410abf1003b726324661ce5b0d1c10de258" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-free/-/fontawesome-free-6.5.1.tgz#55cc8410abf1003b726324661ce5b0d1c10de258"
integrity sha512-CNy5vSwN3fsUStPRLX7fUYojyuzoEMSXPl7zSLJ8TgtRfjv24LOnOWKT2zYwaHZCJGkdyRnTmstR0P+Ah503Gw== integrity sha512-CNy5vSwN3fsUStPRLX7fUYojyuzoEMSXPl7zSLJ8TgtRfjv24LOnOWKT2zYwaHZCJGkdyRnTmstR0P+Ah503Gw==
"@img/[email protected]":
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/[email protected]":
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/[email protected]":
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/[email protected]":
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/[email protected]":
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/[email protected]":
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/[email protected]":
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/[email protected]":
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/[email protected]":
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/[email protected]":
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/[email protected]":
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/[email protected]":
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/[email protected]":
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/[email protected]":
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/[email protected]":
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/[email protected]":
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/[email protected]":
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/[email protected]":
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/[email protected]":
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": "@ioredis/commands@^1.1.1":
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11" resolved "https://registry.yarnpkg.com/@ioredis/commands/-/commands-1.2.0.tgz#6d61b3097470af1fdbbe622795b8921d42018e11"
@ -2024,16 +2144,32 @@ [email protected]:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25"
integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==
color-name@~1.1.4: color-name@^1.0.0, color-name@~1.1.4:
version "1.1.4" version "1.1.4"
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2" resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA== 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: color-support@^1.1.3:
version "1.1.3" version "1.1.3"
resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2" resolved "https://registry.yarnpkg.com/color-support/-/color-support-1.1.3.tgz#93834379a1cc9a0c61f82f52f0d04322251bd5a2"
integrity sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg== 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: colorette@^2.0.10, colorette@^2.0.14:
version "2.0.20" version "2.0.20"
resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" 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" resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.0.4.tgz#978857442c44749e4206613e37946205826abd80"
integrity sha512-3NdhDuEXnfun/z7x9GOElY49LoqVHoGScmOKwmxhsS8N5Y+Z8KyPPDnaSzqWgYt/ji4mqwfTS34Htrk0zPIXVg== 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: dev-ip@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/dev-ip/-/dev-ip-1.0.1.tgz#a76a3ed1855be7a012bb8ac16cb80f3c00dc28f0" 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" call-bind "^1.0.2"
get-intrinsic "^1.2.1" 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: is-bigint@^1.0.1:
version "1.0.4" version "1.0.4"
resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" 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" resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4"
integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== 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" version "7.6.0"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.0.tgz#1a46a4db4bffcccd97b743b5005c8325f23d4e2d"
integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg== integrity sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==
@ -5160,6 +5306,35 @@ shallow-clone@^3.0.0:
dependencies: dependencies:
kind-of "^6.0.2" 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: shebang-command@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
@ -5194,6 +5369,13 @@ [email protected]:
resolved "https://registry.yarnpkg.com/sift/-/sift-16.0.1.tgz#e9c2ccc72191585008cf3e36fc447b2d2633a053" resolved "https://registry.yarnpkg.com/sift/-/sift-16.0.1.tgz#e9c2ccc72191585008cf3e36fc447b2d2633a053"
integrity sha512-Wv6BjQ5zbhW7VFefWusVP33T/EM0vYikCaQ2qR8yULbsilAT8/wQaXvuQ3ptGLpoKx+lihJE3y2UTgKDyyNHZQ== 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: simple-update-notifier@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz#d70b92bdab7d6d90dfd73931195a30b6e3d7cebb" 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: dependencies:
punycode "^2.3.1" 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" version "2.6.2"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae"
integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==

Loading…
Cancel
Save