Browse Source

large, large import with a lot of...features

master
Rob Colbert 2 years ago
parent
commit
e55a009d1e
  1. 73
      .env.default
  2. 3
      .gitignore
  3. 56
      LICENSE
  4. 90
      README.md
  5. 91
      app/controllers/admin/domain.js
  6. 76
      app/controllers/admin/host.js
  7. 77
      app/controllers/admin/job-queue.js
  8. 75
      app/controllers/admin/user.js
  9. 195
      app/controllers/auth.js
  10. 42
      app/controllers/home.js
  11. 125
      app/controllers/image.js
  12. 71
      app/controllers/manifest.js
  13. 162
      app/controllers/user.js
  14. 54
      app/controllers/welcome.js
  15. 29
      app/models/article.js
  16. 34
      app/models/category.js
  17. 21
      app/models/chat-message.js
  18. 18
      app/models/connect-token.js
  19. 20
      app/models/csrf-token.js
  20. 21
      app/models/domain.js
  21. 34
      app/models/email-blacklist.js
  22. 35
      app/models/image.js
  23. 29
      app/models/log.js
  24. 75
      app/models/net-host-stats.js
  25. 45
      app/models/net-host.js
  26. 38
      app/models/otp-account.js
  27. 37
      app/models/user.js
  28. 66
      app/services/article.js
  29. 63
      app/services/cache.js
  30. 64
      app/services/crypto.js
  31. 79
      app/services/csrf-token.js
  32. 125
      app/services/display-engine.js
  33. 72
      app/services/domain.js
  34. 103
      app/services/email.js
  35. 100
      app/services/host-cache.js
  36. 225
      app/services/image.js
  37. 73
      app/services/job-queue.js
  38. 61
      app/services/limiter.js
  39. 100
      app/services/media.js
  40. 76
      app/services/minio.js
  41. 224
      app/services/otp-auth.js
  42. 83
      app/services/session.js
  43. 55
      app/services/sms.js
  44. 385
      app/services/user.js
  45. 10
      app/templates/common/html/footer.pug
  46. 1
      app/templates/common/html/header.pug
  47. 9
      app/templates/common/text/footer.pug
  48. 1
      app/templates/common/text/header.pug
  49. 27
      app/templates/html/welcome.pug
  50. 7
      app/templates/text/welcome.pug
  51. 17
      app/views/admin/category/editor.pug
  52. 21
      app/views/admin/category/index.pug
  53. 26
      app/views/admin/channel-application/index.pug
  54. 79
      app/views/admin/channel-application/view.pug
  55. 28
      app/views/admin/channel/index.pug
  56. 73
      app/views/admin/components/menu.pug
  57. 13
      app/views/admin/domain/form.pug
  58. 18
      app/views/admin/domain/index.pug
  59. 23
      app/views/admin/host/index.pug
  60. 140
      app/views/admin/host/view.pug
  61. 12
      app/views/admin/index.pug
  62. 7
      app/views/admin/job-queue/index.pug
  63. 62
      app/views/admin/job-queue/queue-view.pug
  64. 19
      app/views/admin/layouts/main.pug
  65. 37
      app/views/admin/user/form.pug
  66. 22
      app/views/admin/user/index.pug
  67. 10
      app/views/article/components/article.pug
  68. 8
      app/views/article/view.pug
  69. 6
      app/views/category/components/list-item.pug
  70. 17
      app/views/category/home.pug
  71. 32
      app/views/category/view.pug
  72. 4
      app/views/components/back-button.pug
  73. 15
      app/views/components/crypto-symbol-summary.pug
  74. 2
      app/views/components/csrf-token-input.pug
  75. 60
      app/views/components/file-upload-image.pug
  76. 9
      app/views/components/gab-share-button.pug
  77. 6
      app/views/components/home-menu-button.pug
  78. 84
      app/views/components/item-metabar.pug
  79. 12
      app/views/components/library.pug
  80. 13
      app/views/components/missing-profile-icon.pug
  81. 79
      app/views/components/navbar.pug
  82. 72
      app/views/components/off-canvas.pug
  83. 4
      app/views/components/page-footer.pug
  84. 13
      app/views/components/page-header.pug
  85. 27
      app/views/components/pagination-bar.pug
  86. 20
      app/views/components/pwa-support.pug
  87. 3
      app/views/components/site-link.pug
  88. 8
      app/views/components/social-card/facebook.pug
  89. 5
      app/views/components/social-card/twitter.pug
  90. 6
      app/views/components/term-list-tile.pug
  91. 14
      app/views/components/term-list.pug
  92. 27
      app/views/components/view-title.pug
  93. 17
      app/views/error.pug
  94. 13
      app/views/home/logged-in.member.pug
  95. 72
      app/views/home/logged-in.voter.pug
  96. 20
      app/views/home/logged-out.pug
  97. 16
      app/views/index.pug
  98. 111
      app/views/layouts/main.pug
  99. 27
      app/views/otp/authenticate.pug
  100. 11
      app/views/otp/new-account.pug

73
.env.default

@ -1,4 +1,71 @@
#
# Site configuration
#
DTP_SITE_NAME=
DTP_SITE_DESCRIPTION=
DTP_SITE_DOMAIN=localhost
DTP_SITE_DOMAIN_KEY=
DTP_SITE_COMPANY=Digital Telepresence, LLC
DTP_PASSWORD_SALT=
#
# Mailgun Configuration
#
DTP_MAILGUN_ENABLED=disabled
MAILGUN_API_KEY=
MAILGUN_DOMAIN=
#
# MongoDB configuration
#
MONGODB_HOST=localhost:27017
MONGODB_DATABASE=dtp-sites
#
# Redis configuration
REDIS_HOST=
REDIS_PORT=
REDIS_PASSWORD=
#
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_PASSWORD=
#
# MinIO configuration
#
MINIO_ENDPOINT=localhost
MINIO_PORT=9000
MINIO_USE_SSL=disabled
MINIO_ACCESS_KEY=dtp-sites
MINIO_SECRET_KEY=
MINIO_IMAGE_BUCKET=site-images
MINIO_VIDEO_BUCKET=site-videos
#
# ExpressJS/HTTP configuration
#
HTTP_BIND_ADDRESS=127.0.0.1
HTTP_BIND_PORT=3000
HTTP_SESSION_SECRET=
#
# Log configuration
#
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_DEBUG=enabled
DTP_LOG_INFO=enabled
DTP_LOG_WARN=enabled
DTP_LOG_HTTP_FORMAT=combined

3
.gitignore

@ -1,3 +1,4 @@
.env
node_modules
node_modules
dist

56
LICENSE

@ -1,49 +1,13 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright 2021 Digital Telepresence, LLC
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
1. Definitions.
http://www.apache.org/licenses/LICENSE-2.0
"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
You must give any other recipients of the Work or Derivative Works a copy of this License; and
You must cause any modified files to carry prominent notices stating that You changed the files; and
You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

90
README.md

