Browse Source

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
develop
Rob Colbert 1 year ago
parent
commit
390b5ee0bb
  1. 184
      app/controllers/image.js
  2. 67
      app/models/lib/host-stats.js
  3. 31
      app/models/net-host-stats.js
  4. 53
      app/models/net-host.js
  5. 3
      app/services/chat.js
  6. 140
      app/services/host-cache.js
  7. 293
      app/services/link.js
  8. 128
      app/services/text.js
  9. 2
      app/views/link/components/preview-standalone.pug
  10. 60
      app/views/link/components/preview.pug
  11. 64
      app/views/link/timeline.pug
  12. 808
      app/workers/host-services.js
  13. 6
      dtp-chat.js
  14. 1
      lib/site-lib.js
  15. 3
      lib/site-runtime.js
  16. 5
      package.json
  17. 4
      start-local
  18. 269
      yarn.lock

184
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,
});
}
}
}

67
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,
};

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

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

3
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();

140
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];
}
}
}

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

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

2
app/views/link/components/preview-standalone.pug

@ -0,0 +1,2 @@
include preview
+renderLinkPreview(link)

60
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

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

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

6
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) => {

1
lib/site-lib.js

@ -10,3 +10,4 @@ export { SiteError } from './site-error.js';
export { SiteLog } from './site-log.js';
export { SiteController } from './site-controller.js';
export { SiteService } from './site-service.js';
export { SiteRuntime } from './site-runtime.js';

3
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;

5
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": {

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

269
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 @@ [email protected], debug@^2.2.0:
dependencies:
ms "2.0.0"
[email protected], 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, [email protected], 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"
[email protected]:
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"
[email protected]:
version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
@ -3138,7 +3237,7 @@ [email protected]:
dependencies:
safer-buffer ">= 2.1.2 < 3"
iconv-lite@^0.6.3:
[email protected], 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 @@ [email protected]:
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==
[email protected]:
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"

Loading…
Cancel
Save