Browse Source

Merge branch 'develop' of git.digitaltelepresence.com:digital-telepresence/dtp-base into develop

userService-patch
Rob Colbert 2 years ago
parent
commit
0dd25e5176
  1. 14
      .env.default
  2. 8
      .vscode/launch.json
  3. 2
      LICENSE
  4. 2
      NOTES.md
  5. 6
      app/controllers/admin.js
  6. 2
      app/controllers/admin/content-report.js
  7. 60
      app/controllers/admin/core-node.js
  8. 2
      app/controllers/admin/host.js
  9. 56
      app/controllers/admin/job-queue.js
  10. 2
      app/controllers/admin/log.js
  11. 123
      app/controllers/admin/newsletter.js
  12. 135
      app/controllers/admin/page.js
  13. 152
      app/controllers/admin/post.js
  14. 2
      app/controllers/admin/settings.js
  15. 2
      app/controllers/admin/user.js
  16. 2
      app/controllers/auth.js
  17. 158
      app/controllers/comment.js
  18. 78
      app/controllers/email.js
  19. 64
      app/controllers/hive.js
  20. 72
      app/controllers/hive/kaleidoscope.js
  21. 13
      app/controllers/home.js
  22. 2
      app/controllers/image.js
  23. 4
      app/controllers/manifest.js
  24. 104
      app/controllers/newsletter.js
  25. 70
      app/controllers/user.js
  26. 2
      app/controllers/welcome.js
  27. 25
      app/models/category.js
  28. 2
      app/models/chat-message.js
  29. 2
      app/models/comment.js
  30. 2
      app/models/connect-token.js
  31. 2
      app/models/content-report.js
  32. 2
      app/models/content-vote.js
  33. 37
      app/models/core-node-request.js
  34. 18
      app/models/core-node.js
  35. 2
      app/models/csrf-token.js
  36. 2
      app/models/email-blacklist.js
  37. 2
      app/models/email-body.js
  38. 19
      app/models/email-log.js
  39. 18
      app/models/email-verify.js
  40. 2
      app/models/email.js
  41. 2
      app/models/image.js
  42. 2
      app/models/lib/geo-types.js
  43. 2
      app/models/lib/resource-stats.js
  44. 2
      app/models/log.js
  45. 2
      app/models/net-host-stats.js
  46. 2
      app/models/net-host.js
  47. 21
      app/models/newsletter-recipient.js
  48. 31
      app/models/newsletter.js
  49. 2
      app/models/otp-account.js
  50. 29
      app/models/page.js
  51. 36
      app/models/post.js
  52. 2
      app/models/resource-view.js
  53. 2
      app/models/resource-visit.js
  54. 2
      app/models/user-block.js
  55. 2
      app/models/user-notification.js
  56. 14
      app/models/user.js
  57. 2
      app/services/cache.js
  58. 2
      app/services/chat.js
  59. 2
      app/services/comment.js
  60. 2
      app/services/content-report.js
  61. 2
      app/services/content-vote.js
  62. 97
      app/services/core-node.js
  63. 2
      app/services/crypto.js
  64. 2
      app/services/csrf-token.js
  65. 9
      app/services/display-engine.js
  66. 86
      app/services/email.js
  67. 2
      app/services/host-cache.js
  68. 2
      app/services/image.js
  69. 3
      app/services/job-queue.js
  70. 2
      app/services/limiter.js
  71. 2
      app/services/log.js
  72. 2
      app/services/markdown.js
  73. 2
      app/services/media.js
  74. 2
      app/services/minio.js
  75. 123
      app/services/newsletter.js
  76. 191
      app/services/oauth2.js
  77. 10
      app/services/otp-auth.js
  78. 173
      app/services/page.js
  79. 61
      app/services/phone.js
  80. 2
      app/services/resource.js
  81. 2
      app/services/session.js
  82. 2
      app/services/sms.js
  83. 2
      app/services/user-notification.js
  84. 132
      app/services/user.js
  85. 16
      app/templates/common/html/footer.pug
  86. 3
      app/templates/common/html/header.pug
  87. 15
      app/templates/common/text/footer.pug
  88. 3
      app/templates/common/text/header.pug
  89. 3
      app/templates/html/user-email.pug
  90. 31
      app/templates/html/welcome.pug
  91. 106
      app/templates/layouts/html/system-message.pug
  92. 4
      app/templates/layouts/library.pug
  93. 10
      app/templates/layouts/text/system-message.pug
  94. 5
      app/templates/text/user-email.pug
  95. 8
      app/templates/text/welcome.pug
  96. 17
      app/views/admin/category/editor.pug
  97. 21
      app/views/admin/category/index.pug
  98. 23
      app/views/admin/components/menu.pug
  99. 18
      app/views/admin/core-node/connect.pug
  100. 14
      app/views/admin/core-node/index.pug

14
.env.default

@ -22,7 +22,7 @@ MAILGUN_DOMAIN=
#
MONGODB_HOST=localhost:27017
MONGODB_DATABASE=dtp-sites
MONGODB_DATABASE=dtp-webapp
#
# Redis configuration
@ -39,10 +39,10 @@ REDIS_PASSWORD=
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=disabled
MINIO_ACCESS_KEY=dtp-sites
MINIO_ACCESS_KEY=dtp-webapp
MINIO_SECRET_KEY=
MINIO_IMAGE_BUCKET=site-images
MINIO_VIDEO_BUCKET=site-videos
MINIO_IMAGE_BUCKET=webapp-images
MINIO_VIDEO_BUCKET=webapp-videos
#
# ExpressJS/HTTP configuration
@ -60,9 +60,9 @@ DTP_LOG_CONSOLE=enabled
DTP_LOG_MONGODB=enabled
DTP_LOG_FILE=enabled
DTP_LOG_FILE_PATH=/tmp/dtp-sites/logs
DTP_LOG_FILE_NAME_APP=justjoeradio-app.log
DTP_LOG_FILE_NAME_HTTP=justjoeradio-access.log
DTP_LOG_FILE_PATH=/tmp/dtp-webapp/logs
DTP_LOG_FILE_NAME_APP=webapp-app.log
DTP_LOG_FILE_NAME_HTTP=webapp-access.log
DTP_LOG_DEBUG=enabled
DTP_LOG_INFO=enabled

8
.vscode/launch.json

@ -7,11 +7,11 @@
{
"type": "pwa-node",
"request": "launch",
"name": "web:dtp-sites",
"name": "web:dtp-webapp",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder:dtp-justjoeradio}/dtp-sites.js",
"program": "${workspaceFolder:dtp-sites}/dtp-sites.js",
"console": "integratedTerminal",
"env": {
"HTTP_BIND_PORT": "3333"
@ -20,11 +20,11 @@
{
"type": "pwa-node",
"request": "launch",
"name": "cli:dtp-sites",
"name": "cli:dtp-webapp",
"skipFiles": [
"<node_internals>/**"
],
"program": "${workspaceFolder:dtp-justjoeradio}/dtp-sites-cli.js",
"program": "${workspaceFolder:dtp-sites}/dtp-sites-cli.js",
"console": "integratedTerminal",
"args": ["--action=reset-indexes", "all"]
}

2
LICENSE

@ -1,4 +1,4 @@
Copyright 2021 Digital Telepresence, LLC
Copyright 2022 DTP Technologies, LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

2
NOTES.md

@ -2,8 +2,6 @@
- Password
- Light mode
Want to tune in live
Want chat

6
app/controllers/admin.js

@ -1,5 +1,5 @@
// admin.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
@ -45,12 +45,10 @@ 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('/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('/page', await this.loadChild(path.join(__dirname, 'admin', 'page')));
router.use('/post', await this.loadChild(path.join(__dirname, 'admin', 'post')));
router.use('/settings', await this.loadChild(path.join(__dirname, 'admin', 'settings')));
router.use('/user', await this.loadChild(path.join(__dirname, 'admin', 'user')));

2
app/controllers/admin/content-report.js

@ -1,5 +1,5 @@
// admin/content-report.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

60
app/controllers/admin/core-node.js

@ -0,0 +1,60 @@
// admin/content-report.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'admin:content-report';
const express = require('express');
// const multer = require('multer');
const { /*SiteError,*/ SiteController } = require('../../../lib/site-lib');
class CoreNodeController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
}
async start ( ) {
// const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/upload` });
const router = express.Router();
router.use(async (req, res, next) => {
res.locals.currentView = 'admin';
res.locals.adminView = 'core-node';
return next();
});
router.post('/connect', this.postCoreNodeConnect.bind(this));
router.get('/connect', this.getCoreNodeConnectForm.bind(this));
router.get('/', this.getIndex.bind(this));
return router;
}
async postCoreNodeConnect (req, res, next) {
// const { coreNode: coreNodeService } = this.dtp.services;
try {
} catch (error) {
this.log.error('failed to create Core Node connection request', { error });
return next(error);
}
}
async getCoreNodeConnectForm (req, res) {
res.render('admin/core-node/connect');
}
async getIndex (req, res) {
res.render('admin/core-node/index');
}
}
module.exports = async (dtp) => {
let controller = new CoreNodeController(dtp);
return controller;
};

2
app/controllers/admin/host.js

@ -1,5 +1,5 @@
// admin/host.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

56
app/controllers/admin/job-queue.js