@ -1,2 +1,90 @@
# Digital Telepresence Sites
A content management system based on the Digital Telepresence Platform.
A content management and web hosting system based on the Digital Telepresence Platform.
## Requirements
The only qualified operated system for hosting a DTP Sites suite is [Ubuntu 20.04 LTS](https://releases.ubuntu.com/20.04/). It is acceptable to run it in a virtual machine for development and testing, but it should be run as close to bare metal as can be had for production environments.
You will need MongoDB and MinIO installed and running before you can start DTP Sites web services.
1. [Install MongoDB](https://docs.mongodb.com/manual/tutorial/install-mongodb-on-ubuntu/)
2. [Install MinIO](https://docs.min.io/docs/minio-quickstart-guide.html)
Install redis:
```sh
sudo apt-get install redis
```
## Environment Configuration
On a new host or host image, copy `.env.default` to `.env` and edit it as necessary.
For password salt and service passwords, the `uuidgen` tool may be useful to help generate hard-to-guess passwords for use in a development environment. Something more sophisticated than a UUID should be used in production.
Install application dependencies by running Yarn. You should do this on initial install and after every update or `git pull` as dependencies do change often, become upgraded to new versions, receive security fixes, etc.
```sh
yarn
```
## Starting DTP Sites In Development Mode
1. Make sure `NODE_ENV` is set to `local`
2. Run `./sites-start-local` in a VS Code terminal, then rename that terminal to `services`.
3. In a new VS Code terminal, run `gulp` and rename that terminal `gulp`.
4. Open https://localhost:3000 in your web browser.
You can now make changes to program source code, and the environment will automatically respond, build, pack, and re-load things as needed depending on what you did and what's open/running.
DTP Sites is a multi-tier web hosting engine built on:
- [MongoDB](https://www.mongodb.com/)
- [Redis](https://redis.io/)
- [MinIO](https://min.io/)
- [Node.js](https://nodejs.org/en/)
- [ExpressJS](http://expressjs.com/)
- [UIkit](https://getuikit.com/)
## Production Environment Information
It's impossible to give 100% generic advice here, but it all depends on how large your audience is and how much they use your website. Larger, more active audiences will require a different kind of production server deployment than smaller and less active audiences.
Generally, it's possible to stack all components on one host and operate a fast site for small-to-medium audiences. The size of the host may vary, but it's possible to keep the system stacked and handle quite a large audience.
Beyond a point, you'll need to start isolating services away from each other. Storage will want it's own system(s), MongoDB will want it's own systems (plural), Redis will want it's own system(s), and the Node.js components can each start to want their own system(s).
Once you start scaling horizontally, the host requirements change a little. You will need two networks and network interfaces per host. The production network to handle public requests; and a management network for handling IPC and data-sharing among the hosts themselves.
- [MongoDB](https://docs.mongodb.com/launch-manage/)
Some useful links for learning more about hosting MinIO:
- [Quick Start](https://docs.min.io/docs/minio-quickstart-guide.html)
- [Configuration](https://docs.min.io/docs/minio-server-configuration-guide.html)
- [Docker](https://docs.min.io/docs/minio-docker-quickstart-guide.html)
- [Distributed](https://docs.min.io/docs/distributed-minio-quickstart-guide.html)
- [Monitoring](https://docs.min.io/docs/minio-monitoring-guide.html)
- [Security Overview](https://docs.min.io/docs/minio-security-overview.html)
- [TLS](https://docs.min.io/docs/how-to-secure-access-to-minio-server-with-tls.html)
Redis simply has many different documents to describe it's many different features and their requirements. I'd like to give you a summary page link, but it doesn't exist. This is that summary page. These are those links.
- [Quick Start](https://redis.io/topics/quickstart)
- [Access Control Lists](https://redis.io/topics/acl)
- [Administration](https://redis.io/topics/admin)
- [Configuration](https://redis.io/topics/config)
- [Encryption](https://redis.io/topics/encryption)
- [High Availability](https://redis.io/topics/sentinel)
- [Persistence](https://redis.io/topics/persistence)
- [Replication](https://redis.io/topics/replication)
- [Security](https://redis.io/topics/security)
- [Signals](https://redis.io/topics/signals)
## Software License
The Digital Telepresence Platform Sites engine is licensed under the [Apache 2.0](https://spdx.org/licenses/Apache-2.0.html) open source software license. See [LICENSE](LICENSE) for more information.

91
app/controllers/admin/domain.js

@ -0,0 +1,91 @@
// admin/domain.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const express = require('express');
const { /*SiteError,*/ SiteController } = require('../../../lib/site-lib');
class DomainController extends SiteController {
constructor (dtp) {
super(dtp, 'admin:domain');
}
async start ( ) {
const router = express.Router();
router.use(async (req, res, next) => {
res.locals.currentView = 'admin';
res.locals.adminView = 'domain';
return next();
});
router.param('domainId', this.populateDomainId.bind(this));
router.post('/:domainId', this.postUpdateDomain.bind(this));
router.post('/', this.postCreateDomain.bind(this));
router.get('/create', this.getCreateForm.bind(this));
router.get('/:domainId', this.getDomainView.bind(this));
router.get('/', this.getHomeView.bind(this));
return router;
}
async populateDomainId (req, res, next, domainId) {
const { domain: domainService } = this.dtp.services;
try {
res.locals.domain = await domainService.getById(domainId);
return next();
} catch (error) {
return next(error);
}
}
async postUpdateDomain (req, res, next) {
const { domain: domainService } = this.dtp.services;
try {
await domainService.update(res.locals.domain, req.body);
res.redirect('/admin/domain');
} catch (error) {
return next(error);
}
}
async postCreateDomain (req, res, next) {
const { domain: domainService } = this.dtp.services;
try {
res.locals.domain = await domainService.create(req.body);
res.redirect(`/admin/domain/${res.locals.domain._id}`);
} catch (error) {
return next(error);
}
}
async getDomainView (req, res) {
res.render('admin/domain/form');
}
async getCreateForm (req, res) {
res.render('admin/domain/form');
}
async getHomeView (req, res, next) {
const { domain: domainService } = this.dtp.services;
try {
res.locals.pagination = this.getPaginationParameters(req, 10);
res.locals.domains = await domainService.getDomains(res.locals.pagination);
res.render('admin/domain/index');
} catch (error) {
return next(error);
}
}
}
module.exports = async (dtp) => {
let controller = new DomainController(dtp);
return controller;
};

76
app/controllers/admin/host.js

@ -0,0 +1,76 @@
// admin/host.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'admin:host';
const express = require('express');
const mongoose = require('mongoose');
const NetHost = mongoose.model('NetHost');
const NetHostStats = mongoose.model('NetHostStats');
const { /*SiteError,*/ SiteController } = require('../../../lib/site-lib');
class HostController 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 = 'host';
return next();
});
router.param('hostId', this.populateHostId.bind(this));
router.get('/:hostId', this.getHostView.bind(this));
router.get('/', this.getHomeView.bind(this));
return router;
}
async populateHostId (req, res, next, hostId) {
try {
res.locals.host = await NetHost.findOne({ _id: hostId });
return next();
} catch (error) {
return next(error);
}
}
async getHostView (req, res, next) {
try {
res.locals.stats = await NetHostStats
.find({ host: res.locals.host._id })
.sort({ created: -1 })
.limit(30)
.lean();
res.locals.stats = res.locals.stats.reverse();
res.render('admin/host/view');
} catch (error) {
return next(error);
}
}
async getHomeView (req, res, next) {
try {
res.locals.hosts = await NetHost.find({ status: { $ne: 'inactive' } });
res.render('admin/host/index');
} catch (error) {
return next(error);
}
}
}
module.exports = async (dtp) => {
let controller = new HostController(dtp);
return controller;
};

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

@ -0,0 +1,77 @@
// admin/domain.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'admin:domain';
const express = require('express');
const { /*SiteError,*/ SiteController } = require('../../../lib/site-lib');
class JobQueueController 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 = 'job-queue';
return next();
});
router.param('jobQueueName', this.populateJobQueueName.bind(this));
router.get('/:jobQueueName', this.getJobQueueView.bind(this));
router.get('/', this.getHomeView.bind(this));
return router;
}
async populateJobQueueName (req, res, next, jobQueueName) {
const { jobQueue: jobQueueService } = this.dtp.services;
try {
res.locals.queueName = jobQueueName;
res.locals.queue = await jobQueueService.getJobQueue(jobQueueName);
return next();
} catch (error) {
this.log.error('failed to populate job queue', { jobQueueName, error });
return next(error);
}
}
async getJobQueueView (req, res, next) {
try {
res.locals.jobCounts = await res.locals.queue.getJobCounts();
res.locals.jobs = {
waiting: await res.locals.queue.getWaiting(0, 5),
active: await res.locals.queue.getActive(0, 5),
delayed: await res.locals.queue.getDelayed(0, 5),
failed: await res.locals.queue.getFailed(0, 5),
};
res.render('admin/job-queue/queue-view');
} catch (error) {
this.log.error('failed to populate job queue view', { error });
return next(error);
}
}
async getHomeView (req, res, next) {
const { jobQueue: jobQueueService } = this.dtp.services;
try {
res.locals.queues = await jobQueueService.discoverJobQueues('soapy:*:id');
res.render('admin/job-queue/index');
} catch (error) {
this.log.error('failed to populate job queues view', { error });
return next(error);
}
}
}
module.exports = async (dtp) => {
let controller = new JobQueueController(dtp);
return controller;
};

75
app/controllers/admin/user.js

@ -0,0 +1,75 @@
// admin/user.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'admin:user';
const express = require('express');
const { /*SiteError,*/ SiteController } = require('../../../lib/site-lib');
class UserController 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 = 'user';
return next();
});
router.param('userId', this.populateUserId.bind(this));
router.post('/:userId', this.postUpdateUser.bind(this));
router.get('/:userId', this.getUserView.bind(this));
router.get('/', this.getHomeView.bind(this));
return router;
}
async populateUserId (req, res, next, userId) {
const { user: userService } = this.dtp.services;
try {
res.locals.userAccount = await userService.getUserAccount(userId);
return next();
} catch (error) {
return next(error);
}
}
async postUpdateUser (req, res, next) {
const { user: userService } = this.dtp.services;
try {
await userService.update(res.locals.userAccount, req.body);
res.redirect('/admin/user');
} catch (error) {
return next(error);
}
}
async getUserView (req, res) {
res.render('admin/user/form');
}
async getHomeView (req, res, next) {
const { user: userService } = this.dtp.services;
try {
res.locals.pagination = this.getPaginationParameters(req, 10);
res.locals.userAccounts = await userService.getUserAccounts(res.locals.pagination);
res.render('admin/user/index');
} catch (error) {
return next(error);
}
}
}
module.exports = async (dtp) => {
let controller = new UserController(dtp);
return controller;
};

195
app/controllers/auth.js

@ -0,0 +1,195 @@
// auth.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'auth';
const express = require('express');
const mongoose = require('mongoose');
const multer = require('multer');
const passport = require('passport');
const uuidv4 = require('uuid').v4;
const { SiteController, SiteError } = require('../../lib/site-lib');
const ConnectToken = mongoose.model('ConnectToken');
class AuthController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
}
async start ( ) {
const { limiter: limiterService } = this.dtp.services;
const upload = multer({ });
const router = express.Router();
this.dtp.app.use('/auth', router);
const authRequired = this.dtp.services.session.authCheckMiddleware({ requireLogin: true });
router.post('/otp/enable',
limiterService.create(limiterService.config.auth.postOtpEnable),
this.postOtpEnable.bind(this),
);
router.post('/otp/auth',
limiterService.create(limiterService.config.auth.postOtpAuthenticate),
this.postOtpAuthenticate.bind(this),
);
router.post('/login',
limiterService.create(limiterService.config.auth.postLogin),
upload.none(),
this.postLogin.bind(this),
);
router.get('/api-token/personal',
authRequired,
limiterService.create(limiterService.config.auth.getPersonalApiToken),
this.getPersonalApiToken.bind(this),
);
router.get('/socket-token',
authRequired,
limiterService.create(limiterService.config.auth.getSocketToken),
this.getSocketToken.bind(this),
);
router.get('/logout',
authRequired,
limiterService.create(limiterService.config.auth.getLogout),
this.getLogout.bind(this),
);
return router;
}
async postOtpEnable (req, res, next) {
const { otpAuth: otpAuthService } = this.dtp.services;
const service = req.body['otp-service'];
const secret = req.body['otp-secret'];
const token = req.body['otp-token'];
const otpRedirectURL = req.body['otp-redirect'] || '/';
try {
this.log.info('enabling OTP protections', { service, secret, token });
res.locals.otpAccount = await otpAuthService.createOtpAccount(req, service, secret, token);
res.locals.otpRedirectURL = otpRedirectURL;
res.render('otp/new-account');
} catch (error) {
this.log.error('failed to enable OTP protections', {
service, error,
});
return next(error);
}
}
async postOtpAuthenticate (req, res, next) {
const { otpAuth: otpAuthService } = this.dtp.services;
if (!req.user) {
return res.status(403).json({
success: false,
message: 'Must be logged in',
});
}
const service = req.body['otp-service'];
if (!service) {
return res.status(400).json({
success: false,
message: 'Must specify OTP service name',
});
}
const passcode = req.body['otp-passcode'];
if (!passcode || (typeof passcode !== 'string') || (passcode.length !== 6)) {
return res.status(400).json({
success: false,
message: 'Must include a valid passcode',
});
}
try {
await otpAuthService.startOtpSession(req, service, passcode);
return res.redirect(req.body['otp-redirect']);
} catch (error) {
this.log.error('failed to verify one-time password for 2FA', {
service, error,
}, req.user);
return next(error);
}
}
async postLogin (req, res, next) {
passport.authenticate('dtp-local', (error, user/*, info*/) => {
if (error) {
req.session.loginResult = error.toString();
return next(error);
}
if (!user) {
req.session.loginResult = 'Username or email address is unknown.';
return res.redirect('/welcome/login');
}
this.log.info('user logging in', { user: user.username });
req.login(user, (error) => {
if (error) {
return next(error);
}
return res.redirect('/');
});
})(req, res, next);
}
async getPersonalApiToken (req, res, next) {
try {
const { apiGuard: apiGuardService } = this.dtp.platform.services;
res.locals.apiToken = await apiGuardService.createApiToken(req.user, [
'account-read',
// additional scopes go here
]);
res.render('api-token/view');
} catch (error) {
this.log.error('failed to generate API token', { error });
return next(error);
}
}
async getSocketToken (req, res, next) {
try {
const token = await ConnectToken.create({
created: new Date(),
user: req.user._id,
token: uuidv4(),
});
res.status(200).json({
success: true,
token: token.token
});
} catch (error) {
this.log.error('failed to create Socket.io connect token', { error });
return next(error);
}
}
async getLogout (req, res, next) {
if (!req.user) {
return next(new SiteError(403, 'You are not signed in'));
}
req.logout();
req.session.destroy((err) => {
if (err) {
this.log.error('failed to destroy browser session', { err });
return next(err);
}
res.redirect('/');
});
}
}
module.exports = async (dtp) => {
let controller = new AuthController(dtp);
return controller;
};

42
app/controllers/home.js

@ -4,15 +4,47 @@
'use strict';
class HomeController {
const DTP_COMPONENT_NAME = 'home';
constructor ( ) {
const express = require('express');
const { SiteController } = require('../../lib/site-lib');
class HomeController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
}
async start ( ) {
const { dtp } = this;
const { limiter: limiterService } = dtp.services;
const router = express.Router();
dtp.app.use('/', router);
router.use(async (req, res, next) => {
res.locals.currentView = 'home';
return next();
});
router.get('/',
limiterService.create(limiterService.config.home.getHome),
this.getHome.bind(this),
);
}
async getHome (req, res) {
res.send(`Hello, controller!`);
async getHome (req, res, next) {
try {
res.locals.pagination = this.getPaginationParameters(req, 20);
res.render('index');
} catch (error) {
return next(error);
}
}
}
module.exports.HomeController = HomeController;
module.exports = async (dtp) => {
let controller = new HomeController(dtp);
return controller;
};

125
app/controllers/image.js

@ -0,0 +1,125 @@
// page.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'image';
const fs = require('fs');
const express = require('express');
const mongoose = require('mongoose');
const multer = require('multer');
const { SiteController/*, SiteError*/ } = require('../../lib/site-lib');
class ImageController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
}
async start ( ) {
const { dtp } = this;
const { limiter: limiterService } = dtp.services;
const router = express.Router();
dtp.app.use('/image', router);
const imageUpload = multer({
dest: '/tmp/dtp-sites/upload/image',
limits: {
fileSize: 1024 * 1000 * 5,
},
});
router.use(async (req, res, next) => {
res.locals.currentView = 'image';
return next();
});
router.param('imageId', this.populateImage.bind(this));
router.post('/',
limiterService.create(limiterService.config.image.postCreateImage),
imageUpload.single('file'),
this.postCreateImage.bind(this),
);
router.get('/:imageId',
limiterService.create(limiterService.config.image.getImage),
this.getHostCacheImage.bind(this),
// this.getImage.bind(this),
);
}
async populateImage (req, res, next, imageId) {
try {
res.locals.imageId = mongoose.Types.ObjectId(imageId);
res.locals.image = await this.dtp.services.image.getImageById(res.locals.imageId);
return next();
} catch (error) {
this.log.error('failed to populate image', { error });
return next(error);
}
}
async postCreateImage (req, res, next) {
const { image: imageService } = this.dtp.services;
try {
res.locals.image = await imageService.create(req.user, req.body, req.file);
res.status(200).json({
success: true,
imageId: res.locals.image._id.toString(),
});
} catch (error) {
this.log.error('failed to create image', { error });
return next(error);
}
}
async getHostCacheImage (req, res) {
const { hostCache: hostCacheService } = this.dtp.services;
const { image } = res.locals;
try {
const fileInfo = await hostCacheService.getFile(image.file.bucket, image.file.key);
const stream = fs.createReadStream(fileInfo.file.path);
res.header('Content-Type', image.type);
res.header('Content-Length', fileInfo.file.stats.size);
res.status(200);
stream.pipe(res);
} catch (error) {
this.log.error('failed to fetch image', { image, error });
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
async getImage (req, res) {
const { minio: minioService } = this.dtp.services;
try {
const stream = await minioService.openDownloadStream({
bucket: res.locals.image.file.bucket,
key: res.locals.image.file.key
});
res.header('Content-Type', res.locals.image.type);
res.header('Content-Length', res.locals.image.size);
res.status(200);
stream.pipe(res);
} catch (error) {
return res.status(error.statusCode || 500).json({
success: false,
message: error.message,
});
}
}
}
module.exports = async (dtp) => {
let controller = new ImageController(dtp);
return controller;
};

71
app/controllers/manifest.js

@ -0,0 +1,71 @@
// manifest.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'manifest';
const express = require('express');
const { SiteController } = require('../../lib/site-lib');
class ManifestController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
}
async start ( ) {
const { dtp } = this;
const { limiter: limiterService } = dtp.services;
const router = express.Router();
dtp.app.use('/manifest.json', router);
router.use(async (req, res, next) => {
res.locals.currentView = DTP_COMPONENT_NAME;
return next();
});
router.get('/',
limiterService.create(limiterService.config.manifest.getManifest),
this.getManifest.bind(this),
);
}
async getManifest (req, res, next) {
const DEFAULT_THEME_COLOR = '#4a4a4a';
const DEFAULT_BACKGROUND_COLOR = '#1a1a1a';
try {
const manifest = {
theme_color: DEFAULT_THEME_COLOR,
background_color: DEFAULT_BACKGROUND_COLOR,
display: 'fullscreen',
scope: '/',
start_url: '/',
name: this.dtp.config.site.name,
short_name: this.dtp.config.site.name,
description: this.dtp.config.site.description,
icons: [ ],
};
[512, 384, 256, 192, 144, 96, 72, 48, 32, 16].forEach((size) => {
manifest.icons.push({
src: `/img/icon/${this.dtp.config.site.domainKey}/icon-${size}x${size}.png`,
sizes: `${size}x${size}`,
type: 'image/png'
});
});
res.status(200).json(manifest);
} catch (error) {
return next(error);
}
}
}
module.exports = async (dtp) => {
let controller = new ManifestController(dtp);
return controller;
};

162
app/controllers/user.js

@ -0,0 +1,162 @@
// user.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'user';
const express = require('express');
const mongoose = require('mongoose');
const multer = require('multer');
const { SiteController, SiteError } = require('../../lib/site-lib');
class UserController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
}
async start ( ) {
const { dtp } = this;
const { limiter: limiterService, otpAuth: otpAuthService } = dtp.services;
const upload = multer({ dest: "/tmp" });
const router = express.Router();
dtp.app.use('/user', router);
const otpMiddleware = otpAuthService.middleware('Account', {
adminRequired: false,
otpRequired: false,
otpRedirectURL: async (req) => { return `/user/${req.user._id}`; },
});
router.use(
async (req, res, next) => {
try {
res.locals.currentView = 'user';
res.locals.pageTitle = 'Manage your user account.';
return next();
} catch (error) {
return next(error);
}
},
);
async function checkProfileOwner (req, res, next) {
if (!req.user || !req.user._id.equals(res.locals.userProfile._id)) {
return next(new SiteError(403, 'This is not your user account or profile'));
}
return next();
}
router.param('userId', this.populateUser.bind(this));
router.post('/:userId/settings',
limiterService.create(limiterService.config.user.postUpdateSettings),
upload.none(),
this.postUpdateSettings.bind(this),
);
router.post('/',
limiterService.create(limiterService.config.user.postCreate),
this.postCreateUser.bind(this),
);
router.get('/:userId/settings',
limiterService.create(limiterService.config.user.getSettings),
otpMiddleware,
checkProfileOwner,
this.getUserSettingsView.bind(this),
);
router.get('/:userId',
limiterService.create(limiterService.config.user.getUserProfile),
otpMiddleware,
checkProfileOwner,
this.getUserView.bind(this),
);
}
async populateUser (req, res, next, userId) {
const { user: userService } = this.dtp.services;
try {
userId = mongoose.Types.ObjectId(userId);
} catch (error) {
return next(new SiteError(406, 'Invalid User'));
}
try {
if (!req.user._id.equals(userId)) {
return next(new Error('Invalid account ID'));
}
res.locals.userProfile = await userService.getUserAccount(userId);
return next();
} catch (error) {
this.log.error('failed to populate userId', { userId, error });
return next(error);
}
}
async postCreateUser (req, res, next) {
const { user: userService } = this.dtp.services;
try {
res.locals.user = await userService.create(req.body);
req.login(res.locals.user, (error) => {
if (error) {
return next(error);
}
res.redirect(`/user/${res.locals.user._id}`);
});
} catch (error) {
this.log.error('failed to create new user', { error });
return next(error);
}
}
async postUpdateSettings (req, res) {
const { user: userService, displayEngine: displayEngineService } = this.dtp.services;
try {
const displayList = displayEngineService.createDisplayList('app-settings');
await userService.updateSettings(req.user, req.body);
displayList.showNotification(
'Member account settings updated successfully.',
'success',
'bottom-center',
6000,
);
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 getUserSettingsView (req, res, next) {
try {
res.locals.startTab = req.query.st || 'watch';
res.render('user/settings');
} catch (error) {
this.log.error('failed to produce user settings view', { error });
return next(error);
}
}
async getUserView (req, res, next) {
try {
res.render('user/profile');
} catch (error) {
this.log.error('failed to produce user profile view', { error });
return next(error);
}
}
}
module.exports = async (dtp) => {
let controller = new UserController(dtp);
return controller;
};

54
app/controllers/welcome.js

@ -0,0 +1,54 @@
// welcome.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'welcome';
const express = require('express');
const { SiteController/*, SiteError */ } = require('../../lib/site-lib');
class WelcomeController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
}
async start ( ) {
const { limiter: limiterService } = this.dtp.services;
const welcomeLimiter = limiterService.create(limiterService.config.welcome);
const router = express.Router();
this.dtp.app.use('/welcome', welcomeLimiter, router);
router.get('/signup', this.getSignupView.bind(this));
router.get('/login', this.getLoginView.bind(this));
router.get('/', this.getHomeView.bind(this));
return router;
}
async getSignupView (req, res) {
res.render('welcome/signup');
}
async getLoginView (req, res) {
res.locals.loginResult = req.session.loginResult;
res.render('welcome/login');
}
async getHomeView (req, res, next) {
try {
res.render('welcome/index');
} catch (error) {
return next(error);
}
}
}
module.exports = async (dtp) => {
let controller = new WelcomeController(dtp);
return controller;
};

29
app/models/article.js

@ -0,0 +1,29 @@
// article.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const ArticleSchema = new Schema({
domain: { type: Schema.ObjectId, required: true, index: 1, ref: 'Domain' },
channel: { type: Schema.ObjectId, required: true, index: 1, ref: 'Channel' },
created: { type: Date, default: Date.now, required: true, index: -1 },
author: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' },
image: { type: Schema.ObjectId, required: true, ref: 'Image' },
title: { type: String, required: true },
summary: { type: String },
content: { type: String },
});
ArticleSchema.index({
domain: 1,
channel: 1,
}, {
name: 'article_domain_channel_idx',
});
module.exports = mongoose.model('Article', ArticleSchema);

34
app/models/category.js

@ -0,0 +1,34 @@
// 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({
domain: { type: Schema.ObjectId, required: true, index: 1, ref: 'Domain' },
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: {
liveChannelCount: { type: Number, default: 0, required: true },
currentViewerCount: { type: Number, default: 0, required: true },
},
});
CategorySchema.index({
domain: 1,
slug: 1,
}, {
unique: true,
name: 'domain_category_unique',
});
module.exports = mongoose.model('Category', CategorySchema);

21
app/models/chat-message.js

@ -0,0 +1,21 @@
// chat-message.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const ChatMessageSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1, expires: '10d' },
domain: { type: Schema.ObjectId, required: true, index: 1, ref: 'Domain' },
channel: { type: Schema.ObjectId, required: true, index: 1, ref: 'Channel' },
episode: { type: Schema.ObjectId, index: 1, ref: 'Episode' },
author: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' },
content: { type: String },
stickers: { type: [String] },
});
module.exports = mongoose.model('ChatMessage', ChatMessageSchema);

18
app/models/connect-token.js

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

20
app/models/csrf-token.js

@ -0,0 +1,20 @@
// csrf-token.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const CsrfTokenSchema = new Schema({
created: { type: Date, required: true, default: Date.now, index: -1, expires: '7d' },
expires: { type: Date, required: true, default: Date.now, index: -1 },
claimed: { type: Date },
token: { type: String, index: 1 },
user: { type: Schema.ObjectId, ref: 'User' },
ip: { type: String, required: true },
});
module.exports = mongoose.model('CsrfToken', CsrfTokenSchema);

21
app/models/domain.js

@ -0,0 +1,21 @@
// domain.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const DomainSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1 },
name: { type: String, required: true, lowercase: true, index: 1 },
stats: {
channelCount: { type: Number, default: 0, required: true },
streamCount: { type: Number, default: 0, required: true },
viewerCount: { type: Number, default: 0, required: true },
},
});
module.exports = mongoose.model('Domain', DomainSchema);

34
app/models/email-blacklist.js

@ -0,0 +1,34 @@
// email-blacklist.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const EmailBlacklistSchema = new Schema({
created: { type: Date, required: true, default: Date.now, index: -1, expires: '30d' },
email: {
type: String,
required: true,
lowercase: true,
maxlength: 255,
unique: true,
},
flags: {
isVerified: { type: Boolean, default: false, required: true },
},
});
EmailBlacklistSchema.index({
email: 1,
'flags.isVerified': true,
}, {
partialFilterExpression: {
'flags.isVerified': true,
},
});
module.exports = mongoose.model('EmailBlacklist', EmailBlacklistSchema);

35
app/models/image.js

@ -0,0 +1,35 @@
// image.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const ImageSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1 },
owner: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' },
type: { type: String, required: true },
size: { type: Number, required: true },
file: {
bucket: { type: String, required: true },
key: { type: String, required: true },
etag: { type: String, required: true, index: true },
},
metadata: {
format: { type: String },
size: { type: Number },
width: { type: Number },
height: { type: Number },
space: { type: String },
channels: { type: Number },
depth: { type: String },
density: { type: Number },
hasAlpha: { type: Boolean },
orientation: { type: Number },
},
});
module.exports = mongoose.model('Image', ImageSchema);

29
app/models/log.js

@ -0,0 +1,29 @@
// log.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const LOG_LEVEL_LIST = [
'debug',
'info',
'warn',
'alert',
'error',
'crit',
'fatal',
];
const LogSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1, expires: '7d' },
componentName: { type: String, required: true },
level: { type: String, enum: LOG_LEVEL_LIST, required: true, index: true },
message: { type: String },
metadata: { type: Schema.Types.Mixed },
});
module.exports = mongoose.model('Log', LogSchema);

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

