newsletter and Kaleidoscope token grant

+ brought newsletter down from Sites to Base so everything can have a
newsletter
+ brought newsletter worker to Base, added to start-local
+ A Core accepting a Service Node now grants a Kaleidoscope token
master
rob 2 years ago
parent 49f5510254
commit 42428d84a5

@ -42,14 +42,15 @@ class AdminController extends SiteController {
}),
);
router.use('/content-report',await this.loadChild(path.join(__dirname, 'admin', 'content-report')));
router.use('/core-node',await this.loadChild(path.join(__dirname, 'admin', 'core-node')));
router.use('/core-user',await this.loadChild(path.join(__dirname, 'admin', 'core-user')));
router.use('/host',await this.loadChild(path.join(__dirname, 'admin', 'host')));
router.use('/content-report', await this.loadChild(path.join(__dirname, 'admin', 'content-report')));
router.use('/core-node', await this.loadChild(path.join(__dirname, 'admin', 'core-node')));
router.use('/core-user', await this.loadChild(path.join(__dirname, 'admin', 'core-user')));
router.use('/host', await this.loadChild(path.join(__dirname, 'admin', 'host')));
router.use('/job-queue', await this.loadChild(path.join(__dirname, 'admin', 'job-queue')));
router.use('/log', await this.loadChild(path.join(__dirname, 'admin', 'log')));
router.use('/newsletter', await this.loadChild(path.join(__dirname, 'admin', 'newsletter')));
router.use('/settings', await this.loadChild(path.join(__dirname, 'admin', 'settings')));
router.use('/service-node',await this.loadChild(path.join(__dirname, 'admin', 'service-node')));
router.use('/service-node', await this.loadChild(path.join(__dirname, 'admin', 'service-node')));
router.use('/user', await this.loadChild(path.join(__dirname, 'admin', 'user')));
router.get('/diagnostics', this.getDiagnostics.bind(this));