@ -1,13 +1,13 @@
// admin/job-queue.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
// Copyright (C) 2022 DTP Technologies, LLC
// All Rights Reserved
'use strict';
const DTP_COMPONENT_NAME = 'admin:job-queue';
const express = require('express');
const { /*SiteError,*/ SiteController } = require('../../../lib/site-lib');
const { SiteController, SiteError } = require('../../../lib/site-lib');
class JobQueueController extends SiteController {
@ -24,7 +24,11 @@ class JobQueueController extends SiteController {
});
router.param('jobQueueName', this.populateJobQueueName.bind(this));
router.param('jobId', this.populateJob.bind(this));
router.post('/:jobQueueName/:jobId/action', this.postJobAction.bind(this));
router.get('/:jobQueueName/:jobId', this.getJobView.bind(this));
router.get('/:jobQueueName', this.getJobQueueView.bind(this));
router.get('/', this.getHomeView.bind(this));
@ -36,6 +40,9 @@ class JobQueueController extends SiteController {
try {
res.locals.queueName = jobQueueName;
res.locals.queue = await jobQueueService.getJobQueue(jobQueueName);
if (!res.locals.queue) {
throw new SiteError(404, 'Job queue not found');
}
return next();
} catch (error) {
this.log.error('failed to populate job queue', { jobQueueName, error });
@ -43,6 +50,46 @@ class JobQueueController extends SiteController {
}
}
async populateJob (req, res, next, jobId) {
try {
res.locals.job = await res.locals.queue.getJob(jobId);
if (!res.locals.job) {
throw new SiteError(404, 'Job not found');
}
return next();
} catch (error) {
this.log.error('failed to populate job', { jobId, error });
return next(error);
}
}
async postJobAction (req, res) {
try {
await res.locals.job[req.body.action]();
res.status(200).json({ success: true });
} catch (error) {
this.log.error('failed to execute job action', {
jobId: res.locals.job.id,
action: req.body.action,
error,
});
res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async getJobView (req, res, next) {
try {
res.locals.jobLogs = await res.locals.queue.getJobLogs(res.locals.job.id);
res.render('admin/job-queue/job-view');
} catch (error) {
this.log.error('failed to render job view', { error });
return next(error);
}
}
async getJobQueueView (req, res, next) {
try {
res.locals.jobCounts = await res.locals.queue.getJobCounts();
@ -62,7 +109,8 @@ class JobQueueController extends SiteController {
async getHomeView (req, res, next) {
const { jobQueue: jobQueueService } = this.dtp.services;
try {
res.locals.queues = await jobQueueService.discoverJobQueues('soapy:*:id');
const prefix = process.env.REDIS_KEY_PREFIX || 'dtp';
res.locals.queues = await jobQueueService.discoverJobQueues(`${prefix}:*:id`);
res.render('admin/job-queue/index');
} catch (error) {
this.log.error('failed to populate job queues view', { error });

2
app/controllers/admin/log.js

@ -1,5 +1,5 @@
// admin/log.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

123
app/controllers/admin/newsletter.js

@ -1,123 +0,0 @@
// admin/newsletter.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'admin:newsletter';
const express = require('express');
const { SiteController, SiteError } = require('../../../lib/site-lib');
class NewsletterController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
}
async start ( ) {
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', this.postUpdateNewsletter.bind(this));
router.post('/', this.postCreateNewsletter.bind(this));
router.get('/compose', this.getComposer.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 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 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, ['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 = async (dtp) => {
let controller = new NewsletterController(dtp);
return controller;
};

135
app/controllers/admin/page.js

@ -1,135 +0,0 @@
// admin/page.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'admin:page';
const express = require('express');
const { SiteController, SiteError } = require('../../../lib/site-lib');
class PageController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
}
async start ( ) {
const router = express.Router();
router.use(async (req, res, next) => {
res.locals.currentView = 'admin';
res.locals.adminView = 'page';
return next();
});
router.param('pageId', this.populatePageId.bind(this));
router.post('/:pageId', this.pageUpdatePage.bind(this));
router.post('/', this.pageCreatePage.bind(this));
router.get('/compose', this.getComposer.bind(this));
router.get('/:pageId', this.getComposer.bind(this));
router.get('/', this.getIndex.bind(this));
router.delete('/:pageId', this.deletePage.bind(this));
return router;
}
async populatePageId (req, res, next, pageId) {
const { page: pageService } = this.dtp.services;
try {
res.locals.page = await pageService.getById(pageId);
if (!res.locals.page) {
throw new SiteError(404, 'Page not found');
}
return next();
} catch (error) {
this.log.error('failed to populate pageId', { pageId, error });
return next(error);
}
}
async pageUpdatePage (req, res, next) {
const { page: pageService } = this.dtp.services;
try {
await pageService.update(res.locals.page, req.body);
res.redirect('/admin/page');
} catch (error) {
this.log.error('failed to update page', { newletterId: res.locals.page._id, error });
return next(error);
}
}
async pageCreatePage (req, res, next) {
const { page: pageService } = this.dtp.services;
try {
await pageService.create(req.user, req.body);
res.redirect('/admin/page');
} catch (error) {
this.log.error('failed to create page', { error });
return next(error);
}
}
async getComposer (req, res, next) {
const { page: pageService } = this.dtp.services;
try {
let excludedPages;
if (res.locals.page) {
excludedPages = [res.locals.page._id];
}
res.locals.availablePages = await pageService.getAvailablePages(excludedPages);
res.render('admin/page/editor');
} catch (error) {
this.log.error('failed to serve page editor', { error });
return next(error);
}
}
async getIndex (req, res, next) {
const { page: pageService } = this.dtp.services;
try {
res.locals.pagination = this.getPaginationParameters(req, 20);
res.locals.pages = await pageService.getPages(res.locals.pagination, ['draft', 'published', 'archived']);
res.render('admin/page/index');
} catch (error) {
this.log.error('failed to fetch pages', { error });
return next(error);
}
}
async deletePage (req, res) {
const { page: pageService } = this.dtp.services;
try {
const displayList = this.createDisplayList('delete-page');
await pageService.deletePage(res.locals.page);
displayList.removeElement(`li[data-page-id="${res.locals.page._id}"]`);
displayList.showNotification(
`Page "${res.locals.page.title}" deleted`,
'success',
'bottom-center',
3000,
);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to delete page', {
pageId: res.local.page._id,
error,
});
res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
}
module.exports = async (dtp) => {
let controller = new PageController(dtp);
return controller;
};

152
app/controllers/admin/post.js

@ -1,152 +0,0 @@
// admin/post.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'admin:post';
const express = require('express');
const multer = require('multer');
const { SiteController, SiteError } = require('../../../lib/site-lib');
class PostController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
}
async start ( ) {
const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${DTP_COMPONENT_NAME}` });
const router = express.Router();
router.use(async (req, res, next) => {
res.locals.currentView = 'admin';
res.locals.adminView = 'post';
return next();
});
router.param('postId', this.populatePostId.bind(this));
router.post('/:postId/image', upload.single('imageFile'), this.postUpdateImage.bind(this));
router.post('/:postId', this.postUpdatePost.bind(this));
router.post('/', this.postCreatePost.bind(this));
router.get('/compose', this.getComposer.bind(this));
router.get('/:postId', this.getComposer.bind(this));
router.get('/', this.getIndex.bind(this));
router.delete('/:postId', this.deletePost.bind(this));
return router;
}
async populatePostId (req, res, next, postId) {
const { post: postService } = this.dtp.services;
try {
res.locals.post = await postService.getById(postId);
if (!res.locals.post) {
throw new SiteError(404, 'Post not found');
}
return next();
} catch (error) {
this.log.error('failed to populate postId', { postId, error });
return next(error);
}
}
async postUpdateImage (req, res) {
const { post: postService } = this.dtp.services;
try {
const displayList = this.createDisplayList('post-image');
await postService.updateImage(req.user, res.locals.post, req.file);
displayList.showNotification(
'Profile photo updated successfully.',
'success',
'bottom-center',
2000,
);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to update feature image', { error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async postUpdatePost (req, res, next) {
const { post: postService } = this.dtp.services;
try {
await postService.update(res.locals.post, req.body);
res.redirect('/admin/post');
} catch (error) {
this.log.error('failed to update post', { newletterId: res.locals.post._id, error });
return next(error);
}
}
async postCreatePost (req, res, next) {
const { post: postService } = this.dtp.services;
try {
await postService.create(req.user, req.body);
res.redirect('/admin/post');
} catch (error) {
this.log.error('failed to create post', { error });
return next(error);
}
}
async getComposer (req, res) {
res.render('admin/post/editor');
}
async getIndex (req, res, next) {
const { post: postService } = this.dtp.services;
try {
res.locals.pagination = this.getPaginationParameters(req, 20);
res.locals.posts = await postService.getAllPosts(res.locals.pagination);
res.render('admin/post/index');
} catch (error) {
this.log.error('failed to fetch posts', { error });
return next(error);
}
}
async deletePost (req, res) {
const { post: postService } = this.dtp.services;
try {
const displayList = this.createDisplayList('delete-post');
await postService.deletePost(res.locals.post);
displayList.removeElement(`li[data-post-id="${res.locals.post._id}"]`);
displayList.showNotification(
`Post "${res.locals.post.title}" deleted`,
'success',
'bottom-center',
3000,
);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to delete post', {
postId: res.local.post._id,
error,
});
res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
}
module.exports = async (dtp) => {
let controller = new PostController(dtp);
return controller;
};

2
app/controllers/admin/settings.js

@ -1,5 +1,5 @@
// admin/settings.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/controllers/admin/user.js

@ -1,5 +1,5 @@
// admin/user.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/controllers/auth.js

@ -1,5 +1,5 @@
// auth.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

158
app/controllers/comment.js

@ -1,158 +0,0 @@
// comment.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'comment';
const express = require('express');
const numeral = require('numeral');
const { SiteController, SiteError } = require('../../lib/site-lib');
class CommentController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
}
async start ( ) {
const { dtp } = this;
const { limiter: limiterService, session: sessionService } = dtp.services;
const authRequired = sessionService.authCheckMiddleware({ requiredLogin: true });
const router = express.Router();
dtp.app.use('/comment', router);
router.use(async (req, res, next) => {
res.locals.currentView = DTP_COMPONENT_NAME;
return next();
});
router.param('commentId', this.populateCommentId.bind(this));
router.post('/:commentId/vote', authRequired, this.postVote.bind(this));
router.get('/:commentId/replies', this.getCommentReplies.bind(this));
router.delete('/:commentId',
authRequired,
limiterService.create(limiterService.config.comment.deleteComment),
this.deleteComment.bind(this),
);
}
async populateCommentId (req, res, next, commentId) {
const { comment: commentService } = this.dtp.services;
try {
res.locals.comment = await commentService.getById(commentId);
if (!res.locals.comment) {
return next(new SiteError(404, 'Comment not found'));
}
res.locals.post = res.locals.comment.resource;
return next();
} catch (error) {
this.log.error('failed to populate commentId', { commentId, error });
return next(error);
}
}
async postVote (req, res) {
const { contentVote: contentVoteService } = this.dtp.services;
try {
const displayList = this.createDisplayList('comment-vote');
const { message, stats } = await contentVoteService.recordVote(req.user, 'Comment', res.locals.comment, req.body.vote);
displayList.setTextContent(
`button[data-comment-id="${res.locals.comment._id}"][data-vote="up"] span.dtp-item-value`,
numeral(stats.upvoteCount).format(stats.upvoteCount > 1000 ? '0,0.0a' : '0,0'),
);
displayList.setTextContent(
`button[data-comment-id="${res.locals.comment._id}"][data-vote="down"] span.dtp-item-value`,
numeral(stats.downvoteCount).format(stats.upvoteCount > 1000 ? '0,0.0a' : '0,0'),
);
displayList.showNotification(message, 'success', 'bottom-center', 3000);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to process comment vote', { error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async getCommentReplies (req, res) {
const { comment: commentService } = this.dtp.services;
try {
const displayList = this.createDisplayList('get-replies');
if (req.query.buttonId) {
displayList.removeElement(`li.dtp-load-more[data-button-id="${req.query.buttonId}"]`);
}
Object.assign(res.locals, req.app.locals);
res.locals.countPerPage = parseInt(req.query.cpp || "20", 10);
if (res.locals.countPerPage < 1) {
res.locals.countPerPage = 1;
}
if (res.locals.countPerPage > 20) {
res.locals.countPerPage = 20;
}
res.locals.pagination = this.getPaginationParameters(req, res.locals.countPerPage);
res.locals.comments = await commentService.getReplies(res.locals.comment, res.locals.pagination);
const html = await commentService.renderTemplate('replyList', res.locals);
const replyList = `ul.dtp-reply-list[data-comment-id="${res.locals.comment._id}"]`;
displayList.addElement(replyList, 'beforeEnd', html);
const replyListContainer = `.dtp-reply-list-container[data-comment-id="${res.locals.comment._id}"]`;
displayList.removeAttribute(replyListContainer, 'hidden');
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to display comment replies', { error });
res.status(error.statusCode || 500).json({ success: false, message: error.message });
}
}
async deleteComment (req, res) {
const { comment: commentService } = this.dtp.services;
try {
const displayList = this.createDisplayList('add-recipient');
await commentService.remove(res.locals.comment, 'removed');
let selector = `article[data-comment-id="${res.locals.comment._id}"] .comment-content`;
displayList.setTextContent(selector, 'Comment removed');
displayList.showNotification(
'Comment removed successfully',
'success',
'bottom-center',
5000,
);
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to remove comment', { error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message
});
}
}
}
module.exports = {
slug: 'comment',
name: 'comment',
create: async (dtp) => {
let controller = new CommentController(dtp);
return controller;
},
};

78
app/controllers/email.js

@ -0,0 +1,78 @@
// email.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'email';
const express = require('express');
const { SiteController/*, SiteError*/ } = require('../../lib/site-lib');
class EmailController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
}
async start ( ) {
const { jobQueue: jobQueueService, limiter: limiterService } = this.dtp.services;
this.emailJobQueue = jobQueueService.getJobQueue('email', {
attempts: 3
});
const router = express.Router();
this.dtp.app.use('/email', router);
router.get(
'/verify',
limiterService.create(limiterService.config.email.getEmailVerify),
this.getEmailVerify.bind(this),
);
router.get(
'/opt-out',
limiterService.create(limiterService.config.email.getEmailOptOut),
this.getEmailOptOut.bind(this),
);
return router;
}
async getEmailOptOut (req, res, next) {
const { user: userService } = this.dtp.services;
try {
await userService.emailOptOut(req.query.u, req.query.c);
res.render('email/opt-out-success');
} catch (error) {
this.log.error('failed to opt-out from email', {
userId: req.query.t,
category: req.query.c,
error,
});
return next(error);
}
}
async getEmailVerify (req, res, next) {
const { email: emailService } = this.dtp.services;
try {
await emailService.verifyToken(req.query.t);
res.render('email/verify-success');
} catch (error) {
this.log.error('failed to verify email', { error });
return next(error);
}
}
}
module.exports = {
slug: 'email',
name: 'email',
create: async (dtp) => {
let controller = new EmailController(dtp);
return controller;
},
};

64
app/controllers/hive.js

@ -0,0 +1,64 @@
// hive.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'hive';
const path = require('path');
const express = require('express');
const { SiteController } = require('../../lib/site-lib');
class HiveController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
this.services = [ ];
}
async start ( ) {
const router = express.Router();
this.dtp.app.use('/hive', router);
router.use(
async (req, res, next) => {
res.locals.currentView = 'hive';
res.locals.hiveView = 'home';
/*
* TODO: H1V3 authentication before processing request (HTTP Bearer token)
*/
return next();
},
);
router.use('/kaleidoscope',await this.loadChild(path.join(__dirname, 'hive', 'kaleidoscope')));
this.services.push({ name: 'kaleidoscope', url: '/hive/kaleidoscope' });
router.get('/', this.getHiveRoot.bind(this));
return router;
}
async getHiveRoot (req, res) {
res.status(200).json({
component: DTP_COMPONENT_NAME,
host: this.dtp.pkg.name,
description: this.dtp.pkg.description,
version: this.dtp.pkg.version,
services: this.services,
});
}
}
module.exports = {
slug: 'hive',
name: 'hive',
create: async (dtp) => {
let controller = new HiveController(dtp);
return controller;
},
};

72
app/controllers/hive/kaleidoscope.js

@ -0,0 +1,72 @@
// hive/kaleidoscope.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'hive:kaleidoscope';
const express = require('express');
const { SiteController } = require('../../../lib/site-lib');
class HostController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
this.methods = [
{
name: 'postEvent',
url: '/kaleidoscope/event',
method: 'POST',
},
];
}
async start ( ) {
const router = express.Router();
router.use(async (req, res, next) => {
res.locals.currentView = 'hive';
res.locals.hiveView = 'kaleidoscope';
return next();
});
router.post('/core-node/connect', this.postCoreNodeConnect.bind(this));
router.post('/event', this.postEvent.bind(this));
router.get('/', this.getKaleidoscopeRoot.bind(this));
return router;
}
async postCoreNodeConnect (req, res, next) {
const { coreNode: coreNodeService } = this.dtp.services;
try {
await coreNodeService.connect(req.body);
} catch (error) {
this.log.error('failed to create Core Node connection', { error });
return next(error);
}
}
async postEvent (req, res) {
this.log.debug('kaleidoscope event received', { event: req.body.event });
this.emit('kaleidoscope:event', req, res);
res.status(200).json({ success: true });
}
async getKaleidoscopeRoot (req, res) {
res.status(200).json({
component: DTP_COMPONENT_NAME,
version: this.dtp.pkg.version,
services: this.services,
methods: this.methods,
});
}
}
module.exports = async (dtp) => {
let controller = new HostController(dtp);
return controller;
};

13
app/controllers/home.js

@ -1,5 +1,5 @@
// home.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
@ -65,17 +65,8 @@ class HomeController extends SiteController {
res.render('policy/view');
}
async getHome (req, res, next) {
const { post: postService } = this.dtp.services;
try {
res.locals.pagination = this.getPaginationParameters(req, 20);
res.locals.featuredPosts = await postService.getFeaturedPosts(3);
res.locals.posts = await postService.getPosts(res.locals.pagination);
async getHome (req, res) {
res.render('index');
} catch (error) {
return next(error);
}
}
}

2
app/controllers/image.js

@ -1,5 +1,5 @@
// page.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

4
app/controllers/manifest.js

@ -1,5 +1,5 @@
// manifest.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
@ -52,7 +52,7 @@ class ManifestController extends SiteController {
[512, 384, 256, 192, 144, 96, 72, 48, 32, 16].forEach((size) => {
manifest.icons.push({
src: `/img/icon/icon-${size}x${size}.png`,
src: `/img/icon/${this.dtp.config.site.domainKey}/icon-${size}x${size}.png`,
sizes: `${size}x${size}`,
type: 'image/png'
});

104
app/controllers/newsletter.js

@ -1,104 +0,0 @@
// newsletter.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'newsletter';
const express = require('express');
const multer = require('multer');
const { SiteController } = require('../../lib/site-lib');
class NewsletterController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
}
async start ( ) {
const { dtp } = this;
const { limiter: limiterService } = dtp.services;
const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${DTP_COMPONENT_NAME}` });
const router = express.Router();
dtp.app.use('/newsletter', router);
router.use(async (req, res, next) => {
res.locals.currentView = DTP_COMPONENT_NAME;
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;
},
};

70
app/controllers/user.js

@ -1,5 +1,5 @@
// user.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
@ -31,6 +31,12 @@ class UserController extends SiteController {
dtp.app.use('/user', router);
const authRequired = sessionService.authCheckMiddleware({ requireLogin: true });
const otpSetup = otpAuthService.middleware('Account', {
adminRequired: false,
otpRequired: true,
otpRedirectURL: async (req) => { return `/user/${req.user._id}`; },
});
const otpMiddleware = otpAuthService.middleware('Account', {
adminRequired: false,
otpRequired: false,
@ -58,31 +64,52 @@ class UserController extends SiteController {
router.param('userId', this.populateUser.bind(this));
router.post('/:userId/profile-photo',
router.post(
'/:userId/profile-photo',
limiterService.create(limiterService.config.user.postProfilePhoto),
checkProfileOwner,
upload.single('imageFile'),
this.postProfilePhoto.bind(this),
);
router.post('/:userId/settings',
router.post(
'/:userId/settings',
limiterService.create(limiterService.config.user.postUpdateSettings),
checkProfileOwner,
upload.none(),
this.postUpdateSettings.bind(this),
);
router.post('/',
router.post(
'/',
limiterService.create(limiterService.config.user.postCreate),
this.postCreateUser.bind(this),
);
router.get('/:userId/settings',
router.get(
'/:userId/otp-setup',
limiterService.create(limiterService.config.user.getOtpSetup),
otpSetup,
this.getOtpSetup.bind(this),
);
router.get(
'/:userId/otp-disable',
limiterService.create(limiterService.config.user.getOtpDisable),
authRequired,
this.getOtpDisable.bind(this),
);
router.get(
'/:userId/settings',
limiterService.create(limiterService.config.user.getSettings),
authRequired,
otpMiddleware,
checkProfileOwner,
this.getUserSettingsView.bind(this),
);
router.get('/:userId',
router.get(
'/:userId',
limiterService.create(limiterService.config.user.getUserProfile),
authRequired,
otpMiddleware,
@ -90,7 +117,8 @@ class UserController extends SiteController {
this.getUserView.bind(this),
);
router.delete('/:userId/profile-photo',
router.delete(
'/:userId/profile-photo',
limiterService.create(limiterService.config.user.deleteProfilePhoto),
authRequired,
checkProfileOwner,
@ -198,12 +226,7 @@ class UserController extends SiteController {
await userService.updateSettings(req.user, req.body);
displayList.showNotification(
'Member account settings updated successfully.',
'success',
'bottom-center',
6000,
);
displayList.reload();
res.status(200).json({ success: true, displayList });
} catch (error) {
this.log.error('failed to update account settings', { error });
@ -214,8 +237,29 @@ class UserController extends SiteController {
}
}
async getOtpSetup (req, res) {
res.render('user/otp-setup-complete');
}
async getOtpDisable (req, res) {
const { otpAuth: otpAuthService } = this.dtp.services;
try {
await otpAuthService.destroyOtpSession(req, 'Account');
await otpAuthService.removeForUser(req.user, 'Account');
res.render('user/otp-disabled');
} catch (error) {
this.log.error('failed to disable OTP service for Account', { error });
res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async getUserSettingsView (req, res, next) {
const { otpAuth: otpAuthService } = this.dtp.services;
try {
res.locals.hasOtpAccount = await otpAuthService.isUserProtected(req.user, 'Account');
res.locals.startTab = req.query.st || 'watch';
res.render('user/settings');
} catch (error) {

2
app/controllers/welcome.js

@ -1,5 +1,5 @@
// welcome.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

25
app/models/category.js

@ -1,25 +0,0 @@
// category.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const CategorySchema = new Schema({
name: { type: String },
slug: { type: String, lowercase: true, required: true, index: 1 },
description: { type: String },
images: {
header: { type: Schema.ObjectId },
icon: { type: Schema.ObjectId },
},
stats: {
articleCount: { type: Number, default: 0, required: true },
articleViewCount: { type: Number, default: 0, required: true },
},
});
module.exports = mongoose.model('Category', CategorySchema);

2
app/models/chat-message.js

@ -1,5 +1,5 @@
// chat-message.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License Apache-2.0
'use strict';

2
app/models/comment.js

@ -1,5 +1,5 @@
// comment.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/models/connect-token.js

@ -1,5 +1,5 @@
// connect-token.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/models/content-report.js

@ -1,5 +1,5 @@
// content-report.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/models/content-vote.js

@ -1,5 +1,5 @@
// content-vote.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

37
app/models/core-node-request.js

@ -0,0 +1,37 @@
// core-node-request.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
/*
* Used for authenticating responses received and gathering performance and use
* metrics for communications with Cores.
*
* When a request is created, an authentication token is generated and
* information about the request is stored. This also provides the request ID.
*
* When a resonse is received for a request, this record is fetched. The token
* claimed status and value are checked. Information about the response is
* recorded, and request execution time information is recorded.
*/
const CoreNodeRequestSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: 1 },
core: { type: Schema.ObjectId, required: true, ref: 'CoreNode' },
token: {
value: { type: String, required: true },
claimed: { type: Boolean, default: false, required: true },
},
url: { type: String },
response: {
received: { type: Date },
elapsed: { type: Number },
isError: { type: Boolean, default: false },
},
});
module.exports = mongoose.model('CoreNodeRequest', CoreNodeRequestSchema);

18
app/models/core-node.js

@ -0,0 +1,18 @@
// core-node.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const CoreNodeSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: 1 },
address: {
host: { type: String, required: true },
port: { type: Number, min: 1, max: 65535, required: true },
},
});
module.exports = mongoose.model('CoreNode', CoreNodeSchema);

2
app/models/csrf-token.js

@ -1,5 +1,5 @@
// csrf-token.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/models/email-blacklist.js

@ -1,5 +1,5 @@
// email-blacklist.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/models/email-body.js

@ -1,5 +1,5 @@
// email-body.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

19
app/models/email-log.js

@ -0,0 +1,19 @@
// email-log.js
// Copyright (C) 2022 DTP Technologies, LLC
// All Rights Reserved
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const EmailLogSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1 },
from: { type: String, required: true, },
to: { type: String, required: true },
to_lc: { type: String, required: true, lowercase: true, index: 1 },
subject: { type: String },
messageId: { type: String },
});
module.exports = mongoose.model('EmailLog', EmailLogSchema);

18
app/models/email-verify.js

@ -0,0 +1,18 @@
// email-verify.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const EmailVerifySchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1, expires: '30d' },
verified: { type: Date },
user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' },
token: { type: String, required: true },
});
module.exports = mongoose.model('EmailVerify', EmailVerifySchema);

2
app/models/email.js

@ -1,5 +1,5 @@
// email.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/models/image.js

@ -1,5 +1,5 @@
// image.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/models/lib/geo-types.js

@ -1,5 +1,5 @@
// geo-types.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/models/lib/resource-stats.js

@ -1,5 +1,5 @@
// lib/resource-stats.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/models/log.js

@ -1,5 +1,5 @@
// log.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/models/net-host-stats.js

@ -1,5 +1,5 @@
// net-host-stats.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/models/net-host.js

@ -1,5 +1,5 @@
// net-host.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

21
app/models/newsletter-recipient.js

@ -1,21 +0,0 @@
// newsletter-recipient.js
// Copyright (C) 2021 Digital Telepresence, 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);

31
app/models/newsletter.js

@ -1,31 +0,0 @@
// newsletter.js
// Copyright (C) 2021 Digital Telepresence, 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);

2
app/models/otp-account.js

@ -1,5 +1,5 @@
// otp-account.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

29
app/models/page.js

@ -1,29 +0,0 @@
// page.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const PAGE_STATUS_LIST = ['draft','published','archived'];
const PageSchema = new Schema({
title: { type: String, required: true },
slug: { type: String, required: true, lowercase: true, unique: true },
image: {
header: { type: Schema.ObjectId, ref: 'Image' },
icon: { type: Schema.ObjectId, ref: 'Image' },
},
content: { type: String, required: true, select: false },
status: { type: String, enum: PAGE_STATUS_LIST, default: 'draft', index: true },
menu: {
icon: { type: String, required: true },
label: { type: String, required: true },
order: { type: Number, default: 0, required: true },
parent: { type: Schema.ObjectId, index: 1, ref: 'Page' },
},
});
module.exports = mongoose.model('Page', PageSchema);

36
app/models/post.js

@ -1,36 +0,0 @@
// post.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const path = require('path');
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const {
ResourceStats,
ResourceStatsDefaults,
} = require(path.join(__dirname, 'lib', 'resource-stats.js'));
const POST_STATUS_LIST = ['draft','published','archived'];
const PostSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1 },
updated: { type: Date },
author: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' },
image: { type: Schema.ObjectId, ref: 'Image' },
title: { type: String, required: true },
slug: { type: String, required: true, lowercase: true, unique: true },
summary: { type: String, required: true },
content: { type: String, required: true, select: false },
status: { type: String, enum: POST_STATUS_LIST, default: 'draft', index: true },
stats: { type: ResourceStats, default: ResourceStatsDefaults, required: true },
flags: {
enableComments: { type: Boolean, default: true, index: true },
isFeatured: { type: Boolean, default: false, index: true },
},
});
module.exports = mongoose.model('Post', PostSchema);

2
app/models/resource-view.js

@ -1,5 +1,5 @@
// resource-view.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/models/resource-visit.js

@ -1,5 +1,5 @@
// resource-visit.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/models/user-block.js

@ -1,5 +1,5 @@
// user-block.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/models/user-notification.js

@ -1,5 +1,5 @@
// user-notification.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

14
app/models/user.js

@ -1,5 +1,5 @@
// user.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
@ -10,6 +10,8 @@ const Schema = mongoose.Schema;
const { ResourceStats, ResourceStatsDefaults } = require('./lib/resource-stats');
const DTP_THEME_LIST = ['dtp-light', 'dtp-dark'];
const UserFlagsSchema = new Schema({
isAdmin: { type: Boolean, default: false, required: true },
isModerator: { type: Boolean, default: false, required: true },
@ -20,8 +22,11 @@ const UserPermissionsSchema = new Schema({
canChat: { type: Boolean, default: true, required: true },
canComment: { type: Boolean, default: true, required: true },
canReport: { type: Boolean, default: true, required: true },
canAuthorPages: { type: Boolean, default: false, required: true },
canAuthorPosts: { type: Boolean, default: false, required: true },
});
const UserOptInSchema = new Schema({
system: { type: Boolean, default: true, required: true },
marketing: { type: Boolean, default: true, required: true },
});
const UserSchema = new Schema({
@ -32,13 +37,14 @@ const UserSchema = new Schema({
passwordSalt: { type: String, required: true },
password: { type: String, required: true },
displayName: { type: String },
bio: { type: String, maxlength: 300 },
picture: {
large: { type: Schema.ObjectId, ref: 'Image' },
small: { type: Schema.ObjectId, ref: 'Image' },
},
flags: { type: UserFlagsSchema, select: false },
permissions: { type: UserPermissionsSchema, select: false },
optIn: { type: UserOptInSchema, required: true, select: false },
theme: { type: String, enum: DTP_THEME_LIST, default: 'dtp-light', required: true },
stats: { type: ResourceStats, default: ResourceStatsDefaults, required: true },
});

2
app/services/cache.js

@ -1,5 +1,5 @@
// cache.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/services/chat.js

@ -1,5 +1,5 @@
// chat.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/services/comment.js

@ -1,5 +1,5 @@
// comment.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/services/content-report.js

@ -1,5 +1,5 @@
// content-report.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/services/content-vote.js

@ -1,5 +1,5 @@
// content-vote.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

97
app/services/core-node.js

@ -0,0 +1,97 @@
// core-node.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const uuidv4 = require('uuid').v4;
const mongoose = require('mongoose');
const fetch = require('node-fetch'); // jshint ignore:line
const CoreNode = mongoose.model('CoreNode');
const CoreNodeRequest = mongoose.model('CoreNodeRequest');
const { SiteService, SiteError } = require('../../lib/site-lib');
class CoreNodeService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
}
async create (coreDefinition) {
const core = new CoreNode();
core.created = new Date();
core.address = { };
if (!coreDefinition.host) {
throw new SiteError(406, 'Must provide Core Node host address');
}
core.address.host = coreDefinition.host.trim();
if (!coreDefinition.port) {
throw new SiteError(406, 'Must provide Core Node TCP port number');
}
coreDefinition.port = parseInt(coreDefinition.port, 10);
if (coreDefinition.port < 1 || coreDefinition.port > 65535) {
throw new SiteError(406, 'Core Node port number out of range');
}
await core.save();
return core.toObject();
}
async broadcast (request) {
const results = [ ];
await CoreNode
.find()
.cursor()
.eachAsync(async (core) => {
try {
const response = await this.sendRequest(core, request);
results.push({ coreId: core._id, request, response });
} catch (error) {
this.log.error('failed to send Core Node request', { core: core._id, request: request.url, error });
}
});
return results;
}
async sendRequest (core, request) {
const requestUrl = `https://${core.address.host}:${core.address.port}${request.url}`;
const req = new CoreNodeRequest();
req.created = new Date();
req.core = core._id;
req.token = {
value: uuidv4(),
claimed: false,
};
req.url = request.url;
await req.save();
try {
const response = await fetch(requestUrl, {
method: request.method,
body: request.body,
});
const json = await response.json();
return { request: req.toObject(), response: json };
} catch (error) {
this.log.error('failed to send Core Node request', { core: core._id, request: request.url, error });
throw error;
}
return req.toObject();
}
}
module.exports = {
slug: 'core-node',
name: 'coreNode',
create: (dtp) => { return new CoreNodeService(dtp); },
};

2
app/services/crypto.js

@ -1,5 +1,5 @@
// crypto.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/services/csrf-token.js

@ -1,5 +1,5 @@
// csrf-token.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

9
app/services/display-engine.js

@ -1,5 +1,5 @@
// display-engine.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
@ -109,6 +109,13 @@ class DisplayList {
params: { href },
});
}
reload ( ) {
this.commands.push({
action: 'reload',
params: { },
});
}
}
class DisplayEngineService extends SiteService {

86
app/services/email.js

@ -1,22 +1,23 @@
// email.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const path = require('path');
const pug = require('pug');
const nodemailer = require('nodemailer');
const uuidv4 = require('uuid').v4;
const mongoose = require('mongoose');
const EmailBlacklist = mongoose.model('EmailBlacklist');
const EmailVerify = mongoose.model('EmailVerify');
const EmailLog = mongoose.model('EmailLog');
const disposableEmailDomains = require('disposable-email-provider-domains');
const emailValidator = require('email-validator');
const emailDomainCheck = require('email-domain-check');
const { SiteService } = require('../../lib/site-lib');
const { SiteService, SiteError } = require('../../lib/site-lib');
class EmailService extends SiteService {
@ -27,7 +28,8 @@ class EmailService extends SiteService {
async start ( ) {
await super.start();
if (process.env.DTP_EMAIL_ENABLED !== 'enabled') {
if (process.env.DTP_EMAIL_SERVICE !== 'enabled') {
this.log.info("DTP_EMAIL_SERVICE is disabled, the system can't send email and will not try.");
return;
}
@ -51,17 +53,35 @@ class EmailService extends SiteService {
this.templates = {
html: {
welcome: pug.compileFile(path.join(this.dtp.config.root, 'app', 'templates', 'html', 'welcome.pug')),
userEmail: this.loadAppTemplate('html', 'user-email.pug'),
welcome: this.loadAppTemplate('html', 'welcome.pug'),
},
text: {
welcome: pug.compileFile(path.join(this.dtp.config.root, 'app', 'templates', 'text', 'welcome.pug')),
userEmail: this.loadAppTemplate('text', 'user-email.pug'),
welcome: this.loadAppTemplate('text', 'welcome.pug'),
},
};
}
async renderTemplate (templateId, templateType, templateModel) {
this.log.debug('rendering email template', { templateId, templateType });
return this.templates[templateType][templateId](templateModel);
}
async send (message) {
const NOW = new Date();
this.log.info('sending email', { to: message.to, subject: message.subject });
await this.transport.sendMail(message);
const response = await this.transport.sendMail(message);
await EmailLog.create({
created: NOW,
from: message.from,
to: message.to,
to_lc: message.to.toLowerCase(),
subject: message.subject,
messageId: response.messageId,
});
}
async checkEmailAddress (emailAddress) {
@ -97,8 +117,52 @@ class EmailService extends SiteService {
return false;
}
async renderTemplate (templateId, templateType, message) {
return this.templates[templateType][templateId](message);
async createVerificationToken (user) {
const NOW = new Date();
const verify = new EmailVerify();
verify.created = NOW;
verify.user = user._id;
verify.token = uuidv4();
await verify.save();
this.log.info('created email verification token for user', { user: user._id });
return verify.toObject();
}
async verifyToken (token) {
const NOW = new Date();
const { user: userService } = this.dtp.services;
// fetch the token from the db
const emailVerify = await EmailVerify
.findOne({ token: token })
.populate(this.populateEmailVerify)
.lean();
// verify that the token is at least valid (it exists)
if (!emailVerify) {
this.log.error('email verify token not found', { token });
throw new SiteError(403, 'Email verification token is invalid');
}
// verify that it hasn't already been verified (user clicked link more than once)
if (emailVerify.verified) {
this.log.error('email verify token already claimed', { token });
throw new SiteError(403, 'Email verification token is invalid');
}
this.log.info('marking user email verified', { userId: emailVerify.user._id });
await userService.setEmailVerification(emailVerify.user, true);
await EmailVerify.updateOne({ _id: emailVerify._id }, { $set: { verified: NOW } });
}
async removeVerificationTokensForUser (user) {
this.log.info('removing all pending email address verification tokens for user', { user: user._id });
await EmailVerify.deleteMany({ user: user._id });
}
}

2
app/services/host-cache.js

@ -1,5 +1,5 @@
// host-cache.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/services/image.js

@ -1,5 +1,5 @@
// minio.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

3
app/services/job-queue.js

@ -1,5 +1,5 @@
// job-queue.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
@ -46,7 +46,6 @@ class JobQueueService extends SiteService {
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD,
keyPrefix: process.env.REDIS_KEY_PREFIX,
lazyConnect: true,
},
defaultJobOptions,
});

2
app/services/limiter.js

@ -1,5 +1,5 @@
// limiter.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/services/log.js

@ -1,5 +1,5 @@
// log.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/services/markdown.js

@ -1,5 +1,5 @@
// markdown.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/services/media.js

@ -1,5 +1,5 @@
// article.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/services/minio.js

@ -1,5 +1,5 @@
// minio.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

123
app/services/newsletter.js

@ -1,123 +0,0 @@
// newsletter.js
// Copyright (C) 2021 Digital Telepresence, 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); },
};

191
app/services/oauth2.js

@ -0,0 +1,191 @@
// oauth2.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const passport = require('passport');
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const uuidv4 = require('uuid').v4;
const oauth2orize = require('oauth2orize');
const { SiteService/*, SiteError*/ } = require('../../lib/site-lib');
class OAuth2Service extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
}
async start ( ) {
this.models = { };
/*
* OAuth2Client Model
*/
const ClientSchema = new Schema({
created: { type: Date, default: Date.now, required: true },
updated: { type: Date, default: Date.now, required: true },
secret: { type: String, required: true },
redirectURI: { type: String, required: true },
});
this.log.info('registering OAuth2Client model');
this.models.Client = mongoose.model('OAuth2Client', ClientSchema);
/*
* OAuth2AuthorizationCode model
*/
const AuthorizationCodeSchema = new Schema({
code: { type: String, required: true, index: 1 },
clientId: { type: Schema.ObjectId, required: true, index: 1 },
redirectURI: { type: String, required: true },
user: { type: Schema.ObjectId, required: true, index: 1 },
scope: { type: [String], required: true },
});
this.log.info('registering OAuth2AuthorizationCode model');
this.models.AuthorizationCode = mongoose.model('OAuth2AuthorizationCode', AuthorizationCodeSchema);
/*
* OAuth2AccessToken model
*/
const AccessTokenSchema = new Schema({
token: { type: String, required: true, unique: true, index: 1 },
user: { type: Schema.ObjectId, required: true, index: 1 },
clientId: { type: Schema.ObjectId, required: true, index: 1 },
scope: { type: [String], required: true },
});
this.log.info('registering OAuth2AccessToken model');
this.models.AccessToken = mongoose.model('OAuth2AccessToken', AccessTokenSchema);
/*
* Create OAuth2 server instance
*/
const options = { };
this.log.info('creating OAuth2 server instance', { options });
this.server = oauth2orize.createServer(options);
this.server.grant(oauth2orize.grant.code(this.processGrant.bind(this)));
this.server.exchange(oauth2orize.exchange.code(this.processExchange.bind(this)));
/*
* Register client serialization callbacks
*/
this.log.info('registering OAuth2 client serialization routines');
this.server.serializeClient(this.serializeClient.bind(this));
this.server.deserializeClient(this.deserializeClient.bind(this));
}
async serializeClient (client, done) {
return done(null, client.id);
}
async deserializeClient (clientId, done) {
try {
const client = await this.models.Client.findOne({ _id: clientId }).lean();
return done(null, client);
} catch (error) {
this.log.error('failed to deserialize OAuth2 client', { clientId, error });
return done(error);
}
}
attachRoutes (app) {
const { session: sessionService } = this.dtp.services;
const requireLogin = sessionService.authCheckMiddleware({ requireLogin: true });
app.get(
'/dialog/authorize',
requireLogin,
this.server.authorize(this.processAuthorize.bind(this)),
this.renderAuthorizeDialog.bind(this),
);
app.post(
'/dialog/authorize/decision',
requireLogin,
this.server.decision(),
);
app.post(
'/token',
passport.authenticate(['basic', 'oauth2-client-password'], { session: false }),
this.server.token(),
this.server.errorHandler(),
);
}
async renderAuthorizeDialog (req, res) {
res.locals.transactionID = req.oauth2.transactionID;
res.locals.client = req.oauth2.client;
res.render('oauth2/authorize-dialog');
}
async processAuthorize (clientID, redirectURI, done) {
try {
const client = await this.models.Clients.findOne({ clientID });
if (!client) {
return done(null, false);
}
if (client.redirectUri !== redirectURI) {
return done(null, false);
}
return done(null, client, client.redirectURI);
} catch (error) {
this.log.error('failed to process OAuth2 authorize', { error });
return done(error);
}
}
async processGrant (client, redirectURI, user, ares, done) {
try {
var code = uuidv4();
var ac = new this.models.AuthorizationCode({
code,
clientId: client.id,
redirectURI,
user: user.id,
scope: ares.scope,
});
await ac.save();
return done(null, code);
} catch (error) {
this.log.error('failed to process OAuth2 grant', { error });
return done(error);
}
}
async processExchange (client, code, redirectURI, done) {
try {
const ac = await this.models.AuthorizationCode.findOne({ code });
if (client.id !== ac.clientId) {
return done(null, false);
}
if (redirectURI !== ac.redirectUri) {
return done(null, false);
}
var token = uuidv4();
var at = new this.models.AccessToken({
token,
user: ac.userId,
clientId: ac.clientId,
scope: ac.scope,
});
await at.save();
return done(null, token);
} catch (error) {
this.log.error('failed to process OAuth2 exchange', { error });
return done(error);
}
}
}
module.exports = {
slug: 'oauth2',
name: 'oauth2',
create: (dtp) => { return new OAuth2Service(dtp); },
};

10
app/services/otp-auth.js

@ -1,5 +1,5 @@
// otp-auth.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
@ -213,6 +213,14 @@ class OtpAuthService extends SiteService {
return true;
}
async isUserProtected (user, serviceName) {
const account = await OtpAccount.findOne({ user: user._id, service: serviceName });
if (!account) {
return false;
}
return true;
}
async removeForUser (user) {
return await OtpAccount.deleteMany({ user: user });
}

173
app/services/page.js

@ -1,173 +0,0 @@
// page.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const striptags = require('striptags');
const slug = require('slug');
const { SiteService } = require('../../lib/site-lib');
const mongoose = require('mongoose');
const ObjectId = mongoose.Types.ObjectId;
const Page = mongoose.model('Page');
class PageService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
}
async menuMiddleware (req, res, next) {
try {
const pages = await Page
.find({ parent: { $exists: false } })
.select('slug menu')
.lean();
res.locals.mainMenu = pages
.filter((page) => !page.parent)
.map((page) => {
return {
url: `/page/${page.slug}`,
slug: page.slug,
icon: page.menu.icon,
label: page.menu.label,
order: page.menu.order,
};
})
.sort((a, b) => {
return a.order < b.order;
});
return next();
} catch (error) {
this.log.error('failed to build page menu', { error });
return next();
}
}
async create (author, pageDefinition) {
const page = new Page();
page.title = striptags(pageDefinition.title.trim());
page.slug = this.createPageSlug(page._id, page.title);
page.content = pageDefinition.content.trim();
page.status = pageDefinition.status || 'draft';
page.menu = {
icon: striptags((pageDefinition.menuIcon || 'fa-slash').trim().toLowerCase()),
label: striptags((pageDefinition.menuLabel || page.title.slice(0, 10))),
order: parseInt(pageDefinition.menuOrder || '0', 10),
};
if (pageDefinition.parentPageId && (pageDefinition.parentPageId !== 'none')) {
page.menu.parent = pageDefinition.parentPageId;
}
await page.save();
return page.toObject();
}
async update (page, pageDefinition) {
const NOW = new Date();
const updateOp = {
$set: {
updated: NOW,
},
};
if (pageDefinition.title) {
updateOp.$set.title = striptags(pageDefinition.title.trim());
}
if (pageDefinition.slug) {
let pageSlug = striptags(slug(pageDefinition.slug.trim())).split('-');
while (ObjectId.isValid(pageSlug[pageSlug.length - 1])) {
pageSlug.pop();
}
pageSlug = pageSlug.splice(0, 4);
pageSlug.push(page._id.toString());
updateOp.$set.slug = `${pageSlug.join('-')}`;
}
if (pageDefinition.summary) {
updateOp.$set.summary = striptags(pageDefinition.summary.trim());
}
if (pageDefinition.content) {
updateOp.$set.content = pageDefinition.content.trim();
}
if (pageDefinition.status) {
updateOp.$set.status = striptags(pageDefinition.status.trim());
}
updateOp.$set.menu = {
icon: striptags((pageDefinition.menuIcon || 'fa-slash').trim().toLowerCase()),
label: striptags((pageDefinition.menuLabel || page.title.slice(0, 10))),
order: parseInt(pageDefinition.menuOrder || '0', 10),
};
if (pageDefinition.parentPageId && (pageDefinition.parentPageId !== 'none')) {
updateOp.$set.menu.parent = pageDefinition.parentPageId;
}
await Page.updateOne(
{ _id: page._id },
updateOp,
{ upsert: true },
);
}
async getPages (pagination, status = ['published']) {
if (!Array.isArray(status)) {
status = [status];
}
const pages = await Page
.find({ status: { $in: status } })
.sort({ created: -1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.lean();
return pages;
}
async getById (pageId) {
const page = await Page
.findById(pageId)
.select('+content')
.lean();
return page;
}
async getBySlug (pageSlug) {
const slugParts = pageSlug.split('-');
const pageId = slugParts[slugParts.length - 1];
return this.getById(pageId);
}
async getAvailablePages (excludedPageIds) {
const search = { };
if (excludedPageIds) {
search._id = { $nin: excludedPageIds };
}
const pages = await Page.find(search).lean();
return pages;
}
async deletePage (page) {
this.log.info('deleting page', { pageId: page._id });
await Page.deleteOne({ _id: page._id });
}
createPageSlug (pageId, pageTitle) {
if ((typeof pageTitle !== 'string') || (pageTitle.length < 1)) {
throw new Error('Invalid input for making a page slug');
}
const pageSlug = slug(pageTitle.trim().toLowerCase()).split('-').slice(0, 4).join('-');
return `${pageSlug}-${pageId}`;
}
}
module.exports = {
slug: 'page',
name: 'page',
create: (dtp) => { return new PageService(dtp); },
};

61
app/services/phone.js

@ -0,0 +1,61 @@
// phone.js
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const {
libphonenumber,
striptags,
SiteService,
SiteError,
} = require('../../lib/site-lib');
class PhoneService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
}
async processPhoneNumberInput (phoneNumberInput, country = 'US') {
const { parsePhoneNumber } = libphonenumber;
const phoneCheck = await parsePhoneNumber(striptags(phoneNumberInput.trim()), country);
if (!phoneCheck.isValid()) {
throw new SiteError(400, 'The phone number entered is not valid');
}
// store everything this library provides about the new phone number
const phoneNumber = {
type: phoneCheck.getType(),
number: phoneCheck.number,
countryCallingCode: phoneCheck.countryCallingCode,
nationalNumber: phoneCheck.nationalNumber,
country: phoneCheck.country,
ext: phoneCheck.ext,
carrierCode: phoneCheck.carrierCode,
};
if (phoneCheck.carrierCode) {
phoneNumber.carrierCode = phoneCheck.carrierCode;
}
if (phoneCheck.ext) {
phoneNumber.ext = phoneCheck.ext;
}
phoneNumber.display = {
national: phoneCheck.formatNational(),
international: phoneCheck.formatInternational(),
uri: phoneCheck.getURI(),
};
return phoneNumber;
}
}
module.exports = {
slug: 'phone',
name: 'phone',
create: (dtp) => { return new PhoneService(dtp); },
};

2
app/services/resource.js

@ -1,5 +1,5 @@
// resource.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/services/session.js

@ -1,5 +1,5 @@
// session.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/services/sms.js

@ -1,5 +1,5 @@
// sms.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

2
app/services/user-notification.js

@ -1,5 +1,5 @@
// user-notification.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';

132
app/services/user.js

@ -1,9 +1,11 @@
// user.js
// Copyright (C) 2021 Digital Telepresence, LLC
// Copyright (C) 2022 DTP Technologies, LLC
// License: Apache-2.0
'use strict';
const path = require('path');
const mongoose = require('mongoose');
const User = mongoose.model('User');
@ -24,6 +26,8 @@ class UserService {
this.dtp = dtp;
this.log = new SiteLog(dtp, `svc:${module.exports.slug}`);
this.reservedNames = require(path.join(this.dtp.config.root, 'config', 'reserved-names'));
this.populateUser = [
{
path: 'picture.large',
@ -101,13 +105,18 @@ class UserService {
canChat: true,
canComment: true,
canReport: true,
canAuthorPages: false,
canAuthorPosts: false,
};
user.optIn = {
system: true,
marketing: false,
};
this.log.info('creating new user account', { email: userDefinition.email });
await user.save();
await this.sendWelcomeEmail(user);
return user.toObject();
} catch (error) {
this.log.error('failed to create user', { error });
@ -115,6 +124,71 @@ class UserService {
}
}
async sendWelcomeEmail (user) {
const { email: emailService } = this.dtp.services;
/*
* Remove all pending EmailVerify tokens for the User.
*/
await emailService.removeVerificationTokensForUser(user);
/*
* Create the new/only EmailVerify token for the user. This will be the only
* token accepted. Previous emails sent (if they were received) are invalid
* after this.
*/
const verifyToken = await emailService.createVerificationToken(user);
/*
* Send the welcome email using the new EmailVerify token so it can
* construct a new, valid link to use for verifying the email address.
*/
const templateModel = {
site: this.dtp.config.site,
recipient: user,
emailVerifyToken: verifyToken.token,
};
const message = {
from: process.env.DTP_EMAIL_SMTP_FROM,
to: user.email,
subject: `Welcome to ${this.dtp.config.site.name}!`,
html: await emailService.renderTemplate('welcome', 'html', templateModel),
text: await emailService.renderTemplate('welcome', 'text', templateModel),
};
await emailService.send(message);
}
async setEmailVerification (user, isVerified) {
await User.updateOne(
{ _id: user._id },
{
$set: { 'flags.isEmailVerified': isVerified },
},
);
}
async emailOptOut (userId, category) {
userId = mongoose.Types.ObjectId(userId);
const user = await this.getUserAccount(userId);
if (!user) {
throw new SiteError(406, 'Invalid opt-out token');
}
const updateOp = { $set: { } };
switch (category) {
case 'marketing':
updateOp.$set['optIn.marketing'] = false;
break;
case 'system':
updateOp.$set['optIn.system'] = false;
break;
default:
throw new SiteError(406, 'Invalid opt-out category');
}
await User.updateOne({ _id: userId }, updateOp);
}
async update (user, userDefinition) {
if (!user.flags.canLogin) {
throw SiteError(403, 'Invalid user account operation');
@ -125,9 +199,6 @@ class UserService {
const username_lc = userDefinition.username.toLowerCase();
userDefinition.displayName = striptags(userDefinition.displayName.trim());
if (userDefinition.bio) {
userDefinition.bio = striptags(userDefinition.bio.trim());
}
this.log.info('updating user', { userDefinition });
await User.updateOne(
@ -137,7 +208,8 @@ class UserService {
username: userDefinition.username,
username_lc,
displayName: userDefinition.displayName,
bio: userDefinition.bio,
'optIn.system': userDefinition['optIn.system'] === 'on',
'optIn.marketing': userDefinition['optIn.marketing'] === 'on',
},
},
);
@ -149,9 +221,6 @@ class UserService {
const username_lc = userDefinition.username.toLowerCase();
userDefinition.displayName = striptags(userDefinition.displayName.trim());
if (userDefinition.bio) {
userDefinition.bio = striptags(userDefinition.bio.trim());
}
this.log.info('updating user for admin', { userDefinition });
await User.updateOne(
@ -161,15 +230,12 @@ class UserService {
username: userDefinition.username,
username_lc,
displayName: userDefinition.displayName,
bio: userDefinition.bio,
'flags.isAdmin': userDefinition.isAdmin === 'on',
'flags.isModerator': userDefinition.isModerator === 'on',
'permissions.canLogin': userDefinition.canLogin === 'on',
'permissions.canChat': userDefinition.canChat === 'on',
'permissions.canComment': userDefinition.canComment === 'on',
'permissions.canReport': userDefinition.canReport === 'on',
'permissions.canAuthorPages': userDefinition.canAuthorPages === 'on',
'permissions.canAuthorPosts': userDefinition.canAuthorPosts === 'on',
},
},
);
@ -181,7 +247,6 @@ class UserService {
const username_lc = userDefinition.username.toLowerCase();
userDefinition.displayName = striptags(userDefinition.displayName.trim());
userDefinition.bio = striptags(userDefinition.bio.trim());
this.log.info('updating user settings', { userDefinition });
await User.updateOne(
@ -191,7 +256,7 @@ class UserService {
username: userDefinition.username,
username_lc,
displayName: userDefinition.displayName,
bio: userDefinition.bio,
theme: userDefinition.theme || 'dtp-light',
},
},
);
@ -215,10 +280,10 @@ class UserService {
{ username_lc: accountUsername },
]
})
.select('+passwordSalt +password +flags')
.select('+passwordSalt +password +flags +optIn +permissions')
.lean();
if (!user) {
throw new SiteError(404, 'Member account not found');
throw new SiteError(404, 'Member credentials are invalid');
}
const maskedPassword = crypto.maskPassword(
@ -226,7 +291,7 @@ class UserService {
account.password,
);
if (maskedPassword !== user.password) {
throw new SiteError(403, 'Account credentials do not match');
throw new SiteError(403, 'Member credentials are invalid');
}
// remove these critical fields from the user object
@ -328,7 +393,7 @@ class UserService {
async getUserAccount (userId) {
const user = await User
.findById(userId)
.select('+email +flags +permissions +picture')
.select('+email +flags +permissions +optIn +picture')
.populate(this.populateUser)
.lean();
if (!user) {
@ -345,7 +410,7 @@ class UserService {
const users = await User
.find(search)
.sort({ username_lc: 1 })
.select('+email +flags +permissions')
.select('+email +flags +permissions +optIn')
.skip(pagination.skip)
.limit(pagination.cpp)
.lean()
@ -375,7 +440,7 @@ class UserService {
username = username.trim().toLowerCase();
const user = await User
.findOne({ username_lc: username })
.select('_id created username username_lc displayName bio picture header')
.select('_id created username username_lc displayName picture header')
.populate(this.populateUser)
.lean();
return user;
@ -384,7 +449,7 @@ class UserService {
async getRecent (maxCount = 3) {
const users = User
.find()
.select('_id created username username_lc displayName bio picture')
.select('_id created username username_lc displayName picture')
.sort({ created: -1 })
.limit(maxCount)
.lean();
@ -468,28 +533,7 @@ class UserService {
if (!username || (typeof username !== 'string') || (username.length === 0)) {
throw new SiteError(406, 'Invalid username');
}
const reservedNames = [
'about',
'admin',
'auth',
'digitaltelepresence',
'dist',
'dtp',
'fontawesome',
'fonts',
'img',
'image',
'less',
'manifest.json',
'moment',
'newsletter',
'numeral',
'socket.io',
'uikit',
'user',
'welcome',
];
if (reservedNames.includes(username.trim().toLowerCase())) {
if (this.reservedNames.includes(username.trim().toLowerCase())) {
throw new SiteError(403, 'That username is reserved for system use');
}

16
app/templates/common/html/footer.pug

@ -1,10 +1,8 @@
.common-footer
p This email was sent to #{user.email} because you selected to receive emails from LocalRedAction.com. You can #[a(href=`https://localredaction.com/opt-out/${voter._id}/email`) opt out] at any time to stop receiving these emails.
p You can request to stop receiving these emails in writing at:
address
div Local Red Action
div P.O. Box ########
div McKees Rocks, PA 15136
div USA
p This email was sent to #{recipient.email} because you selected to receive emails from #{site.name}. You can #[a(href=`https://${site.domain}/email/opt-out?u=${recipient._id}&c=marketing`) opt out] at any time to stop receiving these emails.
//- p You can request to stop receiving these emails in writing at:
//- address
//- div Digital Telepresence, LLC
//- div P.O. Box ########
//- div McKees Rocks, PA 15136
//- div USA

3
app/templates/common/html/header.pug

@ -1 +1,2 @@
.greeting Dear #{voter.name},
.common-title= emailTitle || `Greetings from ${site.name}!`
.common-slogan= site.description

15
app/templates/common/text/footer.pug

@ -1,9 +1,10 @@
| - - -
| This email was sent to #{user.email} because you selected to receive emails from LocalRedAction.com. Visit #{`https://localredaction.com/opt-out/${voter._id}/email`} to opt out and stop receiving these emails.
|
| You can request to stop receiving these emails in writing at:
| - - -
| This email was sent to #{recipient.email} because you selected to receive emails from #{site.name}. Visit #{`https://${site.domain}/email/opt-out?u=${recipient._id}&c=marketing`} to opt out and stop receiving these emails.
|
| Local Red Action
| P.O. Box ########
| McKees Rocks, PA 15136
| USA
//- | You can request to stop receiving these emails in writing at:
//- |
//- | #{site.company}
//- | P.O. Box ########
//- | McKees Rocks, PA 15136
//- | USA

3
app/templates/common/text/header.pug

@ -1 +1,2 @@
| Dear #{voter.name},
| Dear #{recipient.displayName || recipient.username},
|

3
app/templates/html/user-email.pug

@ -0,0 +1,3 @@
extends ../layouts/html/system-message
block message-body
.content-message!= htmlMessage

31
app/templates/html/welcome.pug

@ -1,27 +1,4 @@
doctype html
html(lang='en')
head
meta(charset='UTF-8')
meta(name='viewport', content='width=device-width, initial-scale=1.0')
meta(name='description', content= pageDescription || siteDescription)
title= pageTitle ? `${pageTitle} | ${site.name}` : site.name
style(type="text/css").
html, body {
margin: 0;
padding: 0;
}
.greeting { font-size: 1.5em; margin-bottom: 16px; }
.message {}
body
include ../common/html/header
.message
p Welcome to #{service.name}! Please visit #[a(href=`https://localredaction.com/verify?t=${emailVerifyToken}`)= `https://localredaction.com/verify?t=${emailVerifyToken}`] to verify your email address.
p Thank you for supporting your local Republican committee and candidates!
include ../common/html/footer
extends ../layouts/html/system-message
block content
p Welcome to #{site.name}! Please visit #[a(href=`https://${site.domain}/email/verify?t=${emailVerifyToken}`)= `https://${site.domain}/email/verify?t=${emailVerifyToken}`] to verify your email address and enable all features on your new account.
p If you did not sign up for a new account at #{site.name}, please disregard this message.

106
app/templates/layouts/html/system-message.pug

@ -0,0 +1,106 @@
doctype html
html(lang='en')
head
meta(charset='UTF-8')
meta(name='viewport', content='width=device-width, initial-scale=1.0')
meta(name='description', content= pageDescription || siteDescription)
title= pageTitle ? `${pageTitle} | ${site.name}` : site.name
style(type="text/css").
html, body {
padding: 0;
margin: 0;
background-color: #ffffff;
color: #1a1a1a;
}
section {
padding: 20px 0;
background-color: #ffffff;
color: #1a1a1a;
}
section.section-muted {
background-color: #f8f8f8;
color: #2a2a2a;
}
.common-title {
max-width: 640px;
margin: 0 auto 8px auto;
font-size: 1.5em;
}
.common-greeting {
max-width: 640px;
margin: 0 auto 20px auto;
font-size: 1.1em;
}
.common-slogan {
max-width: 640px;
margin: 0 auto;
font-size: 1.1em;
font-style: italic;
}
.content-message {
max-width: 640px;
margin: 0 auto;
background: white;
color: black;
font-size: 14px;
}
.content-signature {
max-width: 640px;
margin: 0 auto;
background: white;
color: black;
font-size: 14px;
}
.common-footer {
max-width: 640px;
margin: 0 auto;
font-size: 10px;
}
.channel-icon {
border-radius: 8px;
}
.action-button {
padding: 6px 20px;
margin: 24px 0;
border: none;
border-radius: 20px;
outline: none;
background-color: #1093de;
color: #ffffff;
font-size: 16px;
font-weight: bold;
}
body
include ../library
section.section-muted
include ../../common/html/header
section
.common-greeting
div Dear #{recipient.displayName || recipient.username},
block message-body
.content-message
block content
.content-signature
p Thank you for your continued support!
p The #{site.name} team.
section.section-muted
include ../../common/html/footer

4
app/templates/layouts/library.pug

@ -0,0 +1,4 @@
-
function formatCount (count) {
return numeral(count).format((count > 1000) ? '0,0.0a' : '0,0');
}

10
app/templates/layouts/text/system-message.pug

@ -0,0 +1,10 @@
include ../library
include ../../common/text/header
|
block content
|
| Thank you for your continued support!
|
| The #{site.name} team.
|
include ../../common/text/footer

5
app/templates/text/user-email.pug

@ -0,0 +1,5 @@
extends ../layouts/text/system-message
block content
|
| #{textMessage}
|

8
app/templates/text/welcome.pug

@ -1,7 +1,7 @@
include ../common/text/header
extends ../layouts/text/system-message
block content
|
| Welcome to #{service.name}! Please visit #[a(href=`https://localredaction.com/verify?t=${emailVerifyToken}`)= `https://localredaction.com/verify?t=${emailVerifyToken}`] to verify your email address.
| Welcome to #{site.name}! Please visit #{`https://${site.domain}/email/verify?t=${emailVerifyToken}`} to verify your email address and enable all features on your new account.
|
| Thank you for supporting your local Republican committee and candidates!
| If you did not sign up for a new account at #{site.name}, please disregard this message.
|
include ../common/text/footer

17
app/views/admin/category/editor.pug

@ -1,17 +0,0 @@
extends ../layouts/main
block content
- var formAction = category ? `/admin/category/${category._id}` : '/admin/category';
pre= JSON.stringify(category, null, 2)
form(method="POST", action= formAction).uk-form
.uk-margin
label(for="name").uk-form-label Category Name
input(id="name", name="name", type="text", placeholder="Enter category name", value= category ? category.name : undefined).uk-input
.uk-margin
label(for="description").uk-form-label Description
textarea(id="description", name="description", rows="3", placeholder="Enter category description").uk-textarea= category ? category.description : undefined
button(type="submit").uk-button.uk-button-primary= category ? 'Update Category' : 'Create Category'

21
app/views/admin/category/index.pug

@ -1,21 +0,0 @@
extends ../layouts/main
block content
.uk-margin
div(uk-grid).uk-flex-middle
.uk-width-expand
h2 Category Manager
.uk-width-auto
a(href="/admin/category/create").uk-button.uk-button-primary
span
i.fas.fa-plus
span.uk-margin-small-left Add category
.uk-margin
if Array.isArray(categories) && (categories.length > 0)
uk.uk-list
each category in categories
li
a(href=`/admin/category/${category._id}`)= category.name
else
h4 There are no categories.

23
app/views/admin/components/menu.pug

@ -14,24 +14,6 @@ ul.uk-nav.uk-nav-default
li.uk-nav-divider
li(class={ 'uk-active': (adminView === 'post') })
a(href="/admin/post")
span.nav-item-icon
i.fas.fa-pen
span.uk-margin-small-left Posts
li(class={ 'uk-active': (adminView === 'page') })
a(href="/admin/page")
span.nav-item-icon
i.fas.fa-file
span.uk-margin-small-left Pages
li(class={ 'uk-active': (adminView === 'newsletter') })
a(href="/admin/newsletter")
span.nav-item-icon
i.fas.fa-newspaper
span.uk-margin-small-left Newsletter
li.uk-nav-divider
li(class={ 'uk-active': (adminView === 'user') })
a(href="/admin/user")
span.nav-item-icon
@ -45,6 +27,11 @@ ul.uk-nav.uk-nav-default
li.uk-nav-divider
li(class={ 'uk-active': (adminView === 'core-node') })
a(href="/admin/core-node")
span.nav-item-icon
i.fas.fa-project-diagram
span.uk-margin-small-left Core Nodes
li(class={ 'uk-active': (adminView === 'host') })
a(href="/admin/host")
span.nav-item-icon

18
app/views/admin/core-node/connect.pug

@ -0,0 +1,18 @@
extends ../layouts/main
block content
form(method="POST", action="/admin/core-node/connect").uk-form
.uk-card.uk-card-default.uk-card-small
.uk-card-header
h1.uk-card-title Connect to New Core
.uk-card-body
.uk-margin
label(for="host").uk-form-label Address
input(id="host", name="host", placeholder="Enter host name or address", required).uk-input
.uk-margin
label(for="port").uk-form-label Port Number
input(id="port", name="port", min="1", max="65535", step="1", value="4200", required).uk-input
.uk-card-footer
button(type="submit").uk-button.uk-button-primary Send Request

14
app/views/admin/core-node/index.pug

@ -0,0 +1,14 @@
extends ../layouts/main
block content
h1 Core Nodes
a(href="/admin/core-node/connect").uk-button.uk-button-primary 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
pre= JSON.stringify(node, null, 2)
else
p There are no registered core nodes.

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save