@ -0,0 +1,75 @@
// net-host-stats.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const CpuInfoSchema = new Schema({
user: { type: Number },
nice: { type: Number },
sys: { type: Number },
idle: { type: Number },
irq: { type: Number },
});
const MemoryInfoSchema = new Schema({
total: { type: Number },
free: { type: Number },
used: { type: Number },
active: { type: Number },
available: { type: Number },
buffers: { type: Number },
cached: { type: Number },
slab: { type: Number },
buffcache: { type: Number },
swaptotal: { type: Number },
swapused: { type: Number },
swapfree: { type: Number },
});
const CacheStatsSchema = new Schema({
itemCount: { type: Number, required: true },
dataSize: { type: Number, required: true },
expireCount: { type: Number, required: true },
expireDataSize: { type: Number, required: true },
hitCount: { type: Number, required: true },
hitDataSize: { type: Number, required: true },
missCount: { type: Number, required: true },
missDataSize: { type: Number, required: true },
});
const DiskUsageSchema = new Schema({
total: { type: Number },
used: { type: Number },
available: { type: Number },
pctUsed: { type: Number },
});
const NetworkInterfaceStatsSchema = new Schema({
iface: { type: String, required: true },
rxPerSecond: { type: Number },
rxDropped: { type: Number },
rxErrors: { type: Number },
txPerSecond: { type: Number },
txDropped: { type: Number },
txErrors: { type: Number },
});
const NetHostStatsSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: 1, expires: '7d' },
host: { type: Schema.ObjectId, required: true, index: 1, ref: 'NetHost' },
load: { type: [Number], required: true },
cpus: { type: [CpuInfoSchema], required: true },
memory: { type: MemoryInfoSchema, required: true },
cache: { type: CacheStatsSchema, required: true },
disk: {
cache: { type: DiskUsageSchema, required: true },
},
network: { type: [NetworkInterfaceStatsSchema], required: true },
});
module.exports = mongoose.model('NetHostStats', NetHostStatsSchema);

45
app/models/net-host.js

@ -0,0 +1,45 @@
// net-host.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const CpuInfoSchema = new Schema({
model: { type: String },
speed: { type: Number },
});
const NetworkInterfaceSchema = new Schema({
iface: { type: String, required: true },
speed: { type: Number },
mac: { type: String },
ip4: { type: String },
ip4subnet: { type: String },
ip6: { type: String },
ip6subnet: { type: String },
flags: {
internal: { type: Boolean },
virtual: { type: Boolean },
},
});
const NetHostSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: 1 },
updated: { type: Date },
status: { type: String, enum: ['starting', 'active', 'shutdown', 'inactive', 'crashed'], required: true, index: 1 },
hostname: { type: String, required: true, index: 1 },
arch: { type: String, required: true },
cpus: { type: [CpuInfoSchema], required: true },
totalmem: { type: Number, required: true },
freemem: { type: Number, required: true },
platform: { type: String, required: true },
release: { type: String, required: true },
version: { type: String, required: true },
network: { type: [NetworkInterfaceSchema] },
});
module.exports = mongoose.model('NetHost', NetHostSchema);

38
app/models/otp-account.js

@ -0,0 +1,38 @@
// otp-account.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
var OtpBackupTokenSchema = new Schema({
token: { type: String, required: true },
claimed: { type: Date },
});
const OtpAccountSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1 },
domain: { type: Schema.ObjectId, required: true, index: true, ref: 'Domain' },
user: { type: Schema.ObjectId, required: true, index: true, ref: 'User' },
service: { type: String, required: true },
secret: { type: String, required: true, select: false },
algorithm: { type: String, required: true },
step: { type: Number, default: 30, required: true, min: 15 },
digits: { type: Number, default: 6, required: true, min: 6 },
backupTokens: { type: [OtpBackupTokenSchema], select: false },
lastVerification: { type: Date },
lastVerificationIp: { type: String },
});
OtpAccountSchema.index({
domain: 1,
user: 1,
service: 1,
}, {
unique: true,
name: 'otp_user_svc_uniq_idx',
});
module.exports = mongoose.model('OtpAccount', OtpAccountSchema);

37
app/models/user.js

@ -0,0 +1,37 @@
// user.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const Schema = mongoose.Schema;
const UserFlagsSchema = new Schema({
isAdmin: { type: Boolean, default: false, required: true },
isModerator: { type: Boolean, default: false, required: true },
});
const UserPermissionsSchema = new Schema({
canLogin: { type: Boolean, default: true, required: true },
canChat: { type: Boolean, default: true, required: true },
});
const UserSchema = new Schema({
created: { type: Date, default: Date.now, required: true, index: -1 },
email: { type: String, required: true, lowercase: true, unique: true },
username: { type: String, required: true },
username_lc: { type: String, required: true, lowercase: true, unique: true, index: 1 },
passwordSalt: { type: String, required: true },
password: { type: String, required: true },
displayName: { type: String },
picture: {
large: { type: Schema.ObjectId, ref: 'Image' },
small: { type: Schema.ObjectId, ref: 'Image' },
},
flags: { type: UserFlagsSchema, select: false },
permissions: { type: UserPermissionsSchema, select: false },
});
module.exports = mongoose.model('User', UserSchema);

66
app/services/article.js

@ -0,0 +1,66 @@
// article.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const fs = require('fs');
const { SiteService } = require('../../lib/site-lib');
const mongoose = require('mongoose');
const Article = mongoose.model('Article');
const marked = require('marked');
class ArticleService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
this.populateArticle = [
{
path: 'channel',
select: 'slug name images.icon status stats links',
},
{
path: 'author',
select: '_id username username_lc displayName picture',
},
];
}
async start ( ) {
this.markedRenderer = new marked.Renderer();
}
async getById (articleId) {
const article = await Article
.findById(articleId)
.populate(this.populateArticle)
.lean();
return article;
}
async getForChannel (channel, pagination) {
const articles = await Article
.find({ channel: channel._id })
.sort({ created: -1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.populate(this.populateArticle)
.lean();
return articles;
}
async renderMarkdown (documentFile) {
const markdown = await fs.promises.readFile(documentFile, 'utf8');
return marked(markdown, { renderer: this.markedRenderer });
}
}
module.exports = {
slug: 'article',
name: 'article',
create: (dtp) => { return new ArticleService(dtp); },
};

63
app/services/cache.js

@ -0,0 +1,63 @@
// cache.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const { SiteService } = require('../../lib/site-lib');
class CacheService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
}
async set (name, value) {
return this.dtp.redis.set(name, value);
}
async setEx (name, seconds, value) {
return this.dtp.redis.setex(name, seconds, value);
}
async get (name) {
return this.dtp.redis.get(name);
}
async setObject (name, value) {
return this.dtp.redis.set(name, JSON.stringify(value));
}
async setObjectEx (name, seconds, value) {
return this.dtp.redis.setex(name, seconds, JSON.stringify(value));
}
async getObject (name) {
const value = await this.dtp.redis.get(name);
if (!value) {
return; // undefined
}
return JSON.parse(value);
}
async del (name) {
return this.dtp.redis.del(name);
}
getKeys (pattern) {
return new Promise((resolve, reject) => {
return this.dtp.redis.keys(pattern, (err, response) => {
if (err) {
return reject(err);
}
return resolve(response);
});
});
}
}
module.exports = {
slug: 'cache',
name: 'cache',
create: (dtp) => { return new CacheService(dtp); },
};

64
app/services/crypto.js

@ -0,0 +1,64 @@
// crypto.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const crypto = require('crypto');
const { SiteService } = require('../../lib/site-lib');
class CryptoService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
}
maskPassword (passwordSalt, password) {
const hash = crypto.createHash('sha256');
hash.update(process.env.DTP_PASSWORD_SALT);
hash.update(passwordSalt);
hash.update(password);
return hash.digest('hex');
}
createHash (content, algorithm = 'sha256') {
const hash = crypto.createHash(algorithm);
hash.update(content);
return hash.digest('hex');
}
hash32 (text) {
var hash = 0, i, chr;
if (text.length === 0) {
return hash;
}
for (i = text.length - 1; i >= 0; --i) {
chr = text.charCodeAt(i);
// jshint ignore:start
hash = ((hash << 5) - hash) + chr;
hash |= 0;
// jshint ignore:end
}
hash = hash.toString(16);
if (hash[0] === '-') {
hash = hash.slice(1);
}
return hash;
}
createProof (secret, challenge) {
let hash = crypto.createHash('sha256');
hash.update(secret);
hash.update(challenge);
return hash.digest('hex');
}
}
module.exports = {
slug: 'crypto',
name: 'crypto',
create: (dtp) => { return new CryptoService(dtp); },
};

79
app/services/csrf-token.js

@ -0,0 +1,79 @@
// csrf-token.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const moment = require('moment');
const mongoose = require('mongoose');
const uuidv4 = require('uuid').v4;
const CsrfToken = mongoose.model('CsrfToken');
const { SiteService } = require('../../lib/site-lib');
class CsrfTokenService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
}
middleware (options) {
return async (req, res, next) => {
const requestToken = req.body[`csrf-token-${options.name}`];
if (!requestToken) {
return next(new Error('Must include valid CSRF token'));
}
const token = await CsrfToken.findOne({ token: requestToken });
if (!token) {
return next(new Error('CSRF request token is invalid'));
}
if (token.ip !== req.ip) {
return next(new Error('CSRF request token client mismatch'));
}
if (token.user) {
if (!req.user) {
return next(new Error('Must be logged in'));
}
if (!token.user.equals(req.user._id)) {
return next(new Error('CSRF request token user mismatch'));
}
}
this.log.info('claiming CSRF token', {
requestToken,
ip: req.ip,
});
await CsrfToken.updateOne(
{ _id: token._id },
{ $set: { claimed: new Date() } },
);
return next();
};
}
async create (req, options) {
options = Object.assign({
expiresMinutes: 30,
}, options);
const now = new Date();
let csrfToken = await CsrfToken.create({
created: now,
expires: moment(now).add(options.expiresMinutes, 'minute').toDate(),
user: req.user ? req.user._id : null,
ip: req.ip,
token: uuidv4(),
});
csrfToken = csrfToken.toObject();
csrfToken.name = `csrf-token-${options.name}`;
return csrfToken;
}
}
module.exports = {
slug: 'csrf-token',
name: 'csrfToken',
create: (dtp) => { return new CsrfTokenService(dtp); },
};

125
app/services/display-engine.js

@ -0,0 +1,125 @@
// display-engine.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const path = require('path');
const pug = require('pug');
const uuidv4 = require('uuid').v4;
const { SiteService, SiteError } = require('../../lib/site-lib');
class DisplayList {
constructor (service, name) {
this.name = name;
this.id = uuidv4();
this.commands = [ ];
}
showNotification (message, status, pos, timeout) {
this.commands.push({
action: 'showNotification',
params: { message, status, pos, timeout },
});
}
addElement (selector, where, html) {
this.commands.push({
selector, action: 'addElement',
params: { where, html },
});
}
setTextContent (selector, text) {
this.commands.push({
selector, action: 'setTextContent',
params: { text },
});
}
replaceElement (selector, html) {
this.commands.push({
selector, action: 'replaceElement',
params: { html },
});
}
removeElement (selector) {
this.commands.push({
selector, action: 'removeElement',
params: { },
});
}
setAttribute (selector, name, value) {
this.commands.push({
selector, action: 'setAttribute',
params: { name, value },
});
}
removeAttribute (selector, name) {
this.commands.push({
selector, action: 'removeAttribute',
params: { name },
});
}
addClass (selector, add) {
this.commands.push({
selector, action: 'addClass',
params: { add },
});
}
removeClass (selector, remove) {
this.commands.push({
selector, action: 'removeClass',
params: { remove },
});
}
replaceClass (selector, remove, add) {
this.commands.push({
selector, action: 'replaceClass',
params: { remove, add },
});
}
}
class DisplayEngineService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
this.templates = { };
}
async start ( ) { }
async stop ( ) { }
loadTemplate (name, pugScript) {
const scriptFile = path.join(this.dtp.config.root, 'app', 'views', pugScript);
this.templates[name] = pug.compileFile(scriptFile);
}
executeTemplate (name, data) {
if (!this.templates[name]) {
this.log.error('view engine template undefined', { name });
throw new SiteError(500, 'Unknown display engine template');
}
data = Object.assign(this.dtp.app.locals, data);
return this.templates[name](data);
}
createDisplayList (name = 'default') { return new DisplayList(this, name); }
}
module.exports = {
slug: 'display-engine',
name: 'displayEngine',
create: (dtp) => { return new DisplayEngineService(dtp); },
};

72
app/services/domain.js

@ -0,0 +1,72 @@
// domain.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const striptags = require('striptags');
const mongoose = require('mongoose');
const Domain = mongoose.model('Domain');
const { SiteService, SiteError } = require('../../lib/site-lib');
class DomainService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
}
async create (domainDefinition) {
const NOW = new Date();
const domain = new Domain();
domain.created = NOW;
domain.name = striptags(domainDefinition.name.trim().toLowerCase());
await domain.save();
return domain.toObject();
}
async update (domain, domainDefinition) {
await Domain.updateOne(
{ _id: domain._id },
{
$set: {
name: striptags(domainDefinition.name.trim().toLowerCase()),
},
},
);
}
async getSiteDomain ( ) {
return this.getByName(process.env.DTP_SITE_DOMAIN_KEY);
}
async getById (domainId) {
const domain = await Domain.findById(domainId).lean();
return domain;
}
async getByName (domainName) {
const domain = await Domain.findOne({ name: domainName.toLowerCase().trim() }).lean();
if (!domain) {
throw new SiteError(404, 'Domain does not exist');
}
return domain;
}
async getDomains (pagination) {
const domains = await Domain
.find()
.sort({ name: 1 })
.skip(pagination.skip)
.limit(pagination.cpp)
.lean();
return domains;
}
}
module.exports = {
slug: 'domain',
name: 'domain',
create: (dtp) => { return new DomainService(dtp); },
};

103
app/services/email.js

@ -0,0 +1,103 @@
// email.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const path = require('path');
const pug = require('pug');
const mailgun = require('mailgun-js');
const mongoose = require('mongoose');
const EmailBlacklist = mongoose.model('EmailBlacklist');
const disposableEmailDomains = require('disposable-email-provider-domains');
const emailValidator = require('email-validator');
const emailDomainCheck = require('email-domain-check');
const { SiteService } = require('../../lib/site-lib');
class EmailService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
}
async start ( ) {
await super.start();
if (process.env.DTP_MAILGUN_ENABLED === 'enabled') {
this.mg = mailgun({
apiKey: process.env.MAILGUN_API_KEY,
domain: process.env.MAILGUN_DOMAIN,
});
}
this.templates = {
html: {
welcome: pug.compileFile(path.join(this.dtp.config.root, 'app', 'templates', 'html', 'welcome.pug')),
},
text: {
welcome: pug.compileFile(path.join(this.dtp.config.root, 'app', 'templates', 'text', 'welcome.pug')),
},
};
}
async send (message) {
return new Promise((resolve, reject) => {
this.log.info('sending email', { to: message.to, subject: message.subject });
this.mg.messages().send(message, async (error, body) => {
if (error) {
return reject(error);
}
resolve(body);
});
});
}
async checkEmailAddress (emailAddress) {
this.log.debug('validating email address', { emailAddress });
if (!emailValidator.validate(emailAddress)) {
throw new Error('Email address is invalid');
}
const domainCheck = await emailDomainCheck(emailAddress);
this.log.debug('email domain check', { domainCheck });
if (!domainCheck) {
throw new Error('Email address is invalid');
}
await this.isEmailBlacklisted(emailAddress);
}
async isEmailBlacklisted (emailAddress) {
emailAddress = emailAddress.toLowerCase().trim();
const domain = emailAddress.split('@')[1];
this.log.debug('checking email domain for blacklist', { domain });
if (disposableEmailDomains.domains.includes(domain)) {
this.log.alert('blacklisted email domain blocked', { emailAddress, domain });
throw new Error('Invalid email address');
}
const blacklistRecord = await EmailBlacklist.findOne({ email: emailAddress });
if (blacklistRecord) {
throw new Error('Email address has requested to not receive emails', { blacklistRecord });
}
return false;
}
async renderTemplate (templateId, templateType, message) {
return this.templates[templateType][templateId](message);
}
}
module.exports = {
slug: 'email',
name: 'email',
create: (dtp) => {
return new EmailService(dtp);
},
};

100
app/services/host-cache.js

@ -0,0 +1,100 @@
// host-cache.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const dgram = require('dgram');
const uuidv4 = require('uuid').v4;
const { SiteService, SiteError } = require('../../lib/site-lib');
class HostCacheService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
this.transactions = { };
}
async start ( ) {
await super.start();
this.log.info('creating UDP host-cache socket');
this.hostCache = dgram.createSocket('udp4', this.onMessage.bind(this));
this.hostCache.on('error', this.onError.bind(this));
this.log.info('connecting UDP host-cache socket');
this.hostCache.bind(0, '127.0.0.1');
this.hostCache.connect(8000, '127.0.0.1');
}
async stop ( ) {
if (this.hostCache) {
this.log.info('disconnecting UDP host-cache socket');
this.hostCache.disconnect();
delete this.hostCache;
}
}
async getFile (bucket, key) {
return new Promise((resolve, reject) => {
const transaction = { tid: uuidv4(), bucket, key, resolve, reject };
this.transactions[transaction.tid] = transaction;
const message = JSON.stringify({
tid: transaction.tid,
cmd: 'getFile',
params: { bucket, key },
});
this.hostCache.send(message);
});
}
async onMessage (message, rinfo) {
message = message.toString('utf8');
message = JSON.parse(message);
switch (message.res.cmd) {
case 'getFile':
return this.onGetFile(message, rinfo);
}
}
async onGetFile (message) {
const transaction = this.transactions[message.tid];
if (!transaction) {
this.log.error('getFile response received with no matching transaction', { tid: message.tid });
return;
}
if (!message.res.success) {
transaction.reject(new SiteError(message.res.statusCode, message.res.message));
delete this.transactions[message.tid];
return;
}
transaction.resolve({
success: message.res.success,
message: message.res.message,
file: message.res.file,
flags: message.flags,
duration: message.duration,
});
delete this.transactions[message.tid];
}
async onError (error) {
this.log.error('onError', { error });
if ((error.errno !== -111) || (error.code !== 'ECONNREFUSED')) {
return;
}
const keys = Object.keys(this.transactions);
keys.forEach((key) => {
const transaction = this.transactions[key];
transaction.reject(error);
delete this.transactions[key];
});
}
}
module.exports = {
slug: 'host-cache',
name: 'hostCache',
create: (dtp) => { return new HostCacheService(dtp); },
};

