261 changed files with 17289 additions and 138 deletions
@ -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_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 |
@ -1,3 +1,4 @@ |
|||
.env |
|||
|
|||
node_modules |
|||
dist |
|||
|
@ -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. |
@ -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. |
@ -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; |
|||
}; |
@ -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; |
|||
}; |
@ -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; |
|||
}; |
@ -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; |
|||
}; |
@ -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; |
|||
}; |
@ -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; |
|||
}; |
@ -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; |
|||
}; |
@ -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; |
|||
}; |
@ -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; |
|||
}; |
@ -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); |
@ -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); |
@ -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); |
@ -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); |
@ -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); |
@ -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); |
@ -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); |
@ -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); |
@ -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); |
@ -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); |
@ -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); |
@ -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); |
@ -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); |
@ -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); }, |
|||
}; |
@ -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); }, |
|||
}; |
@ -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); }, |
|||
}; |
@ -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); }, |
|||
}; |
@ -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); }, |
|||
}; |
@ -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); }, |
|||
}; |
@ -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); |
|||
}, |
|||
}; |
@ -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); }, |
|||
}; |
@ -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); }, |
|||
}; |
@ -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); }, |
|||
}; |
@ -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); }, |
|||
}; |
@ -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); }, |
|||
}; |
@ -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); }, |
|||
}; |
@ -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); }, |
|||
}; |
@ -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); }, |
|||
}; |
@ -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); |
|||
}, |
|||
}; |
@ -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); }, |
|||
}; |
@ -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 |
@ -0,0 +1 @@ |
|||
.greeting Dear #{voter.name}, |
@ -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 |
@ -0,0 +1 @@ |
|||
| Dear #{voter.name}, |
@ -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 |
@ -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 |
@ -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' |
@ -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. |
@ -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 |
@ -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 |
@ -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') |
@ -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 |
@ -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' |
@ -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 |
@ -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' |
@ -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(); |
|||
}); |
@ -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)) |
@ -0,0 +1,7 @@ |
|||
extends ../layouts/main |
|||
block content |
|||
|
|||
ul.uk-list |
|||
each queueName in queues |
|||
li |
|||
a(href=`/admin/job-queue/${queueName}`)= queueName |
@ -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) |
@ -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 |
@ -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 |
@ -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 |
@ -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 |
@ -0,0 +1,8 @@ |
|||
extends ../layouts/main |
|||
block content |
|||
|
|||
include components/article |
|||
|
|||
section.uk-section.uk-section-default |
|||
.uk-container |
|||
+renderArticle(article) |
@ -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 |
@ -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. |
@ -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) |
@ -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 |
@ -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')} |
@ -0,0 +1,2 @@ |
|||
mixin renderCsrfTokenInput (csrfToken) |
|||
input(type="hidden", name= csrfToken.name, value= csrfToken.token) |
@ -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 |
@ -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] |
@ -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 |
@ -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') |
@ -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 |
@ -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)") |
@ -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! |
@ -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 © #{moment().format('YYYY')} #{site.company} |
|||
div Made In USA 🇺🇸 |
@ -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 © 2021 #[+renderSiteLink()] |
@ -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' |
@ -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 |
@ -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") |
@ -0,0 +1,3 @@ |
|||
mixin renderSiteLink ( ) |
|||
a(href="/").uk-link-reset |
|||
span.brand-emphasis #{site.name} |
@ -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}`) |
@ -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) |
@ -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) |
@ -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! |
@ -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 |
@ -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 |
@ -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 |
@ -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. |
@ -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 |
@ -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! |
@ -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 |
@ -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 |
@ -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…
Reference in new issue