@ -7,7 +7,7 @@
const express = require('express');
// const multer = require('multer');
const { SiteController } = require('../../../lib/site-lib');
const { SiteController, SiteError } = require('../../../lib/site-lib');
class CoreNodeController extends SiteController {
@ -25,16 +25,33 @@ class CoreNodeController extends SiteController {
return next();
});
router.param('coreNodeId', this.populateCoreNodeId.bind(this));
router.post('/connect', this.postCoreNodeConnect.bind(this));
router.get('/resolve', this.getCoreNodeResolveForm.bind(this));
router.get('/connect', this.getCoreNodeConnectForm.bind(this));
router.get('/:coreNodeId', this.getCoreNodeView.bind(this));
router.get('/', this.getIndex.bind(this));
return router;
}
async populateCoreNodeId (req, res, next, coreNodeId) {
const { coreNode: coreNodeService } = this.dtp.services;
try {
res.locals.coreNode = await coreNodeService.getCoreById(coreNodeId);
if (!res.locals.coreNode) {
throw new SiteError(404, 'Core Node not found');
}
return next();
} catch (error) {
this.log.error('failed to populate Core Node', { coreNodeId, error });
return next(error);
}
}
async postCoreNodeConnect (req, res) {
const { coreNode: coreNodeService } = this.dtp.services;
@ -99,6 +116,18 @@ class CoreNodeController extends SiteController {
res.render('admin/core-node/connect');
}
async getCoreNodeView (req, res, next) {
const { coreNode: coreNodeService } = this.dtp.services;
try {
res.locals.pagination = this.getPaginationParameters(req, 20);
res.locals.requestHistory = await coreNodeService.getCoreRequestHistory(res.locals.coreNode, res.locals.pagination);
res.render('admin/core-node/view');
} catch (error) {
this.log.error('failed to render Core Node view', { error });
return next(error);
}
}
async getIndex (req, res, next) {
const { coreNode: coreNodeService } = this.dtp.services;
try {

@ -0,0 +1,177 @@
// admin/newsletter.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const express = require('express');
const { SiteController, SiteError } = require('../../../lib/site-lib');
const Bull = require('bull');
class NewsletterController extends SiteController {
constructor (dtp) {
super(dtp, module.exports);
}
async start ( ) {
const { jobQueue: jobQueueService } = this.dtp.services;
const { config } = this.dtp;
this.newsletterQueue = await jobQueueService.getJobQueue(
'newsletter',
config.jobQueues.newsletter,
);
const router = express.Router();
router.use(async (req, res, next) => {
res.locals.currentView = 'admin';
res.locals.adminView = 'newsletter';
return next();
});
router.param('newsletterId', this.populateNewsletterId.bind(this));
router.post('/:newsletterId/transmit', this.postTransmitNewsletter.bind(this));
router.post('/:newsletterId', this.postUpdateNewsletter.bind(this));
router.post('/', this.postCreateNewsletter.bind(this));
router.get('/compose', this.getComposer.bind(this));
router.get('/job-status', this.getJobStatusView.bind(this));
router.get('/:newsletterId', this.getComposer.bind(this));
router.get('/', this.getIndex.bind(this));
router.delete('/:newsletterId', this.deleteNewsletter.bind(this));
return router;
}
async populateNewsletterId (req, res, next, newsletterId) {
const { newsletter: newsletterService } = this.dtp.services;
try {
res.locals.newsletter = await newsletterService.getById(newsletterId);
if (!res.locals.newsletter) {
throw new SiteError(404, 'Newsletter not found');
}
return next();
} catch (error) {
this.log.error('failed to populate newsletterId', { newsletterId, error });
return next(error);
}
}
async postTransmitNewsletter (req, res, next) {
try {
const displayList = this.createDisplayList('transmit-newsletter');
res.locals.jobData = {
newsletterId: res.locals.newsletter._id,
};
this.log.info('creating newsletter transmit job', { jobData: res.locals.jobData });
res.locals.job = await this.newsletterQueue.add('transmit', res.locals.jobData);
displayList.navigateTo(`/admin/job-queue/newsletter/${res.locals.job.id}`);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to create newsletter transmit job', { error });
return next(error);
}
}
async postUpdateNewsletter (req, res, next) {
const { newsletter: newsletterService } = this.dtp.services;
try {
await newsletterService.update(res.locals.newsletter, req.body);
res.redirect('/admin/newsletter');
} catch (error) {
this.log.error('failed to update newsletter', { newletterId: res.locals.newsletter._id, error });
return next(error);
}
}
async postCreateNewsletter (req, res, next) {
const { newsletter: newsletterService } = this.dtp.services;
try {
await newsletterService.create(req.user, req.body);
res.redirect('/admin/newsletter');
} catch (error) {
this.log.error('failed to create newsletter', { error });
return next(error);
}
}
async getComposer (req, res) {
res.render('admin/newsletter/editor');
}
async getJobStatusView (req, res, next) {
const { jobQueue: jobQueueService } = this.dtp.services;
try {
res.locals.newsletterView = 'job-status';
res.locals.queueName = 'newsletter';
res.locals.newsletterQueue = await jobQueueService.getJobQueue(res.locals.queueName);
res.locals.newsletterQueue = this.newsletterQueue;
res.locals.jobCounts = await this.newsletterQueue.getJobCounts();
res.locals.jobs = {
waiting: await this.newsletterQueue.getWaiting(0, 5),
active: await this.newsletterQueue.getActive(0, 5),
delayed: await this.newsletterQueue.getDelayed(0, 5),
failed: await this.newsletterQueue.getFailed(0, 5),
};
res.render('admin/newsletter/job-status');
} catch (error) {
this.log.error('failed to render job status view', { error });
return next(error);
}
}
async getIndex (req, res, next) {
const { newsletter: newsletterService } = this.dtp.services;
try {
res.locals.newsletterView = 'index';
res.locals.pagination = this.getPaginationParameters(req, 20);
res.locals.newsletters = await newsletterService.getNewsletters(res.locals.pagination, ['draft', 'published']);
res.render('admin/newsletter/index');
} catch (error) {
return next(error);
}
}
async deleteNewsletter (req, res) {
const { newsletter: newsletterService } = this.dtp.services;
try {
const displayList = this.createDisplayList('delete-newsletter');
await newsletterService.deleteNewsletter(res.locals.newsletter);
displayList.removeElement(`li[data-newsletter-id="${res.locals.newsletter._id}"]`);
displayList.showNotification(
`Newsletter "${res.locals.newsletter.title}" deleted`,
'success',
'bottom-center',
3000,
);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to delete newsletter', {
newsletterId: res.local.newsletter._id,
error,
});
res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
}
module.exports = {
name: 'adminNewsletter',
slug: 'admin-newsletter',
create: async (dtp) => {
let controller = new NewsletterController(dtp);
return controller;
},
};

@ -0,0 +1,102 @@
// newsletter.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const express = require('express');
const multer = require('multer');
const { SiteController } = require('../../lib/site-lib');
class NewsletterController extends SiteController {
constructor (dtp) {
super(dtp, module.exports);
}
async start ( ) {
const { dtp } = this;
const { limiter: limiterService } = dtp.services;
const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${module.exports.slug}` });
const router = express.Router();
dtp.app.use('/newsletter', router);
router.use(async (req, res, next) => {
res.locals.currentView = module.exports.slug;
return next();
});
router.param('newsletterId', this.populateNewsletterId.bind(this));
router.post('/', upload.none(), this.postAddRecipient.bind(this));
router.get('/:newsletterId',
limiterService.create(limiterService.config.newsletter.getView),
this.getView.bind(this),
);
router.get('/',
limiterService.create(limiterService.config.newsletter.getIndex),
this.getIndex.bind(this),
);
}
async populateNewsletterId (req, res, next, newsletterId) {
const { newsletter: newsletterService } = this.dtp.services;
try {
res.locals.newsletter = await newsletterService.getById(newsletterId);
return next();
} catch (error) {
this.log.error('failed to populate newsletterId', { newsletterId, error });
return next(error);
}
}
async postAddRecipient (req, res) {
const { newsletter: newsletterService } = this.dtp.services;
try {
const displayList = this.createDisplayList('add-recipient');
await newsletterService.addRecipient(req.body.email);
displayList.showNotification(
'You have been added to the newsletter. Please check your email and verify your email address.',
'success',
'bottom-center',
10000,
);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to update account settings', { error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async getView (req, res) {
res.render('newsletter/view');
}
async getIndex (req, res, next) {
const { newsletter: newsletterService } = this.dtp.services;
try {
res.locals.pagination = this.getPaginationParameters(req, 20);
res.locals.newsletters = await newsletterService.getNewsletters(res.locals.pagination);
res.render('newsletter/index');
} catch (error) {
return next(error);
}
}
}
module.exports = {
slug: 'newsletter',
name: 'newsletter',
create: async (dtp) => {
let controller = new NewsletterController(dtp);
return controller;
},
};

@ -22,7 +22,7 @@ const { RequestTokenSchema } = require('./lib/core-request-token');
*/
const CoreNodeRequestSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: 1 },
created: { type: Date, default: Date.now, required: true, index: 1, expires: '7d' },
core: { type: Schema.ObjectId, required: true, ref: 'CoreNode' },
token: { type: RequestTokenSchema },
method: { type: String, required: true },
@ -30,6 +30,7 @@ const CoreNodeRequestSchema = new Schema({
response: {
received: { type: Date },
elapsed: { type: Number },
statusCode: { type: String },
success: { type: Boolean },
},
});

@ -24,6 +24,9 @@ const CoreNodeSchema = new Schema({
scopes: { type: [String] },
redirectUri: { type: String },
},
kaleidoscope: {
token: { type: String },
},
meta: {
name: { type: String },
description: { type: String },

@ -0,0 +1,21 @@
// newsletter-recipient.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const NewsletterRecipientSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: 1 },
address: { type: String, required: true },
address_lc: { type: String, required: true, lowercase: true, unique: true, index: 1 },
flags: {
isVerified: { type: Boolean, default: false, required: true, index: 1 },
isOptIn: { type: Boolean, default: false, required: true, index: 1 },
isRejected: { type: Boolean, default: false, required: true, index: 1 },
},
});
module.exports = mongoose.model('NewsletterRecipient', NewsletterRecipientSchema);

@ -0,0 +1,31 @@
// newsletter.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const NEWSLETTER_STATUS_LIST = ['draft', 'published', 'archived'];
const NewsletterSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1 },
author: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' },
title: { type: String, required: true },
summary: { type: String },
content: {
html: { type: String, required: true, select: false, },
text: { type: String, required: true, select: false, },
},
status: {
type: String,
enum: NEWSLETTER_STATUS_LIST,
default: 'draft',
required: true,
index: true,
},
});
module.exports = mongoose.model('Newsletter', NewsletterSchema);

@ -21,6 +21,9 @@ const OAuth2ClientSchema = new Schema({
secret: { type: String, required: true },
scopes: { type: [String], required: true },
callbackUrl: { type: String, required: true },
kaleidoscope: {
token: { type: String, required: true, index: 1 },
},
flags: {
isActive: { type: Boolean, default: true, required: true, index: 1 },
},

@ -59,6 +59,7 @@ class CoreNodeService extends SiteService {
async start ( ) {
const cores = await this.getConnectedCores(null, true);
this.log.info('Core Node service starting', { connectedCoreCount: cores.length });
cores.forEach((core) => this.registerPassportCoreOAuth2(core));
}
@ -197,6 +198,13 @@ class CoreNodeService extends SiteService {
return address.parse(host);
}
async getCoreById (coreNodeId) {
const core = await CoreNode
.findOne({ _id: coreNodeId })
.lean();
return core;
}
async getCoreByAddress (address) {
const core = await CoreNode
.findOne({
@ -361,30 +369,17 @@ class CoreNodeService extends SiteService {
try {
json = await response.json();
} catch (error) {
await this.setRequestResponse(req, response);
throw new SiteError(response.status, response.statusText);
}
this.log.debug('received failure response', { json });
await this.setRequestResponse(req, response, json);
throw new SiteError(response.status, json.message || response.statusText);
}
const json = await response.json();
/*
* capture a little inline health monitoring data, which can be used to
* generate health alerts.
*/
const DONE = new Date();
const ELAPSED = DONE.valueOf() - req.created.valueOf();
await CoreNodeRequest.updateOne(
{ _id: req._id },
{
$set: {
'response.received': DONE,
'response.elapsed': ELAPSED,
'response.success': json.success,
},
},
);
await this.setRequestResponse(req, response, json);
this.log.info('Core node request complete', { request: req });
return { request: req.toObject(), response: json };
@ -394,6 +389,36 @@ class CoreNodeService extends SiteService {
}
}
async setRequestResponse (request, response, json) {
const DONE = new Date();
const ELAPSED = DONE.valueOf() - request.created.valueOf();
const updateOp = {
$set: {
'response.received': DONE,
'response.elapsed': ELAPSED,
'response.statusCode': response.status,
},
};
if (json) {
updateOp.$set['response.success'] = json.success;
}
await CoreNodeRequest.updateOne({ _id: request._id }, updateOp);
}
async getCoreRequestHistory (core, pagination) {
const requests = await CoreNodeRequest
.find({ core: core._id })
.sort({ created: -1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.lean();
for (const req of requests) {
req.core = core;
}
const totalRequestCount = await CoreNodeRequest.countDocuments({ core: core._id });
return { requests, totalRequestCount };
}
async connect (response) {
const request = await CoreNodeRequest
.findOne({ 'token.value': response.token })
@ -481,6 +506,7 @@ class CoreNodeService extends SiteService {
return request;
}
async acceptServiceNode (requestToken, appNode) {
const { oauth2: oauth2Service } = this.dtp.services;
const response = { token: requestToken };

@ -71,10 +71,12 @@ class EmailService extends SiteService {
async send (message) {
const NOW = new Date();
await this.checkEmailAddress(message.to);
this.log.info('sending email', { to: message.to, subject: message.subject });
const response = await this.transport.sendMail(message);
await EmailLog.create({
const log = await EmailLog.create({
created: NOW,
from: message.from,
to: message.to,
@ -82,6 +84,8 @@ class EmailService extends SiteService {
subject: message.subject,
messageId: response.messageId,
});
return { response, log };
}
async checkEmailAddress (emailAddress) {

@ -0,0 +1,123 @@
// newsletter.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const striptags = require('striptags');
const { SiteService } = require('../../lib/site-lib');
const mongoose = require('mongoose');
const Newsletter = mongoose.model('Newsletter');
const NewsletterRecipient = mongoose.model('NewsletterRecipient');
class NewsletterService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
this.populateNewsletter = [
{
path: 'author',
select: '_id username username_lc displayName picture',
},
];
}
async create (author, newsletterDefinition) {
const NOW = new Date();
const newsletter = new Newsletter();
newsletter.created = NOW;
newsletter.author = author._id;
newsletter.title = striptags(newsletterDefinition.title.trim());
newsletter.summary = striptags(newsletterDefinition.summary.trim());
newsletter.content.html = newsletterDefinition['content.html'].trim();
newsletter.content.text = striptags(newsletterDefinition['content.text'].trim());
newsletter.status = 'draft';
await newsletter.save();
return newsletter.toObject();
}
async update (newsletter, newsletterDefinition) {
const updateOp = { $set: { } };
if (newsletterDefinition.title) {
updateOp.$set.title = striptags(newsletterDefinition.title.trim());
}
if (newsletterDefinition.summary) {
updateOp.$set.summary = striptags(newsletterDefinition.summary.trim());
}
if (newsletterDefinition['content.html']) {
updateOp.$set['content.html'] = newsletterDefinition['content.html'].trim();
}
if (newsletterDefinition['content.text']) {
updateOp.$set['content.text'] = striptags(newsletterDefinition['content.text'].trim());
}
if (newsletterDefinition.status) {
updateOp.$set.status = striptags(newsletterDefinition.status.trim());
}
if (Object.keys(updateOp.$set).length === 0) {
return; // no update to perform
}
await Newsletter.updateOne(
{ _id: newsletter._id },
updateOp,
{ upsert: true },
);
}
async getNewsletters (pagination, status = ['published']) {
if (!Array.isArray(status)) {
status = [status];
}
const newsletters = await Newsletter
.find({ status: { $in: status } })
.sort({ created: -1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.lean();
return newsletters;
}
async getById (newsletterId) {
const newsletter = await Newsletter
.findById(newsletterId)
.select('+content.html +content.text')
.populate(this.populateNewsletter)
.lean();
return newsletter;
}
async addRecipient (emailAddress) {
const { email: emailService } = this.dtp.services;
const NOW = new Date();
await emailService.checkEmailAddress(emailAddress);
const recipient = new NewsletterRecipient();
recipient.created = NOW;
recipient.address = striptags(emailAddress.trim());
recipient.address_lc = recipient.address.toLowerCase();
await recipient.save();
return recipient.toObject();
}
async deleteNewsletter (newsletter) {
this.log.info('deleting newsletter', { newsletterId: newsletter._id });
await Newsletter.deleteOne({ _id: newsletter._id });
}
}
module.exports = {
slug: 'newsletter',
name: 'newsletter',
create: (dtp) => { return new NewsletterService(dtp); },
};

@ -286,6 +286,9 @@ class OAuth2Service extends SiteService {
secret: clientDefinition.secret,
scopes: clientDefinition.coreAuth.scopes,
callbackUrl: clientDefinition.coreAuth.callbackUrl,
kaleidoscope: {
token: generatePassword(256, false),
},
},
},
{

@ -1,4 +1,4 @@
ul.uk-nav.uk-nav-default
ul(uk-nav).uk-nav-default
li.uk-nav-header Admin Menu
li(class={ 'uk-active': (adminView === 'home') })
@ -27,6 +27,35 @@ ul.uk-nav.uk-nav-default
i.fas.fa-ban
span.uk-margin-small-left Content Reports
li(class={ 'uk-active': (adminView === 'newsletter') }).uk-parent
a
span.nav-item-icon
i.fas.fa-newspaper
span.uk-margin-small-left Newsletter
span.uk-nav-parent-icon
ul.uk-nav-sub
li(class={ 'uk-active': (newsletterView === 'index') })
a(href="/admin/newsletter")
span.nav-item-icon
i.fas.fa-newspaper
span.uk-margin-small-left Home
li(class={ 'uk-active': (newsletterView === 'editor') })
a(href="/admin/newsletter/compose")
span.nav-item-icon
i.fas.fa-edit
span.uk-margin-small-left Composer
li(class={ 'uk-active': (newsletterView === 'recipient') })
a(href="/admin/newsletter/recipient")
span.nav-item-icon
i.fas.fa-user
span.uk-margin-small-left Recipients
li(class={ 'uk-active': (newsletterView === 'job-status') })
a(href="/admin/newsletter/job-status")
span.nav-item-icon
i.fas.fa-newspaper
span.uk-margin-small-left Job Status
li.uk-nav-divider
li(class={ 'uk-active': (adminView === 'core-node') })
@ -47,12 +76,6 @@ ul.uk-nav.uk-nav-default
i.fas.fa-puzzle-piece
span.uk-margin-small-left Service Nodes
li(class={ 'uk-active': (adminView === 'connect-queue') })
a(href="/admin/service-node/connect-queue")
span.nav-item-icon
i.fas.fa-plug
span.uk-margin-small-left Connect Queue
li.uk-nav-divider
li(class={ 'uk-active': (adminView === 'host') })

@ -2,7 +2,7 @@ extends ../layouts/main
block content
.uk-margin
+renderSectionTitle('Content Reports')
h1(style="line-height: 1em;").uk-margin-remove.uk-text-truncate Content Reports
if Array.isArray(reports) && (reports.length > 0)
.uk-overflow-auto

@ -0,0 +1,29 @@
mixin renderCoreNodeListItem (coreNode)
.uk-tile.uk-tile-default.uk-tile-small.uk-padding-small.dtp-border.uk-border-rounded
.uk-margin
div(uk-grid).uk-flex-between
.uk-width-auto
+renderCell('Name', coreNode.meta.name)
.uk-width-auto
+renderCell('Admin', coreNode.meta.admin)
.uk-width-auto
+renderCell('Domain', coreNode.meta.domain)
.uk-width-auto
+renderCell('Domain Key', coreNode.meta.domainKey)
.uk-margin
div(uk-grid).uk-flex-between
.uk-width-auto
+renderCell('Connected', moment(coreNode.created).format('MMM DD, YYYY'))
.uk-width-auto
+renderCell('Updated', moment(coreNode.updated).format('MMM DD, YYYY'))
.uk-width-auto
+renderCell('Version', coreNode.meta.version)
.uk-width-auto
+renderCell('Host', coreNode.address.host)
.uk-width-auto
+renderCell('Port', coreNode.address.port)
.uk-width-auto
+renderCell('Connected', coreNode.flags.isConnected)
.uk-width-auto
+renderCell('Blocked', coreNode.flags.isBlocked)

@ -15,4 +15,4 @@ block content
input(id="host", name="host", placeholder="Enter host:port of Core to connect", required).uk-input
.uk-card-footer
button(type="submit").uk-button.uk-button-primary Send Request
button(type="submit").uk-button.uk-button-primary.uk-border-rounded Send Request

@ -1,43 +1,21 @@
extends ../layouts/main
block content
h1 Core Nodes
a(href="/admin/core-node/connect").uk-button.uk-button-primary Connect Core
include components/list-item
.uk-margin
div(uk-grid)
.uk-width-expand
h1(style="line-height: 1em;").uk-margin-remove.uk-text-truncate Core Nodes
.uk-width-auto
a(href="/admin/core-node/connect").uk-button.uk-button-primary.uk-border-rounded Connect Core
p You can register with one or more Core nodes to exchange information with those nodes.
if Array.isArray(coreNodes) && (coreNodes.length > 0)
ul.uk-list
each node in coreNodes
.uk-tile.uk-tile-default.uk-tile-small
.uk-margin
div(uk-grid)
.uk-width-auto
+renderCell('Name', node.meta.name)
.uk-width-auto
+renderCell('Domain', node.meta.domain)
.uk-width-auto
+renderCell('Domain Key', node.meta.domainKey)
.uk-width-auto
+renderCell('Connected', moment(node.created).format('MMM DD, YYYY'))
.uk-width-auto
+renderCell('Updated', moment(node.updated).format('MMM DD, YYYY'))
.uk-width-auto
+renderCell('Version', node.meta.version)
.uk-margin
div(uk-grid)
.uk-width-auto
+renderCell('Host', node.address.host)
.uk-width-auto
+renderCell('Port', node.address.port)
.uk-width-auto
+renderCell('Connected', node.flags.isConnected)
.uk-width-auto
+renderCell('Blocked', node.flags.isBlocked)
.uk-width-auto
+renderCell('Admin', node.meta.admin)
.uk-width-auto
+renderCell('Support', node.meta.supportEmail)
a(href=`/admin/core-node/${node._id}`).uk-display-block.uk-link-reset
+renderCoreNodeListItem(node)
else
p There are no registered core nodes.

@ -0,0 +1,46 @@
extends ../layouts/main
block content
include ../../components/pagination-bar
include components/list-item
.uk-margin
div(uk-grid).uk-grid-small.uk-flex-middle
div(class="uk-width-1-1 uk-width-expand@m")
h1(style="line-height: 1em;") Core Node
div(class="uk-width-1-1 uk-width-auto@m")
a(href=`mailto:${coreNode.meta.supportEmail}?subject=${encodeURIComponent(`Support request from ${site.name}`)}`)
span
i.fas.fa-envelope
span.uk-margin-small-left Email Support
div(class="uk-width-1-1 uk-width-auto@m")
span.uk-label(style="line-height: 1.75em;", class={
'uk-label-success': coreNode.flags.isConnected,
'uk-label-warning': !coreNode.flags.isConnected && !coreNode.flags.isBlocked,
'uk-label-danger': coreNode.flags.isBlocked,
}).no-select= coreNode.flags.isConnected ? 'Connected' : 'Pending'
+renderCoreNodeListItem(coreNode)
.uk-margin
table.uk-table.uk-table-small
thead
tr
th Timestamp
th Method
th URL
th Status
th Result
th Perf
tbody
each request in requestHistory.requests
tr
td= moment(request.created).format('YYYY-MM-DD HH:mm:ss.SSS')
td= request.method
td= request.url
td= (request.response && request.response.statusCode) ? request.response.statusCode : '- - -'
td= (request.response) ? ((request.response.success) ? 'success' : 'fail') : '- - -'
td= request.response ? `${numeral(request.response.elapsed).format('0,0')}ms` : '- - -'
.uk-margin
+renderPaginationBar(`/admin/core-node/${coreNode._id}`, requestHistory.totalRequestCount)

@ -0,0 +1,18 @@
mixin renderJobQueueJobList (jobQueue, jobList)
if !Array.isArray(jobList) || (jobList.length === 0)
div No jobs
else
table.uk-table.uk-table-small
thead
th ID
th Name
th Attempts
th Progress
tbody
each job in jobList
tr
td= job.id
td
a(href=`/admin/job-queue/${jobQueue.name}/${job.id}`)= job.name
td= job.attemptsMade
td #{job.progress()}%

@ -1,24 +1,7 @@
extends ../layouts/main
block content
mixin renderJobList (jobList)
if !Array.isArray(jobList) || (jobList.length === 0)
div No jobs
else
table.uk-table.uk-table-small
thead
th ID
th Name
th Attempts
th Progress
tbody
each job in jobList
tr
td= job.id
td
a(href=`/admin/job-queue/${queue.name}/${job.id}`)= job.name
td= job.attemptsMade
td #{job.progress()}%
include components/job-list
.uk-margin
h1 Job Queue: #{queueName}
@ -38,25 +21,25 @@ block content
.uk-card-header
h3.uk-card-title Active
.uk-card-body
+renderJobList(jobs.active)
+renderJobQueueJobList(queue, jobs.active)
div(class="uk-width-1-1 uk-width-1-2@l")
.uk-card.uk-card-default.uk-card-small
.uk-card-header
h3.uk-card-title Waiting
.uk-card-body
+renderJobList(jobs.waiting)
+renderJobQueueJobList(queue, jobs.waiting)
div(class="uk-width-1-1 uk-width-1-2@l")
.uk-card.uk-card-default.uk-card-small
.uk-card-header
h3.uk-card-title Delayed
.uk-card-body
+renderJobList(jobs.delayed)
+renderJobQueueJobList(queue, jobs.delayed)
div(class="uk-width-1-1 uk-width-1-2@l")
.uk-card.uk-card-default.uk-card-small
.uk-card-header
h3.uk-card-title Failed
.uk-card-body
+renderJobList(jobs.failed)
+renderJobQueueJobList(queue, jobs.failed)

@ -0,0 +1,67 @@
extends ../layouts/main
block content
- var actionUrl = newsletter ? `/admin/newsletter/${newsletter._id}` : `/admin/newsletter`;
form(method="POST", action= actionUrl).uk-form
.uk-margin
label(for="title").uk-form-label.sr-only Newsletter title
input(id="title", name="title", type="text", placeholder= "Enter newsletter title", value= newsletter ? newsletter.title : undefined, required).uk-input
.uk-margin
label(for="summary").uk-form-label.sr-only Newsletter summary
textarea(id="summary", name="summary", rows="4", placeholder= "Enter newsletter summary (text only, no HTML)", required).uk-textarea= newsletter ? newsletter.summary : undefined
.uk-margin
label(for="content-html").uk-form-label.sr-only Newsletter HTML body
textarea(id="content-html", name="content.html", rows="4").uk-textarea= newsletter ? newsletter.content.html : undefined
.uk-margin
button(type="button", onclick="return dtp.app.copyHtmlToText(event, 'content-text');").uk-button.dtp-button-default Copy HTML to Text
.uk-margin
label(for="content-text").uk-form-label.sr-only Newsletter text body
textarea(id="content-text", name="content.text", rows="4", placeholder= "Enter text-only version of newsletter.", required).uk-textarea= newsletter ? newsletter.content.text : undefined
button(type="submit").uk-button.dtp-button-primary= newsletter ? 'Update newsletter' : 'Save newsletter'
block viewjs
script(src="/tinymce/tinymce.min.js")
script.
const useDarkMode = document.body.classList.contains('dtp-dark');
window.addEventListener('dtp-load', async ( ) => {
const toolbarItems = [
'undo redo',
'blocks visualblocks',
'bold italic backcolor',
'alignleft aligncenter alignright alignjustify',
'bullist numlist outdent indent removeformat',
'link image media code',
'help'
];
const pluginItems = [
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap',
'preview', 'anchor', 'searchreplace', 'visualblocks', 'code',
'fullscreen', 'insertdatetime', 'media', 'table',
'help', 'wordcount',
]
const editors = await tinymce.init({
selector: 'textarea#content-html',
height: 500,
menubar: false,
plugins: pluginItems.join(' '),
toolbar: toolbarItems.join('|'),
branding: false,
images_upload_url: '/image/tinymce',
image_class_list: [
{ title: 'Body Image', value: 'dtp-image-body' },
{ title: 'Title Image', value: 'dtp-image-title' },
],
convert_urls: false,
skin: useDarkMode ? "oxide-dark" : "oxide",
content_css: useDarkMode ? "dark" : "default",
});
window.dtp.app.editor = editors[0];
});

@ -0,0 +1,36 @@
extends ../layouts/main
block content
.uk-margin
h1(style="line-height: 1em;").uk-text-truncate.uk-margin-remove Newsletters
.uk-margin
if (Array.isArray(newsletters) && (newsletters.length > 0))
ul.uk-list
each newsletter in newsletters
li(data-newsletter-id= newsletter._id)
div(uk-grid).uk-grid-small.uk-flex-middle
.uk-width-expand
a(href=`/admin/newsletter/${newsletter._id}`).uk-display-block.uk-text-large.uk-text-truncate= newsletter.title
.uk-width-auto
div(uk-grid).uk-grid-small
.uk-width-auto
button(
type="button",
data-newsletter-id= newsletter._id,
data-newsletter-title= newsletter.title,
onclick="return dtp.adminApp.deleteNewsletter(event);",
).uk-button.uk-button-danger
+renderButtonIcon('fa-trash', 'Delete')
.uk-width-auto
button(
type="button",
data-newsletter-id= newsletter._id,
data-newsletter-title= newsletter.title,
onclick="return dtp.adminApp.sendNewsletter(event);",
).uk-button.uk-button-default
+renderButtonIcon('fa-paper-plane', 'Send')
else
div There are no newsletters at this time.

@ -0,0 +1,45 @@
extends ../layouts/main
block content
include ../job-queue/components/job-list
.uk-margin
h1 Job Queue: #{queueName}
div(uk-grid).uk-flex-between
- var pendingJobCount = jobCounts.waiting + jobCounts.delayed + jobCounts.paused + jobCounts.active
.uk-width-auto Total#[br]#{numeral(pendingJobCount).format('0,0')}
.uk-width-auto Waiting#[br]#{numeral(jobCounts.waiting).format('0,0')}
.uk-width-auto Delayed#[br]#{numeral(jobCounts.delayed).format('0,0')}
.uk-width-auto Paused#[br]#{numeral(jobCounts.paused).format('0,0')}
.uk-width-auto Active#[br]#{numeral(jobCounts.active).format('0,0')}
.uk-width-auto Completed#[br]#{numeral(jobCounts.completed).format('0,0')}
.uk-width-auto Failed#[br]#{numeral(jobCounts.failed).format('0,0')}
div(uk-grid)
div(class="uk-width-1-1 uk-width-1-2@l")
.uk-card.uk-card-default.uk-card-small
.uk-card-header
h3.uk-card-title Active
.uk-card-body
+renderJobQueueJobList(newsletterQueue, jobs.active)
div(class="uk-width-1-1 uk-width-1-2@l")
.uk-card.uk-card-default.uk-card-small
.uk-card-header
h3.uk-card-title Waiting
.uk-card-body
+renderJobQueueJobList(newsletterQueue, jobs.waiting)
div(class="uk-width-1-1 uk-width-1-2@l")
.uk-card.uk-card-default.uk-card-small
.uk-card-header
h3.uk-card-title Delayed
.uk-card-body
+renderJobQueueJobList(newsletterQueue, jobs.delayed)
div(class="uk-width-1-1 uk-width-1-2@l")
.uk-card.uk-card-default.uk-card-small
.uk-card-header
h3.uk-card-title Failed
.uk-card-body
+renderJobQueueJobList(newsletterQueue, jobs.failed)

@ -3,7 +3,14 @@ block content
include components/list-item
h1 Service Nodes
div(uk-grid)
.uk-width-expand
h1(style="line-height: 1em;").uk-margin-remove Service Nodes
.uk-width-auto
a(href='/admin/service-node/connect-queue').uk-button.uk-button-secondary.uk-border-rounded
span
i.fas.fa-plug
span.uk-margin-small-left Connect Queue
if Array.isArray(serviceNodes) && (serviceNodes.length > 0)
ul.uk-list.uk-list-divider

@ -16,7 +16,7 @@ include section-title
mixin renderCell (label, value, className)
div(title=`${label}: ${numeral(value).format('0,0')}`).uk-tile.uk-tile-default.uk-padding-remove.no-select
div(class=className)= value
div(class=className)!= value
.uk-text-muted.uk-text-small= label
mixin renderBackButton ( )

@ -0,0 +1,15 @@
extends ../layouts/main
block content
section.uk-section.uk-section-default
.uk-container
h1 #{site.name} Newsletters
if Array.isArray(newsletters) && (newsletters.length > 0)
ul.uk-list
each newsletter of newsletters
li
a(href=`/newsletter/${newsletter._id}`).uk-link-reset= newsletter.title
else
div There are no newsletters at this time. Please check back later.

@ -0,0 +1,155 @@
// newsletter.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT = { name: 'newsletter', slug: 'newsletter' };
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') });
const mongoose = require('mongoose');
const { SiteWorker, SiteLog } = require(path.join(__dirname, '..', '..', 'lib', 'site-lib'));
module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json'));
module.config = {
component: DTP_COMPONENT,
root: path.resolve(__dirname, '..', '..'),
};
class NewsletterWorker extends SiteWorker {
constructor (dtp) {
super(dtp, dtp.config.component);
this.newsletters = this.newsletters || { };
}
async start ( ) {
await super.start();
const { jobQueue: jobQueueService } = this.dtp.services;
this.jobQueue = await jobQueueService.getJobQueue('newsletter', {
attempts: 3,
});
this.jobQueue.process('transmit', this.transmitNewsletter.bind(this));
this.jobQueue.process('email-send', this.sendNewsletterEmail.bind(this));
}
async stop ( ) {
if (this.jobQueue) {
this.log.info('stopping newsletter job queue');
await this.jobQueue.close();
delete this.jobQueue;
}
await super.stop();
}
async loadNewsletter (newsletterId) {
const { newsletter: newsletterService } = this.dtp.services;
let newsletter = this.newsletters[newsletterId];
if (!newsletter) {
newsletter = await newsletterService.getById(newsletterId);
this.newsletters[newsletterId] = newsletter;
}
return newsletter;
}
async transmitNewsletter (job) {
const User = mongoose.model('User');
const NewsletterRecipient = mongoose.model('NewsletterRecipient');
this.log.info('newsletter email job received', { data: job.data });
try {
/*
* Transmit first to all local user accounts with verified email who've
* opted in for receiving marketing email.
*/
await User
.find({
'flags.isEmailVerified': true,
'optIn.marketing': true,
})
.select('email displayName username username_lc')
.lean()
.cursor()
.eachAsync(async (user) => {
try {
const jobData = {
newsletterId: job.data.newsletterId,
recipient: user.email,
recipientName: user.displayName || user.username,
};
const jobOptions = { attempts: 3 };
await this.jobQueue.add('email-send', jobData, jobOptions);
} catch (error) {
this.log.error('failed to create newsletter email job', { error });
}
}, { parallel: 4 });
/*
* Transmit to all newsletter recipients on file who've joined through the
* widget on the site w/o signing up for an account.
*/
await NewsletterRecipient
.find({ 'flags.isVerified': true, 'flags.isOptIn': true, 'flags.isRejected': false })
.lean()
.cursor()
.eachAsync(async (recipient) => {
try {
const jobData = {
newsletterId: job.data.newsletterId,
recipient: recipient.address,
};
const jobOptions = { attempts: 3 };
await this.jobQueue.add('email-send', jobData, jobOptions);
} catch (error) {
this.log.error('failed to create newsletter email job', { error });
}
}, { parallel: 4 });
} catch (error) {
this.log.error('failed to send newsletter', { newsletterId: job.data.newsletterId, error });
throw error;
}
}
async sendNewsletterEmail (job) {
const { email: emailService } = this.dtp.services;
const { newsletterId, recipient } = job.data;
try {
let newsletter = await this.loadNewsletter(newsletterId);
if (!newsletter) {
throw new Error('newsletter not found');
}
const result = await emailService.send({
from: process.env.DTP_EMAIL_SMTP_FROM || `noreply@${this.dtp.config.site.domainKey}`,
to: recipient,
subject: newsletter.title,
html: newsletter.content.html,
text: newsletter.content.text,
});
job.log(`newsletter email sent: ${result}`);
this.log.info('newsletter email sent', { recipient, result });
} catch (error) {
this.log.error('failed to send newsletter email', { newsletterId, recipient, error });
throw error; // throw error to Bull so it can report in job reports
}
}
}
(async ( ) => {
try {
module.log = new SiteLog(module, module.config.component);
module.worker = new NewsletterWorker(module);
await module.worker.start();
module.log.info(`${module.pkg.name} v${module.pkg.version} Newsletter worker started`);
} catch (error) {
module.log.error('failed to start Newsletter worker', { error });
process.exit(-1);
}
})();

@ -238,6 +238,29 @@ export default class DtpSiteAdminHostStatsApp extends DtpApp {
}
}
async sendNewsletter (event) {
const newsletterId = event.currentTarget.getAttribute('data-newsletter-id');
const newsletterTitle = event.currentTarget.getAttribute('data-newsletter-title');
console.log(newsletterId, newsletterTitle);
try {
await UIkit.modal.confirm(`Are you sure you want to transmit "${newsletterTitle}"`);
} catch (error) {
this.log.info('sendNewsletter', 'aborted');
return;
}
try {
const response = await fetch(`/admin/newsletter/${newsletterId}/transmit`, {
method: 'POST',
});
if (!response.ok) {
throw new Error('Server error');
}
await this.processResponse(response);
} catch (error) {
this.log.error('sendNewsletter', 'failed to send newsletter', { newsletterId, newsletterTitle, error });
UIkit.modal.alert(`Failed to send newsletter: ${error.message}`);
}
}
async deleteNewsletter (event) {
const newsletterId = event.currentTarget.getAttribute('data-newsletter-id');

@ -5,7 +5,8 @@
'use strict';
module.exports = {
// 'queue-name': {
// attempts: 3,
// },
'newsletter': {
attempts: 3,
removeOnComplete: false,
},
};

@ -132,6 +132,22 @@ module.exports = {
}
},
/*
* NewsletterController
*/
newsletter: {
getView: {
total: 5,
expire: ONE_MINUTE,
message: 'You are reading newsletters too quickly',
},
getIndex: {
total: 60,
expire: ONE_MINUTE,
message: 'You are fetching newsletters too quickly',
},
},
/*
* UserController
*/

@ -9,8 +9,10 @@ export MINIO_ROOT_USER MINIO_ROOT_PASSWORD MINIO_CI_CD
forever start --killSignal=SIGINT app/workers/host-services.js
forever start --killSignal=SIGINT app/workers/reeeper.js
forever start --killSignal=SIGINT app/workers/newsletter.js
minio server ./data/minio --address ":9010" --console-address ":9011"
forever stop app/workers/newsletter.js
forever stop app/workers/reeeper.js
forever stop app/workers/host-services.js
Loading…
Cancel
Save