225
app/services/image.js

@ -0,0 +1,225 @@
// minio.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const path = require('path');
const fs = require('fs');
const mongoose = require('mongoose');
const SiteImage = mongoose.model('Image');
const sharp = require('sharp');
const { SiteService, SiteAsync } = require('../../lib/site-lib');
class ImageService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
this.populateImage = [
{
path: 'owner',
select: '_id username username_lc displayName picture'
},
];
}
async start ( ) {
await super.start();
await fs.promises.mkdir(process.env.DTP_IMAGE_WORK_PATH, { recursive: true });
}
async create (owner, imageDefinition, file) {
const NOW = new Date();
const { minio: minioService } = this.dtp.services;
this.log.debug('processing uploaded image', { imageDefinition, file });
const sharpImage = await sharp(file.path);
const metadata = await sharpImage.metadata();
// create an Image model instance, but leave it here in application memory.
// we don't persist it to the db until MinIO accepts the binary data.
const image = new SiteImage();
image.created = NOW;
image.owner = owner._id;
image.type = file.mimetype;
image.size = file.size;
image.file.bucket = process.env.MINIO_IMAGE_BUCKET;
image.metadata = this.makeImageMetadata(metadata);
const imageId = image._id.toString();
const ownerId = owner._id.toString();
const fileKey = `/${ownerId.slice(0, 3)}/${ownerId}/${imageId.slice(0, 3)}/${imageId}`;
image.file.key = fileKey;
// upload the image file to MinIO
const response = await minioService.uploadFile({
bucket: image.file.bucket,
key: image.file.key,
filePath: file.path,
metadata: {
'Content-Type': file.mimetype,
'Content-Length': file.size,
},
});
// store the eTag from MinIO in the Image model
image.file.etag = response.etag;
// save the Image model to the db
await image.save();
this.log.info('processed uploaded image', { ownerId, imageId, fileKey });
return image.toObject();
}
async getImageById (imageId) {
const image = await SiteImage
.findById(imageId)
.populate(this.populateImage);
return image;
}
async getRecentImagesForOwner (owner) {
const images = await SiteImage
.find({ owner: owner._id })
.sort({ created: -1 })
.limit(10)
.populate(this.populateImage)
.lean();
return images;
}
async deleteImage (image) {
const { minio: minioService } = this.dtp.services;
this.log.debug('removing image from storage', { bucket: image.file.bucket, key: image.file.key });
await minioService.removeObject(image.file.bucket, image.file.key);
this.log.debug('removing image from MongoDB', { _id: image._id });
await SiteImage.deleteOne({ _id: image._id });
}
async processImageFile (owner, file, outputs, options) {
this.log.debug('processing image file', { owner, file, outputs });
const sharpImage = sharp(file.path);
return this.processImage(owner, sharpImage, outputs, options);
}
async processImage (owner, sharpImage, outputs, options) {
const NOW = new Date();
const service = this;
const { minio: minioService } = this.dtp.services;
options = Object.assign({
removeWorkFiles: true,
}, options);
const imageWorkPath = process.env.DTP_IMAGE_WORK_PATH || '/tmp';
const metadata = await sharpImage.metadata();
async function processOutputImage (output) {
const outputMetadata = service.makeImageMetadata(metadata);
outputMetadata.width = output.width;
outputMetadata.height = output.height;
service.log.debug('processing image', { output, outputMetadata });
const image = new SiteImage();
image.created = NOW;
image.owner = owner._id;
image.type = `image/${output.format}`;
image.metadata = outputMetadata;
try {
let chain = sharpImage.clone().resize({ width: output.width, height: output.height });
chain = chain[output.format](output.formatParameters);
output.filePath = path.join(imageWorkPath, `${image._id}.${output.width}x${output.height}.${output.format}`);
output.mimetype = `image/${output.format}`;
await chain.toFile(output.filePath);
output.stat = await fs.promises.stat(output.filePath);
} catch (error) {
service.log.error('failed to process output image', { output, error });
throw error;
}
try {
const imageId = image._id.toString();
const ownerId = owner._id.toString();
const fileKey = `/${ownerId.slice(0, 3)}/${ownerId}/images/${imageId.slice(0, 3)}/${imageId}.${output.format}`;
image.file.bucket = process.env.MINIO_IMAGE_BUCKET;
image.file.key = fileKey;
image.size = output.stat.size;
// upload the image file to MinIO
const response = await minioService.uploadFile({
bucket: image.file.bucket,
key: image.file.key,
filePath: output.filePath,
metadata: {
'Content-Type': output.mimetype,
'Content-Length': output.stat.size,
},
});
// store the eTag from MinIO in the Image model
image.file.etag = response.etag;
// save the Image model to the db
await image.save();
service.log.info('processed uploaded image', { ownerId, imageId, fileKey });
if (options.removeWorkFiles) {
service.log.debug('removing work file', { path: output.filePath });
await fs.promises.unlink(output.filePath);
delete output.filePath;
}
output.image = {
_id: image._id,
bucket: image.file.bucket,
key: image.file.key,
};
} catch (error) {
service.log.error('failed to persist output image', { output, error });
if (options.removeWorkFiles) {
service.log.debug('removing work file', { path: output.filePath });
await SiteAsync.each(outputs, async (output) => {
await fs.promises.unlink(output.filePath);
delete output.filePath;
}, 4);
}
throw error;
}
}
await SiteAsync.each(outputs, processOutputImage, 4);
}
makeImageMetadata (metadata) {
return {
format: metadata.format,
size: metadata.size,
width: metadata.width,
height: metadata.height,
space: metadata.space,
channels: metadata.channels,
depth: metadata.depth,
density: metadata.density,
hasAlpha: metadata.hasAlpha,
orientation: metadata.orientation,
};
}
}
module.exports = {
slug: 'image',
name: 'image',
create: (dtp) => { return new ImageService(dtp); },
};

73
app/services/job-queue.js

@ -0,0 +1,73 @@
// job-queue.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const BullQueue = require('bull');
const { /*SiteError,*/ SiteService } = require('../../lib/site-lib');
class JobQueueService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
this.queues = { };
}
async start ( ) { }
async stop ( ) { }
getJobQueue (name, defaultJobOptions) {
/*
* If we have a named queue, return it.
*/
let queue = this.queues[name];
if (queue) {
return queue;
}
/*
* Create a new named queue
*/
defaultJobOptions = Object.assign({
priority: 10,
delay: 0,
attempts: 1,
removeOnComplete: true,
removeOnFail: false,
}, defaultJobOptions);
queue = new BullQueue(name, {
prefix: process.env.DTP_BULL_PREFIX || 'dtp',
redis: {
host: process.env.REDIS_HOST,
port: parseInt(process.env.REDIS_PORT || '6379', 10),
password: process.env.REDIS_PASSWORD,
keyPrefix: process.env.REDIS_KEY_PREFIX,
lazyConnect: true,
},
defaultJobOptions,
});
queue.setMaxListeners(64);
this.queues[name] = queue;
return queue;
}
async discoverJobQueues (pattern) {
const { cache: cacheService } = this.dtp.services;
let bullQueues = await cacheService.getKeys(pattern);
return bullQueues
.map((queue) => queue.split(':')[1])
.sort()
;
}
}
module.exports = {
slug: 'job-queue',
name: 'jobQueue',
create: (dtp) => { return new JobQueueService(dtp); },
};

61
app/services/limiter.js

@ -0,0 +1,61 @@
// limiter.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const path = require('path');
const expressLimiter = require('express-limiter');
const { SiteService, SiteError } = require('../../lib/site-lib');
class LimiterService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
this.config = require(path.resolve(dtp.config.root, 'config', 'limiter.js'));
this.limiter = expressLimiter(this.dtp.app, this.dtp.redis);
this.handlers = {
lookup: this.limiterLookup.bind(this),
whitelist: this.limiterWhitelist.bind(this),
};
}
create (config) {
const options = {
total: config.total,
expire: config.expire,
lookup: this.handlers.lookup,
whitelist: this.handlers.whitelist,
onRateLimited: async (req, res, next) => {
this.emit('limiter:block', req);
next(new SiteError(config.status || 429, config.message || 'Rate limit exceeded'));
},
};
const middleware = this.limiter(options);
return async (req, res, next) => {
return middleware(req, res, next);
};
}
limiterLookup (req, res, options, next) {
if (req.user) {
options.lookup = 'user._id'; // req.user._id, populated by PassportJS session
} else {
options.lookup = 'ip'; // req.ip, populated by ExpressJS with trust_proxy=1
}
return next();
}
limiterWhitelist (req) {
return req.user && req.user.flags.isAdmin;
}
}
module.exports = {
slug: 'limiter',
name: 'limiter',
create: (dtp) => { return new LimiterService(dtp); },
};

100
app/services/media.js

@ -0,0 +1,100 @@
// article.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const util = require('util');
const execFile = util.promisify(require('child_process').execFile);
const { SiteService } = require('../../lib/site-lib');
class MediaService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
}
async ffmpeg (ffmpegArgs) {
try {
await execFile(process.env.DTP_FFMPEG_PATH, ffmpegArgs, {
cwd: this.dtp.config.root,
encoding: 'utf8',
});
} catch (error) {
this.log.error('failed to execute ffprobe', { ffmpegArgs, error });
throw error;
}
}
async ffprobe (input, options) {
options = Object.assign({
streams: true,
format: true,
error: true,
}, options);
const ffprobeOpts = ['-print_format', 'json'];
if (options.streams) {
ffprobeOpts.push('-show_streams');
}
if (options.format) {
ffprobeOpts.push('-show_format');
}
if (options.error) {
ffprobeOpts.push('-show_error');
}
ffprobeOpts.push(input);
try {
const { stdout } = await execFile(process.env.DTP_FFPROBE_PATH, ffprobeOpts, {
cwd: this.dtp.config.root,
encoding: 'utf8',
});
const probe = JSON.parse(stdout);
if (probe.format && probe.format.tags) {
let keys = Object.keys(probe.format.tags);
keys.forEach((key) => {
probe.format.tags[key.replace(/\./g, '_')] = probe.format.tags[key];
delete probe.format.tags[key];
});
}
probe.duration = probe.format.duration;
probe.width = probe.format.width;
if (Array.isArray(probe.streams) && probe.streams.length) {
const stream = probe.streams
.find((stream) => stream.codec_type === 'video') || probe.streams[0];
if (stream.duration) {
probe.duration = parseFloat(stream.duration);
} else {
probe.duration = parseFloat(probe.format.duration);
}
probe.width = stream.width;
probe.height = stream.height;
const fpsFraction = stream.avg_frame_rate.split('/').map((value) => parseInt(value, 10));
if (Array.isArray(fpsFraction) && fpsFraction[1] !== 0) {
probe.fps = fpsFraction[0] / fpsFraction[1];
}
} else {
probe.duration = undefined;
probe.width = undefined;
probe.height = undefined;
}
return probe;
} catch (error) {
this.log.error('failed to execute ffprobe', { input, error });
throw error;
}
}
}
module.exports = {
slug: 'media',
name: 'media',
create: (dtp) => { return new MediaService(dtp); },
};

76
app/services/minio.js

@ -0,0 +1,76 @@
// minio.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const Minio = require('minio');
const { SiteService } = require('../../lib/site-lib');
class MinioService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
}
async start ( ) {
await super.start();
this.minio = new Minio.Client({
endPoint: process.env.MINIO_ENDPOINT,
port: parseInt(process.env.MINIO_PORT, 10),
useSSL: (process.env.MINIO_USE_SSL === 'enabled'),
accessKey: process.env.MINIO_ACCESS_KEY,
secretKey: process.env.MINIO_SECRET_KEY,
});
}
async stop ( ) {
this.log.info(`stopping ${module.exports.name} service`);
}
async uploadFile (fileInfo) {
try {
const result = await this.minio.fPutObject(
fileInfo.bucket,
fileInfo.key,
fileInfo.filePath,
fileInfo.metadata
);
return result;
} catch (error) {
this.log.error('failed to upload file to MinIO', { fileInfo, error });
throw error;
}
}
async downloadFile (fileInfo) {
await this.minio.fGetObject(fileInfo.bucket, fileInfo.key, fileInfo.filePath);
}
async openDownloadStream (fi) {
if (fi.range) {
const length = fi.range.end - fi.range.start + 1;
const stream = await this.minio.getPartialObject(fi.bucket, fi.key, fi.range.start, length);
return stream;
}
const stream = await this.minio.getObject(fi.bucket, fi.key);
return stream;
}
async removeObject (bucket, key) {
try {
await this.minio.removeObject(bucket, key);
} catch (error) {
this.log.error('failed to remove object', { bucket, key, error });
throw error;
}
}
}
module.exports = {
slug: 'minio',
name: 'minio',
create: (dtp) => { return new MinioService(dtp); },
};

224
app/services/otp-auth.js

@ -0,0 +1,224 @@
// otp-auth.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
// const striptags = require('striptags');
const mongoose = require('mongoose');
const OtpAccount = mongoose.model('OtpAccount');
const ONE_HOUR = 1000 * 60 * 60;
const OTP_SESSION_DURATION = (process.env.NODE_ENV === 'local') ? (ONE_HOUR * 24) : (ONE_HOUR * 2);
const { authenticator } = require('otplib');
const uuidv4 = require('uuid').v4;
const { SiteService, SiteError } = require('../../lib/site-lib');
class DomainService extends SiteService {
constructor (dtp) {
super(dtp, module.exports);
}
async start ( ) {
const { domain: domainService } = this.dtp.services;
this.siteDomain = await domainService.getSiteDomain();
authenticator.options = {
algorithm: 'sha1',
step: 30,
digits: 6,
};
}
middleware (serviceName, options) {
options = Object.assign({
otpRequired: false,
otpRedirectURL: '/',
adminRequired: false,
}, options);
return async (req, res, next) => {
res.locals.otp = { }; // will decorate view model with OTP information
if (!req.session) {
return next(new SiteError(403, 'Request session is invalid'));
}
if (!req.user) {
return next(new SiteError(403, 'Must be logged in'));
}
if (options.adminRequired && !req.user.flags.isAdmin) {
return next(new SiteError(403, 'Admin privileges are required'));
}
req.session.otp = req.session.otp || { };
if (await this.checkOtpSession(req, serviceName)) {
return next(); // user is OTP-authenticated on this service
}
res.locals.otpOptions = authenticator.options;
res.locals.otpServiceName = serviceName;
res.locals.otpAlgorithm = authenticator.options.algorithm.toUpperCase();
res.locals.otpDigits = authenticator.options.digits;
res.locals.otpPeriod = authenticator.options.step;
if (typeof options.otpRedirectURL === 'function') {
// allows redirect to things like /user/:userId using current session's user
res.locals.otpRedirectURL = await options.otpRedirectURL(req, res);
} else {
res.locals.otpRedirectURL = options.otpRedirectURL;
}
res.locals.otpAccount = await OtpAccount
.findOne({
domain: this.siteDomain._id,
user: req.user._id,
service: serviceName,
});
if (!res.locals.otpAccount && !options.otpRequired) {
return next(); // route not guarded (am I a joke to you?)
}
if (!res.locals.otpAccount) {
res.locals.otpTempSecret = authenticator.generateSecret();
res.locals.otpKeyURI = authenticator.keyuri(
req.user.username.trim(),
`${this.dtp.config.site.name}: ${serviceName}`,
res.locals.otpTempSecret,
);
req.session.otp[serviceName] = req.session.otp[serviceName] || { };
req.session.otp[serviceName].secret = res.locals.otpTempSecret;
req.session.otp[serviceName].redirectURL = res.locals.otpRedirectURL;
return res.render('otp/welcome');
}
res.locals.otpSession = req.session.otp[serviceName];
this.log.debug('request on OTP-required route with no authentication', {
service: serviceName,
ip: req.ip,
session: res.locals.otpSession,
}, req.user);
req.session.otp[serviceName] = req.session.otp[serviceName] || { };
req.session.otp[serviceName].redirectURL = res.locals.otpRedirectURL;
await this.saveSession(req);
if (!res.locals.otpSession || !res.locals.otpSession.isAuthenticated) {
return res.render('otp/authenticate');
}
return next();
};
}
async createOtpAccount (req, service, secret, passcode) {
const NOW = new Date();
const { crypto: cryptoService } = this.dtp.services;
try {
this.log.info('verifying user passcode', {
user: req.user._id,
username: req.user.username,
service, secret, passcode,
}, req.user);
if (authenticator.check(passcode, secret)) {
throw new SiteError(403, 'Invalid passcode');
}
const backupTokens = [ ];
for (let i = 0; i < 10; ++i) {
backupTokens.push({
token: cryptoService.createHash(secret + uuidv4()),
});
}
const now = new Date();
const account = await OtpAccount.create({
created: NOW,
domain: this.siteDomain._id,
user: req.user._id,
service,
secret,
algorithm: authenticator.options.algorithm,
step: authenticator.options.step,
digits: authenticator.options.digits,
backupTokens,
lastVerification: now,
lastVerificationIp: req.ip,
});
return account;
} catch (error) {
this.log.error('failed to create OTP account', {
service, secret, passcode, error,
}, req.user);
throw error;
}
}
async startOtpSession (req, serviceName, passcode) {
if (!passcode || (typeof passcode !== 'string')) {
throw new SiteError(403, 'Invalid passcode');
}
try {
const account = await OtpAccount
.findOne({ user: req.user._id, service: serviceName })
.select('+secret')
.lean();
if (!account) {
throw new SiteError(400, 'Two-Factor Authentication not enabled');
}
const now = new Date();
if (!authenticator.check(passcode, account.secret)) {
throw new SiteError(403, 'Invalid passcode');
}
req.session.otp = req.session.otp || { };
req.session.otp[serviceName] = req.session.otp[serviceName] || { };
req.session.otp[serviceName].isAuthenticated = true;
req.session.otp[serviceName].expiresAt = now.valueOf() + OTP_SESSION_DURATION;
await this.saveSession(req);
} catch (error) {
this.log.error('failed to start OTP session', {
serviceName, passcode, error,
});
throw error;
}
}
async checkOtpSession (req, serviceName) {
if (!req.session || !req.session.otp) {
return false;
}
const session = req.session.otp[serviceName];
if (!session) {
return false;
}
if (!session.isAuthenticated) {
return false;
}
const NOW = Date.now();
if (NOW >= session.expiresAt) {
session.isAuthenticated = false;
delete session.expiresAt;
await this.saveSession(req);
return false;
}
session.expiresAt = NOW + OTP_SESSION_DURATION;
await this.saveSession(req);
return true;
}
}
module.exports = {
slug: 'otp-auth',
name: 'otpAuth',
create: (dtp) => { return new DomainService(dtp); },
};

