From 390b5ee0bbce42b8b7492635c90fe3bbd263382b Mon Sep 17 00:00:00 2001 From: rob Date: Sun, 14 Apr 2024 17:51:26 -0400 Subject: [PATCH] low-level image and host/platform services System: - config updates - added HostCacheService - added LinkService - added SiteHostServices worker - start-local now starts/stops host-services worker - added ImageController Models: - Added NetHost and NetHostStatus Dependencies: - added NPM cron, diskusage-ng, jsdom, systeminformation, user-agents --- app/controllers/image.js | 184 ++++ app/models/lib/host-stats.js | 67 ++ app/models/net-host-stats.js | 31 + app/models/net-host.js | 53 ++ app/services/chat.js | 3 + app/services/host-cache.js | 140 +++ app/services/link.js | 293 +++++++ app/services/text.js | 128 +++ .../link/components/preview-standalone.pug | 2 + app/views/link/components/preview.pug | 60 ++ app/views/link/timeline.pug | 64 ++ app/workers/host-services.js | 808 ++++++++++++++++++ dtp-chat.js | 6 +- lib/site-lib.js | 3 +- lib/site-runtime.js | 3 +- package.json | 5 + start-local | 6 +- yarn.lock | 269 +++++- 18 files changed, 2114 insertions(+), 11 deletions(-) create mode 100644 app/controllers/image.js create mode 100644 app/models/lib/host-stats.js create mode 100644 app/models/net-host-stats.js create mode 100644 app/models/net-host.js create mode 100644 app/services/host-cache.js create mode 100644 app/services/link.js create mode 100644 app/views/link/components/preview-standalone.pug create mode 100644 app/views/link/components/preview.pug create mode 100644 app/views/link/timeline.pug create mode 100644 app/workers/host-services.js diff --git a/app/controllers/image.js b/app/controllers/image.js new file mode 100644 index 0000000..1c1c2ed --- /dev/null +++ b/app/controllers/image.js @@ -0,0 +1,184 @@ +// page.js +// Copyright (C) 2022,2023 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import fs from 'node:fs'; +import express from 'express'; +import mongoose from 'mongoose'; + +import { SiteController, SiteError } from '../../lib/site-lib.js'; + +export default class ImageController extends SiteController { + + static get name ( ) { return 'ImageController'; } + static get slug ( ) { return 'image'; } + + constructor (dtp) { + super(dtp, ImageController); + } + + async start ( ) { + const { dtp } = this; + const { limiter: limiterService } = dtp.services; + + const router = express.Router(); + dtp.app.use('/image', router); + + const imageUpload = this.createMulter(ImageController.slug, { + limits: { + fileSize: 1024 * 1000 * 5, + }, + }); + + router.use(async (req, res, next) => { + res.locals.currentView = 'image'; + return next(); + }); + + router.param('imageId', this.populateImage.bind(this)); + + router.post('/', + limiterService.create(limiterService.config.image.postCreateImage), + imageUpload.single('file'), + this.postCreateImage.bind(this), + ); + + router.get('/proxy', + limiterService.create(limiterService.config.image.getProxyImage), + this.getProxyImage.bind(this), + ); + + router.get('/:imageId', + limiterService.create(limiterService.config.image.getImage), + this.getHostCacheImage.bind(this), + // this.getImage.bind(this), + ); + + router.delete('/:imageId', + limiterService.create(limiterService.config.image.deleteImage), + this.deleteImage.bind(this), + ); + } + + async populateImage (req, res, next, imageId) { + const { image: imageService } = this.dtp.services; + try { + res.locals.imageId = mongoose.Types.ObjectId(imageId); + res.locals.image = await imageService.getImageById(res.locals.imageId); + if (!res.locals.image) { + throw new SiteError(404, 'Image not found'); + } + return next(); + } catch (error) { + this.log.error('failed to populate image', { error }); + return next(error); + } + } + + async postCreateImage (req, res, next) { + const { image: imageService } = this.dtp.services; + try { + res.locals.image = await imageService.create(req.user, req.body, req.file); + res.status(200).json({ + success: true, + imageId: res.locals.image._id.toString(), + }); + } catch (error) { + this.log.error('failed to create image', { error }); + return next(error); + } + } + + async getProxyImage (req, res) { + const { hostCache: hostCacheService } = this.dtp.services; + try { + if (!req.query || !req.query.url) { + throw new SiteError(400, 'Missing url parameter'); + } + const fileInfo = await hostCacheService.fetchUrl(req.query.url); + const stream = fs.createReadStream(fileInfo.file.path); + + res.setHeader('Cache-Control', 'public, maxage=86400, s-maxage=86400, immutable'); + res.setHeader('Content-Type', fileInfo.file.meta.contentType); + res.setHeader('Content-Length', fileInfo.file.stats.size); + + res.status(200); + stream.pipe(res); + } catch (error) { + this.log.error('failed to fetch web resource', { url: req.query.url, error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + + async getHostCacheImage (req, res) { + const { hostCache: hostCacheService } = this.dtp.services; + const { image } = res.locals; + try { + const fileInfo = await hostCacheService.getFile(image.file.bucket, image.file.key); + const stream = fs.createReadStream(fileInfo.file.path); + + res.setHeader('Cache-Control', 'public, maxage=86400, s-maxage=86400, immutable'); + res.setHeader('Content-Type', image.type); + res.setHeader('Content-Length', fileInfo.file.stats.size); + + res.status(200); + stream.pipe(res); + } catch (error) { + this.log.error('failed to fetch image', { image, error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + + async getImage (req, res) { + const { minio: minioService } = this.dtp.services; + try { + const stream = await minioService.openDownloadStream({ + bucket: res.locals.image.file.bucket, + key: res.locals.image.file.key + }); + + res.setHeader('Cache-Control', 'public, maxage=86400, s-maxage=86400, immutable'); + res.setHeader('Content-Type', res.locals.image.type); + res.setHeader('Content-Length', res.locals.image.size); + + res.status(200); + stream.pipe(res); + } catch (error) { + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + + async deleteImage (req, res) { + const { image: imageService } = this.dtp.services; + try { + if (!req.user) { + throw new SiteError(403, 'Must be logged in to delete images you own'); + } + if (!req.user._id.equals(res.locals.image.owner._id)) { + throw new SiteError(403, 'You are not the owner of the requested image'); + } + + this.log.debug('deleting image', { imageId: res.locals.image._id }); + await imageService.deleteImage(res.locals.image); + + res.status(200).json({ success: true }); + } catch (error) { + this.log.error('failed to delete image', { error }); + res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } +} \ No newline at end of file diff --git a/app/models/lib/host-stats.js b/app/models/lib/host-stats.js new file mode 100644 index 0000000..cd3320e --- /dev/null +++ b/app/models/lib/host-stats.js @@ -0,0 +1,67 @@ +// net-host-stats.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import mongoose from 'mongoose'; +const Schema = mongoose.Schema; + +const CpuInfoSchema = new Schema({ + user: { type: Number }, + nice: { type: Number }, + sys: { type: Number }, + idle: { type: Number }, + irq: { type: Number }, +}); + +const MemoryInfoSchema = new Schema({ + total: { type: Number }, + free: { type: Number }, + used: { type: Number }, + active: { type: Number }, + available: { type: Number }, + buffers: { type: Number }, + cached: { type: Number }, + slab: { type: Number }, + buffcache: { type: Number }, + swaptotal: { type: Number }, + swapused: { type: Number }, + swapfree: { type: Number }, +}); + +const CacheStatsSchema = new Schema({ + itemCount: { type: Number, required: true }, + dataSize: { type: Number, required: true }, + expireCount: { type: Number, required: true }, + expireDataSize: { type: Number, required: true }, + hitCount: { type: Number, required: true }, + hitDataSize: { type: Number, required: true }, + missCount: { type: Number, required: true }, + missDataSize: { type: Number, required: true }, +}); + +const DiskUsageSchema = new Schema({ + total: { type: Number }, + used: { type: Number }, + available: { type: Number }, + pctUsed: { type: Number }, +}); + +const NetworkInterfaceStatsSchema = new Schema({ + iface: { type: String, required: true }, + rxPerSecond: { type: Number }, + rxDropped: { type: Number }, + rxErrors: { type: Number }, + txPerSecond: { type: Number }, + txDropped: { type: Number }, + txErrors: { type: Number }, +}); + +export { + CpuInfoSchema, + MemoryInfoSchema, + CacheStatsSchema, + DiskUsageSchema, + NetworkInterfaceStatsSchema, +}; \ No newline at end of file diff --git a/app/models/net-host-stats.js b/app/models/net-host-stats.js new file mode 100644 index 0000000..cbb462d --- /dev/null +++ b/app/models/net-host-stats.js @@ -0,0 +1,31 @@ +// net-host-stats.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import mongoose from 'mongoose'; +const Schema = mongoose.Schema; + +import { + CpuInfoSchema, + MemoryInfoSchema, + CacheStatsSchema, + DiskUsageSchema, + NetworkInterfaceStatsSchema, +} from './lib/host-stats.js'; + +const NetHostStatsSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: 1, expires: '7d' }, + host: { type: Schema.ObjectId, required: true, index: 1, ref: 'NetHost' }, + load: { type: [Number], required: true }, + cpus: { type: [CpuInfoSchema], required: true }, + memory: { type: MemoryInfoSchema, required: true }, + cache: { type: CacheStatsSchema, required: true }, + disk: { + cache: { type: DiskUsageSchema, required: true }, + }, + network: { type: [NetworkInterfaceStatsSchema], required: true }, +}); + +export default mongoose.model('NetHostStats', NetHostStatsSchema); \ No newline at end of file diff --git a/app/models/net-host.js b/app/models/net-host.js new file mode 100644 index 0000000..f34a677 --- /dev/null +++ b/app/models/net-host.js @@ -0,0 +1,53 @@ +// net-host.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import mongoose from 'mongoose'; +const Schema = mongoose.Schema; + +const NETHOST_STATUS_LIST = [ + 'starting', + 'active', + 'shutdown', + 'inactive', + 'crashed', +]; + +const CpuInfoSchema = new Schema({ + model: { type: String }, + speed: { type: Number }, +}); + +const NetworkInterfaceSchema = new Schema({ + iface: { type: String, required: true }, + speed: { type: Number }, + mac: { type: String }, + ip4: { type: String }, + ip4subnet: { type: String }, + ip6: { type: String }, + ip6subnet: { type: String }, + flags: { + internal: { type: Boolean }, + virtual: { type: Boolean }, + }, +}); + +const NetHostSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: 1 }, + updated: { type: Date }, + status: { type: String, enum: NETHOST_STATUS_LIST, required: true, index: 1 }, + hostname: { type: String, required: true, index: 1 }, + arch: { type: String, required: true }, + cpus: { type: [CpuInfoSchema], required: true }, + totalmem: { type: Number, required: true }, + freemem: { type: Number, required: true }, + node: { type: String, required: true }, + platform: { type: String, required: true }, + release: { type: String, required: true }, + version: { type: String, required: true }, + network: { type: [NetworkInterfaceSchema] }, +}); + +export default mongoose.model('NetHost', NetHostSchema); \ No newline at end of file diff --git a/app/services/chat.js b/app/services/chat.js index 60f01c5..39c7554 100644 --- a/app/services/chat.js +++ b/app/services/chat.js @@ -210,6 +210,9 @@ export default class ChatService extends SiteService { message.author = author._id; message.content = textService.filter(messageDefinition.content); + message.mentions = await textService.findMentions(message.content); + message.hashtags = await textService.findHashtags(message.content); + await message.save(); const messageObj = message.toObject(); diff --git a/app/services/host-cache.js b/app/services/host-cache.js new file mode 100644 index 0000000..5ab83d7 --- /dev/null +++ b/app/services/host-cache.js @@ -0,0 +1,140 @@ +// host-cache.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import dgram from 'node:dgram'; +import { v4 as uuidv4 } from 'uuid'; + +import { SiteService, SiteError } from '../../lib/site-lib.js'; + +export default class HostCacheService extends SiteService { + + static get name ( ) { return 'HostCacheService'; } + static get slug ( ) { return 'hostCache'; } + + constructor (dtp) { + super(dtp, HostCacheService); + this.transactions = { }; + } + + async start ( ) { + await super.start(); + + this.log.info('creating UDP host-cache socket'); + this.hostCache = dgram.createSocket('udp4', this.onMessage.bind(this)); + this.hostCache.on('error', this.onError.bind(this)); + + this.log.info('connecting UDP host-cache socket'); + // this.hostCache.bind(0, '127.0.0.1'); + this.hostCache.connect( + parseInt(process.env.HOST_CACHE_PORT || '8000', 10), + process.env.HOST_CACHE_HOST || '127.0.0.1', + ); + } + + async stop ( ) { + if (this.hostCache) { + this.log.info('disconnecting UDP host-cache socket'); + this.hostCache.disconnect(); + delete this.hostCache; + } + } + + async getFile (bucket, key) { + return new Promise((resolve, reject) => { + const transaction = { tid: uuidv4(), bucket, key, resolve, reject }; + this.transactions[transaction.tid] = transaction; + const message = JSON.stringify({ + tid: transaction.tid, + cmd: 'getFile', + params: { bucket, key }, + }); + this.hostCache.send(message); + }); + } + + async fetchUrl (url) { + return new Promise((resolve, reject) => { + const transaction = { tid: uuidv4(), url, resolve, reject }; + this.transactions[transaction.tid] = transaction; + const message = JSON.stringify({ + tid: transaction.tid, + cmd: 'fetchUrl', + params: { url }, + }); + this.hostCache.send(message); + }); + } + + async onMessage (message, rinfo) { + message = message.toString('utf8'); + message = JSON.parse(message); + switch (message.res.cmd) { + case 'getFile': + return this.onGetFile(message, rinfo); + case 'fetchUrl': + return this.onFetchUrl(message, rinfo); + } + } + + async onGetFile (message) { + const transaction = this.transactions[message.tid]; + if (!transaction) { + this.log.error('getFile response received with no matching transaction', { tid: message.tid }); + return; + } + + delete this.transactions[message.tid]; + + if (!message.res.success) { + transaction.reject(new SiteError(message.res.statusCode, message.res.message)); + return; + } + + transaction.resolve({ + success: message.res.success, + message: message.res.message, + file: message.res.file, + flags: message.flags, + duration: message.duration, + }); + } + + async onFetchUrl (message) { + const transaction = this.transactions[message.tid]; + if (!transaction) { + this.log.error('fetchUrl response received with no matching transaction', { tid: message.tid }); + return; + } + + delete this.transactions[message.tid]; + + if (!message.res.success) { + transaction.reject(new SiteError(message.res.statusCode || 500, message.res.message)); + return; + } + + transaction.resolve({ + success: message.res.success, + message: message.res.message, + file: message.res.file, + flags: message.flags, + duration: message.duration, + }); + } + + async onError (error) { + this.log.error('onError', { error }); + if ((error.errno !== -111) || (error.code !== 'ECONNREFUSED')) { + return; + } + for (const key of this.transactions) { + this.log.alert('destroying host cache transaction', { key }); + const transaction = this.transactions[key]; + transaction.reject(error); + delete this.transactions[key]; + } + } +} \ No newline at end of file diff --git a/app/services/link.js b/app/services/link.js new file mode 100644 index 0000000..4d8dd95 --- /dev/null +++ b/app/services/link.js @@ -0,0 +1,293 @@ +// link.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +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'; + +export default class LinkService extends SiteService { + + static get name ( ) { return 'LinkService'; } + static get slug ( ) { return 'link'; } + constructor (dtp) { + super(dtp, LinkService); + } + + async start ( ) { + await super.start(); + + const userAgent = new UserAgent(); + this.userAgent = userAgent.toString(); + + this.templates = { + linkPreview: this.loadViewTemplate('link/components/preview-standalone.pug'), + }; + + const { user: userService } = this.dtp.services; + this.populateLink = [ + { + path: 'submittedBy', + select: userService.USER_SELECT, + }, + ]; + } + + async getRecent (pagination) { + const search = { }; + const links = await Link + .find(search) + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateLink) + .lean(); + const totalLinkCount = await Link.estimatedDocumentCount(); + return { links, totalLinkCount }; + } + + async getById (linkId) { + return Link + .findOne({ _id: linkId }) + .populate(this.populateLink) + .lean(); + } + + async recordVisit (link) { + await Link.updateOne( + { _id: link._id }, + { + $inc: { 'stats.visitCount': 1 }, + }, + ); + } + + async isDomainBlocked (domain) { + const { domain: domainService } = this.dtp.services; + return domainService.isDomainBlacklisted(domain); + } + + async ingest (author, url) { + const NOW = new Date(); + const domain = new URL(url).hostname.toLowerCase(); + if (domain.endsWith('.cn')) { + throw new SiteError(403, 'Linking to Chinese websites is prohibited.'); + } + if (domain.endsWith('.il')) { + throw new SiteError(403, 'Linking to websites in Israel is prohibited.'); + } + + if (await this.isDomainBlocked(domain)) { + this.log.alert('detected blocked domain in shared link', { + author: { _id: author._id, username: author.username }, + domain, url, + }); + throw new SiteError(403, `All links/URLs pointing to ${domain} are prohibited.`); + } + + /* + * An upsert is used to create a document if one doesn't exist. The domain + * and url are set on insert, and lastShared is always set so it will be + * current. + * + * submittedBy is an array that holds the User._id of each member that + * submitted the link. This enables their Link History view, which becomes + * it's own feed. + */ + const link = await Link.findOneAndUpdate( + { domain, url }, + { + $setOnInsert: { + created: NOW, + domain, url, + }, + $addToSet: { submittedBy: author._id }, + $set: { lastShared: NOW }, + }, + { 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', { + submitterId: author._id, + linkId: link._id, + }); + // } + + return link; + } + + async generatePagePreview (url, options) { + const NOW = new Date(); + const linkUrlObj = new URL(url); + this.log.debug('generating page preview', { url, linkUrlObj }); + + const { /*window,*/ document } = await this.loadUrlAsDOM(url, options); + const preview = { + fetched: NOW, + domain: linkUrlObj.hostname, + tags: [ ], + images: [ ], + videos: [ ], + audios: [ ], + favicons: [ ], + }; + + function getMetaContent (selector) { + const element = document.querySelector(selector); + if (!element) { + return; + } + return element.getAttribute('content'); + } + + function getElementContent (selector) { + const element = document.querySelector(selector); + if (!element) { + return; + } + return element.textContent; + } + + function getLinkHref (selector) { + const element = document.querySelector(selector); + if (!element) { + return; + } + return element.getAttribute('href'); + } + + preview.mediaType = getMetaContent('head meta[property="og:type"]'); + preview.title = + getMetaContent('head meta[property="og:title"]') || + getElementContent(`head title`); + preview.siteName = + getMetaContent('head meta[property="og:site_name') || + getElementContent(`head title`); + + preview.description = + getMetaContent('head meta[property="og:description"]') || + getMetaContent('head meta[name="description"]'); + + let href = + getMetaContent('head meta[property="og:image:secure_url') || + getMetaContent('head meta[property="og:image') || + getMetaContent('head meta[name="twitter:image:src"]'); + if (href) { + preview.images.push(href); + } + + href = getLinkHref('head link[rel="shortcut icon"]'); + if (href) { + preview.favicons.push(href); + } + + const keywords = getMetaContent('head meta[name="keywords"]'); + if (keywords) { + preview.tags = keywords.split(',').map((keyword) => keyword.trim()); + } + + const videoTags = document.querySelectorAll('head meta[property="og:video:tag"]'); + if (videoTags) { + videoTags.forEach((tag) => { + tag = tag.getAttribute('content'); + if (!tag) { + return; + } + tag = tag.trim().toLowerCase(); + if (!tag.length) { + return; + } + preview.tags.push(tag); + }); + } + + const icons = document.querySelectorAll('head link[rel="icon"]'); + if (icons) { + icons.forEach((icon) => { + preview.favicons.push(icon.getAttribute('href')); + }); + } + + //TODO: oEmbed spec allows for JSON and XML. May need to implement an XML + // reader for `head link[rel="alternate"][type="text/xml+oembed"]` + + preview.oembed = { }; + preview.oembed.href = getLinkHref('head link[type="application/json+oembed"]'); + if (preview.oembed.href) { + this.log.info('fetching oEmbed data for url', { url, href: preview.oembed.href }); + const json = await this.fetchOembedJson(preview.oembed.href); + + preview.oembed.version = json.version; + preview.oembed.type = json.type; + if (json.cache_age) { + preview.oembed.cache_age = json.cache_age; + } + preview.oembed.title = json.title; + preview.oembed.provider_name = json.provider_name; + preview.oembed.provider_url = json.provider_url; + preview.oembed.author_name = json.author_name; + preview.oembed.author_url = json.author_url; + preview.oembed.thumbnail_url = json.thumbnail_url; + preview.oembed.thumbnail_width = json.thumbnail_width; + preview.oembed.thumbnail_height = json.thumbnail_height; + + switch (json.type) { + case 'video': + preview.oembed.html = json.html; + preview.oembed.width = json.width; + preview.oembed.height = json.height; + break; + + case 'photo': + preview.oembed.url = json.url; + preview.oembed.width = json.width; + preview.oembed.height = json.height; + break; + } + } + + return preview; + } + + async fetchOembedJson (url) { + const response = await fetch(url); + const json = await response.json(); + return json; + } + + async loadUrlAsDOM (url, options) { + options = Object.assign({ + userAgent: this.userAgent, + acceptLanguage: 'en-US', + }, options); + + const response = await fetch(url, { + method: "GET", + headers: { + "User-Agent": options.userAgent, + "Accept-Language": options.acceptLanguage, + }, + }); + + const html = await response.text(); + const { window } = new JSDOM(html); + return { window, document: window.document }; + } + + async renderPreview (viewModel) { + return this.renderTemplate(this.templates.linkPreview, viewModel); + } +} \ No newline at end of file diff --git a/app/services/text.js b/app/services/text.js index 612f569..51843a6 100644 --- a/app/services/text.js +++ b/app/services/text.js @@ -5,6 +5,9 @@ 'use strict'; import mongoose from 'mongoose'; + +const User = mongoose.model('User'); +const Link = mongoose.model('Link'); const ChatFilter = mongoose.model('ChatFilter'); import striptags from 'striptags'; @@ -81,4 +84,129 @@ export default class TextService extends SiteService { this.chatFilters = this.chatFilters.map((filter) => filter.filter); this.log.debug('loading chat filters', { count: this.chatFilters.length }); } + + /** + * Scans input text for username mentions (`@username`) and resolves those + * names to an array of User IDs. + * @param {String} content The text content to be scanned for mentions + * @returns Array of user ID values for valid username(s) mentioned. + */ + async findMentions (content) { + let usernames = content.match(/\B@[a-z0-9_-]+/gi); + if (!Array.isArray(usernames) || (usernames.length === 0)) { + return [ ]; + } + + /* + * Remove @, lowercase, and remove duplicates. + */ + usernames = usernames + .map((username) => username.trim().slice(1).toLowerCase()) + .filter((username, index, self) => { return self.indexOf(username) === index; }); + + this.log.debug('findMentions found usernames', { usernames }); + const mentions = await User + .find({ username_lc: { $in: usernames } }) + .select('_id') + .lean(); + + return mentions; + } + + findHashtags (content) { + let tags = content.match(/\B\#[a-z0-9_-]+/gi); + if (!Array.isArray(tags) || (tags.length === 0)) { + return [ ]; + } + tags = tags.map((tag) => tag.trim().slice(1).toLowerCase()); + this.log.debug('hashtags extracted', { tags }); + return tags; + } + + /** + * Scans input text for links/URLs, performs some checks, and schedules them + * for ingest using a worker. The worker will emit socket.io messages to + * populate the UI with resolved link previews. + * + * Uses https://github.com/StevenBlack/hosts/tree/master/alternates/porn to + * eliminate blocked domains, which are stored in Redis. + * + * @param {User} author the author of the status being scanned + * @param {*} content the content of the status being scanned + * @returns array of links detected or an empty array + */ + async findLinks (author, content) { + const NOW = new Date(); + const { link: linkService } = this.dtp.services; + + if (!author.permissions.canShareLinks) { + throw new SiteError(403, 'You are not permitted to share links in your posts.'); + } + + var urlRegex = /(((https?:\/\/)|(www\.))[^\s]+)/g; + const urls = content.match(urlRegex); + if (!Array.isArray(urls) || (urls.length === 0)) { + this.log.debug('post content contains no URLs/links'); + return [ ]; + } + + const links = [ ]; + for await (let url of urls) { + const domain = new URL(url).hostname.toLowerCase(); + if (domain.endsWith('.cn')) { + throw new SiteError(403, 'Linking to Chinese websites is prohibited.'); + } + if (domain.endsWith('.il')) { + throw new SiteError(403, 'Linking to websites in Israel is prohibited.'); + } + + if (await linkService.isDomainBlocked(domain)) { + this.log.alert('detected blocked domain in shared link', { + author: { _id: author._id, username: author.username }, + domain, url, + }); + throw new SiteError(403, `All links/URLs pointing to ${domain} are prohibited.`); + } + + /* + * An upsert is used to create a document if one doesn't exist. The domain + * and url are set on insert, and lastShared is always set so it will be + * current. + * + * submittedBy is an array that holds the User._id of each member that + * submitted the link. This enables their Link History view, which becomes + * it's own feed. + */ + const link = await Link.findOneAndUpdate( + { domain, url }, + { + $setOnInsert: { + created: NOW, + domain, url, + }, + $addToSet: { submittedBy: author._id }, + $set: { lastShared: NOW }, + }, + { 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', { + submitterId: author._id, + linkId: link._id, + }); + // } + + this.log.debug('adding detected link', { domain, url, link: link._id }); + links.push(link._id); + } + + return links; + } } \ No newline at end of file diff --git a/app/views/link/components/preview-standalone.pug b/app/views/link/components/preview-standalone.pug new file mode 100644 index 0000000..04622d7 --- /dev/null +++ b/app/views/link/components/preview-standalone.pug @@ -0,0 +1,2 @@ +include preview ++renderLinkPreview(link) \ No newline at end of file diff --git a/app/views/link/components/preview.pug b/app/views/link/components/preview.pug new file mode 100644 index 0000000..344293c --- /dev/null +++ b/app/views/link/components/preview.pug @@ -0,0 +1,60 @@ +mixin renderLinkPreview (link, options) + - + options = Object.assign({ layout: 'responsive' }, options); + function proxyUrl (url) { + return `/image/proxy?url=${encodeURIComponent(url)}`; + } + + div(data-link-id= link._id).link-container + case link.mediaType + when 'video.other' + .link-preview + if !link.oembed + pre= JSON.stringify(link, null, 2) + else + if link.oembed.html + div!= link.oembed.html + else + .uk-margin-small + a(href= link.url, target="_blank", uk-tooltip={ title: `Watch ${link.title} on ${link.oembed.provider_name}` }).uk-link-reset + img(src= link.images[0]) + .uk-margin-small + .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)) + + 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) + a(href= link.url, + data-link-id= link._id, + onclick= "return dtp.app.visitLink(event);", + ).uk-link-reset + img(src= proxyUrl(link.images[0])).link-thumbnail.uk-border-rounded + + div(class="uk-width-1-1 uk-width-expand@s") + .uk-margin-small + .uk-text-bold + 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)) + + .uk-flex.uk-flex-middle.uk-text-small + if Array.isArray(link.favicons) && (link.favicons.length > 0) + .uk-width-auto + img( + src= proxyUrl(link.favicons[0]), + style="height: 1em; width: auto;", + onerror=`this.src = '/img/icon/globe-icon.svg';`, + ) + .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 diff --git a/app/views/link/timeline.pug b/app/views/link/timeline.pug new file mode 100644 index 0000000..5afb88e --- /dev/null +++ b/app/views/link/timeline.pug @@ -0,0 +1,64 @@ +extends ../layouts/main +block viewcss + link(rel='stylesheet', href=`/highlight.js/styles/obsidian.css?v=${pkg.version}`) +block content + + include ../member/components/status + include ../components/pagination-bar + include ../user/components/user-icon + + include components/preview + + section.uk-section.uk-section-default.uk-section-small + .uk-container + + h1 + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-auto + +renderBackButton() + .uk-width-expand + span Link Timeline + + div(uk-grid) + div(class="uk-width-1-1 uk-width-1-3@s") + .uk-margin + +renderLinkPreview(link, { layout: 'sidebar' }) + + .uk-card.uk-card-secondary.uk-card-small.uk-border-rounded + .uk-card-body + .uk-margin-small + .uk-text-small Link URL + .uk-text-bold.uk-text-break= link.url + + .uk-margin-small + .uk-text-small Site + .uk-text-bold= link.siteName || link.domain + + .uk-margin-small + div(uk-grid).uk-grid-small + .uk-width-expand + .uk-text-small Last shared + .uk-text-bold= moment(link.lastShared).format('MMM DD, YYYY') + .uk-width-auto + .uk-text-small Shares + .uk-text-bold= formatCount(link.stats.shareCount) + .uk-width-auto + .uk-text-small Visits + .uk-text-bold= formatCount(link.stats.visitCount) + + .uk-margin-small + .uk-text-small Submitted by: + div(uk-grid).uk-grid-small + each submitter in link.submittedBy + .uk-width-auto + a(href=`/member/${submitter.username}`, uk-tooltip={ title: submitter.displayName || submitter.username }) + +renderUserIcon(submitter) + + div(class="uk-width-1-1 uk-width-2-3@s") + if Array.isArray(timeline.statuses) && (timeline.statuses.length > 0) + each status in timeline.statuses + +renderStatus(status, { statusToken, commentToken }) + else + .uk-text-center #{site.name} has no remaining posts sharing this link. + + +renderPaginationBar(timelineUrl, timeline.totalStatusCount) \ No newline at end of file diff --git a/app/workers/host-services.js b/app/workers/host-services.js new file mode 100644 index 0000000..f015ca3 --- /dev/null +++ b/app/workers/host-services.js @@ -0,0 +1,808 @@ +// host-services.js +// Copyright (C) 2024 DTP Technologies, LLC +// All Rights Reserved + +'use strict'; + +import 'dotenv/config'; + +import os from 'node:os'; +import path, { dirname } from 'path'; +import fs from 'node:fs'; + +import diskusage from 'diskusage-ng'; +import sysinfo from 'systeminformation'; + +import si from 'systeminformation'; + +import dgram from 'node:dgram'; + +import { SiteRuntime, SiteAsync, SiteError } from '../../lib/site-lib.js'; + +import { CronJob } from 'cron'; + +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); // jshint ignore:line + +const CRON_TIMEZONE = 'America/New_York'; + +class CacheStats { + + constructor ( ) { + this.itemCount = 0; + this.dataSize = 0; + + this.expireCount = 0; + this.expireDataSize = 0; + + this.hitCount = 0; + this.hitDataSize = 0; + + this.missCount = 0; + this.missDataSize = 0; + } + + add (size) { + this.itemCount += 1; + this.dataSize += (size / 1024.0); + } + + remove (size) { + this.itemCount -= 1; + this.dataSize -= size / 1024.0; + } + + hit (size) { + this.hitCount += 1; + this.hitDataSize += (size / 1024.0); + } + + miss (size) { + this.missCount += 1; + this.missDataSize += (size / 1024.0); + } + + expire (size) { + this.expireCount += 1; + this.expireDataSize += size; + } + + report ( ) { + const report = { + itemCount: this.itemCount, + dataSize: this.dataSize, + + expireCount: this.expireCount, + expireDataSize: this.expireDataSize, + + hitCount: this.hitCount, + hitDataSize: this.hitDataSize, + + missCount: this.missCount, + missDataSize: this.missDataSize, + }; + + this.resetCounters(); + + return report; + } + + resetCounters ( ) { + this.expireCount = 0; + this.expireDataSize = 0; + + this.hitCount = 0; + this.hitDataSize = 0; + + this.missCount = 0; + this.missDataSize = 0; + } +} + +class HostCacheTransaction { + + get tid ( ) { return this.message.tid; } + get cmd ( ) { return this.message.cmd; } + get params ( ) { return this.message.params; } + + get address ( ) { return this.rinfo.address; } + get port ( ) { return this.rinfo.port; } + get size ( ) { return this.rinfo.size; } + + constructor (dtp, message, rinfo) { + this.dtp = dtp; + this.created = Date.now(); // timestamp, not Date instance + + this.message = message; + this.rinfo = rinfo; + + this.flags = { + isFetched: false, + isCached: false, + isResolved: false, + isError: false, + }; + } + + async getFile ( ) { + const { minio: minioService } = this.dtp.services; + const filePath = path.join( + process.env.HOST_CACHE_PATH, + this.params.bucket, + this.params.key, + ); + const res = { + cmd: this.cmd, + success: true, + message: undefined, + file: { + stats: undefined, + path: undefined, + }, + }; + + try { + res.file.stats = await fs.promises.stat(filePath); + if (!res.file.stats.isFile()) { + throw new SiteError(500, 'invalid object requested'); + } + res.file.path = filePath; + this.flags.isCached = true; + this.dtp.cacheStats.hit(res.file.stats.size); + return this.dtp.manager.resolveTransaction(this, res); + } catch (error) { + if (error.code !== 'ENOENT') { + this.dtp.log.error('failed to stat requested object', { transaction: this, error }); + res.success = false; + res.statusCode = 500; + res.message = error.message; + this.error = error; + this.flags.isError = true; + return this.dtp.manager.resolveTransaction(this, res); + } + // fall through to MinIO fetch since file not found in cache + } + + try { + await minioService.downloadFile({ + bucket: this.params.bucket, + key: this.params.key, + filePath, + }); + res.file.path = filePath; + res.file.stats = await fs.promises.stat(filePath); + if (!res.file.stats.isFile()) { + throw new SiteError(500, 'invalid object requested'); + } + this.flags.isFetched = true; + this.dtp.cacheStats.add(res.file.stats.size); + this.dtp.cacheStats.miss(res.file.stats.size); + return this.dtp.manager.resolveTransaction(this, res); + } catch (error) { + if (error.code !== 'NotFound') { + this.dtp.log.error('failed to fetch requested object from MinIO', { transaction: this, error }); + res.success = false; + res.statusCode = 500; + res.message = error.message; + this.error = error; + this.flags.isError = true; + return this.dtp.manager.resolveTransaction(this, res); + } + } + + res.success = false; + res.statusCode = 404; + res.message = 'Not Found'; + this.error = new SiteError(404, 'Not Found'); + this.flags.isError = true; + return this.dtp.manager.resolveTransaction(this, res); + } + + /** + * Will fetch the contents of a URL to local storage with a separate JSON + * metadata file to describe the data file. + */ + async fetchUrl ( ) { + const { crypto: cryptoService } = this.dtp.services; + + const res = { + cmd: this.cmd, + success: true, + message: undefined, + file: { + stats: undefined, + path: undefined, + }, + }; + + const urlHash = cryptoService.createHash(this.params.url, 'sha256'); + const basePath = path.join(process.env.HOST_CACHE_PATH, 'web-resource', urlHash.slice(0, 4)); + await fs.promises.mkdir(basePath, { recursive: true }); + + const resourceFilename = path.join(basePath, `${urlHash}.dat`); + const resourceMetaFilename = path.join(basePath, `${urlHash}.json`); + + /* + * Try first to read from local storage. If not found, proceed to fetch + * and store logic below. + */ + try { + res.file.stats = await fs.promises.stat(resourceFilename); + if (!res.file.stats.isFile()) { + throw new SiteError(500, 'invalid object requested'); + } + res.file.path = resourceFilename; + res.file.meta = require(resourceMetaFilename); + this.flags.isCached = true; + this.dtp.cacheStats.hit(res.file.stats.size); + return this.dtp.manager.resolveTransaction(this, res); + } catch (error) { + if (error.code !== 'ENOENT') { + this.dtp.log.error('failed to stat requested object', { transaction: this, error }); + res.success = false; + res.statusCode = 500; + res.message = error.message; + this.error = error; + this.flags.isError = true; + return this.dtp.manager.resolveTransaction(this, res); + } + // fall through to HTTP fetch since file not found in cache + } + + /* + * HTTP fetch of URL to retrieve the resource from its origin source. + * + * It is commonly advised and good practice to operate these fetches through + * an HTTP Proxy to prevent exposing your origin server IP. + */ + try { + const response = await fetch(this.params.url); + if (!response.ok) { + this.error = new Error('Failed to fetch URL'); + this.flags.isError = true; + this.dtp.log.error(this.error.message, { transaction: this, status: response.status }); + + res.success = false; + res.statusCode = response.status; + res.message = this.error.message; + return this.dtp.manager.resolveTransaction(this, res); + } + + /* + * Set up to receive binary data stream as the resource file contents. + */ + + let contentType = response.headers.get('content-type'); + let contentSize = response.headers.get('content-length'); + if (contentSize) { + contentSize = parseInt(contentSize, 10); + } + + this.dtp.log.debug('writing initial meta file', { resourceMetaFilename }); + await fs.promises.writeFile(resourceMetaFilename, JSON.stringify({ contentType, contentSize })); + + this.dtp.log.info('writing web resource file', resourceFilename); + let writeStream = fs.createWriteStream(resourceFilename, { + autoClose: true, + encoding: 'binary', + }); + + writeStream.on('close', async ( ) => { + res.file.path = resourceFilename; + res.file.stats = await fs.promises.stat(resourceFilename); + if (!res.file.stats.isFile()) { + throw new SiteError(500, 'invalid object requested'); + } + + // now that it's our own file, ensure that contentSize reflects our answer + contentSize = res.file.stats.size; + res.file.meta = { contentType, contentSize }; + + this.dtp.log.debug('writing meta file', { resourceMetaFilename }); + await fs.promises.writeFile(resourceMetaFilename, JSON.stringify(res.file.meta)); + + this.flags.isFetched = true; + this.dtp.cacheStats.add(res.file.stats.size); + this.dtp.cacheStats.miss(res.file.stats.size); + + this.dtp.manager.resolveTransaction(this, res); + }); + + const { Readable } = await import('stream'); + Readable.fromWeb(response.body).pipe(writeStream); + } catch (error) { + this.error = error; + this.flags.isError = true; + this.dtp.log.error(this.error.message, { transaction: this, error }); + + res.success = false; + res.statusCode = error.statusCode || 500; + res.message = this.error.message; + return this.dtp.manager.resolveTransaction(this, res); + } + } + + async cancel (reason) { + const res = { + res: this.cmd, + success: false, + message: `Operation canceled: ${reason}`, + }; + return this.dtp.manager.resolveTransaction(this, res); + } + + async sendResponse (res) { + const NOW = Date.now(); + const duration = this.duration = (NOW - this.created) / 1000.0; + + const { flags } = this; + flags.isResolved = true; + + const payload = { tid: this.tid, res, flags, duration }; + const reply = Buffer.from(JSON.stringify(payload)); + this.dtp.server.send(reply, this.port, this.address); + } +} + +class TransactionManager { + + constructor (dtp) { + this.dtp = dtp; + this.transactions = { }; + } + + async addTransaction (transaction) { + if (this.hasPendingRequest(transaction)) { + this.transactions[transaction.tid] = transaction; // queue it and be done + return; + } + + this.transactions[transaction.tid] = transaction; // queue it and process the command + switch (transaction.cmd) { + case 'getFile': + return transaction.getFile(); + + case 'fetchUrl': + return transaction.fetchUrl(); + + default: + break; // unknown/undefined command + } + + this.dtp.log.error('invalid host-services command', { + cmd: transaction.cmd, + params: transaction.params, + from: { + address: transaction.address, + port: transaction.port, + }, + }); + await this.dtp.manager.cancelTransaction(transaction, 'Rejected'); + } + + hasPendingRequest (transaction) { + if (!transaction) { return false; } + const keys = Object.keys(this.transactions); + const match = keys.find((key) => { + const cmp = this.transactions[key]; + if (!cmp) { return false; } + if (cmp.cmd !== transaction.cmd) { return false; } + if (cmp.params.bucket !== transaction.params.bucket) { return false; } + if (cmp.params.key !== transaction.params.key) { return false; } + return true; + }); + return !!match; + } + + async resolveTransaction (transaction, res) { + await transaction.sendResponse(res); + this.removeTransaction(transaction, 'resolved'); + + const removed = [ ]; + for (const key in this.transactions) { + const t = this.transactions[key]; + if (!t) { + delete this.transactions[key]; + return; + } + if ((transaction.params.bucket === t.params.bucket) && (transaction.params.key === t.params.key)) { + await t.sendResponse(res); + removed.push(t); + } + } + + for (const t of removed) { + this.removeTransaction(t, 'resolved'); + } + } + + async cancelTransaction (transaction, reason) { + await transaction.cancel(); + this.removeTransaction(transaction, reason); + } + + removeTransaction (transaction) { + if (this.transactions[transaction.tid]) { + delete this.transactions[transaction.tid]; + } + } + + async expireTransactions ( ) { + const NOW = Date.now(); + let expired = 0; + + for (const key in this.transactions) { + const transaction = this.transactions[key]; + const age = NOW - transaction.created; + if (age > (1000 * 30)) { + this.dtp.log.alert('expiring transaction', { transaction }); + await this.cancelTransaction(transaction, 'expired'); + ++expired; + } + } + } +} + +class SiteHostServices extends SiteRuntime { + + static get name ( ) { return 'SiteHostServices'; } + static get slug ( ) { return 'hostServices'; } + + constructor (rootPath) { + super(SiteHostServices, rootPath); + } + + async start (basePath) { + await super.start(); + this.config.hostCache = { + host: process.env.HOST_CACHE_HOST || 'localhost', + port: parseInt(process.env.HOST_CACHE_PORT || '8000', 10), + }; + + 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(); + + /* + * Host Cache server socket setup + */ + this.log.info('creating server UDP socket'); + this.server = dgram.createSocket('udp4', this.onHostCacheMessage.bind(this)); + this.log.info('binding server UDP socket', { + port: this.config.hostCache.port, + host: this.config.hostCache.host, + }); + this.server.bind(this.config.hostCache.port, this.config.hostCache.host); + + this.manager = new TransactionManager(this); + this.expireJob = new CronJob( + '*/5 * * * * *', + this.expireTransactions.bind(this), + null, + true, + CRON_TIMEZONE, + ); + + const cleanCronJob = process.env.HOST_CACHE_CLEAN_CRON || '*/30 * * * * *'; + this.log.info('starting host cache clean cron', { cleanCronJob }); + this.cleanupJob = new CronJob( + cleanCronJob, + this.cleanHostCache.bind(this), + null, + true, + CRON_TIMEZONE, + ); + + this.log.info('starting stats report job'); + this.statsReportJob = new CronJob( + '*/5 * * * * *', + this.reportHostStats.bind(this), + null, + true, + CRON_TIMEZONE, + ); + + this.log.info('starting host expiration job'); + this.expireHostsJob = new CronJob( + '*/20 * * * * *', + this.expireNetHosts.bind(this), + null, + true, + CRON_TIMEZONE, + ); + + this.log.info('registering host with Site platform'); + await this.registerHost(); + await this.setHostStatus('active'); + + this.log.info(`${this.config.pkg.name} v${this.config.pkg.version} ${SiteHostServices.name} started`); + } + + async shutdown ( ) { + await this.setHostStatus('shutdown'); + } + + async onHostCacheMessage (message, rinfo) { + try { + message = message.toString('utf8'); + message = JSON.parse(message); + + const transaction = new HostCacheTransaction(this, message, rinfo); + this.manager.addTransaction(transaction); + } catch (error) { + this.log.error('failed to receive UDP message', { message, error }); + } + } + + /** + * When a file is accessed for read or otherwise, it's atime is updated. If the + * atime of a file exceeds the configured max file idle time, the file is + * removed. + * @param {String} basePath + */ + async cleanHostCache (basePath) { + const NOW = Date.now(); // timestamp, not Date instance + basePath = basePath || process.env.HOST_CACHE_PATH; + + const dir = await fs.promises.opendir(basePath); + for await (const dirent of dir) { + if (dirent.isDirectory()) { + await this.cleanHostCache(path.join(basePath, dirent.name)); + } + if (dirent.isFile()) { + const filePath = path.join(basePath, dirent.name); + const stats = await fs.promises.stat(filePath); + const age = NOW - stats.atime.valueOf(); + if ((age / 1000.0 / 60.0) > 60.0) { + await fs.promises.rm(filePath, { force: true }); + this.cacheStats.remove(stats.size); + this.cacheStats.expire(stats.size); + } + } + } + } + + async expireTransactions ( ) { + await this.manager.expireTransactions(); + } + + async registerHost ( ) { + const NOW = new Date(); + + const mongoose = await import('mongoose'); + const NetHost = mongoose.model('NetHost'); + + const memory = await si.mem(); + + this.host = new NetHost(); + this.host.created = NOW; + this.host.status = 'starting'; + this.host.hostname = os.hostname(); + + this.host.arch = os.arch(); + this.host.cpus = os.cpus().map((cpu) => { + return { + model: cpu.model, + speed: cpu.speed, + }; + }); + + this.host.totalmem = memory.total; + this.host.freemem = memory.available; + + this.host.node = process.version; + this.host.platform = os.platform(); + this.host.release = os.release(); + this.host.version = os.version(); + + this.host.network = (await si.networkInterfaces()).map((iface) => { + return { + iface: iface.iface, + speed: iface.speed, + mac: iface.mac, + ip4: iface.ip4, + ip4subnet: iface.ip4subnet, + ip6: iface.ip6, + ip6subnet: iface.ip6subnet, + flags: { + internal: iface.internal, + virtual: iface.virtual, + }, + }; + }); + + await this.host.save(); + this.host = this.host.toObject(); + + return this.host; + } + + async reportHostStats ( ) { + const NOW = new Date(); + + const mongoose = await import('mongoose'); + const NetHost = mongoose.model('NetHost'); + const NetHostStats = mongoose.model('NetHostStats'); + + const memory = await this.reportMemoryInformation(); + const network = await this.reportNetworkInformation(); + + const load = this.reportLoadAvg(); + const cache = this.cacheStats.report(); + const disk = { + cache: await this.reportDiskUsage(process.env.HOST_CACHE_PATH), + }; + + const newCpuTimes = this.reportCpuTimes(); + const cpuDeltas = this.reportCpuTimeDeltas(this.oldCpuTimes, newCpuTimes); + this.oldCpuTimes = newCpuTimes; + + await NetHostStats.create({ + created: NOW, + host: this.host._id, + load, + cpus: cpuDeltas, + memory, cache, disk, network, + }); + await NetHost.updateOne( + { _id: this.host._id }, + { + updated: NOW, + freemem: memory.available, + }, + ); + } + + async setHostStatus (status) { + if (!this.host) { + return; + } + + const NOW = new Date(); + const mongoose = await import('mongoose'); + const NetHost = mongoose.model('NetHost'); + + await NetHost.updateOne( + { _id: this.host._id }, + { + $set: { + updated: NOW, + status, + }, + }, + ); + } + + async expireNetHosts ( ) { + const NOW = new Date(); + const OLDEST = new Date(Date.now() - 1000 * 60 * 2); + + const mongoose = await import('mongoose'); + const NetHost = mongoose.model('NetHost'); + + const hosts = await NetHost.find({ + status: { $nin: ['inactive', 'crashed'] }, + updated: { $lt: OLDEST } + }); + + await SiteAsync.each(hosts, async (host) => { + try { + await NetHost.updateOne( + { _id: host._id }, + { + $set: { + updated: NOW, + status: 'crashed', + }, + }, + ); + } catch (error) { + this.log.error('failed to clean expired host', { host, error }); + } + }, 4); + } + + async reportMemoryInformation ( ) { + return sysinfo.mem(); + } + + async reportNetworkInformation ( ) { + return (await sysinfo.networkStats('*')).map((iface) => { + const record = { iface: iface.iface }; + + record.rxDropped = iface.rx_dropped; + record.rxErrors = iface.rx_errors; + + record.txDropped = iface.tx_dropped; + record.txErrors = iface.tx_errors; + + if (iface.ms !== 0) { + record.rxPerSecond = iface.rx_sec / (iface.ms / 1000.0); + record.txPerSecond = iface.tx_sec / (iface.ms / 1000.0); + } else { + record.rxPerSecond = 0; + record.txPerSecond = 0; + } + + return record; + }); + } + + reportDiskUsage (pathname) { + return new Promise((resolve, reject) => { + diskusage(pathname, (err, usage) => { + if (err) { + return reject(err); + } + usage.pctUsed = (usage.used / usage.total) * 100.0; + return resolve(usage); + }); + }); + } + + reportLoadAvg ( ) { + return os.loadavg(); + } + + reportCpuTimes ( ) { + return os + .cpus() + .map((cpu) => { + return { + user: cpu.times.user, + nice: cpu.times.nice, + sys: cpu.times.sys, + idle: cpu.times.idle, + irq: cpu.times.irq, + }; + }); + } + + reportCpuTimeDeltas (oldCpuStats, newCpuStats) { + const cpuDeltas = [ ]; + if (oldCpuStats) { + for (let idx = 0; idx < newCpuStats.length; ++idx) { + cpuDeltas.push({ + user: newCpuStats[idx].user - oldCpuStats[idx].user, + nice: newCpuStats[idx].nice - oldCpuStats[idx].nice, + sys: newCpuStats[idx].sys - oldCpuStats[idx].sys, + idle: newCpuStats[idx].idle - oldCpuStats[idx].idle, + irq: newCpuStats[idx].irq - oldCpuStats[idx].irq, + }); + } + } else { + newCpuStats.forEach(( ) => { + cpuDeltas.push({ + user: 0, + nice: 0, + sys: 0, + idle: 0, + irq: 0, + }); + }); + } + return cpuDeltas; + } +} + +(async ( ) => { + + try { + const { fileURLToPath } = await import('node:url'); + const __dirname = dirname(fileURLToPath(import.meta.url)); // jshint ignore:line + + const worker = new SiteHostServices(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/dtp-chat.js b/dtp-chat.js index b4342f3..23bb9e5 100644 --- a/dtp-chat.js +++ b/dtp-chat.js @@ -87,7 +87,7 @@ class SiteWebApp extends SiteRuntime { this.sessionOptions = { name: `dtp.${process.env.DTP_SITE_DOMAIN_KEY}.${process.env.NODE_ENV}`, - secret: process.env.DTP_HTTP_SESSION_SECRET, + secret: process.env.HTTP_SESSION_SECRET, resave: false, saveUninitialized: true, proxy: (process.env.NODE_ENV === 'production') || (process.env.HTTP_SESSION_TRUST_PROXY === 'enabled'), @@ -219,8 +219,8 @@ class SiteWebApp extends SiteRuntime { */ return new Promise((resolve, reject) => { - const host = process.env.DTP_HTTP_HOST || '127.0.0.1'; - const port = parseInt(process.env.DTP_HTTP_PORT || '3000', 10); + const host = process.env.HTTP_HOST || '127.0.0.1'; + const port = parseInt(process.env.HTTP_PORT || '3000', 10); this.log.info('starting HTTP server', { host, port }); this.httpServer.listen(port, host, (err) => { diff --git a/lib/site-lib.js b/lib/site-lib.js index 23d16d5..4075b67 100644 --- a/lib/site-lib.js +++ b/lib/site-lib.js @@ -9,4 +9,5 @@ export { SiteAsync } from './site-async.js'; export { SiteError } from './site-error.js'; export { SiteLog } from './site-log.js'; export { SiteController } from './site-controller.js'; -export { SiteService } from './site-service.js'; \ No newline at end of file +export { SiteService } from './site-service.js'; +export { SiteRuntime } from './site-runtime.js'; \ No newline at end of file diff --git a/lib/site-runtime.js b/lib/site-runtime.js index a1ee31d..15d1f08 100644 --- a/lib/site-runtime.js +++ b/lib/site-runtime.js @@ -22,7 +22,7 @@ import hljs from 'highlight.js'; import mongoose from 'mongoose'; import { Redis } from 'ioredis'; -import { SiteLog } from './site-lib.js'; +import { SiteLog } from './site-log.js'; import { SiteTripwire } from './site-tripwire.js'; import { Emitter } from '@socket.io/redis-emitter'; @@ -81,6 +81,7 @@ export class SiteRuntime { } async loadConfig ( ) { + this.log.debug('loading config', { root: this.config.root }); this.config.pkg = require(path.join(this.config.root, 'package.json')); this.config.site = (await import(path.join(this.config.root, 'config', 'site.js'))).default; this.config.limiter = (await import(path.join(this.config.root, 'config', 'limiter.js'))).default; diff --git a/package.json b/package.json index dc69927..ef99919 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,11 @@ "chart.js": "^4.4.2", "connect-redis": "^7.1.1", "cookie-parser": "^1.4.6", + "cron": "^3.1.7", "cropperjs": "^1.6.1", "dayjs": "^1.11.10", "diacritics": "^1.3.0", + "diskusage-ng": "^1.0.4", "disposable-email-provider-domains": "^1.0.9", "dotenv": "^16.4.5", "email-domain-check": "^1.1.4", @@ -37,6 +39,7 @@ "express-session": "^1.18.0", "highlight.js": "^11.9.0", "ioredis": "^5.3.2", + "jsdom": "^24.0.0", "marked": "^12.0.1", "mediasoup": "^3.13.24", "minio": "^7.1.3", @@ -58,7 +61,9 @@ "socket.io": "^4.7.5", "striptags": "^3.2.0", "svg-captcha": "^1.4.0", + "systeminformation": "^5.22.7", "unzalgo": "^3.0.0", + "user-agents": "^1.1.174", "uuid": "^9.0.1" }, "devDependencies": { diff --git a/start-local b/start-local index 6479dc3..e4ab081 100755 --- a/start-local +++ b/start-local @@ -8,4 +8,8 @@ MINIO_ROOT_USER="dtp-chat" MINIO_ROOT_PASSWORD="dd039ca4-1bab-4a6c-809b-0bbb43c46def" export MINIO_ROOT_USER MINIO_ROOT_PASSWORD -minio server ./data/minio --address ":9080" --console-address ":9081" \ No newline at end of file +forever start --killSignal=SIGINT app/workers/host-services.js + +minio server ./data/minio --address ":9080" --console-address ":9081" + +forever stop app/workers/host-services.js \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 78497b7..4bccce7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1211,6 +1211,11 @@ resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== +"@types/luxon@~3.4.0": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@types/luxon/-/luxon-3.4.2.tgz#e4fc7214a420173cea47739c33cdf10874694db7" + integrity sha512-TifLZlFudklWlMBfhubvgqTXRzLDI5pCbGa4P8a3wPyUQSW+1xQ5eDsreP9DWHX3tjq1ke96uYG/nwundroWcA== + "@types/ms@*": version "0.7.34" resolved "https://registry.yarnpkg.com/@types/ms/-/ms-0.7.34.tgz#10964ba0dee6ac4cd462e2795b6bebd407303433" @@ -1433,6 +1438,13 @@ acorn@^8.7.1, acorn@^8.8.2: resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.11.3.tgz#71e0b14e13a4ec160724b38fb7b0f233b1b81d7a" integrity sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg== +agent-base@^7.0.2, agent-base@^7.1.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.1.tgz#bdbded7dfb096b751a2a087eeeb9664725b2e317" + integrity sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA== + dependencies: + debug "^4.3.4" + ajv-formats@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-2.1.1.tgz#6e669400659eb74973bbf2e33327180a0996b520" @@ -1597,6 +1609,11 @@ async@^3.2.3, async@^3.2.4: resolved "https://registry.yarnpkg.com/async/-/async-3.2.5.tgz#ebd52a8fdaf7a2289a24df399f8d8485c8a46b66" integrity sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg== +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== + at-least-node@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" @@ -2022,6 +2039,13 @@ colorette@^2.0.10, colorette@^2.0.14: resolved "https://registry.yarnpkg.com/colorette/-/colorette-2.0.20.tgz#9eb793e6833067f7235902fcd3b09917a000a95a" integrity sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w== +combined-stream@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" + integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== + dependencies: + delayed-stream "~1.0.0" + commander@^10.0.1: version "10.0.1" resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.1.tgz#881ee46b4f77d1c1dccc5823433aa39b022cbe06" @@ -2171,6 +2195,14 @@ cron-parser@^4.2.1: dependencies: luxon "^3.2.1" +cron@^3.1.7: + version "3.1.7" + resolved "https://registry.yarnpkg.com/cron/-/cron-3.1.7.tgz#3423d618ba625e78458fff8cb67001672d49ba0d" + integrity sha512-tlBg7ARsAMQLzgwqVxy8AZl/qlTc5nibqYwtNGoCrd+cV+ugI+tvZC1oT/8dFH8W455YrywGykx/KMmAqOr7Jw== + dependencies: + "@types/luxon" "~3.4.0" + luxon "~3.4.0" + cropperjs@^1.6.1: version "1.6.1" resolved "https://registry.yarnpkg.com/cropperjs/-/cropperjs-1.6.1.tgz#fd132021d93b824b1b0f2c2c3b763419fb792d89" @@ -2209,11 +2241,26 @@ cssesc@^3.0.0: resolved "https://registry.yarnpkg.com/cssesc/-/cssesc-3.0.0.tgz#37741919903b868565e1c09ea747445cd18983ee" integrity sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg== +cssstyle@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/cssstyle/-/cssstyle-4.0.1.tgz#ef29c598a1e90125c870525490ea4f354db0660a" + integrity sha512-8ZYiJ3A/3OkDd093CBT/0UKDWry7ak4BdPTFP2+QEP7cmhouyq/Up709ASSj2cK02BbZiMgk7kYjZNS4QP5qrQ== + dependencies: + rrweb-cssom "^0.6.0" + data-uri-to-buffer@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== +data-urls@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-5.0.0.tgz#2f76906bce1824429ffecb6920f45a0b30f00dde" + integrity sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg== + dependencies: + whatwg-mimetype "^4.0.0" + whatwg-url "^14.0.0" + data-view-buffer@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.1.tgz#8ea6326efec17a2e42620696e671d7d5a8bc66b2" @@ -2258,7 +2305,7 @@ debug@2.6.9, debug@^2.2.0: dependencies: ms "2.0.0" -debug@4.x, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2, debug@~4.3.4: +debug@4, debug@4.x, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.4, debug@~4.3.1, debug@~4.3.2, debug@~4.3.4: version "4.3.4" resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== @@ -2277,6 +2324,11 @@ decamelize@^1.2.0: resolved "https://registry.yarnpkg.com/decamelize/-/decamelize-1.2.0.tgz#f6534d15148269b20352e7bee26f501f9a191290" integrity sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA== +decimal.js@^10.4.3: + version "10.4.3" + resolved "https://registry.yarnpkg.com/decimal.js/-/decimal.js-10.4.3.tgz#1044092884d245d1b7f65725fa4ad4c6f781cc23" + integrity sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA== + decode-uri-component@^0.2.2: version "0.2.2" resolved "https://registry.yarnpkg.com/decode-uri-component/-/decode-uri-component-0.2.2.tgz#e69dbe25d37941171dd540e024c444cd5188e1e9" @@ -2305,6 +2357,11 @@ define-properties@^1.1.3, define-properties@^1.2.0, define-properties@^1.2.1: has-property-descriptors "^1.0.0" object-keys "^1.1.1" +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== + denque@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1" @@ -2345,6 +2402,11 @@ dijkstrajs@^1.0.1: resolved "https://registry.yarnpkg.com/dijkstrajs/-/dijkstrajs-1.0.3.tgz#4c8dbdea1f0f6478bff94d9c49c784d623e4fc23" integrity sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA== +diskusage-ng@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/diskusage-ng/-/diskusage-ng-1.0.4.tgz#20eee47d647304b6031f743974b29edc328da52f" + integrity sha512-30qT0Bn2dNGii6dAXllGgYRaIt+7gPhwCJks+byhutkCOCgsqfqvXmAV2zTDOvU1ylnGHUjZMMaKhM95FyQQrg== + disposable-email-provider-domains@^1.0.9: version "1.0.9" resolved "https://registry.yarnpkg.com/disposable-email-provider-domains/-/disposable-email-provider-domains-1.0.9.tgz#0ac18ca5477a8d5e6f7f53c5862de8f0dcdee055" @@ -2515,6 +2577,11 @@ entities@^2.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-2.2.0.tgz#098dc90ebb83d8dffa089d55256b351d34c4da55" integrity sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A== +entities@^4.4.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + envinfo@^7.7.3: version "7.11.1" resolved "https://registry.yarnpkg.com/envinfo/-/envinfo-7.11.1.tgz#2ffef77591057081b0129a8fd8cf6118da1b94e1" @@ -2870,6 +2937,15 @@ for-each@^0.3.3: dependencies: is-callable "^1.1.3" +form-data@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" + integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.8" + mime-types "^2.1.12" + formdata-polyfill@^4.0.10: version "4.0.10" resolved "https://registry.yarnpkg.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz#24807c31c9d402e002ab3d8c720144ceb8848423" @@ -3090,6 +3166,13 @@ highlight.js@^11.9.0: resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-11.9.0.tgz#04ab9ee43b52a41a047432c8103e2158a1b8b5b0" integrity sha512-fJ7cW7fQGCYAkgv4CPfwFHrfd/cLS4Hau96JuJ+ZTOWhjnhoeN1ub1tFmALm/+lW5z4WCAuAV9bm05AP0mS6Gw== +html-encoding-sniffer@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz#696df529a7cfd82446369dc5193e590a3735b448" + integrity sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ== + dependencies: + whatwg-encoding "^3.1.1" + htmlparser2@3.8.x: version "3.8.3" resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.8.3.tgz#996c28b191516a8be86501a7d79757e5c70c1068" @@ -3122,6 +3205,14 @@ http-errors@~1.6.2: setprototypeof "1.1.0" statuses ">= 1.4.0 < 2" +http-proxy-agent@^7.0.0: + version "7.0.2" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" + integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig== + dependencies: + agent-base "^7.1.0" + debug "^4.3.4" + http-proxy@^1.18.1: version "1.18.1" resolved "https://registry.yarnpkg.com/http-proxy/-/http-proxy-1.18.1.tgz#401541f0534884bbf95260334e72f88ee3976549" @@ -3131,6 +3222,14 @@ http-proxy@^1.18.1: follow-redirects "^1.0.0" requires-port "^1.0.0" +https-proxy-agent@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz#8e97b841a029ad8ddc8731f26595bad868cb4168" + integrity sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg== + dependencies: + agent-base "^7.0.2" + debug "4" + iconv-lite@0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" @@ -3138,7 +3237,7 @@ iconv-lite@0.4.24: dependencies: safer-buffer ">= 2.1.2 < 3" -iconv-lite@^0.6.3: +iconv-lite@0.6.3, iconv-lite@^0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== @@ -3384,6 +3483,11 @@ is-plain-object@^2.0.4: dependencies: isobject "^3.0.1" +is-potential-custom-element-name@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" + integrity sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ== + is-promise@^2.0.0: version "2.2.2" resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1" @@ -3520,6 +3624,33 @@ js-tokens@^4.0.0: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== +jsdom@^24.0.0: + version "24.0.0" + resolved "https://registry.yarnpkg.com/jsdom/-/jsdom-24.0.0.tgz#e2dc04e4c79da368481659818ee2b0cd7c39007c" + integrity sha512-UDS2NayCvmXSXVP6mpTj+73JnNQadZlr9N68189xib2tx5Mls7swlTNao26IoHv46BZJFvXygyRtyXd1feAk1A== + dependencies: + cssstyle "^4.0.1" + data-urls "^5.0.0" + decimal.js "^10.4.3" + form-data "^4.0.0" + html-encoding-sniffer "^4.0.0" + http-proxy-agent "^7.0.0" + https-proxy-agent "^7.0.2" + is-potential-custom-element-name "^1.0.1" + nwsapi "^2.2.7" + parse5 "^7.1.2" + rrweb-cssom "^0.6.0" + saxes "^6.0.0" + symbol-tree "^3.2.4" + tough-cookie "^4.1.3" + w3c-xmlserializer "^5.0.0" + webidl-conversions "^7.0.0" + whatwg-encoding "^3.1.1" + whatwg-mimetype "^4.0.0" + whatwg-url "^14.0.0" + ws "^8.16.0" + xml-name-validator "^5.0.0" + jsesc@^2.5.1: version "2.5.2" resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" @@ -3661,6 +3792,11 @@ lodash.clone@^4.3.2, lodash.clone@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.clone/-/lodash.clone-4.5.0.tgz#195870450f5a13192478df4bc3d23d2dea1907b6" integrity sha512-GhrVeweiTD6uTmmn5hV/lzgCQhccwReIVRLHp7LT4SopOjqEZ5BbX8b5WWEtAKasjmy8hR7ZPwsYlxRCku5odg== +lodash.clonedeep@^4.5.0: + version "4.5.0" + resolved "https://registry.yarnpkg.com/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" + integrity sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ== + lodash.debounce@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" @@ -3710,7 +3846,7 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" -luxon@^3.2.1: +luxon@^3.2.1, luxon@~3.4.0: version "3.4.4" resolved "https://registry.yarnpkg.com/luxon/-/luxon-3.4.4.tgz#cf20dc27dc532ba41a169c43fdcc0063601577af" integrity sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA== @@ -3802,7 +3938,7 @@ mime-db@1.52.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@^2.1.27, mime-types@^2.1.31, mime-types@^2.1.35, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34: +mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@^2.1.35, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== @@ -4110,6 +4246,11 @@ numeral@^2.0.6: resolved "https://registry.yarnpkg.com/numeral/-/numeral-2.0.6.tgz#4ad080936d443c2561aed9f2197efffe25f4e506" integrity sha512-qaKRmtYPZ5qdw4jWJD6bxEf1FJEqllJrwxCLIm0sQU/A7v2/czigzOb+C2uSiFsa9lBUzeH7M1oK+Q+OLxL3kA== +nwsapi@^2.2.7: + version "2.2.7" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.7.tgz#738e0707d3128cb750dddcfe90e4610482df0f30" + integrity sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ== + object-assign@^4, object-assign@^4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -4208,6 +4349,13 @@ parse-node-version@^1.0.0, parse-node-version@^1.0.1: resolved "https://registry.yarnpkg.com/parse-node-version/-/parse-node-version-1.0.1.tgz#e2b5dbede00e7fa9bc363607f53327e8b073189b" integrity sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA== +parse5@^7.1.2: + version "7.1.2" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.1.2.tgz#0736bebbfd77793823240a23b7fc5e010b7f8e32" + integrity sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw== + dependencies: + entities "^4.4.0" + parseurl@~1.3.2, parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" @@ -4399,6 +4547,11 @@ prr@~1.0.1: resolved "https://registry.yarnpkg.com/prr/-/prr-1.0.1.tgz#d3fc114ba06995a45ec6893f484ceb1d78f5f476" integrity sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw== +psl@^1.1.33: + version "1.9.0" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.9.0.tgz#d0df2a137f00794565fcaf3b2c00cd09f8d5a5a7" + integrity sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag== + pstree.remy@^1.1.8: version "1.1.8" resolved "https://registry.yarnpkg.com/pstree.remy/-/pstree.remy-1.1.8.tgz#c242224f4a67c21f686839bbdb4ac282b8373d3a" @@ -4507,7 +4660,7 @@ pug@^3.0.2: pug-runtime "^3.0.1" pug-strip-comments "^2.0.0" -punycode@^2.1.0, punycode@^2.3.0: +punycode@^2.1.0, punycode@^2.1.1, punycode@^2.3.0, punycode@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== @@ -4539,6 +4692,11 @@ query-string@^7.1.3: split-on-first "^1.0.0" strict-uri-encode "^2.0.0" +querystringify@^2.1.1: + version "2.2.0" + resolved "https://registry.yarnpkg.com/querystringify/-/querystringify-2.2.0.tgz#3345941b4153cb9d082d8eee4cda2016a9aef7f6" + integrity sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ== + randexp@^0.5.3: version "0.5.3" resolved "https://registry.yarnpkg.com/randexp/-/randexp-0.5.3.tgz#f31c2de3148b30bdeb84b7c3f59b0ebb9fec3738" @@ -4776,6 +4934,11 @@ rotating-file-stream@^3.2.1: resolved "https://registry.yarnpkg.com/rotating-file-stream/-/rotating-file-stream-3.2.1.tgz#1d0a536d75884eedc3a677f5b0871fdc69f97d22" integrity sha512-n2B18CJb+n2VA5Tdle+1NP2toEcRv68CjAOBjHmwcyswNwMVsrN3gVRZ9ymH3sapaiGY8jc9OhhV5b6I5rAeiA== +rrweb-cssom@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/rrweb-cssom/-/rrweb-cssom-0.6.0.tgz#ed298055b97cbddcdeb278f904857629dec5e0e1" + integrity sha512-APM0Gt1KoXBz0iIkkdB/kfvGOwC4UuJFeG/c+yV7wSc7q96cG/kJ0HiYCnzivD9SB53cLV1MlHFNfOuPaadYSw== + rx@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/rx/-/rx-4.1.0.tgz#a5f13ff79ef3b740fe30aa803fb09f98805d4782" @@ -4820,6 +4983,13 @@ sax@>=0.6.0, sax@^1.2.4: resolved "https://registry.yarnpkg.com/sax/-/sax-1.3.0.tgz#a5dbe77db3be05c9d1ee7785dbd3ea9de51593d0" integrity sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA== +saxes@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/saxes/-/saxes-6.0.0.tgz#fe5b4a4768df4f14a201b1ba6a65c1f3d9988cc5" + integrity sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA== + dependencies: + xmlchars "^2.2.0" + schema-utils@^3.1.1, schema-utils@^3.2.0: version "3.3.0" resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.3.0.tgz#f50a88877c3c01652a15b622ae9e9795df7a60fe" @@ -5327,6 +5497,16 @@ svg-captcha@^1.4.0: dependencies: opentype.js "^0.7.3" +symbol-tree@^3.2.4: + version "3.2.4" + resolved "https://registry.yarnpkg.com/symbol-tree/-/symbol-tree-3.2.4.tgz#430637d248ba77e078883951fb9aa0eed7c63fa2" + integrity sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw== + +systeminformation@^5.22.7: + version "5.22.7" + resolved "https://registry.yarnpkg.com/systeminformation/-/systeminformation-5.22.7.tgz#9a20810c7eacad4aebe7591cb7c78c0dd96dbd1a" + integrity sha512-AWxlP05KeHbpGdgvZkcudJpsmChc2Y5Eo/GvxG/iUA/Aws5LZKHAMSeAo+V+nD+nxWZaxrwpWcnx4SH3oxNL3A== + tapable@^2.1.1, tapable@^2.2.0, tapable@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" @@ -5436,6 +5616,16 @@ touch@^3.1.0: dependencies: nopt "~1.0.10" +tough-cookie@^4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-4.1.3.tgz#97b9adb0728b42280aa3d814b6b999b2ff0318bf" + integrity sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw== + dependencies: + psl "^1.1.33" + punycode "^2.1.1" + universalify "^0.2.0" + url-parse "^1.5.3" + tr46@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/tr46/-/tr46-1.0.1.tgz#a8b13fd6bfd2489519674ccde55ba3693b706d09" @@ -5450,6 +5640,13 @@ tr46@^4.1.1: dependencies: punycode "^2.3.0" +tr46@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-5.0.0.tgz#3b46d583613ec7283020d79019f1335723801cec" + integrity sha512-tk2G5R2KRwBd+ZN0zaEXpmzdKyOYksXwywulIX95MBODjSzMIuQnQ3m8JxgbhnL1LeVo7lqQKsYa1O3Htl7K5g== + dependencies: + punycode "^2.3.1" + tslib@^2.0.0, tslib@^2.3.0: version "2.6.2" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" @@ -5594,6 +5791,11 @@ universalify@^0.1.0: resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg== +universalify@^0.2.0: + version "0.2.0" + resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.2.0.tgz#6451760566fa857534745ab1dde952d1b1761be0" + integrity sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg== + universalify@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" @@ -5631,6 +5833,21 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" +url-parse@^1.5.3: + version "1.5.10" + resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.10.tgz#9d3c2f736c1d75dd3bd2be507dcc111f1e2ea9c1" + integrity sha512-WypcfiRhfeUP9vvF0j6rw0J3hrWrw6iZv3+22h6iRMJ/8z1Tj6XfLP4DsUix5MhMPnXpiHDoKyoZ/bdCkwBCiQ== + dependencies: + querystringify "^2.1.1" + requires-port "^1.0.0" + +user-agents@^1.1.174: + version "1.1.174" + resolved "https://registry.yarnpkg.com/user-agents/-/user-agents-1.1.174.tgz#9bccbcf48fd68fc7523e4003882e0e5ad577ce5a" + integrity sha512-S4WyQZTs85TOJ2ABEF+0K9mT9sp3j2SsXEiYODOCwNNWnuacheNsngLV1zGNkmLyCE2D5e4a1/f0DaStIFQQRA== + dependencies: + lodash.clonedeep "^4.5.0" + util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -5684,6 +5901,13 @@ void-elements@^3.1.0: resolved "https://registry.yarnpkg.com/void-elements/-/void-elements-3.1.0.tgz#614f7fbf8d801f0bb5f0661f5b2f5785750e4f09" integrity sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w== +w3c-xmlserializer@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz#f925ba26855158594d907313cedd1476c5967f6c" + integrity sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA== + dependencies: + xml-name-validator "^5.0.0" + watchpack@^2.4.1: version "2.4.1" resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.1.tgz#29308f2cac150fa8e4c92f90e0ec954a9fed7fff" @@ -5813,6 +6037,18 @@ webpack@^5.91.0: watchpack "^2.4.1" webpack-sources "^3.2.3" +whatwg-encoding@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz#d0f4ef769905d426e1688f3e34381a99b60b76e5" + integrity sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ== + dependencies: + iconv-lite "0.6.3" + +whatwg-mimetype@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz#bc1bf94a985dc50388d54a9258ac405c3ca2fc0a" + integrity sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg== + whatwg-url@^13.0.0: version "13.0.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-13.0.0.tgz#b7b536aca48306394a34e44bda8e99f332410f8f" @@ -5821,6 +6057,14 @@ whatwg-url@^13.0.0: tr46 "^4.1.1" webidl-conversions "^7.0.0" +whatwg-url@^14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-14.0.0.tgz#00baaa7fd198744910c4b1ef68378f2200e4ceb6" + integrity sha512-1lfMEm2IEr7RIV+f4lUNPOqfFL+pO+Xw3fJSqmjX9AbXcXcYOkCe1P6+9VBZB6n94af16NfZf+sSk0JCBZC9aw== + dependencies: + tr46 "^5.0.0" + webidl-conversions "^7.0.0" + whatwg-url@^7.0.0: version "7.1.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.1.0.tgz#c2c492f1eca612988efd3d2266be1b9fc6170d06" @@ -6072,11 +6316,21 @@ wrappy@1: resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ== +ws@^8.16.0: + version "8.16.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.16.0.tgz#d1cd774f36fbc07165066a60e40323eab6446fd4" + integrity sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ== + ws@~8.11.0: version "8.11.0" resolved "https://registry.yarnpkg.com/ws/-/ws-8.11.0.tgz#6a0d36b8edfd9f96d8b25683db2f8d7de6e8e143" integrity sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg== +xml-name-validator@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz#82be9b957f7afdacf961e5980f1bf227c0bf7673" + integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg== + xml2js@^0.5.0: version "0.5.0" resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.5.0.tgz#d9440631fbb2ed800203fad106f2724f62c493b7" @@ -6095,6 +6349,11 @@ xmlbuilder@~11.0.0: resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA== +xmlchars@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/xmlchars/-/xmlchars-2.2.0.tgz#060fe1bcb7f9c76fe2a17db86a9bc3ab894210cb" + integrity sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw== + xmlhttprequest-ssl@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz#91360c86b914e67f44dce769180027c0da618c67"