83
app/services/session.js

@ -0,0 +1,83 @@
// session.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const util = require('util');
const passport = require('passport');
const { SiteError, SiteLog } = require('../../lib/site-lib');
class SessionService {
constructor (dtp) {
this.dtp = dtp;
this.log = new SiteLog(dtp, `svc:${module.exports.slug}`);
}
async start ( ) {
this.log.info(`starting ${module.exports.name} service`);
passport.serializeUser(this.serializeUser.bind(this));
passport.deserializeUser(this.deserializeUser.bind(this));
}
async stop ( ) {
this.log.info(`stopping ${module.exports.name} service`);
}
middleware ( ) {
return async (req, res, next) => {
res.locals.user = req.user;
res.locals.query = req.query;
if (req.user) {
if (req.user.flags.isAdmin) {
res.locals.config = this.dtp.config;
res.locals.session = req.session;
res.locals.util = util;
}
}
return next();
};
}
authCheckMiddleware (options) {
options = Object.assign({
requireLogin: true,
requireAdmin: false,
}, options);
return async (req, res, next) => {
if (options.requireLogin && !req.user) {
return next(new SiteError(403, 'Must sign in to proceed'));
}
if (options.requireAdmin && (!req.user || !req.user.flags.isAdmin)) {
return next(new SiteError(403, 'Administrator privileges are required'));
}
return next();
};
}
async serializeUser (user, done) {
return done(null, user._id);
}
async deserializeUser (userId, done) {
const { user: userService } = this.dtp.services;
try {
const user = await userService.getUserAccount(userId);
return done(null, user);
} catch (error) {
this.log.error('failed to deserialize user from session', { error });
return done(null, null);
}
}
}
module.exports = {
slug: 'session',
name: 'session',
create: (dtp) => { return new SessionService(dtp); },
};

55
app/services/sms.js

@ -0,0 +1,55 @@
// sms.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
// const mongoose = require('mongoose');
const libphonenumber = require('libphonenumber-js');
const { SiteLog } = require('../../lib/site-lib');
class SmsService {
constructor (dtp) {
this.dtp = dtp;
this.log = new SiteLog(dtp, `svc:${module.exports.slug}`);
}
async start ( ) {
this.log.info(`starting ${module.exports.name} service`);
}
async stop ( ) {
this.log.info(`stopping ${module.exports.name} service`);
}
async send (message) {
this.log.info('sending SMS', message);
}
/**
*
* @param {*} phoneNumber
* @returns
*/
async checkPhoneNumber (phoneNumber) {
const { parsePhoneNumber } = libphonenumber;
const phoneCheck = parsePhoneNumber(phoneNumber, 'US');
if (!phoneCheck.isValid() || !phoneCheck.number) {
throw new Error('Invalid phone number');
}
return phoneCheck; // in case caller wants full data
}
}
module.exports = {
slug: 'sms',
name: 'sms',
create: (dtp) => {
return new SmsService(dtp);
},
};

385
app/services/user.js

@ -0,0 +1,385 @@
// user.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const mongoose = require('mongoose');
const User = mongoose.model('User');
const passport = require('passport');
const PassportLocal = require('passport-local');
const striptags = require('striptags');
const uuidv4 = require('uuid').v4;
const { SiteError, SiteLog } = require('../../lib/site-lib');
class UserService {
constructor (dtp) {
this.dtp = dtp;
this.log = new SiteLog(dtp, `svc:${module.exports.slug}`);
}
async start ( ) {
this.log.info(`starting ${module.exports.name} service`);
this.registerPassportLocal();
if (process.env.DTP_ADMIN === 'enabled') {
this.registerPassportAdmin();
}
}
async stop ( ) {
this.log.info(`stopping ${module.exports.name} service`);
}
async create (userDefinition) {
const NOW = new Date();
const {
crypto: cryptoService,
email: mailService,
} = this.dtp.services;
try {
userDefinition.email = userDefinition.email.trim().toLowerCase();
// strip characters we don't want to allow in username
userDefinition.username = userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, '');
const username_lc = userDefinition.username.toLowerCase();
// test the email address for validity, blacklisting, etc.
await mailService.checkEmailAddress(userDefinition.email);
// test if we already have a user with this email address
let user = await User.findOne({ 'email': userDefinition.email.toLowerCase().trim() }).lean();
if (user) {
throw new SiteError(400, `An account with email address ${userDefinition.email} already exists.`);
}
// test if we already have a user with this username
user = await User.findOne({ username_lc }).lean();
if (user) {
throw new SiteError(400, `An account with username ${userDefinition.username} already exists.`);
}
const passwordSalt = uuidv4();
const maskedPassword = cryptoService.maskPassword(passwordSalt, userDefinition.password);
user = new User();
user.created = NOW;
user.email = userDefinition.email;
user.username = userDefinition.username;
user.username_lc = username_lc;
user.passwordSalt = passwordSalt;
user.password = maskedPassword;
user.flags = {
isAdmin: false,
isModerator: false,
};
user.permissions = {
canLogin: true,
canChat: true,
};
this.log.info('creating new user account', { email: userDefinition.email });
await user.save();
return user.toObject();
} catch (error) {
this.log.error('failed to create user', { error });
throw error;
}
}
async update (user, userDefinition) {
// strip characters we don't want to allow in username
userDefinition.username = striptags(userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, ''));
const username_lc = userDefinition.username.toLowerCase();
userDefinition.displayName = striptags(userDefinition.displayName.trim());
this.log.info('updating user', { userDefinition });
await User.updateOne(
{ _id: user._id },
{
$set: {
username: userDefinition.username,
username_lc,
displayName: userDefinition.displayName,
'flags.isAdmin': userDefinition.isAdmin === 'on',
'flags.isModerator': userDefinition.isModerator === 'on',
'permissions.canLogin': userDefinition.canLogin === 'on',
'permissions.canChat': userDefinition.canChat === 'on',
},
},
);
}
async updateSettings (user, userDefinition) {
// strip characters we don't want to allow in username
userDefinition.username = striptags(userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, ''));
const username_lc = userDefinition.username.toLowerCase();
userDefinition.displayName = striptags(userDefinition.displayName.trim());
this.log.info('updating user settings', { userDefinition });
await User.updateOne(
{ _id: user._id },
{
$set: {
username: userDefinition.username,
username_lc,
displayName: userDefinition.displayName,
},
},
);
}
async authenticate (account, options) {
const { crypto } = this.dtp.services;
options = Object.assign({
adminRequired: false,
}, options);
const accountEmail = account.username.trim().toLowerCase();
const accountUsername = await this.filterUsername(accountEmail);
this.log.debug('locating user record', { accountEmail, accountUsername });
const user = await User
.findOne({
$or: [
{ email: accountEmail },
{ username_lc: accountUsername },
]
})
.select('+passwordSalt +password +flags')
.lean();
if (!user) {
throw new SiteError(404, 'Member account not found');
}
const maskedPassword = crypto.maskPassword(
user.passwordSalt,
account.password,
);
if (maskedPassword !== user.password) {
throw new SiteError(403, 'Account credentials do not match');
}
// remove these critical fields from the user object
delete user.passwordSalt;
delete user.password;
if (options.adminRequired && !user.flags.isAdmin) {
throw new SiteError(403, 'Admin privileges required');
}
this.log.debug('user authenticated', { user });
return user;
}
registerPassportLocal ( ) {
const options = {
usernameField: 'username',
passwordField: 'password',
session: true,
};
passport.use('dtp-local', new PassportLocal(options, this.handleLocalLogin.bind(this)));
}
async handleLocalLogin (username, password, done) {
const now = new Date();
this.log.info('handleLocalLogin', { username, password });
try {
const user = await this.authenticate({ username, password }, { adminRequired: false });
await this.startUserSession(user, now);
done(null, this.filterUserObject(user));
} catch (error) {
this.log.error('failed to process local user login', { error });
done(error);
}
}
registerPassportAdmin ( ) {
const options = {
usernameField: 'username',
passwordField: 'password',
session: true,
};
this.log.info('registering PassportJS admin strategy', { options });
passport.use('dtp-admin', new PassportLocal(options, this.handleAdminLogin.bind(this)));
}
async handleAdminLogin (email, password, done) {
const now = new Date();
try {
const user = await this.authenticate({ email, password }, { adminRequired: true });
await this.startUserSession(user, now);
done(null, this.filterUserObject(user));
} catch (error) {
this.log.error('failed to process admin user login', { error });
done(error);
}
}
async startUserSession (user, now) {
await User.updateOne(
{ _id: user._id },
{
$set: { 'stats.lastLogin': now },
$inc: { 'stats.loginCount': 1 },
},
);
}
filterUserObject (user) {
return {
_id: user._id,
email: user.email,
created: user.created,
flags: user.flags,
permissions: user.permissions,
};
}
async getUserAccount (userId) {
const user = await User
.findById(userId)
.select('+email +flags +permissions')
.lean()
;
if (!user) {
throw new SiteError(404, 'Member account not found');
}
return user;
}
async getUserAccounts (pagination) {
const users = await User
.find()
.sort({ username_lc: 1 })
.select('+email +flags +permissions')
.skip(pagination.skip)
.limit(pagination.cpp)
.lean()
;
return users;
}
async getUserProfile (userId) {
let user;
try {
userId = mongoose.Types.ObjectId(userId); // will throw if invalid format
user = User.findById(userId);
} catch (error) {
user = User.findOne({ username: userId });
}
user = await user.select('+email +flags +settings').lean();
return user;
}
async setUserSettings (user, settings) {
const {
crypto: cryptoService,
mail: mailService,
phone: phoneService,
} = this.dtp.platform.services;
const update = { $set: { } };
const actions = [ ];
if (settings.name && (settings.name !== user.name)) {
update.name = striptags(settings.name.trim());
update.name_lc = update.name.toLowerCase();
actions.push('Display name updated');
}
if (settings.username && (settings.username !== user.username)) {
update.username = this.filterUsername(settings.username);
const isReserved = await this.isUsernameReserved(update.username);
if (!isReserved) {
throw new SiteError(403, 'The username you entered is taken');
}
}
if (settings.email && (settings.email !== user.email)) {
settings.email = settings.email.toLowerCase().trim();
await mailService.checkEmailAddress(settings.email);
update.$set['flags.isEmailVerified'] = false;
update.$set.email = settings.email;
actions.push('Email address updated and verification email sent. Please check your inbox and follow the instructions included to complete the change of your email address.');
}
/*
* User is changing the phone number stored on the account.
* "There's a lot to unpack here"
*/
if (settings.phone) {
// update the phone number (there's a lot going on here)
try {
update.$set.phone = await phoneService.processPhoneNumberInput(settings.phone);
} catch (error) {
throw error;
}
// un-verify the account's phone number
update.$set['flags.isPhoneVerified'] = false;
actions.push('Phone number updated and verification message sent. Please follow the instructions in the text message to complete the change of your mobile phone number.');
}
if (settings.password) {
if (settings.password !== settings.passwordv) {
throw new SiteError(400, 'Password and password verification do not match.');
}
update.$set.passwordSalt = uuidv4();
update.$set.password = cryptoService.maskPassword(update.$set.passwordSalt, settings.password);
actions.push('Password changed successfully.');
}
if (settings.theme) {
update.$set['settings.theme'] = striptags(settings.theme.trim());
}
if (settings.language) {
update.$set['settings.language'] = mongoose.Types.ObjectId(settings.language);
actions.push('Interface language changed.');
}
await User.updateOne({ _id: user._id }, update);
return actions;
}
async filterUsername (username) {
return striptags(username.trim().toLowerCase()).replace(/\W/g, '');
}
async isUsernameReserved (username) {
const reservedNames = ['digitaltelepresence', 'dtp', 'rob', 'amy', 'zack'];
if (reservedNames.includes(username)) {
this.log.alert('prohibiting use of reserved username', { username });
return true;
}
const user = await User.findOne({ username: username}).select('username').lean();
if (user) {
this.log.alert('username is already registered', { username });
return true;
}
return false;
}
}
module.exports = {
slug: 'user',
name: 'user',
create: (dtp) => { return new UserService(dtp); },
};

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

@ -0,0 +1,10 @@
.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

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

@ -0,0 +1 @@
.greeting Dear #{voter.name},

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

@ -0,0 +1,9 @@
| - - -
| 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:
|
| Local Red Action
| P.O. Box ########
| McKees Rocks, PA 15136
| USA

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

@ -0,0 +1 @@
| Dear #{voter.name},

27
app/templates/html/welcome.pug

@ -0,0 +1,27 @@
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

7
app/templates/text/welcome.pug

@ -0,0 +1,7 @@
include ../common/text/header
|
| 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.
|
| Thank you for supporting your local Republican committee and candidates!
|
include ../common/text/footer

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

@ -0,0 +1,17 @@
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

@ -0,0 +1,21 @@
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.

26
app/views/admin/channel-application/index.pug

@ -0,0 +1,26 @@
extends ../layouts/main
block content
table.uk-table
thead
th Channel Name
th Domain
th Created
th Member
th Audience
th Status
tbody
each application in applications
tr
td
a(href=`/admin/channel-application/${application._id}`)= application.name
td= application.domain ? application.domain.name : 'N/A'
td= moment(application.created).fromNow()
td
a(href=`/admin/member/${application.owner._id}`)= application.owner.username
td= numeral(application.interview.audienceSize).format('0,0')
td(class={
'uk-text-primary': ['new','review'].includes(application.status),
'uk-text-success': (application.status === 'approved'),
'uk-text-danger': (application.status === 'rejected'),
})= application.status

79
app/views/admin/channel-application/view.pug

@ -0,0 +1,79 @@
extends ../layouts/main
block content
.uk-card.uk-card-secondary.uk-card-body.uk-margin
fieldset.uk-fieldset
legend.sr-only Channel Information
div(uk-grid)
.uk-width-expand
.uk-margin
label.uk-form-label Channel Name:
.uk-text-large.uk-text-bold= application.name
.uk-width-auto
.uk-margin
label.uk-form-label Owner:
div
a(href=`mailto:${application.owner.email}?subject=${encodeURIComponent(site.name)}${encodeURIComponent(': ')}${encodeURIComponent(application.name)}`)= application.owner.displayName || application.owner.email
.uk-width-auto
.uk-margin
label.uk-form-label Phone:
div
a(href=`tel:${application.contact.phone.number}`)= application.contact.phone.number
.uk-width-auto
.uk-margin
label.uk-form-label Submitted:
div= moment(application.created).fromNow()
.uk-margin
label.uk-form-label Description
div!= marked(application.description)
div(uk-grid)
.uk-width-auto
.uk-margin
label.uk-form-label Category
div= application.category.name
.uk-width-auto
.uk-margin
label.uk-form-label Short Name
div= application.slug
.uk-width-auto
.uk-margin
label.uk-form-label Search Tags
div= application.tags ? application.tags.join(',') : 'N/A'
.uk-card.uk-card-secondary.uk-card-body.uk-margin
fieldset.uk-fieldset
legend.uk-legend Interview
div(uk-grid)
div(class="uk-width-1-1 uk-width-auto@m")
.uk-margin
label.uk-form-label Audience Size
div= application.interview.audienceSize
div(class="uk-width-1-1 uk-width-auto@m")
.uk-margin
label.uk-form-label Demo URL
div
a(href= application.interview.demoUrl)= application.interview.demoUrl
.uk-margin
label.uk-form-label History
div!= marked(application.interview.history)
.uk-card.uk-card-secondary.uk-card-body.uk-margin
form(method="POST", action=`/admin/channel-application/${application._id}`).uk-form
.uk-margin
label(for="rejected-reason").uk-form-label Rejection explanation
textarea(id="rejected-reason", name="rejectedReason", rows="4", placeholder= "Enter reason for rejecting").uk-textarea
div(uk-grid)
.uk-width-auto
button(type="submit", name="action", value="approve").uk-button.uk-button-primary
span
i.fas.fa-check
span.uk-text-bold.uk-margin-small-left Approve
.uk-width-auto
button(type="submit", name="action", value="reject").uk-button.uk-button-danger
span
i.fas.fa-times
span.uk-text-bold.uk-margin-small-left Reject

28
app/views/admin/channel/index.pug

@ -0,0 +1,28 @@
extends ../layouts/main
block content
.uk-overflow-auto
table.uk-table.uk-table-small.uk-table-divider
thead
th Channel
th Owner
th Category
th Status
th Created
tbody
each channel in channels
tr
td
a(href=`/admin/channel/${channel._id}`)= channel.name
td
a(href=`/admin/user/${channel.owner._id}`)= channel.owner.username
td= channel.category.name
td(class={
'uk-text-success': (channel.status === 'live'),
'uk-text-default': (channel.status === 'offline'),
})= channel.status
td= moment(channel.created).format('MMM DD, YYYY')

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

@ -0,0 +1,73 @@
ul.uk-nav.uk-nav-default
li.uk-nav-header Admin Menu
li(class={ 'uk-active': (adminView === 'home') })
a(href="/admin")
span.nav-item-icon
i.fas.fa-home
span.uk-margin-small-left Home
li.uk-nav-divider
li(class={ 'uk-active': (adminView === 'domain') })
a(href="/admin/domain")
span.nav-item-icon
i.fas.fa-globe-americas
span.uk-margin-small-left Domains
li(class={ 'uk-active': (adminView === 'host') })
a(href="/admin/host")
span.nav-item-icon
i.fas.fa-tachometer-alt
span.uk-margin-small-left Platform
li(class={ 'uk-active': (adminView === 'job-queue') })
a(href="/admin/job-queue")
span.nav-item-icon
i.fas.fa-microchip
span.uk-margin-small-left Jobs
li.uk-nav-divider
li(class={ 'uk-active': (adminView === 'channel-application') })
a(href="/admin/channel-application")
span.nav-item-icon
i.fas.fa-user-tie
span.uk-margin-small-left Channel Applications
li(class={ 'uk-active': (adminView === 'channel') })
a(href="/admin/channel")
span.nav-item-icon
i.fas.fa-broadcast-tower
span.uk-margin-small-left Channels
li(class={ 'uk-active': (adminView === 'user') })
a(href="/admin/user")
span.nav-item-icon
i.fas.fa-user
span.uk-margin-small-left Users
li.uk-nav-divider
li(class={ 'uk-active': (adminView === 'stream') })
a(href="/admin/stream")
span.nav-item-icon
i.fas.fa-tv
span.uk-margin-small-left Live Streams
li(class={ 'uk-active': (adminView === 'replay') })
a(href="/admin/replay")
span.nav-item-icon
i.fas.fa-hdd
span.uk-margin-small-left Replay DVR
li.uk-nav-divider
li(class={ 'uk-active': (adminView === 'category') })
a(href="/admin/category")
span.nav-item-icon
i.fas.fa-box
span.uk-margin-small-left Categories
li(class={ 'uk-active': (adminView === 'survey') })
a(href="/admin/survey")
span.nav-item-icon
i.fas.fa-poll-h
span.uk-margin-small-left Surveys

13
app/views/admin/domain/form.pug

@ -0,0 +1,13 @@
extends ../layouts/main
block content
- var formAction = domain ? `/admin/domain/${domain._id}` : "/admin/domain";
h2 Domain Manager
form(method="POST", action= formAction).uk-form
.uk-margin
label(for="name").uk-form-label Name
input(id="name", name="name", type="text", placeholder= "Enter domain name", value= domain ? domain.name : undefined).uk-input
.uk-margin
button(type="submit").uk-button.uk-button-primary= domain ? 'Update domain' : 'Create domain'

18
app/views/admin/domain/index.pug

@ -0,0 +1,18 @@
extends ../layouts/main
block content
.uk-margin
div(uk-grid).uk-flex-middle
.uk-width-expand
h2 Domain Manager
.uk-width-auto
a(href="/admin/domain/create").uk-button.uk-button-primary
span
i.fas.fa-plus
span.uk-margin-small-left Add domain
.uk-margin
uk.uk-list
each domain in domains
li
a(href=`/admin/domain/${domain._id}`)= domain.name

23
app/views/admin/host/index.pug

@ -0,0 +1,23 @@
extends ../layouts/main
block content
table.uk-table.uk-table-small.uk-table-divider
thead
th Host
th Status
th Memory
th Platform
th Arch
th Created
th Updated
tbody
each host in hosts
tr
td
a(href=`/admin/host/${host._id}`)= host.hostname
td= host.status
td= numeral((host.totalmem - host.freemem) / host.totalmem).format('0.00%')
td= host.platform
td= host.arch
td= moment(host.created).fromNow()
td= host.updated ? moment(host.updated).fromNow() : 'N/A'

140
app/views/admin/host/view.pug

@ -0,0 +1,140 @@
extends ../layouts/main
block content
- var latestReport = stats[stats.length - 1];
mixin renderCpuStatsGraph (cpu)
- var totalTime = cpu.user + cpu.nice + cpu.sys + cpu.idle + cpu.irq
.no-select
canvas(width="320", height="100", class= {
'cpu-overload': ((cpu.idle / totalTime) < 0.1),
}).dtp-cpu-graph
.dtp-stats-bar
.dtp-cpu-stat-bar.dtp-cpu-user(style=`width: ${(cpu.user / totalTime) * 100}%;`)= numeral(cpu.user / totalTime).format('0%')
.dtp-cpu-stat-bar.dtp-cpu-nice(style=`width: ${(cpu.nice / totalTime) * 100}%;`)= numeral(cpu.nice / totalTime).format('0%')
.dtp-cpu-stat-bar.dtp-cpu-sys(style=`width: ${(cpu.sys / totalTime) * 100}%;`)= numeral(cpu.sys / totalTime).format('0%')
.dtp-cpu-stat-bar.dtp-cpu-irq(style=`width: ${(cpu.irq / totalTime) * 100}%;`)= numeral(cpu.irq / totalTime).format('0%')
.dtp-cpu-stat-bar.dtp-cpu-idle(style=`width: ${(cpu.idle / totalTime) * 100}%;`)= numeral(cpu.idle / totalTime).format('0%')
mixin renderStatCell (label, value)
.dtp-stat-cell.uk-width-auto
.uk-text-bold= value
.uk-text-small= label
.uk-margin
.uk-overflow-auto
table.uk-table.uk-table-small.uk-table-divider
thead
th Host
th Status
th Memory
th Disk
th Platform
th Arch
th Created
th Updated
tbody
tr
td
a(href=`/admin/host/${host._id}`)= host.hostname
td= host.status
td= numeral((host.totalmem - host.freemem) / host.totalmem).format('0.00%')
td= numeral(latestReport.disk.cache.pctUsed / 100.0).format('0.00%')
td= host.platform
td= host.arch
td= moment(host.created).fromNow()
td= host.updated ? moment(host.updated).fromNow() : 'N/A'
.dtp-dashboard-cluster.uk-margin
fieldset
legend [Processor]
div(uk-grid).uk-grid-small.uk-flex-between
each cpu in latestReport.cpus
div(class="uk-width-1-1 uk-width-auto@m")
+renderCpuStatsGraph(cpu)
.dtp-dashboard-cluster.uk-margin
div(uk-grid).uk-flex-between
div(class="uk-width-1-1 uk-width-auto@m")
fieldset
legend [Core Memory]
.dtp-stats-bar
.dtp-mem-stat-bar.dtp-mem-used(style=`width: ${(latestReport.memory.active / latestReport.memory.total) * 100}%;`)= numeral(latestReport.memory.active / latestReport.memory.total).format('0%')
.dtp-mem-stat-bar.dtp-mem-available(style=`width: ${(latestReport.memory.available / latestReport.memory.total) * 100}%;`)= numeral(latestReport.memory.available / latestReport.memory.total).format('0%')
.uk-margin-small-top
div(uk-grid)
+renderStatCell('total', numeral(latestReport.memory.total).format('0,0.0 b'))
+renderStatCell('avail.', numeral(latestReport.memory.available).format('0,0.0 b'))
+renderStatCell('active', numeral(latestReport.memory.active).format('0,0.0 b'))
+renderStatCell('used', numeral(latestReport.memory.used).format('0,0.0 b'))
+renderStatCell('free', numeral(latestReport.memory.free).format('0,0.0 b'))
div(class="uk-width-1-1 uk-width-auto@m")
fieldset
legend [Buffers]
.dtp-stats-bar
.dtp-mem-stat-bar.dtp-mem-cached(style=`width: ${(latestReport.memory.cached / latestReport.memory.buffcache) * 100}%;`)= numeral(latestReport.memory.cached / latestReport.memory.buffcache).format('0%')
.dtp-mem-stat-bar.dtp-mem-buffers(style=`width: ${(latestReport.memory.buffers / latestReport.memory.buffcache) * 100}%;`)= numeral(latestReport.memory.buffers / latestReport.memory.buffcache).format('0%')
.dtp-mem-stat-bar.dtp-mem-slab(style=`width: ${(latestReport.memory.slab / latestReport.memory.buffcache) * 100}%;`)= numeral(latestReport.memory.slab / latestReport.memory.buffcache).format('0%')
.uk-margin-small-top
div(uk-grid)
+renderStatCell('buffers', numeral(latestReport.memory.buffers).format('0,0.0 b'))
+renderStatCell('cached', numeral(latestReport.memory.cached).format('0,0.0 b'))
+renderStatCell('slab', numeral(latestReport.memory.slab).format('0,0.0 b'))
+renderStatCell('buffcache', numeral(latestReport.memory.buffcache).format('0,0.0 b'))
div(class="uk-width-1-1 uk-width-auto@m")
fieldset
legend [Swap]
.dtp-stats-bar
.dtp-mem-stat-bar.dtp-mem-used(style=`width: ${(latestReport.memory.swapused / latestReport.memory.swaptotal) * 100}%;`)= numeral(latestReport.memory.swapused / latestReport.memory.swaptotal).format('0%')
.dtp-mem-stat-bar.dtp-mem-available(style=`width: ${(latestReport.memory.swapfree / latestReport.memory.swaptotal) * 100}%;`)= numeral(latestReport.memory.swapfree / latestReport.memory.swaptotal).format('0%')
.uk-margin-small-top
div(uk-grid)
+renderStatCell('used', numeral(latestReport.memory.swapused).format('0,0.0 b'))
+renderStatCell('free', numeral(latestReport.memory.swapfree).format('0,0.0 b'))
+renderStatCell('total', numeral(latestReport.memory.swaptotal).format('0,0.0 b'))
div(class="uk-width-1-1 uk-width-auto@m")
fieldset
legend [Load Avg]
div(uk-grid)
+renderStatCell('1 min', latestReport.load[0])
+renderStatCell('5 min', latestReport.load[1])
+renderStatCell('15 min', latestReport.load[2])
.dtp-dashboard-cluster.uk-margin
fieldset
legend [Host Cache]
div(uk-grid).uk-flex-between
+renderStatCell('objects', numeral(latestReport.cache.itemCount).format('0,0'))
+renderStatCell('data size', numeral(latestReport.cache.dataSize).format('0,0.00b'))
+renderStatCell('expire count', numeral(latestReport.cache.expireCount).format('0,0'))
+renderStatCell('expire size', numeral(latestReport.cache.expireDataSize).format('0,0.00b'))
+renderStatCell('hits', numeral(latestReport.cache.hitCount).format('0,0'))
+renderStatCell('hit size', numeral(latestReport.cache.hitDataSize).format('0,0.00b'))
+renderStatCell('misses', numeral(latestReport.cache.missCount).format('0,0'))
+renderStatCell('miss size', numeral(latestReport.cache.missDataSize).format('0,0.00b'))
.dtp-dashboard-cluster.uk-margin
div(uk-grid).uk-grid-small
each iface in latestReport.network
div(class="uk-width-1-1 uk-width-auto@m")
fieldset
legend= `[${iface.iface}]`
canvas(data-iface= iface.iface, width="300", height="100").dtp-iface-graph
block viewjs
script(src="/chart.js/chart.min.js")
script.
const dtp = window.dtp = window.dtp || { };
dtp.hostStats = !{JSON.stringify(stats)};
window.addEventListener('dtp-load-admin', ( ) => {
dtp.adminApp.prepareGraphData();
dtp.adminApp.renderCpuGraphs();
dtp.adminApp.renderNetworkGraphs();
});

12
app/views/admin/index.pug

@ -0,0 +1,12 @@
extends layouts/main
block content
div(uk-grid).uk-grid-small.uk-flex-between.uk-flex-middle
.uk-width-auto
+renderCell('Members', formatCount(stats.memberCount))
.uk-width-auto
+renderCell('Channels', formatCount(stats.channelCount))
.uk-width-auto
+renderCell('Streams', formatCount(stats.streamCount))
.uk-width-auto
+renderCell('Viewers', formatCount(stats.viewerCount))

7
app/views/admin/job-queue/index.pug

@ -0,0 +1,7 @@
extends ../layouts/main
block content
ul.uk-list
each queueName in queues
li
a(href=`/admin/job-queue/${queueName}`)= queueName

62
app/views/admin/job-queue/queue-view.pug

@ -0,0 +1,62 @@
extends ../layouts/main
block content
mixin renderJobList (jobList)
if !Array.isArray(jobList) || (jobList.length === 0)
div No jobs
else
table.uk-table.uk-table-small
thead
th ID
th Name
th Attempts
th Progress
tbody
each job in jobList
tr
td= job.id
td
a(href=`/admin/job-queue/${queue.name}/${job.id}`)= job.name
td= job.attemptsMade
td #{job.progress()}%
.uk-margin
h1 Job Queue: #{queueName}
div(uk-grid).uk-flex-between
- var pendingJobCount = jobCounts.waiting + jobCounts.delayed + jobCounts.paused + jobCounts.active
.uk-width-auto Total#[br]#{numeral(pendingJobCount).format('0,0')}
.uk-width-auto Waiting#[br]#{numeral(jobCounts.waiting).format('0,0')}
.uk-width-auto Delayed#[br]#{numeral(jobCounts.delayed).format('0,0')}
.uk-width-auto Paused#[br]#{numeral(jobCounts.paused).format('0,0')}
.uk-width-auto Active#[br]#{numeral(jobCounts.active).format('0,0')}
.uk-width-auto Completed#[br]#{numeral(jobCounts.completed).format('0,0')}
.uk-width-auto Failed#[br]#{numeral(jobCounts.failed).format('0,0')}
div(uk-grid)
div(class="uk-width-1-1 uk-width-1-2@l")
.uk-card.uk-card-default.uk-card-small
.uk-card-header
h3.uk-card-title Active
.uk-card-body
+renderJobList(jobs.active)
div(class="uk-width-1-1 uk-width-1-2@l")
.uk-card.uk-card-default.uk-card-small
.uk-card-header
h3.uk-card-title Waiting
.uk-card-body
+renderJobList(jobs.waiting)
div(class="uk-width-1-1 uk-width-1-2@l")
.uk-card.uk-card-default.uk-card-small
.uk-card-header
h3.uk-card-title Delayed
.uk-card-body
+renderJobList(jobs.delayed)
div(class="uk-width-1-1 uk-width-1-2@l")
.uk-card.uk-card-default.uk-card-small
.uk-card-header
h3.uk-card-title Failed
.uk-card-body
+renderJobList(jobs.failed)

19
app/views/admin/layouts/main.pug

@ -0,0 +1,19 @@
extends ../../layouts/main
block content-container
block page-header
section.uk-section.uk-section-header.uk-section-xsmall
.uk-container
h1.uk-text-center DTP Sites Engine
block admin-layout
section.uk-section.uk-section-default.uk-section-small
.uk-container.uk-container-expand
div(uk-grid)
div(class="uk-width-1-1 uk-flex-last uk-width-auto@m uk-flex-first@m")
.uk-card.uk-card-secondary.uk-card-body.uk-border-rounded
include ../components/menu
div(class="uk-width-1-1 uk-flex-first uk-width-expand@m").uk-width-expand
block content

37
app/views/admin/user/form.pug

@ -0,0 +1,37 @@
extends ../layouts/main
block content
.uk-margin
.uk-text-large= userAccount.displayName || userAccount.email
div= userAccount.username
form(method="POST", action=`/admin/user/${userAccount._id}`).uk-form
input(type="hidden", name="username", value= userAccount.username)
input(type="hidden", name="displayName", value= userAccount.displayName)
.uk-margin
div(uk-grid)
div(class="uk-width-1-1 uk-width-1-2@m")
fieldset
legend Flags
.uk-margin
div(uk-grid).uk-grid-small
label
input(id="is-admin", name="isAdmin", type="checkbox", checked= userAccount.flags.isAdmin)
| Admin
label
input(id="is-moderator", name="isModerator", type="checkbox", checked= userAccount.flags.isModerator)
| Moderator
div(class="uk-width-1-1 uk-width-1-2@m")
fieldset
legend Permissions
.uk-margin
div(uk-grid).uk-grid-small
label
input(id="can-login", name="canLogin", type="checkbox", checked= userAccount.permissions.canLogin)
| Can Login
label
input(id="can-chat", name="canChat", type="checkbox", checked= userAccount.permissions.canChat)
| Can Chat
button(type="submit").uk-button.uk-button-primary Update User

22
app/views/admin/user/index.pug

@ -0,0 +1,22 @@
extends ../layouts/main
block content
.uk-overflow-auto
table.uk-table.uk-table-divider.uk-table-hover.uk-table-small.uk-table-justify
thead
th Username
th Display Name
th Created
th User ID
tbody
each userAccount in userAccounts
tr
td
a(href=`/admin/user/${userAccount._id}`)= userAccount.username
td
if userAccount.displayName
a(href=`/admin/user/${userAccount._id}`)= userAccount.displayName
else
.uk-text-muted N/A
td= moment(userAccount.created).format('YYYY-MM-DD hh:mm a')
td= userAccount._id

10
app/views/article/components/article.pug

@ -0,0 +1,10 @@
mixin renderArticle (article)
article.uk-article
if article.image
img(src="/img/payment/payment-option.jpg").responsive
h1.uk-article-title= article.title
if article.meta
p.uk-article-meta= article.meta
if article.lead
p.-uk-text-lead= article.lead
div!= article.content

8
app/views/article/view.pug

@ -0,0 +1,8 @@
extends ../layouts/main
block content
include components/article
section.uk-section.uk-section-default
.uk-container
+renderArticle(article)

6
app/views/category/components/list-item.pug

@ -0,0 +1,6 @@
mixin renderCategoryListItem (category)
a(href=`/category/${category.slug}`).uk-display-block.uk-link-reset
img(src='/img/default-poster.jpg').uk-display-block.uk-margin-small.responsive.uk-border-rounded
.uk-link-reset.uk-text-bold= category.name
.uk-ling-reset.uk-text-muted #{numeral(category.stats.liveChannelCount).format("0,0")} live channels
.uk-ling-reset.uk-text-muted #{numeral(category.stats.currentViewerCount).format("0,0.0a")} viewers

17
app/views/category/home.pug

@ -0,0 +1,17 @@
extends ../layouts/main
block content
include components/list-item
section.uk-section.uk-section-default.uk-section-small
.uk-container.uk-container-expand
if Array.isArray(categories) && (categories.length > 0)
div(uk-grid).uk-flex-center.uk-grid-small
each category in categories
.uk-width-auto
.uk-width-medium
.uk-margin
+renderCategoryListItem(category)
else
h4.uk-text-center There are no categories or the system is down for maintenance.

32
app/views/category/view.pug

@ -0,0 +1,32 @@
extends ../layouts/main
block content
include ../channel/components/list-item
section(style="font: Verdana;").uk-section.uk-section-muted.uk-section-small
.uk-container
div(uk-grid).uk-grid-small
.uk-width-auto
img(src="/img/default-poster.jpg").uk-width-small
.uk-width-expand
h1.uk-margin-remove.uk-padding-remove= category.name
div= category.description
div(uk-grid).uk-grid-small
.uk-width-auto #{category.stats.streamCount} live shows.
.uk-width-auto #{category.stats.viewerCount} total viewers.
section.uk-section.uk-section-default
.uk-container
if Array.isArray(channels) && (channels.length > 0)
div(uk-grid).uk-flex-center.uk-grid-small
each channel in channels
div(class="uk-width-1-1 uk-width-1-2@s uk-width-1-3@m uk-width-1-4@l")
+renderChannelListItem(channel)
else
.uk-text-lead No channels in this category, check back later.
include ../components/back-button
//- pre= JSON.stringify(category, null, 2)
pre= JSON.stringify(category, null, 2)
pre= JSON.stringify(channels, null, 2)

4
app/views/components/back-button.pug

@ -0,0 +1,4 @@
button(type="button", onclick= "return window.history.back();").uk-button.dtp-button-primary
span
i.fas.fa-chevron-left
span.uk-margin-small-left Back

15
app/views/components/crypto-symbol-summary.pug

@ -0,0 +1,15 @@
mixin renderCryptoSymbolSummary (currency)
div(uk-grid).uk-grid-small.uk-flex-middle
.uk-width-auto
img(src=`/img/payment/${currency.sourceSymbol.toLowerCase()}.svg`, width="40", height="40")
.uk-width-expand
div
span.uk-text-bold= currency.sourceSymbol
span.uk-margin-small-left= numeral(currency.last).format('$0,0.00')
span.uk-margin-small-left.uk-text-small(class={
'uk-text-success': (currency.changes.price.day > 0),
'uk-text-danger': (currency.changes.price.day < 0),
})= numeral(currency.changes.price.day).format('$0,0.00')
.uk-text-small
span bid: #{numeral(currency.bid).format('$0,0.00')}
span.uk-margin-small-left ask: #{numeral(currency.ask).format('$0,0.00')}

2
app/views/components/csrf-token-input.pug

@ -0,0 +1,2 @@
mixin renderCsrfTokenInput (csrfToken)
input(type="hidden", name= csrfToken.name, value= csrfToken.token)

60
app/views/components/file-upload-image.pug

@ -0,0 +1,60 @@
mixin renderFileUploadImage (actionUrl, containerId, imageId, imageClass, defaultImage, currentImage, cropperOptions)
div(id= containerId).dtp-file-upload
form(method="POST", action= actionUrl, enctype="multipart/form-data", onsubmit= "return dtp.app.submitForm(event);").uk-form
.uk-margin
.uk-card.uk-card-default.uk-card-small
.uk-card-body
div(uk-grid).uk-flex-middle.uk-flex-center
div(class="uk-width-1-1 uk-width-auto@m")
.upload-image-container.size-512
if !!currentImage
img(id= imageId, data-cropper-options= cropperOptions, src= `/image/${currentImage._id}`, class= imageClass).sb-large
else
img(id= imageId, data-cropper-options= cropperOptions, src= defaultImage, class= imageClass).sb-large
div(class="uk-width-1-1 uk-width-auto@m")
.uk-text-small.uk-margin(hidden= !!currentImage)
if !currentImage
#file-select
.uk-margin(class="uk-text-center uk-text-left@m")
span.uk-text-middle Select an image
div(uk-form-custom).uk-margin-small-left
input(
type="file",
name="imageFile",
formenctype="multipart/form-data",
accept=".jpg,.png,image/jpeg,image/png",
data-file-select-container= containerId,
data-file-select="test-image-upload",
data-file-size-element= "file-size",
data-file-max-size= 15 * 1024000,
data-image-id= imageId,
data-image-w= 512,
data-image-h= 512,
onchange="return dtp.app.selectImageFile(event);",
)
button(type="button", tabindex="-1").uk-button.dtp-button-default Select
#file-info(class="uk-text-center uk-text-left@m", hidden)
#file-name.uk-text-bold
if currentImage
div resolution: #[span#image-resolution-w= numeral(currentImage.metadata.width).format('0,0')]x#[span#image-resolution-h= numeral(currentImage.metadata.height).format('0,0')]
div size: #[span#file-size= numeral(currentImage.metadata.size).format('0,0.00b')]
div last modified: #[span#file-modified= moment(currentImage.created).format('MMM DD, YYYY')]
else
div resolution: #[span#image-resolution-w 512]x#[span#image-resolution-h 512]
div size: #[span#file-size N/A]
div last modified: #[span#file-modified N/A]
.uk-card-footer
div(class="uk-flex-center", uk-grid)
#remove-btn(hidden= !currentImage).uk-width-auto
button(
type= "button",
onclick= "return dtp.app.removeImageFile(event);",
).uk-button.uk-button-danger Remove
#file-save-btn(hidden).uk-width-auto
button(
type="submit",
).uk-button.uk-button-primary Save

9
app/views/components/gab-share-button.pug

@ -0,0 +1,9 @@
mixin renderGabShareButton (shareUrl, shareMsg)
a(href=`https://gab.com/compose?url=${shareUrl}&text=${shareMsg}`, target="_frenshare").fren-button
span(style="color: white;")
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="20px" height="20px" viewBox="0 4 20 30" xml:space="preserve">
<g>
<path fill="white" d="M13.8,7.6h-2.4v0.7V9l-0.4-0.3C10.2,7.8,9,7.2,7.7,7.2c-0.2,0-0.4,0-0.4,0c-0.1,0-0.3,0-0.5,0c-5.6,0.3-8.7,7.2-5.4,12.1c2.3,3.4,7.1,4.1,9.7,1.5l0.3-0.3l0,0.7c0,1-0.1,1.5-0.4,2.2c-1,2.4-4.1,3-6.8,1.3c-0.2-0.1-0.4-0.2-0.4-0.2c-0.1,0.1-1.9,3.5-1.9,3.6c0,0.1,0.5,0.4,0.8,0.6c2.2,1.4,5.6,1.7,8.3,0.8c2.7-0.9,4.5-3.2,5-6.4c0.2-1.1,0.2-0.8,0.2-8.4l0-7.1H13.8z M9.7,17.6c-2.2,1.2-4.9-0.4-4.9-2.9C4.8,12.6,7,11,9,11.6C11.8,12.4,12.3,16.1,9.7,17.6z"></path>
</g>
</svg>
span(class="uk-visible@s") Share #[span(class="uk-visible@l") to Gab]

6
app/views/components/home-menu-button.pug

@ -0,0 +1,6 @@
mixin renderHomeMenuButton (buttonClass, className, label, url)
a(href= url).uk-button.uk-button-text.uk-display-block
.home-menu-button(class= buttonClass)
.button-icon
i(class= className)
.button-label!= label

84
app/views/components/item-metabar.pug

@ -0,0 +1,84 @@
mixin renderItemMetabar (dictionaryItem, itemType, options)
-
options = Object.assign({ hideActions: false }, options);
const decoratedItem = dictionaryItems.find((item) => item._id.equals(dictionaryItem._id));
if (decoratedItem) {
dictionaryItem = decoratedItem; // override it
}
var submitterName = (dictionaryItem.submitter.name && (dictionaryItem.submitter.name.length > 0)) ? dictionaryItem.submitter.name : '[Submitter Name Is Empty]';
var reportUrl = user ? `/content-report/${itemType}/${dictionaryItem._id}/submit` : '/welcome?src=report';
.frenspeak-metabar(data-item-type= itemType, data-item-id= dictionaryItem._id)
.metabar-submitter.uk-text-muted.uk-text-small(title="Submitted by")
div(uk-grid).uk-grid-collapse
.uk-width-auto
span(style="margin-right: 4px;") #{itemType} submitted by:
.uk-width-expand
a(href=`/user/${dictionaryItem.submitter._id}`).uk-text-truncate= submitterName
if !options.hideActions
div(uk-grid).uk-grid-collapse.uk-flex-bottom
.uk-width-expand
if user
button.metabar-button(
type="button",
title="Upvotes",
data-item-type= itemType,
data-item-id= dictionaryItem._id,
data-vote= "up",
onclick=`return window.dtp.app.processVote(event);`,
)
span(class={ 'uk-text-success': dictionaryItem.myVote && (dictionaryItem.myVote.vote === 'up') }).metabutton-icon.upvote-icon
i.fas.fa-thumbs-up
span.upvote-label.icon-label= numeral(dictionaryItem.stats.upVoteCount).format('0,0a')
button.metabar-button(
type="button",
title="Downvotes",
data-item-type= itemType,
data-item-id= dictionaryItem._id,
data-vote= "down",
onclick=`return window.dtp.app.processVote(event);`,
)
span(class={ 'uk-text-danger': dictionaryItem.myVote && (dictionaryItem.myVote.vote === 'down' ) }).metabutton-icon.downvote-icon
i.fas.fa-thumbs-down
span.downvote-label.icon-label= numeral(dictionaryItem.stats.downVoteCount).format('0,0a')
button.metabar-button(
type="button",
title="ReeeVotes",
data-item-type= itemType,
data-item-id= dictionaryItem._id,
data-vote= "reee",
onclick=`return window.dtp.app.processVote(event);`,
)
span(class={ 'uk-text-warning': dictionaryItem.myVote && (dictionaryItem.myVote.vote === 'reee') }).metabutton-icon.reeevote-icon
i.fas.fa-biohazard
span.reeevote-label.icon-label= numeral(dictionaryItem.stats.reeeVoteCount).format('0,0a')
else
a(href="/welcome?src=voting", title="Upvotes").metabar-button.uk-link-text
span(class={ 'uk-text-success': dictionaryItem.myVote && (dictionaryItem.myVote.vote === 'up') }).metabutton-icon.upvote-icon
i.fas.fa-thumbs-up
span.upvote-label.icon-label= numeral(dictionaryItem.stats.upVoteCount).format('0,0a')
a(href="/welcome?src=voting", title="Downvotes").metabar-button.uk-link-text
span(class={ 'uk-text-danger': dictionaryItem.myVote && (dictionaryItem.myVote.vote === 'down' ) }).metabutton-icon.downvote-icon
i.fas.fa-thumbs-down
span.downvote-label.icon-label= numeral(dictionaryItem.stats.downVoteCount).format('0,0a')
a(href="/welcome?src=voting", title="ReeeVotes").metabar-button.uk-link-text
span(class={ 'uk-text-warning': dictionaryItem.myVote && (dictionaryItem.myVote.vote === 'reee') }).metabutton-icon.reeevote-icon
i.fas.fa-biohazard
span.reeevote-label.icon-label= numeral(dictionaryItem.stats.reeeVoteCount).format('0,0a')
button(type="button").metabar-button
span.metabutton-icon
i.fas.fa-ellipsis-h
div(uk-dropdown={ mode: 'click' })
ul.uk-nav.uk-dropdown-nav
li
a(href= reportUrl) Report #{itemType}
.uk-width-auto
.uk-text-meta(title="Date submitted")= moment(dictionaryItem.created).format('MMM DD, YYYY')

12
app/views/components/library.pug

@ -0,0 +1,12 @@
//- common routines for all views everywhere
-
function formatCount(value) {
value = value || 0;
return (value < 1000) ? numeral(value).format('0,0') : numeral(value).format('0,0.0a');
}
mixin renderCell (label, value, className)
div(style="padding: 10px 20px;", title=`${label}: ${numeral(value).format('0,0')}`).uk-card.uk-card-default.uk-card-body.no-select
.uk-text-muted= label
div(class=className)= value

13
app/views/components/missing-profile-icon.pug

@ -0,0 +1,13 @@
svg(
version="1.1", xmlns="http://www.w3.org/2000/svg", xmlns:xlink="http://www.w3.org/1999/xlink",
width="32", height="32",
viewBox="0 0 600 600",
).profile-navbar
defs
clipPath(id="circular-border")
circle(cx="300", cy="300", r="280")
clipPath(id="avoid-antialiasing-bugs")
rect(width="100%", height="498")
circle(cx="300", cy="300", r="280", fill="black", clip-path="url(#avoid-antialiasing-bugs)")
circle(cx="300", cy="230", r="115")
circle(cx="300", cy="550", r="205", clip-path="url(#circular-border)")

79
app/views/components/navbar.pug

@ -0,0 +1,79 @@
.uk-navbar-container.uk-padding-remove.uk-position-fixed.uk-position-top
nav(uk-navbar)
.uk-navbar-left
.uk-navbar-item
button(type="button", uk-toggle="target: #dtp-offcanvas").uk-button.uk-button-link.uk-padding-small
i.fas.fa-chevron-right
.uk-navbar-center
a(href="/").uk-navbar-item.uk-logo
img(src=`/img/icon/${site.domainKey}/icon-48x48.png`)
.uk-navbar-right
.uk-navbar-item
if user
div.no-select
if user.picture_url
img(
src= user.picture_url || '/img/default-member.png',
title="Member Menu",
).profile-navbar
else
include missing-profile-icon
div(uk-dropdown={ mode: 'click' }).uk-navbar-dropdown
ul.uk-nav.uk-navbar-dropdown-nav(style="z-index: 1024;")
li.uk-nav-heading.uk-text-center= user.displayName || user.username
li.uk-nav-divider
if (user.channel)
li
a(href=`/channel/${user.channel.slug}`)
span.nav-item-icon
i.fas.fa-broadcast-tower
span(style="max-width: 120px;").uk-text-truncate= user.channel.name
if (user.channel.liveEpisode)
li
a(href=`/channel/${user.channel.slug}/broadcaster`)
span.nav-item-icon
i.fas.fa-box-open
span Broadcaster
li
a(href='/dashboard')
span.nav-item-icon
i.fas.fa-tachometer-alt
span Dashboard
li.uk-nav-divider
li
a(href=`/user/${user._id}`)
span.nav-item-icon
i.fas.fa-user
span Profile
li
a(href=`/user/${user._id}/settings`)
span.nav-item-icon
i.fas.fa-cog
span Settings
if user.flags && user.flags.isAdmin
li.uk-nav-divider
li
a(href='/admin')
span.nav-item-icon
i.fas.fa-user-lock
span Admin
li.uk-nav-divider
li
a(href='/auth/logout')
span.nav-item-icon
i.fas.fa-sign-out-alt
span Logout
else
ul.uk-navbar-nav
li
a(href='/welcome').uk-button.uk-button-link
span.nav-item-icon
i.fas.fa-sign-in-alt
span(class="uk-visible@m").uk-margin-small-left GET STARTED!

72
app/views/components/off-canvas.pug

@ -0,0 +1,72 @@
#dtp-offcanvas(uk-offcanvas="mode: slide; overlay: true; bg-close: true;")
.uk-offcanvas-bar
.uk-margin
a(href="/", style="color: white;").uk-display-block
.uk-text-large= site.name
.uk-text-small.uk-text-muted= site.description
ul.uk-nav.uk-nav-default.dtp-app-menu
li(class={ "uk-active": (currentView === 'home') })
a(href='/').uk-display-block
div(uk-grid).uk-grid-collapse
.uk-width-auto
.app-menu-icon
i(class=`fas fa-home`)
.uk-width-expand Home
if user
li.uk-nav-header Member Menu
li(class={ "uk-active": (currentView === 'user-settings') })
a(href=`/user/${user._id}`).uk-display-block
div(uk-grid).uk-grid-collapse
.uk-width-auto
.app-menu-icon
i.fas.fa-user
.uk-width-expand Profile
li(class={ "uk-active": (currentView === 'user-settings') })
a(href=`/user/${user._id}/settings`).uk-display-block
div(uk-grid).uk-grid-collapse
.uk-width-auto
.app-menu-icon
i.fas.fa-cog
.uk-width-expand Settings
if user.permissions.isAdmin
a(href="/admin").uk-display-block
li(class={ "uk-active": currentView === 'admin' })
div(uk-grid).uk-grid-collapse
.uk-width-auto
.app-menu-icon
i.fas.fa-user-shield
.uk-width-expand Admin
li
a(href="/auth/logout").uk-display-block
div(uk-grid).uk-grid-collapse
.uk-width-auto
.app-menu-icon
i.fas.fa-sign-out-alt
.uk-width-expand Logout
li.uk-nav-header Legal
li
a(href="/policy/terms-of-service").uk-display-block
div(uk-grid).uk-grid-collapse
.uk-width-auto
.app-menu-icon
i.fas.fa-balance-scale
.uk-width-expand Terms of Service
li
a(href="/policy/privacy").uk-display-block
div(uk-grid).uk-grid-collapse
.uk-width-auto
.app-menu-icon
i.fas.fa-balance-scale
.uk-width-expand Privacy Policy
.uk-text-small.uk-text-muted.uk-margin-medium
div &copy; #{moment().format('YYYY')} #{site.company}
div Made In USA 🇺🇸

4
app/views/components/page-footer.pug

@ -0,0 +1,4 @@
section.uk-section.uk-section-muted.uk-section-small
.uk-container.uk-text-small.uk-text-muted
div(class="uk-text-center uk-text-left@m")
div Copyright &copy; 2021 #[+renderSiteLink()]

13
app/views/components/page-header.pug

@ -0,0 +1,13 @@
section.uk-section.uk-section-header.uk-section-xsmall.header-section
.uk-container
div(uk-grid).uk-grid-small.uk-flex-middle
div(class="uk-hidden@m").uk-width-1-1
h1.uk-text-center
a(href='/').uk-link-reset= pageCommitteeName || 'Local Red Action'
div(class="uk-visible@m").uk-width-auto
a(href="/").uk-link-reset
img(src= pageLogoURL || "/img/lra-logo.png", alt="Robinson Township Republicans").committee-logo
div(class="uk-width-1-1 uk-text-center uk-width-expand@m uk-text-left@m")
h1(class="uk-visible@m").uk-margin-small
a(href="/").uk-link-reset= pageCommitteeName || 'Local Red Action'
.uk-text-large= pageTitle || 'Local leadership to preserve our communities'

27
app/views/components/pagination-bar.pug

@ -0,0 +1,27 @@
mixin renderPaginationBar (baseUrl, totalItemCount, urlParameters = '')
-
var startPage = pagination.p - 2;
if (startPage < 1) {
startPage = 1;
}
var endPage = startPage + 4;
var lastPage = Math.floor(totalItemCount / pagination.cpp);
if ((totalItemCount % pagination.cpp) !== 0) {
++lastPage;
}
if (endPage > lastPage) {
endPage = lastPage;
}
ul(aria-label="Page navigation").uk-pagination.uk-flex-center
li(class= pagination.p === 1 ? 'uk-disabled' : undefined)
a(href=`${baseUrl}?p=${pagination.p - 1}${urlParameters}`)
span(uk-pagination-previous).uk-margin-small-right
span prev
while startPage <= endPage
li(class= startPage === pagination.p ? 'active' : undefined)
a(href=`${baseUrl}?p=${startPage}${urlParameters}`)= startPage++
li(class= pagination.p === lastPage ? 'disabled' : undefined)
a(href=`${baseUrl}?p=${pagination.p + 1}${urlParameters}`)
span next
span(uk-pagination-next).uk-margin-small-left

20
app/views/components/pwa-support.pug

@ -0,0 +1,20 @@
link(rel="apple-touch-icon" sizes="57x57" href=`/img/icon/${site.domainKey}/icon-57x57.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="60x60" href=`/img/icon/${site.domainKey}/icon-60x60.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="72x72" href=`/img/icon/${site.domainKey}/icon-72x72.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="76x76" href=`/img/icon/${site.domainKey}/icon-76x76.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="114x114" href=`/img/icon/${site.domainKey}/icon-114x114.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="120x120" href=`/img/icon/${site.domainKey}/icon-120x120.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="144x144" href=`/img/icon/${site.domainKey}/icon-144x144.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="152x152" href=`/img/icon/${site.domainKey}/icon-152x152.png?v=${pkg.version}`)
link(rel="apple-touch-icon" sizes="180x180" href=`/img/icon/${site.domainKey}/icon-180x180.png?v=${pkg.version}`)
link(rel="icon" type="image/png" sizes="32x32" href=`/img/icon/${site.domainKey}/icon-32x32.png?v=${pkg.version}`)
link(rel="icon" type="image/png" sizes="96x96" href=`/img/icon/${site.domainKey}/icon-96x96.png?v=${pkg.version}`)
link(rel="icon" type="image/png" sizes="16x16" href=`/img/icon/${site.domainKey}/icon-16x16.png?v=${pkg.version}`)
link(rel="icon" type="image/png" sizes="512x512" href=`/img/icon/${site.domainKey}/icon-512x512.png?v=${pkg.version}`)
link(rel="icon" type="image/png" sizes="384x384" href=`/img/icon/${site.domainKey}/icon-384x384.png?v=${pkg.version}`)
link(rel="icon" type="image/png" sizes="256x256" href=`/img/icon/${site.domainKey}/icon-512x512.png?v=${pkg.version}`)
link(rel="icon" type="image/png" sizes="192x192" href=`/img/icon/${site.domainKey}/icon-192x192.png?v=${pkg.version}`)
link(rel="manifest" href=`/manifest.json?v=${pkg.version}`)
meta(name="msapplication-TileColor" content="#f1c52f")
meta(name="msapplication-TileImage" content=`/img/icon/ms-icon-144x144.png?v=${pkg.version}`)
meta(name="theme-color" content="#f1c52f")

3
app/views/components/site-link.pug

@ -0,0 +1,3 @@
mixin renderSiteLink ( )
a(href="/").uk-link-reset
span.brand-emphasis #{site.name}

8
app/views/components/social-card/facebook.pug

@ -0,0 +1,8 @@
block facebook-card
meta(property='og:site_name', content= site.name)
meta(property='og:type', content='website')
meta(property='og:image', content= `https://${site.domain}/img/social-cards/${site.domain}.png?v=${pkg.version}`)
meta(property='og:url', content= `https://${site.domain}${dtp.request.url}`)
meta(property='og:title', content= `${site.name} | ${site.description}`)
meta(property='og:description', content= site.description)
meta(property='og:image:alt', content= `${site.name} | ${site.description}`)

5
app/views/components/social-card/twitter.pug

@ -0,0 +1,5 @@
block twitter-card
meta(name='twitter:card', content='summary_large_image')
meta(name='twitter:image' content= `https://${site.domain}/img/social-cards/${site.domain}.png?v=${pkg.version}`)
meta(name='twitter:title', content= `${site.name} | ${site.description}`)
meta(name='twitter:description', content= site.description)

6
app/views/components/term-list-tile.pug

@ -0,0 +1,6 @@
include term-list
mixin renderTermListTile (termList, tileTitle)
section.uk-section.uk-section-muted.uk-section-xsmall
.uk-container.uk-container-expand
h4.uk-heading.uk-text-center= tileTitle
+renderTermList(termList)

14
app/views/components/term-list.pug

@ -0,0 +1,14 @@
include item-metabar
mixin renderTermList (terms)
- var submitUrl = user ? '/term/submit' : '/welcome?src=submit';
if Array.isArray(terms) && (terms.length > 0)
ul.uk-list.uk-list-divider
each term in terms
li(data-term-id= term._id)
a(href=`/term/${term._id}`).uk-display-block.uk-link-reset.uk-margin-small
h4.uk-heading.uk-margin-remove= term.term
if term.definition && term.definition.content
.markdown-block!= anchorme(marked(term.definition.content))
+renderItemMetabar(term, 'term')
else
.uk-text-muted There are no terms at this time. #[a(href=submitUrl) Submit one] now to get started!

27
app/views/components/view-title.pug

@ -0,0 +1,27 @@
mixin renderViewTitle (title, options)
.uk-navbar-container.uk-padding-remove.uk-position-fixed.uk-position-top
nav(uk-navbar)
.uk-navbar-left
.uk-navbar-item
div(uk-grid).uk-grid-small.uk-flex-middle
.uk-width-expand
.uk-text-large.uk-margin-remove.no-select
if options && options.iconClass
span.uk-margin-small-right
i(class= options.iconClass)
span= title
.uk-navbar-right
if options && options.href && options.prompt
.uk-navbar-item
a(href= options.href).uk-button.dtp-button-navbar.uk-button-small.uk-border-rounded
span
i.fas.fa-plus
span(class="uk-visible@s").uk-margin-small-left= options.prompt
if options && options.includeBackButton
.uk-navbar-item
button(type="button", onclick="window.history.back();").uk-button.dtp-button-navbar.uk-button-small.uk-border-rounded
span
i.fas.fa-chevron-left
span(class="uk-visible@s").uk-margin-small-left Back
//- section(uk-sticky).uk-section.uk-section-secondary.uk-section-xsmall
//- .uk-container.uk-container-expand

17
app/views/error.pug

@ -0,0 +1,17 @@
extends layouts/main
block content
section.uk-section.uk-section-default.uk-section-xsmall
.uk-container
.uk-text-large= message
//- if error.stack
//- pre= error.stack
if error && error.status
div.uk-text-small.uk-text-muted status:#{error.status}
section.uk-section.uk-section-primary.uk-section-xsmall
.uk-container
a(href="/").uk-button.uk-button-default.uk-border-rounded
span.uk-margin-small-right
i.fas.fa-home
span Home

13
app/views/home/logged-in.member.pug

@ -0,0 +1,13 @@
section.uk-section.uk-section-default
.uk-container
h1(class="uk-text-center uk-text-left@m") #[small Welcome to]#[br]#[+renderSiteLink()]
p Please create a #[a(href='/voter/signup') Voter Profile] to start supporting committees and candidates. As you do, this home page will become your Voter Dashboard with all the latest updates from the committees and candidates you are supporting.
div(class="uk-flex uk-flex-center uk-flex-left@m")
.uk-width-auto
a(href="/voter/signup").uk-button.uk-button-primary
span
i.fas.fa-user
span.uk-margin-small-left.uk-text-bold Create Voter Profile

72
app/views/home/logged-in.voter.pug

@ -0,0 +1,72 @@
include ../committee/components/post
include ../committee/components/article-summary
section.uk-section.uk-section-default
.uk-container
div(uk-grid)
div(class="uk-width-1-3 uk-visible@m uk-width-1-4@l")
ul(class="uk-visible@m").uk-nav.uk-nav-default.uk-nav-divider
li.uk-nav-header Site Navigation
li(class={ 'uk-active': (currentView === 'home')})
a(href="/")
span.nav-item-icon
i.fas.fa-home
span.uk-margin-small-left Home
li
a(href=`/user/${user._id}`)
span.nav-item-icon
i.fas.fa-user
span.uk-margin-small-left Profile
if committee
li.uk-nav-header Committee Links
li(class={ 'uk-active': (currentView === 'committee') })
a(href=`/committee/${committee.slug}`)
span.nav-item-icon
i.fas.fa-home
span.uk-margin-small-left= committee.name
a(href=`/committee/${committee.slug}/dashboard`)
span.nav-item-icon
i.fas.fa-tachometer-alt
span.uk-margin-small-left Dashboard
if voter
li.uk-nav-header Voter Links
li
a(href="/voter")
span.nav-item-icon
i.fas.fa-tachometer-alt
span.uk-margin-small-left Voter Dashboard
div(class="uk-width-1-1 uk-width-expand@m")
ul.uk-list
each feedItem in siteFeed
li
case feedItem.itemType
when 'Article'
+renderCommitteeArticleSummary(feedItem.item)
when 'Post'
+renderCommitteePost(feedItem.item)
div(class="uk-width-1-3 uk-visible@m uk-width-1-4@l")
ul.uk-list
li
.uk-card.uk-card-secondary.uk-card-body.uk-border-rounded
h3.uk-card-title Featured Candidates
p This will be a list of candidates running for office who are growing their support base in your area.
li
.uk-card.uk-card-secondary.uk-card-body.uk-border-rounded
h3.uk-card-title Committee Spotlight
p This will be a list of committees who are growing their support base in your area.
li
.uk-card.uk-card-secondary.uk-card-body.uk-border-rounded
h3.uk-card-title Recent Wins
p This will highlight posts tagged as wins
li
.uk-card.uk-card-secondary.uk-card-body.uk-border-rounded
h3.uk-card-title Recent Lessons
p This will highlight posts tagged as lessons learned.

20
app/views/home/logged-out.pug

@ -0,0 +1,20 @@
section.uk-section.uk-section-default
.uk-container
.uk-text-lead Welcome to #[+renderSiteLink()]
p We are a new organization that exists to empower small and grassroots conservative Republican Political Action Committees with the resources more commonly reserved for larger organizations.
section.uk-section.uk-section-primary.uk-section-xsmall
.uk-container
div(uk-grid).uk-grid-small.uk-flex-around
.uk-width-auto
.uk-margin
a(href="/welcome/signup").uk-button.uk-button-primary
span
i.fas.fa-user-plus
span.uk-margin-small-left JOIN NOW
.uk-width-auto
.uk-margin
a(href="/welcome/login").uk-button.uk-button-secondary
span
i.fas.fa-sign-in-alt
span.uk-margin-small-left SIGN IN

16
app/views/index.pug

@ -0,0 +1,16 @@
extends layouts/main
block content
section.uk-section.uk-section-default.uk-padding-remove
.uk-container
div(style="position: relative; overflow: hidden; width: 100%; padding-top: 56.25%")
iframe(
src="https://tv.gab.com/channel/mrjoeprich/embed/what-is-just-joe-radio-61ad9b2165a83d20e95a465d",
width="960",
height="540",
style="position: absolute; top: 0; right: 0; bottom: 0; left: 0; width: 100%; height: 100%;",
)
section.uk-section.uk-section-secondary
.uk-container
.uk-text-lead.uk-text-center Welcome to the new online home of #{site.name}! We just getting started. Hang tight!

111
app/views/layouts/main.pug

@ -0,0 +1,111 @@
include ../components/library
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
meta(name="robots", content= "index,follow")
meta(name="googlebot", conten= "index,follow")
meta(content="#4a4a4a" name="theme-color")
meta(content="black-translucent" name="apple-mobile-web-app-status-bar-style")
block css
link(rel='stylesheet', href=`/fontawesome/css/all.min.css?v=${pkg.version}`)
block vendorcss
link(rel='stylesheet', href=`/dist/css/style.css?v=${pkg.version}`)
block js
script(src=`/uikit/js/uikit.min.js?v=${pkg.version}`)
script(src=`/uikit/js/uikit-icons.min.js?v=${pkg.version}`)
script(src=`/fontawesome/js/fontawesome.min.js?v=${pkg.version}`)
block pwa-support
include ../components/pwa-support
block social-card
include ../components/social-card/twitter
include ../components/social-card/facebook
block view-header
script.
function onImageLoadError (event) {
const imageType = event.currentTarget.getAttribute('data-image-type') || 'thumb';
console.error('image error', imageType, event);
switch (imageType) {
case 'profile':
event.currentTarget.setAttribute('src', '/img/default-member.png');
break;
case 'thumb':
event.currentTarget.setAttribute('src', '/img/default-poster.jpg');
break;
}
}
body.dtp(class= 'dtp-dark', data-dtp-env= process.env.NODE_ENV, data-dtp-domain= site.domainKey, data-current-view= currentView)
include ../components/site-link
block view-globals
block content-container
block content
//- block page-footer
//- include ../components/page-footer
block dtp-navbar
include ../components/navbar
block view-title
block dtp-off-canvas
include ../components/off-canvas
block clientjs
if user
-
var safeUser = {
_id: user._id,
created: user.created,
username: user.username,
username_lc: user.username_lc,
displayName: user.displayName,
};
script(src=`/moment/moment.min.js?v=${pkg.version}`)
script(src=`/numeral/numeral.min.js?v=${pkg.version}`)
script(src=`/socket.io/socket.io.js?v=${pkg.version}`)
block vendorjs
script.
window.dtp = window.dtp || { };
if user
script.
window.dtp.user = !{JSON.stringify(safeUser, null, 2)}
if channel
script.
dtp.channel = !{JSON.stringify(channel || null)};
if DTP_SCRIPT_DEBUG
script(src=`/dist/js/dtpsites-app.js?v=${pkg.version}`, type="module")
else
script(src=`/dist/js/dtpsites-app.min.js?v=${pkg.version}`, type="module")
if user && user.flags.isAdmin
if DTP_SCRIPT_DEBUG
script(src=`/dist/js/dtpsites-admin.js?v=${pkg.version}`, type="module")
else
script(src=`/dist/js/dtpsites-admin.min.js?v=${pkg.version}`, type="module")
block viewjs

27
app/views/otp/authenticate.pug

@ -0,0 +1,27 @@
extends ../layouts/main
block content
section.uk-section.uk-section-primary.uk-section-xsmall
.uk-container.uk-text-center
h1 #{site.name} #{otpServiceName} Passcode Required
.uk-text-large A one-time passcode is required to access #{site.name} #{otpServiceName} on this server
section.uk-section.uk-section-default.uk-section-xsmall
.uk-container
form(method="POST", action="/auth/otp/auth")
input(type="hidden", name="otp-service", value= otpServiceName)
input(type="hidden", name="otp-redirect", value= otpRedirectURL)
.uk-width-1-2.uk-margin-auto
.uk-margin.uk-text-center
label(for="otp-passcode").uk-form-label Enter passcode:
input(
id="otp-passcode",
name="otp-passcode",
type="text",
placeholder="######",
autocomplete="off",
).uk-input.uk-form-large.uk-text-center
.uk-text-muted.uk-text-small Please enter a passcode from your authenticator app for #{site.name} #{otpServiceName}:#{user.username}
.uk-margin.uk-text-center
button(type="submit").uk-button.uk-button-primary.uk-border-pill Login

11
app/views/otp/new-account.pug

@ -0,0 +1,11 @@
extends ../layouts/main
block content
section.uk-section.uk-section-primary.uk-section-xsmall
.uk-container
h1 2FA Setup Successful
section.uk-section.uk-section-default.uk-section-xsmall
.uk-container
p Your account is now enabled with access to #{site.name} #{otpServiceName}.
a(href= otpRedirectURL, title="Continue").uk-button.uk-button-primary.uk-border-pill Continue

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

Loading…
Cancel
Save