From 929a8875ef25f780dd0c74cf87e14e67e1f1deb5 Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 25 Dec 2021 01:32:40 -0500 Subject: [PATCH] integrate updates from LibertyLinks engine enhancements; new features for blog --- .gitignore | 1 + .vscode/launch.json | 15 +- app/controllers/admin.js | 35 ++- app/controllers/admin/content-report.js | 95 ++++++ app/controllers/admin/host.js | 49 ++- app/controllers/admin/log.js | 57 ++++ app/controllers/admin/newsletter.js | 4 +- app/controllers/admin/page.js | 4 +- app/controllers/admin/post.js | 4 +- app/controllers/admin/user.js | 4 +- app/controllers/auth.js | 14 +- app/controllers/comment.js | 118 +++++++ app/controllers/content-report.js | 112 +++++++ app/controllers/home.js | 11 +- app/controllers/image.js | 14 +- app/controllers/manifest.js | 10 +- app/controllers/newsletter.js | 16 +- app/controllers/page.js | 13 +- app/controllers/post.js | 56 +++- app/controllers/user.js | 141 ++++++++- app/controllers/welcome.js | 12 +- app/models/comment.js | 11 +- app/models/content-report.js | 31 ++ app/models/content-vote.js | 26 ++ app/models/email-blacklist.js | 4 +- app/models/lib/geo-types.js | 22 ++ app/models/lib/resource-stats.js | 12 +- app/models/log.js | 4 +- app/models/page.js | 1 + app/models/post.js | 5 +- app/models/resource-view.js | 4 +- app/models/resource-visit.js | 29 ++ app/models/user-block.js | 15 + app/models/user-notification.js | 20 ++ app/models/user.js | 8 +- app/services/chat.js | 53 ++++ app/services/comment.js | 60 +++- app/services/content-report.js | 127 ++++++++ app/services/content-vote.js | 98 ++++++ app/services/display-engine.js | 7 + app/services/log.js | 44 +++ app/services/minio.js | 12 +- app/services/otp-auth.js | 18 +- app/services/page.js | 18 +- app/services/resource.js | 74 ++++- app/services/user-notification.js | 100 ++++++ app/services/user.js | 185 ++++++++++- app/views/admin/components/menu.pug | 21 +- app/views/admin/content-report/index.pug | 31 ++ app/views/admin/content-report/view.pug | 48 +++ app/views/admin/host/index.pug | 52 +-- app/views/admin/index.pug | 22 +- app/views/admin/layouts/main.pug | 5 +- app/views/admin/log/index.pug | 46 +++ app/views/admin/page/editor.pug | 4 + app/views/admin/settings/editor.pug | 68 +++- app/views/admin/user/form.pug | 6 + app/views/admin/user/index.pug | 15 +- app/views/comment/components/comment-list.pug | 11 + .../comment/components/comment-review.pug | 13 + app/views/comment/components/comment.pug | 155 ++++++--- app/views/comment/components/composer.pug | 36 +++ app/views/comment/components/report-form.pug | 35 +++ app/views/components/file-upload-image.pug | 13 +- app/views/components/labeled-icon.pug | 4 + app/views/components/library.pug | 1 + app/views/components/navbar.pug | 36 ++- app/views/components/off-canvas.pug | 23 +- app/views/components/page-footer.pug | 69 +++- app/views/components/page-sidebar.pug | 2 +- app/views/components/social-card/facebook.pug | 8 +- app/views/components/social-card/twitter.pug | 6 +- app/views/error.pug | 5 +- app/views/index.pug | 14 +- app/views/layouts/main-sidebar.pug | 6 +- app/views/layouts/main.pug | 5 +- .../components/notification-standalone.pug | 2 + .../notification/components/notification.pug | 5 + app/views/page/view.pug | 5 +- app/views/post/view.pug | 54 +--- app/views/user/profile.pug | 21 +- app/views/user/settings.pug | 22 +- app/views/welcome/index.pug | 7 +- app/views/welcome/signup.pug | 4 +- app/workers/reeeper.js | 78 +++++ client/img/social-icons/bitchute.svg | 62 ++++ client/img/social-icons/dlive.svg | 69 ++++ client/img/social-icons/odysee.svg | 102 ++++++ client/img/social-icons/rumble.svg | 95 ++++++ client/js/site-app.js | 295 ++++++++++++++++-- client/less/site/button.less | 25 ++ client/less/site/comment.less | 61 ++++ client/less/site/dashboard.less | 7 +- client/less/site/image.less | 6 - client/less/style.less | 1 + config/limiter.js | 36 ++- deploy | 8 + lib/client/js/dtp-display-engine.js | 6 +- lib/site-controller.js | 8 +- lib/site-log.js | 39 ++- lib/site-platform.js | 94 ++++-- lib/site-service.js | 6 +- package.json | 3 + sites-start-local => start-local | 0 start-production | 12 + stop-production | 12 + yarn.lock | 15 + 107 files changed, 3317 insertions(+), 376 deletions(-) create mode 100644 app/controllers/admin/content-report.js create mode 100644 app/controllers/admin/log.js create mode 100644 app/controllers/comment.js create mode 100644 app/controllers/content-report.js create mode 100644 app/models/content-report.js create mode 100644 app/models/content-vote.js create mode 100644 app/models/lib/geo-types.js create mode 100644 app/models/resource-visit.js create mode 100644 app/models/user-block.js create mode 100644 app/models/user-notification.js create mode 100644 app/services/chat.js create mode 100644 app/services/content-report.js create mode 100644 app/services/content-vote.js create mode 100644 app/services/log.js create mode 100644 app/services/user-notification.js create mode 100644 app/views/admin/content-report/index.pug create mode 100644 app/views/admin/content-report/view.pug create mode 100644 app/views/admin/log/index.pug create mode 100644 app/views/comment/components/comment-list.pug create mode 100644 app/views/comment/components/comment-review.pug create mode 100644 app/views/comment/components/composer.pug create mode 100644 app/views/comment/components/report-form.pug create mode 100644 app/views/components/labeled-icon.pug create mode 100644 app/views/notification/components/notification-standalone.pug create mode 100644 app/views/notification/components/notification.pug create mode 100644 app/workers/reeeper.js create mode 100644 client/img/social-icons/bitchute.svg create mode 100644 client/img/social-icons/dlive.svg create mode 100644 client/img/social-icons/odysee.svg create mode 100644 client/img/social-icons/rumble.svg create mode 100644 client/less/site/comment.less create mode 100755 deploy rename sites-start-local => start-local (100%) create mode 100755 start-production create mode 100755 stop-production diff --git a/.gitignore b/.gitignore index f92418e..2347915 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .env +data/minio node_modules dist data/minio diff --git a/.vscode/launch.json b/.vscode/launch.json index 83fd18a..b41e9f1 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -7,7 +7,7 @@ { "type": "pwa-node", "request": "launch", - "name": "dtp-sites", + "name": "web:dtp-sites", "skipFiles": [ "/**" ], @@ -16,6 +16,17 @@ "env": { "HTTP_BIND_PORT": "3333" } + }, + { + "type": "pwa-node", + "request": "launch", + "name": "cli:dtp-sites", + "skipFiles": [ + "/**" + ], + "program": "${workspaceFolder:dtp-sites}/dtp-sites-cli.js", + "console": "integratedTerminal", + "args": ["--action=reset-indexes", "all"] } ] -} \ No newline at end of file +} diff --git a/app/controllers/admin.js b/app/controllers/admin.js index 77ee48a..5fdc4e8 100644 --- a/app/controllers/admin.js +++ b/app/controllers/admin.js @@ -44,19 +44,32 @@ class AdminController extends SiteController { }), ); + router.use('/content-report',await this.loadChild(path.join(__dirname, 'admin', 'content-report'))); router.use('/host',await this.loadChild(path.join(__dirname, 'admin', 'host'))); - router.use('/job-queue',await this.loadChild(path.join(__dirname, 'admin', 'job-queue'))); - router.use('/newsletter',await this.loadChild(path.join(__dirname, 'admin', 'newsletter'))); - router.use('/page',await this.loadChild(path.join(__dirname, 'admin', 'page'))); - router.use('/post',await this.loadChild(path.join(__dirname, 'admin', 'post'))); - router.use('/settings',await this.loadChild(path.join(__dirname, 'admin', 'settings'))); + router.use('/job-queue', await this.loadChild(path.join(__dirname, 'admin', 'job-queue'))); + router.use('/log', await this.loadChild(path.join(__dirname, 'admin', 'log'))); + router.use('/newsletter', await this.loadChild(path.join(__dirname, 'admin', 'newsletter'))); + router.use('/page', await this.loadChild(path.join(__dirname, 'admin', 'page'))); + router.use('/post', await this.loadChild(path.join(__dirname, 'admin', 'post'))); + router.use('/settings', await this.loadChild(path.join(__dirname, 'admin', 'settings'))); router.use('/user', await this.loadChild(path.join(__dirname, 'admin', 'user'))); + router.get('/diagnostics', this.getDiagnostics.bind(this)); + router.get('/', this.getHomeView.bind(this)); return router; } + async getDiagnostics (req, res) { + res.status(200).json({ + success: true, + url: req.url, + ip: req.ip, + headers: req.headers, + }); + } + async getHomeView (req, res) { res.locals.stats = { memberCount: await User.estimatedDocumentCount(), @@ -65,7 +78,11 @@ class AdminController extends SiteController { } } -module.exports = async (dtp) => { - let controller = new AdminController(dtp); - return controller; -}; +module.exports = { + slug: 'admin', + name: 'admin', + create: async (dtp) => { + let controller = new AdminController(dtp); + return controller; + }, +}; \ No newline at end of file diff --git a/app/controllers/admin/content-report.js b/app/controllers/admin/content-report.js new file mode 100644 index 0000000..1fb071a --- /dev/null +++ b/app/controllers/admin/content-report.js @@ -0,0 +1,95 @@ +// admin/content-report.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const DTP_COMPONENT_NAME = 'admin:content-report'; + +const express = require('express'); +const multer = require('multer'); + +const { /*SiteError,*/ SiteController } = require('../../../lib/site-lib'); + +class ContentReportController extends SiteController { + + constructor (dtp) { + super(dtp, DTP_COMPONENT_NAME); + } + + async start ( ) { + const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/upload` }); + + const router = express.Router(); + router.use(async (req, res, next) => { + res.locals.currentView = 'admin'; + res.locals.adminView = 'host'; + return next(); + }); + + router.param('reportId', this.populateReportId.bind(this)); + + router.post('/:reportId/action', upload.none(), this.postReportAction.bind(this)); + + router.get('/:reportId', this.getReportView.bind(this)); + + router.get('/', this.getIndex.bind(this)); + + return router; + } + + async populateReportId (req, res, next, reportId) { + const { contentReport: contentReportService } = this.dtp.services; + try { + res.locals.report = await contentReportService.getById(reportId); + return next(); + } catch (error) { + this.log.error('failed to populate content report', { reportId, error }); + return next(error); + } + } + + async postReportAction (req, res, next) { + const { contentReport: contentReportService } = this.dtp.services; + try { + this.log.info('postReportAction', { body: req.body }); + switch (req.body.verb) { + case 'remove': + await contentReportService.removeResource(res.locals.report); + break; + + case 'dismiss': + await contentReportService.setStatus(res.locals.report, 'ignored'); + break; + } + + const displayList = this.createDisplayList('add-recipient'); + displayList.navigateTo('/admin/content-report'); + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to perform content report action', { verb: req.body.verb, error }); + return next(error); + } + } + + async getReportView (req, res) { + res.render('admin/content-report/view'); + } + + async getIndex (req, res, next) { + const { contentReport: contentReportService } = this.dtp.services; + try { + res.locals.pagination = this.getPaginationParameters(req, 20); + res.locals.reports = await contentReportService.getReports(['new'], res.locals.pagination); + res.render('admin/content-report/index'); + } catch (error) { + this.log.error('failed to display content report index', { error }); + return next(error); + } + } +} + +module.exports = async (dtp) => { + let controller = new ContentReportController(dtp); + return controller; +}; diff --git a/app/controllers/admin/host.js b/app/controllers/admin/host.js index b8ec0de..f6a945a 100644 --- a/app/controllers/admin/host.js +++ b/app/controllers/admin/host.js @@ -30,6 +30,8 @@ class HostController extends SiteController { router.param('hostId', this.populateHostId.bind(this)); + router.post('/:hostId/deactivate', this.postDeactiveHost.bind(this)); + router.get('/:hostId', this.getHostView.bind(this)); router.get('/', this.getHomeView.bind(this)); @@ -46,6 +48,38 @@ class HostController extends SiteController { } } + async postDeactiveHost (req, res) { + try { + const displayList = this.createDisplayList('deactivate-host'); + + await NetHost.updateOne( + { _id: res.locals.host._id }, + { + $set: { status: 'inactive' }, + }, + ); + + displayList.removeElement(`tr[data-host-id="${res.locals.host._id}"]`); + displayList.showNotification( + `Host "${res.locals.host.hostname}" deactivated`, + 'success', + 'bottom-center', + 3000, + ); + res.status(200).json({ success: true, displayList }); + + } catch (error) { + this.log.error('failed to deactivate host', { + hostId: res.local.host._id, + error, + }); + res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + async getHostView (req, res, next) { try { res.locals.stats = await NetHostStats @@ -62,7 +96,20 @@ class HostController extends SiteController { async getHomeView (req, res, next) { try { - res.locals.hosts = await NetHost.find({ status: { $ne: 'inactive' } }); + const HOST_SELECT = '_id created updated hostname status platform arch totalmem freemem'; + + res.locals.activeHosts = await NetHost + .find({ status: 'active' }) + .select(HOST_SELECT) + .sort({ updated: 1 }) + .lean(); + + res.locals.crashedHosts = await NetHost + .find({ status: 'crashed' }) + .select(HOST_SELECT) + .sort({ updated: 1 }) + .lean(); + res.render('admin/host/index'); } catch (error) { return next(error); diff --git a/app/controllers/admin/log.js b/app/controllers/admin/log.js new file mode 100644 index 0000000..a6c9e56 --- /dev/null +++ b/app/controllers/admin/log.js @@ -0,0 +1,57 @@ +// admin/log.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const DTP_COMPONENT_NAME = 'admin:log'; +const express = require('express'); + +const { SiteController } = require('../../../lib/site-lib'); + +class LogController 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 = 'log'; + return next(); + }); + + router.get('/', this.getIndex.bind(this)); + + return router; + } + + async getIndex (req, res, next) { + const { log: logService } = this.dtp.services; + try { + res.locals.query = req.query; + + res.locals.components = await logService.getComponentNames(); + res.locals.pagination = this.getPaginationParameters(req, 25); + + const search = { }; + if (req.query.component) { + search.componentName = req.query.component; + } + res.locals.logs = await logService.getRecords(search, res.locals.pagination); + + res.locals.totalLogCount = await logService.getTotalCount(); + + res.render('admin/log/index'); + } catch (error) { + return next(error); + } + } +} + +module.exports = async (dtp) => { + let controller = new LogController(dtp); + return controller; +}; \ No newline at end of file diff --git a/app/controllers/admin/newsletter.js b/app/controllers/admin/newsletter.js index 9eaf061..78a8a05 100644 --- a/app/controllers/admin/newsletter.js +++ b/app/controllers/admin/newsletter.js @@ -90,9 +90,9 @@ class NewsletterController extends SiteController { } async deleteNewsletter (req, res) { - const { newsletter: newsletterService, displayEngine: displayEngineService } = this.dtp.services; + const { newsletter: newsletterService } = this.dtp.services; try { - const displayList = displayEngineService.createDisplayList('delete-newsletter'); + const displayList = this.createDisplayList('delete-newsletter'); await newsletterService.deleteNewsletter(res.locals.newsletter); diff --git a/app/controllers/admin/page.js b/app/controllers/admin/page.js index 6ff84f9..69c9299 100644 --- a/app/controllers/admin/page.js +++ b/app/controllers/admin/page.js @@ -102,9 +102,9 @@ class PageController extends SiteController { } async deletePage (req, res) { - const { page: pageService, displayEngine: displayEngineService } = this.dtp.services; + const { page: pageService } = this.dtp.services; try { - const displayList = displayEngineService.createDisplayList('delete-page'); + const displayList = this.createDisplayList('delete-page'); await pageService.deletePage(res.locals.page); diff --git a/app/controllers/admin/post.js b/app/controllers/admin/post.js index 143e282..6642427 100644 --- a/app/controllers/admin/post.js +++ b/app/controllers/admin/post.js @@ -91,9 +91,9 @@ class PostController extends SiteController { } async deletePost (req, res) { - const { post: postService, displayEngine: displayEngineService } = this.dtp.services; + const { post: postService } = this.dtp.services; try { - const displayList = displayEngineService.createDisplayList('delete-post'); + const displayList = this.createDisplayList('delete-post'); await postService.deletePost(res.locals.post); diff --git a/app/controllers/admin/user.js b/app/controllers/admin/user.js index 01aac69..04ea8b1 100644 --- a/app/controllers/admin/user.js +++ b/app/controllers/admin/user.js @@ -27,6 +27,7 @@ class UserController extends SiteController { 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)); @@ -61,7 +62,8 @@ class UserController extends SiteController { const { user: userService } = this.dtp.services; try { res.locals.pagination = this.getPaginationParameters(req, 10); - res.locals.userAccounts = await userService.getUserAccounts(res.locals.pagination); + res.locals.userAccounts = await userService.getUserAccounts(res.locals.pagination, req.query.u); + res.locals.totalUserCount = await userService.getTotalCount(); res.render('admin/user/index'); } catch (error) { return next(error); diff --git a/app/controllers/auth.js b/app/controllers/auth.js index b33b311..304a023 100644 --- a/app/controllers/auth.js +++ b/app/controllers/auth.js @@ -24,7 +24,7 @@ class AuthController extends SiteController { async start ( ) { const { limiter: limiterService } = this.dtp.services; - const upload = multer({ }); + const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${DTP_COMPONENT_NAME}` }); const router = express.Router(); this.dtp.app.use('/auth', router); @@ -189,7 +189,11 @@ class AuthController extends SiteController { } } -module.exports = async (dtp) => { - let controller = new AuthController(dtp); - return controller; -}; \ No newline at end of file +module.exports = { + slug: 'auth', + name: 'auth', + create: async (dtp) => { + let controller = new AuthController(dtp); + return controller; + }, +}; diff --git a/app/controllers/comment.js b/app/controllers/comment.js new file mode 100644 index 0000000..70c6523 --- /dev/null +++ b/app/controllers/comment.js @@ -0,0 +1,118 @@ +// comment.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const DTP_COMPONENT_NAME = 'comment'; + +const express = require('express'); +const numeral = require('numeral'); + +const { SiteController, SiteError } = require('../../lib/site-lib'); + +class CommentController extends SiteController { + + constructor (dtp) { + super(dtp, DTP_COMPONENT_NAME); + } + + async start ( ) { + const { dtp } = this; + const { limiter: limiterService, session: sessionService } = dtp.services; + + const authRequired = sessionService.authCheckMiddleware({ requiredLogin: true }); + + const router = express.Router(); + dtp.app.use('/comment', router); + + router.use(async (req, res, next) => { + res.locals.currentView = DTP_COMPONENT_NAME; + return next(); + }); + + router.param('commentId', this.populateCommentId.bind(this)); + + router.post('/:commentId/vote', authRequired, this.postVote.bind(this)); + + router.delete('/:commentId', + authRequired, + limiterService.create(limiterService.config.comment.deleteComment), + this.deleteComment.bind(this), + ); + } + + async populateCommentId (req, res, next, commentId) { + const { comment: commentService } = this.dtp.services; + try { + res.locals.comment = await commentService.getById(commentId); + if (!res.locals.comment) { + return next(new SiteError(404, 'Comment not found')); + } + return next(); + } catch (error) { + this.log.error('failed to populate commentId', { commentId, error }); + return next(error); + } + } + + async postVote (req, res) { + const { contentVote: contentVoteService } = this.dtp.services; + try { + const displayList = this.createDisplayList('comment-vote'); + const { message, stats } = await contentVoteService.recordVote(req.user, 'Comment', res.locals.comment, req.body.vote); + displayList.setTextContent( + `button[data-comment-id="${res.locals.comment._id}"][data-vote="up"] span.dtp-item-value`, + numeral(stats.upvoteCount).format(stats.upvoteCount > 1000 ? '0,0.0a' : '0,0'), + ); + displayList.setTextContent( + `button[data-comment-id="${res.locals.comment._id}"][data-vote="down"] span.dtp-item-value`, + numeral(stats.downvoteCount).format(stats.upvoteCount > 1000 ? '0,0.0a' : '0,0'), + ); + displayList.showNotification(message, 'success', 'bottom-center', 3000); + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to process comment vote', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + + async deleteComment (req, res) { + const { comment: commentService } = this.dtp.services; + try { + const displayList = this.createDisplayList('add-recipient'); + + await commentService.remove(res.locals.comment, 'removed'); + + let selector = `article[data-comment-id="${res.locals.comment._id}"] .comment-content`; + displayList.setTextContent(selector, 'Comment removed'); + + displayList.showNotification( + 'Comment removed successfully', + 'success', + 'bottom-center', + 5000, + ); + + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to remove comment', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message + }); + } + } +} + +module.exports = { + slug: 'comment', + name: 'comment', + create: async (dtp) => { + let controller = new CommentController(dtp); + return controller; + }, +}; \ No newline at end of file diff --git a/app/controllers/content-report.js b/app/controllers/content-report.js new file mode 100644 index 0000000..cc2e03c --- /dev/null +++ b/app/controllers/content-report.js @@ -0,0 +1,112 @@ +// content-report.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const DTP_COMPONENT_NAME = 'content-report'; + +const express = require('express'); +const multer = require('multer'); + +const { SiteController } = require('../../lib/site-lib'); + +class ContentReportController extends SiteController { + + constructor (dtp) { + super(dtp, DTP_COMPONENT_NAME); + } + + async start ( ) { + const { dtp } = this; + const { limiter: limiterService, session: sessionService } = dtp.services; + + const authRequired = sessionService.authCheckMiddleware({ requiredLogin: true }); + const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads` }); + + const router = express.Router(); + dtp.app.use('/content-report', router); + + router.use(this.dtp.services.gabTV.channelMiddleware('mrjoeprich')); + router.use(async (req, res, next) => { + res.locals.currentView = 'content-report'; + return next(); + }); + + router.post('/comment/form', + limiterService.create(limiterService.config.contentReport.postCommentReportForm), + authRequired, + upload.none(), + this.postCommentReportForm.bind(this), + ); + router.post('/comment', + limiterService.create(limiterService.config.contentReport.postCommentReport), + authRequired, + upload.none(), + this.postCommentReport.bind(this), + ); + } + + async postCommentReportForm (req, res, next) { + const { comment: commentService } = this.dtp.services; + try { + res.locals.comment = await commentService.getById(req.body.commentId); + res.locals.params = req.body; + res.render('comment/components/report-form'); + } catch (error) { + return next(error); + } + } + + async postCommentReport (req, res) { + const { + contentReport: contentReportService, + comment: commentService, + user: userService, + } = this.dtp.services; + + const displayList = this.createDisplayList('add-recipient'); + + try { + res.locals.report = await contentReportService.create(req.user, { + resourceType: 'Comment', + resourceId: req.body.commentId, + category: req.body.category, + reason: req.body.reason, + }); + displayList.showNotification('Comment reported successfully', 'success', 'bottom-center', 5000); + + if (req.body.blockAuthor === 'on') { + const comment = await commentService.getById(req.body.commentId); + await userService.blockUser(req.user._id, comment.author._id || comment.author); + displayList.showNotification('Comment author blocked successfully', 'success', 'bottom-center', 5000); + } + + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to post comment report', { error }); + if (error.code === 11000) { + displayList.showNotification( + 'You already reported this comment', + 'primary', + 'bottom-center', + 5000, + ); + return res.status(200).json({ success: true, displayList }); + } + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } +} + +module.exports = { + slug: 'content-report', + name: 'contentReport', + create: async (dtp) => { + let controller = new ContentReportController(dtp); + return controller; + }, +}; \ No newline at end of file diff --git a/app/controllers/home.js b/app/controllers/home.js index 9d821ff..d3a9831 100644 --- a/app/controllers/home.js +++ b/app/controllers/home.js @@ -47,7 +47,12 @@ class HomeController extends SiteController { } } -module.exports = async (dtp) => { - let controller = new HomeController(dtp); - return controller; +module.exports = { + slug: 'home', + name: 'home', + isHome: true, + create: async (dtp) => { + let controller = new HomeController(dtp); + return controller; + }, }; diff --git a/app/controllers/image.js b/app/controllers/image.js index 0dcfb66..4cd3a65 100644 --- a/app/controllers/image.js +++ b/app/controllers/image.js @@ -29,7 +29,7 @@ class ImageController extends SiteController { dtp.app.use('/image', router); const imageUpload = multer({ - dest: '/tmp/dtp-sites/upload/image', + dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${DTP_COMPONENT_NAME}`, limits: { fileSize: 1024 * 1000 * 12, }, @@ -135,7 +135,11 @@ class ImageController extends SiteController { } } -module.exports = async (dtp) => { - let controller = new ImageController(dtp); - return controller; -}; \ No newline at end of file +module.exports = { + slug: 'image', + name: 'image', + create: async (dtp) => { + let controller = new ImageController(dtp); + return controller; + }, +}; diff --git a/app/controllers/manifest.js b/app/controllers/manifest.js index a10d4d0..cbd83e5 100644 --- a/app/controllers/manifest.js +++ b/app/controllers/manifest.js @@ -65,7 +65,11 @@ class ManifestController extends SiteController { } } -module.exports = async (dtp) => { - let controller = new ManifestController(dtp); - return controller; +module.exports = { + slug: 'manifest', + name: 'manifest', + create: async (dtp) => { + let controller = new ManifestController(dtp); + return controller; + }, }; diff --git a/app/controllers/newsletter.js b/app/controllers/newsletter.js index 8676303..9924704 100644 --- a/app/controllers/newsletter.js +++ b/app/controllers/newsletter.js @@ -21,7 +21,7 @@ class NewsletterController extends SiteController { const { dtp } = this; const { limiter: limiterService } = dtp.services; - const upload = multer({ dest: '/tmp' }); + const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${DTP_COMPONENT_NAME}` }); const router = express.Router(); dtp.app.use('/newsletter', router); @@ -58,9 +58,9 @@ class NewsletterController extends SiteController { } async postAddRecipient (req, res) { - const { newsletter: newsletterService, displayEngine: displayEngineService } = this.dtp.services; + const { newsletter: newsletterService } = this.dtp.services; try { - const displayList = displayEngineService.createDisplayList('add-recipient'); + const displayList = this.createDisplayList('add-recipient'); await newsletterService.addRecipient(req.body.email); displayList.showNotification( 'You have been added to the newsletter. Please check your email and verify your email address.', @@ -94,7 +94,11 @@ class NewsletterController extends SiteController { } } -module.exports = async (dtp) => { - let controller = new NewsletterController(dtp); - return controller; +module.exports = { + slug: 'newsletter', + name: 'newsletter', + create: async (dtp) => { + let controller = new NewsletterController(dtp); + return controller; + }, }; diff --git a/app/controllers/page.js b/app/controllers/page.js index 69d7aa1..d8abf3b 100644 --- a/app/controllers/page.js +++ b/app/controllers/page.js @@ -25,7 +25,7 @@ class PageController extends SiteController { router.use(this.dtp.services.gabTV.channelMiddleware('mrjoeprich')); router.use(async (req, res, next) => { - res.locals.currentView = 'home'; + res.locals.currentView = 'page'; return next(); }); @@ -55,6 +55,7 @@ class PageController extends SiteController { const { resource: resourceService } = this.dtp.services; try { await resourceService.recordView(req, 'Page', res.locals.page._id); + res.locals.pageSlug = res.locals.page.slug; res.render('page/view'); } catch (error) { this.log.error('failed to service page view', { pageId: res.locals.page._id, error }); @@ -63,7 +64,11 @@ class PageController extends SiteController { } } -module.exports = async (dtp) => { - let controller = new PageController(dtp); - return controller; +module.exports = { + slug: 'page', + name: 'page', + create: async (dtp) => { + let controller = new PageController(dtp); + return controller; + }, }; \ No newline at end of file diff --git a/app/controllers/post.js b/app/controllers/post.js index a538d33..28b5e3e 100644 --- a/app/controllers/post.js +++ b/app/controllers/post.js @@ -19,8 +19,13 @@ class PostController extends SiteController { async start ( ) { const { dtp } = this; - const { limiter: limiterService } = dtp.services; + const { + comment: commentService, + limiter: limiterService, + session: sessionService, + } = dtp.services; + const authRequired = sessionService.authCheckMiddleware({ requiredLogin: true }); const upload = multer({ dest: `/tmp/dtp-sites/${this.dtp.config.site.domainKey}`}); const router = express.Router(); @@ -33,8 +38,10 @@ class PostController extends SiteController { }); router.param('postSlug', this.populatePostSlug.bind(this)); + router.param('commentId', commentService.populateCommentId.bind(commentService)); - router.post('/:postSlug/comment', upload.none(), this.postComment.bind(this)); + router.post('/:postSlug/comment/:commentId/block-author', authRequired, upload.none(), this.postBlockCommentAuthor.bind(this)); + router.post('/:postSlug/comment', authRequired, upload.none(), this.postComment.bind(this)); router.get('/:postSlug', limiterService.create(limiterService.config.post.getView), @@ -61,13 +68,31 @@ class PostController extends SiteController { } } + async postBlockCommentAuthor (req, res) { + const { user: userService } = this.dtp.services; + try { + const displayList = this.createDisplayList('add-recipient'); + await userService.blockUser(req.user._id, req.body.userId); + displayList.showNotification( + 'Comment author blocked', + 'success', + 'bottom-center', + 4000, + ); + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to report comment', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + async postComment (req, res) { - const { - comment: commentService, - displayEngine: displayEngineService, - } = this.dtp.services; + const { comment: commentService } = this.dtp.services; try { - const displayList = displayEngineService.createDisplayList('add-recipient'); + const displayList = this.createDisplayList('add-recipient'); res.locals.comment = await commentService.create(req.user, 'Post', res.locals.post, req.body); @@ -98,9 +123,14 @@ class PostController extends SiteController { await resourceService.recordView(req, 'Post', res.locals.post._id); res.locals.pagination = this.getPaginationParameters(req, 20); + + if (req.query.comment) { + res.locals.featuredComment = await commentService.getById(req.query.comment); + } + res.locals.comments = await commentService.getForResource( res.locals.post, - ['published', 'mod-warn'], + ['published', 'mod-warn', 'mod-removed', 'removed'], res.locals.pagination, ); @@ -123,7 +153,11 @@ class PostController extends SiteController { } } -module.exports = async (dtp) => { - let controller = new PostController(dtp); - return controller; +module.exports = { + slug: 'post', + name: 'post', + create: async (dtp) => { + let controller = new PostController(dtp); + return controller; + }, }; \ No newline at end of file diff --git a/app/controllers/user.js b/app/controllers/user.js index abeca5e..cd3494c 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -20,12 +20,17 @@ class UserController extends SiteController { async start ( ) { const { dtp } = this; - const { limiter: limiterService, otpAuth: otpAuthService } = dtp.services; + const { + limiter: limiterService, + otpAuth: otpAuthService, + session: sessionService, + } = dtp.services; - const upload = multer({ dest: "/tmp" }); + const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${DTP_COMPONENT_NAME}` }); const router = express.Router(); dtp.app.use('/user', router); + const authRequired = sessionService.authCheckMiddleware({ requireLogin: true }); const otpMiddleware = otpAuthService.middleware('Account', { adminRequired: false, otpRequired: false, @@ -53,6 +58,12 @@ class UserController extends SiteController { router.param('userId', this.populateUser.bind(this)); + router.post('/:userId/profile-photo', + limiterService.create(limiterService.config.user.postProfilePhoto), + upload.single('imageFile'), + this.postProfilePhoto.bind(this), + ); + router.post('/:userId/settings', limiterService.create(limiterService.config.user.postUpdateSettings), upload.none(), @@ -66,16 +77,25 @@ class UserController extends SiteController { router.get('/:userId/settings', limiterService.create(limiterService.config.user.getSettings), + authRequired, otpMiddleware, checkProfileOwner, this.getUserSettingsView.bind(this), ); router.get('/:userId', limiterService.create(limiterService.config.user.getUserProfile), + authRequired, otpMiddleware, checkProfileOwner, this.getUserView.bind(this), ); + + router.delete('/:userId/profile-photo', + limiterService.create(limiterService.config.user.deleteProfilePhoto), + authRequired, + checkProfileOwner, + this.deleteProfilePhoto.bind(this), + ); } async populateUser (req, res, next, userId) { @@ -100,7 +120,23 @@ class UserController extends SiteController { async postCreateUser (req, res, next) { const { user: userService } = this.dtp.services; try { + // verify that the request has submitted a captcha + if ((typeof req.body.captcha !== 'string') || req.body.captcha.length === 0) { + throw new SiteError(403, 'Invalid signup attempt'); + } + // verify that the session has a signup captcha + if (!req.session.captcha || !req.session.captcha.signup) { + throw new SiteError(403, 'Invalid signup attempt'); + } + // verify that the captcha from the form matches the captcha in the signup session flow + if (req.body.captcha !== req.session.captcha.signup) { + throw new SiteError(403, 'The captcha value is not correct'); + } + + // create the user account res.locals.user = await userService.create(req.body); + + // log the user in req.login(res.locals.user, (error) => { if (error) { return next(error); @@ -113,10 +149,52 @@ class UserController extends SiteController { } } + async postProfilePhoto (req, res) { + const { user: userService } = this.dtp.services; + try { + const displayList = this.createDisplayList('profile-photo'); + await userService.updatePhoto(req.user, req.file); + displayList.showNotification( + 'Profile photo updated successfully.', + 'success', + 'bottom-center', + 2000, + ); + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to update profile photo', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + + async postHeaderImage (req, res) { + const { user: userService } = this.dtp.services; + try { + const displayList = this.createDisplayList('header-image'); + await userService.updateHeaderImage(req.user, req.file); + displayList.showNotification( + 'Header image updated successfully.', + 'success', + 'bottom-center', + 2000, + ); + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to update header image', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + async postUpdateSettings (req, res) { - const { user: userService, displayEngine: displayEngineService } = this.dtp.services; + const { user: userService } = this.dtp.services; try { - const displayList = displayEngineService.createDisplayList('app-settings'); + const displayList = this.createDisplayList('app-settings'); await userService.updateSettings(req.user, req.body); @@ -147,16 +225,65 @@ class UserController extends SiteController { } async getUserView (req, res, next) { + const { comment: commentService } = this.dtp.services; try { + res.locals.pagination = this.getPaginationParameters(req, 20); + res.locals.commentHistory = await commentService.getForAuthor(req.user, res.locals.pagination); res.render('user/profile'); } catch (error) { this.log.error('failed to produce user profile view', { error }); return next(error); } } + + async deleteProfilePhoto (req, res) { + const { user: userService } = this.dtp.services; + try { + const displayList = this.createDisplayList('app-settings'); + await userService.removePhoto(req.user); + displayList.showNotification( + 'Profile photo removed successfully.', + 'success', + 'bottom-center', + 2000, + ); + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to remove profile photo', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + + async deleteHeaderImage (req, res) { + const { user: userService } = this.dtp.services; + try { + const displayList = this.createDisplayList('remove-header-image'); + await userService.removeHeaderImage(req.user); + displayList.showNotification( + 'Header image removed successfully.', + 'success', + 'bottom-center', + 2000, + ); + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to remove header image', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } } -module.exports = async (dtp) => { - let controller = new UserController(dtp); - return controller; +module.exports = { + slug: 'user', + name: 'user', + create: async (dtp) => { + let controller = new UserController(dtp); + return controller; + }, }; diff --git a/app/controllers/welcome.js b/app/controllers/welcome.js index 5a9964e..b77acb4 100644 --- a/app/controllers/welcome.js +++ b/app/controllers/welcome.js @@ -68,7 +68,11 @@ class WelcomeController extends SiteController { } } -module.exports = async (dtp) => { - let controller = new WelcomeController(dtp); - return controller; -}; \ No newline at end of file +module.exports = { + slug: 'welcome', + name: 'welcome', + create: async (dtp) => { + let controller = new WelcomeController(dtp); + return controller; + }, +}; diff --git a/app/models/comment.js b/app/models/comment.js index 608692b..bce3420 100644 --- a/app/models/comment.js +++ b/app/models/comment.js @@ -14,19 +14,26 @@ const CommentHistorySchema = new Schema({ content: { type: String, maxlength: 3000 }, }); -const { CommentStats, CommentStatsDefaults } = require(path.join(__dirname, 'lib', 'resource-stats.js')); +const { + RESOURCE_TYPE_LIST, + CommentStats, + CommentStatsDefaults, +} = require(path.join(__dirname, 'lib', 'resource-stats.js')); const COMMENT_STATUS_LIST = ['published', 'removed', 'mod-warn', 'mod-removed']; const CommentSchema = new Schema({ created: { type: Date, default: Date.now, required: true, index: 1 }, - resourceType: { type: String, enum: ['Post', 'Page', 'Newsletter'], required: true }, + resourceType: { type: String, enum: RESOURCE_TYPE_LIST, required: true }, resource: { type: Schema.ObjectId, required: true, index: 1, refPath: 'resourceType' }, author: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, replyTo: { type: Schema.ObjectId, index: 1, ref: 'Comment' }, status: { type: String, enum: COMMENT_STATUS_LIST, default: 'published', required: true }, content: { type: String, required: true, maxlength: 3000 }, contentHistory: { type: [CommentHistorySchema], select: false }, + flags: { + isNSFW: { type: Boolean, default: false, required: true }, + }, stats: { type: CommentStats, default: CommentStatsDefaults, required: true }, }); diff --git a/app/models/content-report.js b/app/models/content-report.js new file mode 100644 index 0000000..6423f18 --- /dev/null +++ b/app/models/content-report.js @@ -0,0 +1,31 @@ +// content-report.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const REPORT_STATUS_LIST = ['new','resolved','ignored']; +const REPORT_CATEGORY_LIST = ['spam','violence','threat','porn','doxxing','other']; + +const ContentReportSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: 1, expires: '30d' }, + user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, + resourceType: { type: String, enum: [ ], required: true }, + resource: { type: Schema.ObjectId, required: true, index: 1, refPath: 'resourceType' }, + status: { type: String, enum: REPORT_STATUS_LIST, required: true, index: 1 }, + category: { type: String, enum: REPORT_CATEGORY_LIST, required: true }, + reason: { type: String }, +}); + +ContentReportSchema.index({ + user: 1, + resource: 1, +}, { + unique: true, + name: 'unique_user_content_report', +}); + +module.exports = mongoose.model('ContentReport', ContentReportSchema); \ No newline at end of file diff --git a/app/models/content-vote.js b/app/models/content-vote.js new file mode 100644 index 0000000..0cbe24a --- /dev/null +++ b/app/models/content-vote.js @@ -0,0 +1,26 @@ +// content-vote.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const ContentVoteSchema = new Schema({ + created: { type: Date, default: Date.now, required: true }, + resourceType: { type: String, required: true }, + resource: { type: Schema.ObjectId, required: true, refPath: 'resourceType' }, + user: { type: Schema.ObjectId, required: true, ref: 'User' }, + vote: { type: String, enum: ['up','down'], required: true }, +}); + +ContentVoteSchema.index({ + user: 1, + resource: 1, +}, { + unique: true, + name: 'unique_user_content_vote', +}); + +module.exports = mongoose.model('ContentVote', ContentVoteSchema); \ No newline at end of file diff --git a/app/models/email-blacklist.js b/app/models/email-blacklist.js index c579560..230c8af 100644 --- a/app/models/email-blacklist.js +++ b/app/models/email-blacklist.js @@ -24,11 +24,11 @@ const EmailBlacklistSchema = new Schema({ EmailBlacklistSchema.index({ email: 1, - 'flags.isVerified': true, + 'flags.isVerified': 1, }, { partialFilterExpression: { 'flags.isVerified': true, }, }); -module.exports = mongoose.model('EmailBlacklist', EmailBlacklistSchema); \ No newline at end of file +module.exports = mongoose.model('EmailBlacklist', EmailBlacklistSchema); diff --git a/app/models/lib/geo-types.js b/app/models/lib/geo-types.js new file mode 100644 index 0000000..dd5cdce --- /dev/null +++ b/app/models/lib/geo-types.js @@ -0,0 +1,22 @@ +// geo-types.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +module.exports.GeoPoint = new Schema({ + type: { type: String, enum: ['Point'], default: 'Point', required: true }, + coordinates: { type: [Number], required: true }, +}); + +module.exports.GeoIp = new Schema({ + country: { type: String }, + region: { type: String }, + eu: { type: String }, + timezone: { type: String }, + city: { type: String }, + location: { type: module.exports.GeoPoint }, +}); \ No newline at end of file diff --git a/app/models/lib/resource-stats.js b/app/models/lib/resource-stats.js index 10ad788..7a15bc9 100644 --- a/app/models/lib/resource-stats.js +++ b/app/models/lib/resource-stats.js @@ -9,17 +9,13 @@ const mongoose = require('mongoose'); const Schema = mongoose.Schema; module.exports.ResourceStats = new Schema({ - totalViewCount: { type: Number, default: 0, required: true }, - upvoteCount: { type: Number, default: 0, required: true }, - downvoteCount: { type: Number, default: 0, required: true }, - commentCount: { type: Number, default: 0, required: true }, + uniqueVisitCount: { type: Number, default: 0, required: true, index: -1 }, + totalVisitCount: { type: Number, default: 0, required: true }, }); module.exports.ResourceStatsDefaults = { - totalViewCount: 0, - upvoteCount: 0, - downvoteCount: 0, - commentCount: 0, + uniqueVisitCount: 0, + totalVisitCount: 0, }; module.exports.CommentStats = new Schema({ diff --git a/app/models/log.js b/app/models/log.js index 574e5ae..b2220d7 100644 --- a/app/models/log.js +++ b/app/models/log.js @@ -20,10 +20,10 @@ const LOG_LEVEL_LIST = [ const LogSchema = new Schema({ created: { type: Date, default: Date.now, required: true, index: -1, expires: '7d' }, - componentName: { type: String, required: true }, + componentName: { type: String, required: true, index: 1 }, 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); \ No newline at end of file +module.exports = mongoose.model('Log', LogSchema); diff --git a/app/models/page.js b/app/models/page.js index c728337..705fb82 100644 --- a/app/models/page.js +++ b/app/models/page.js @@ -19,6 +19,7 @@ const PageSchema = new Schema({ content: { type: String, required: true, select: false }, status: { type: String, enum: PAGE_STATUS_LIST, default: 'draft', index: true }, menu: { + icon: { type: String, required: true }, label: { type: String, required: true }, order: { type: Number, default: 0, required: true }, parent: { type: Schema.ObjectId, index: 1, ref: 'Page' }, diff --git a/app/models/post.js b/app/models/post.js index 9d45be9..750ec45 100644 --- a/app/models/post.js +++ b/app/models/post.js @@ -9,7 +9,10 @@ const mongoose = require('mongoose'); const Schema = mongoose.Schema; -const { ResourceStats, ResourceStatsDefaults } = require(path.join(__dirname, 'lib', 'resource-stats.js')); +const { + ResourceStats, + ResourceStatsDefaults, +} = require(path.join(__dirname, 'lib', 'resource-stats.js')); const POST_STATUS_LIST = ['draft','published','archived']; diff --git a/app/models/resource-view.js b/app/models/resource-view.js index 05c4fa5..1f9f447 100644 --- a/app/models/resource-view.js +++ b/app/models/resource-view.js @@ -15,7 +15,7 @@ const ResourceViewSchema = new Schema({ resourceType: { type: String, enum: RESOURCE_TYPE_LIST, required: true }, resource: { type: Schema.ObjectId, required: true, index: 1, refPath: 'resourceType' }, uniqueKey: { type: String, required: true, index: 1 }, - viewCount: { type: Number, default: 0, required: true }, + visitCount: { type: Number, default: 0, required: true }, }); ResourceViewSchema.index({ @@ -27,4 +27,4 @@ ResourceViewSchema.index({ name: 'res_view_daily_unique', }); -module.exports = mongoose.model('ResourceView', ResourceViewSchema); \ No newline at end of file +module.exports = mongoose.model('ResourceView', ResourceViewSchema); diff --git a/app/models/resource-visit.js b/app/models/resource-visit.js new file mode 100644 index 0000000..f0bbcc9 --- /dev/null +++ b/app/models/resource-visit.js @@ -0,0 +1,29 @@ +// resource-visit.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const { GeoIp } = require('./lib/geo-types'); + +const ResourceVisitSchema = new Schema({ + created: { type: Date, required: true, default: Date.now, index: -1, expires: '7d' }, + resourceType: { type: String, enum: ['Page','Post','User'], required: true }, + resource: { type: Schema.ObjectId, required: true, index: 1, ref: 'Link' }, + user: { type: Schema.ObjectId, ref: 'User' }, + geoip: { type: GeoIp }, +}); + +ResourceVisitSchema.index({ + user: 1, +}, { + partialFilterExpression: { + user: { $exists: true }, + }, + name: 'resource_visits_for_user', +}); + +module.exports = mongoose.model('ResourceVisit', ResourceVisitSchema); \ No newline at end of file diff --git a/app/models/user-block.js b/app/models/user-block.js new file mode 100644 index 0000000..31e6505 --- /dev/null +++ b/app/models/user-block.js @@ -0,0 +1,15 @@ +// user-block.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const UserBlockSchema = new Schema({ + user: { type: Schema.ObjectId, required: true, index: true, ref: 'User' }, + blockedUsers: { type: [Schema.ObjectId], ref: 'User' }, +}); + +module.exports = mongoose.model('UserBlock', UserBlockSchema); \ No newline at end of file diff --git a/app/models/user-notification.js b/app/models/user-notification.js new file mode 100644 index 0000000..6d73366 --- /dev/null +++ b/app/models/user-notification.js @@ -0,0 +1,20 @@ +// user-notification.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); +const Schema = mongoose.Schema; + +const UserNotificationSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: -1, expires: '7d' }, + user: { type: Schema.ObjectId, required: true, index: 1, ref: 'User' }, + source: { type: String, required: true }, + message: { type: String, required: true }, + status: { type: String, enum: ['new', 'seen'], default: 'new', required: true }, + attachmentType: { type: String }, + attachment: { type: Schema.ObjectId, refPath: 'attachmentType' }, +}); + +module.exports = mongoose.model('UserNotification', UserNotificationSchema); \ No newline at end of file diff --git a/app/models/user.js b/app/models/user.js index ae9933c..70142cc 100644 --- a/app/models/user.js +++ b/app/models/user.js @@ -8,6 +8,8 @@ const mongoose = require('mongoose'); const Schema = mongoose.Schema; +const { ResourceStats, ResourceStatsDefaults } = require('./lib/resource-stats'); + const UserFlagsSchema = new Schema({ isAdmin: { type: Boolean, default: false, required: true }, isModerator: { type: Boolean, default: false, required: true }, @@ -16,6 +18,8 @@ const UserFlagsSchema = new Schema({ const UserPermissionsSchema = new Schema({ canLogin: { type: Boolean, default: true, required: true }, canChat: { type: Boolean, default: true, required: true }, + canComment: { type: Boolean, default: true, required: true }, + canReport: { type: Boolean, default: true, required: true }, }); const UserSchema = new Schema({ @@ -26,12 +30,14 @@ const UserSchema = new Schema({ passwordSalt: { type: String, required: true }, password: { type: String, required: true }, displayName: { type: String }, + bio: { type: String, maxlength: 300 }, picture: { large: { type: Schema.ObjectId, ref: 'Image' }, small: { type: Schema.ObjectId, ref: 'Image' }, }, flags: { type: UserFlagsSchema, select: false }, permissions: { type: UserPermissionsSchema, select: false }, + stats: { type: ResourceStats, default: ResourceStatsDefaults, required: true }, }); -module.exports = mongoose.model('User', UserSchema); \ No newline at end of file +module.exports = mongoose.model('User', UserSchema); diff --git a/app/services/chat.js b/app/services/chat.js new file mode 100644 index 0000000..facb404 --- /dev/null +++ b/app/services/chat.js @@ -0,0 +1,53 @@ +// chat.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); +const ChatMessage = mongoose.model('ChatMessage'); + +const ioEmitter = require('socket.io-emitter'); + + +const { SiteService } = require('../../lib/site-lib'); + +class ChatService extends SiteService { + + constructor (dtp) { + super(dtp, module.exports); + this.populateContentReport = [ + { + path: 'user', + select: '_id username username_lc displayName picture', + }, + { + path: 'resource', + populate: [ + { + path: 'author', + select: '_id username username_lc displayName picture', + }, + ], + }, + ]; + } + + async start ( ) { + this.emitter = ioEmitter(this.dtp.redis); + } + + async removeMessage (message) { + await ChatMessage.deleteOne({ _id: message._id }); + this.emitter(`site:${this.dtp.config.site.domainKey}:chat`, { + command: 'removeMessage', + params: { messageId: message._id }, + }); + } +} + +module.exports = { + slug: 'chat', + name: 'chat', + create: (dtp) => { return new ChatService(dtp); }, +}; \ No newline at end of file diff --git a/app/services/comment.js b/app/services/comment.js index 175782c..f07468d 100644 --- a/app/services/comment.js +++ b/app/services/comment.js @@ -22,12 +22,30 @@ class CommentService extends SiteService { this.populateComment = [ { path: 'author', - select: '', + select: '_id username username_lc displayName picture', }, { path: 'replyTo', }, ]; + this.populateCommentWithResource = [ + { + path: 'author', + select: '_id username username_lc displayName picture', + }, + { + path: 'replyTo', + }, + { + path: 'resource', + populate: [ + { + path: 'author', + select: '_id username username_lc displayName picture', + }, + ], + }, + ]; } async start ( ) { @@ -35,6 +53,19 @@ class CommentService extends SiteService { this.templates.comment = pug.compileFile(path.join(this.dtp.config.root, 'app', 'views', 'comment', 'components', 'comment-standalone.pug')); } + async populateCommentId (req, res, next, commentId) { + try { + res.locals.comment = await this.getById(commentId); + if (!res.locals.comment) { + throw new SiteError(404, 'Comment not found'); + } + return next(); + } catch (error) { + this.log.error('failed to populate comment', { commentId, error }); + return next(error); + } + } + async create (author, resourceType, resource, commentDefinition) { const NOW = new Date(); let comment = new Comment(); @@ -50,6 +81,10 @@ class CommentService extends SiteService { comment.content = striptags(commentDefinition.content.trim()); } + comment.flags = { + isNSFW: commentDefinition.isNSFW === 'on', + }; + await comment.save(); const model = mongoose.model(resourceType); @@ -116,6 +151,7 @@ class CommentService extends SiteService { * @param {String} status */ async remove (comment, status = 'removed') { + const { contentReport: contentReportService } = this.dtp.services; await Comment.updateOne( { _id: comment._id }, { @@ -131,6 +167,15 @@ class CommentService extends SiteService { }, }, ); + await contentReportService.removeForResource(comment); + } + + async getById (commentId) { + const comment = await Comment + .findById(commentId) + .populate(this.populateComment) + .lean(); + return comment; } async getForResource (resource, statuses, pagination) { @@ -144,6 +189,17 @@ class CommentService extends SiteService { return comments; } + async getForAuthor (author, pagination) { + const comments = await Comment + .find({ author: author._id, status: { $in: ['published', 'mod-warn'] } }) + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateCommentWithResource) + .lean(); + return comments; + } + async getContentHistory (comment, pagination) { /* * Extract a page from the contentHistory using $slice on the array @@ -170,4 +226,4 @@ module.exports = { slug: 'comment', name: 'comment', create: (dtp) => { return new CommentService(dtp); }, -}; +}; \ No newline at end of file diff --git a/app/services/content-report.js b/app/services/content-report.js new file mode 100644 index 0000000..ef432b5 --- /dev/null +++ b/app/services/content-report.js @@ -0,0 +1,127 @@ +// content-report.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); + +const mongoose = require('mongoose'); + +const ContentReport = mongoose.model('ContentReport'); + +const pug = require('pug'); +const striptags = require('striptags'); + +const { SiteService, SiteError } = require('../../lib/site-lib'); + +class ContentReportService extends SiteService { + + constructor (dtp) { + super(dtp, module.exports); + this.populateContentReport = [ + { + path: 'user', + select: '_id username username_lc displayName picture', + }, + { + path: 'resource', + populate: [ + { + path: 'author', + select: '_id username username_lc displayName picture', + }, + ], + }, + ]; + } + + async start ( ) { + this.templates = { }; + this.templates.comment = pug.compileFile(path.join(this.dtp.config.root, 'app', 'views', 'comment', 'components', 'comment-standalone.pug')); + } + + async create (user, reportDefinition) { + const NOW = new Date(); + const report = new ContentReport(); + + report.created = NOW; + report.user = user._id; + report.resourceType = reportDefinition.resourceType; + report.resource = reportDefinition.resourceId; + report.status = 'new'; + report.category = reportDefinition.category; + report.reason = striptags(reportDefinition.reason.trim()); + + await report.save(); + + return report.toObject(); + } + + async getReports (status, pagination) { + if (!Array.isArray(status)) { + status = [status]; + } + const reports = await ContentReport + .find({ status: { $in: status } }) + .sort({ created: 1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateContentReport) + .lean(); + return reports; + } + + async getById (reportId) { + const report = await ContentReport + .findById(reportId) + .populate(this.populateContentReport) + .lean(); + return report; + } + + async setStatus (report, status) { + await ContentReport.updateOne({ _id: report._id }, { $set: { status } }); + } + + async removeResource (report) { + switch (report.resourceType) { + case 'Comment': + return this.removeComment(report); + case 'Chat': + return this.removeChatMessage(report); + default: + break; + } + this.log.error('invalid resource type in content report', { + reportId: report._id, + resourceType: report.resourceType, + }); + throw new SiteError(406, 'Invalid resource type in content report'); + } + + async removeComment (report) { + const { comment: commentService } = this.dtp.services; + await commentService.remove(report.resource._id || report.resource, 'mod-removed'); + await this.setStatus(report, 'resolved'); + } + + async removeChatMessage (report) { + const { chat: chatService } = this.dtp.services; + await chatService.removeMessage(report.resource); + } + + async removeForResource (resource) { + await ContentReport.deleteMany({ resource: resource._id }); + } + + async removeReport (report) { + await ContentReport.deleteOne({ _id: report._id }); + } +} + +module.exports = { + slug: 'content-report', + name: 'contentReport', + create: (dtp) => { return new ContentReportService(dtp); }, +}; \ No newline at end of file diff --git a/app/services/content-vote.js b/app/services/content-vote.js new file mode 100644 index 0000000..e441365 --- /dev/null +++ b/app/services/content-vote.js @@ -0,0 +1,98 @@ +// content-vote.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); +const ContentVote = mongoose.model('ContentVote'); + +const ioEmitter = require('socket.io-emitter'); + +const { SiteService } = require('../../lib/site-lib'); + +class ContentVoteService extends SiteService { + + constructor (dtp) { + super(dtp, module.exports); + } + + async start ( ) { + this.emitter = ioEmitter(this.dtp.redis); + } + + async recordVote (user, resourceType, resource, vote) { + const NOW = new Date(); + const ResourceModel = mongoose.model(resourceType); + const updateOp = { $inc: { } }; + let message; + + const contentVote = await ContentVote.findOne({ + user: user._id, + resource: resource._id, + }); + + this.log.debug('processing content vote', { resource: resource._id, user: user._id, vote, contentVote }); + + if (!contentVote) { + /* + * It's a new vote from a new user + */ + await ContentVote.create({ + created: NOW, + resourceType, + resource: resource._id, + user: user._id, + vote + }); + if (vote === 'up') { + updateOp.$inc['stats.upvoteCount'] = 1; + message = 'Comment upvote recorded'; + } else { + updateOp.$inc['stats.downvoteCount'] = 1; + message = 'Comment downvote recorded'; + } + } else { + /* + * If vote not changed, do no further work. + */ + if (contentVote.vote === vote) { + const updatedResource = await ResourceModel.findById(resource._id).select('stats'); + return { message: "Comment vote unchanged", stats: updatedResource.stats }; + } + + /* + * Update the user's existing vote + */ + await ContentVote.updateOne( + { _id: contentVote._id }, + { $set: { vote } } + ); + + /* + * Adjust resource's stats based on the changed vote + */ + if (vote === 'up') { + updateOp.$inc['stats.upvoteCount'] = 1; + updateOp.$inc['stats.downvoteCount'] = -1; + message = 'Comment vote changed to upvote'; + } else { + updateOp.$inc['stats.upvoteCount'] = -1; + updateOp.$inc['stats.downvoteCount'] = 1; + message = 'Comment vote changed to downvote'; + } + } + + this.log.info('updating resource stats', { resourceType, resource, updateOp }); + await ResourceModel.updateOne({ _id: resource._id }, updateOp); + + const updatedResource = await ResourceModel.findById(resource._id).select('stats'); + return { message, stats: updatedResource.stats }; + } +} + +module.exports = { + slug: 'content-vote', + name: 'contentVote', + create: (dtp) => { return new ContentVoteService(dtp); }, +}; \ No newline at end of file diff --git a/app/services/display-engine.js b/app/services/display-engine.js index 77d322f..dcd95c3 100644 --- a/app/services/display-engine.js +++ b/app/services/display-engine.js @@ -102,6 +102,13 @@ class DisplayList { params: { remove, add }, }); } + + navigateTo (href) { + this.commands.push({ + action: 'navigateTo', + params: { href }, + }); + } } class DisplayEngineService extends SiteService { diff --git a/app/services/log.js b/app/services/log.js new file mode 100644 index 0000000..f6f5e57 --- /dev/null +++ b/app/services/log.js @@ -0,0 +1,44 @@ +// log.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const mongoose = require('mongoose'); + +const Log = mongoose.model('Log'); + +const { SiteService } = require('../../lib/site-lib'); + +class SystemLogService extends SiteService { + + constructor (dtp) { + super(dtp, module.exports); + } + + async getRecords (search, pagination) { + const logs = await Log + .find(search) + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .lean(); + return logs; + } + + async getComponentNames ( ) { + return await Log.distinct('componentName'); + } + + async getTotalCount ( ) { + const count = await Log.estimatedDocumentCount(); + this.log.debug('log message total count', { count }); + return count; + } +} + +module.exports = { + slug: 'log', + name: 'log', + create: (dtp) => { return new SystemLogService(dtp); }, +}; \ No newline at end of file diff --git a/app/services/minio.js b/app/services/minio.js index 2aa1ebc..c5039a6 100644 --- a/app/services/minio.js +++ b/app/services/minio.js @@ -32,6 +32,16 @@ class MinioService extends SiteService { this.log.info(`stopping ${module.exports.name} service`); } + async makeBucket (name, region) { + try { + const result = await this.minio.makeBucket(name, region); + return result; + } catch (error) { + this.log.error('failed to create bucket on MinIO', { name, region, error }); + throw error; + } + } + async uploadFile (fileInfo) { try { const result = await this.minio.fPutObject( @@ -75,4 +85,4 @@ module.exports = { slug: 'minio', name: 'minio', create: (dtp) => { return new MinioService(dtp); }, -}; \ No newline at end of file +}; diff --git a/app/services/otp-auth.js b/app/services/otp-auth.js index 0f02ec5..edad956 100644 --- a/app/services/otp-auth.js +++ b/app/services/otp-auth.js @@ -77,12 +77,14 @@ class OtpAuthService extends SiteService { } if (!res.locals.otpAccount) { + let issuer; + if (process.env.NODE_ENV === 'production') { + issuer = `${this.dtp.config.site.name}: ${serviceName}`; + } else { + issuer = `${this.dtp.config.site.name}:${process.env.NODE_ENV}: ${serviceName}`; + } res.locals.otpTempSecret = authenticator.generateSecret(); - res.locals.otpKeyURI = authenticator.keyuri( - req.user.username.trim(), - `${this.dtp.config.site.name}: ${serviceName}`, - res.locals.otpTempSecret, - ); + res.locals.otpKeyURI = authenticator.keyuri(req.user.username.trim(), issuer, 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; @@ -210,10 +212,14 @@ class OtpAuthService extends SiteService { return true; } + + async removeForUser (user) { + return await OtpAccount.deleteMany({ user: user }); + } } module.exports = { slug: 'otp-auth', name: 'otpAuth', create: (dtp) => { return new OtpAuthService(dtp); }, -}; \ No newline at end of file +}; diff --git a/app/services/page.js b/app/services/page.js index 981bfb5..7c8b15a 100644 --- a/app/services/page.js +++ b/app/services/page.js @@ -22,11 +22,18 @@ class PageService extends SiteService { async menuMiddleware (req, res, next) { try { - const pages = await Page.find({ parent: { $exists: false } }).lean(); + const pages = await Page + .find({ parent: { $exists: false } }) + .select('slug menu') + .lean(); + res.locals.mainMenu = pages + .filter((page) => !page.parent) .map((page) => { return { url: `/page/${page.slug}`, + slug: page.slug, + icon: page.menu.icon, label: page.menu.label, order: page.menu.order, }; @@ -47,10 +54,13 @@ class PageService extends SiteService { page.slug = this.createPageSlug(page._id, page.title); page.content = pageDefinition.content.trim(); page.status = pageDefinition.status || 'draft'; + page.menu = { - label: pageDefinition.menuLabel || page.title.slice(0, 10), + icon: striptags((pageDefinition.menuIcon || 'fa-slash').trim().toLowerCase()), + label: striptags((pageDefinition.menuLabel || page.title.slice(0, 10))), order: parseInt(pageDefinition.menuOrder || '0', 10), }; + if (pageDefinition.parentPageId && (pageDefinition.parentPageId !== 'none')) { page.menu.parent = pageDefinition.parentPageId; } @@ -90,9 +100,11 @@ class PageService extends SiteService { } updateOp.$set.menu = { - label: pageDefinition.menuLabel || updateOp.$set.title.slice(0, 10), + icon: striptags((pageDefinition.menuIcon || 'fa-slash').trim().toLowerCase()), + label: striptags((pageDefinition.menuLabel || page.title.slice(0, 10))), order: parseInt(pageDefinition.menuOrder || '0', 10), }; + if (pageDefinition.parentPageId && (pageDefinition.parentPageId !== 'none')) { updateOp.$set.menu.parent = pageDefinition.parentPageId; } diff --git a/app/services/resource.js b/app/services/resource.js index d07536d..7dc5196 100644 --- a/app/services/resource.js +++ b/app/services/resource.js @@ -6,9 +6,12 @@ const { SiteService } = require('../../lib/site-lib'); +const geoip = require('geoip-lite'); + const mongoose = require('mongoose'); const ResourceView = mongoose.model('ResourceView'); +const ResourceVisit = mongoose.model('ResourceVisit'); class ResourceService extends SiteService { @@ -32,7 +35,11 @@ class ResourceService extends SiteService { * a view is being tracked. */ async recordView (req, resourceType, resourceId) { - const CURRENT_DAY = new Date(); + const Model = mongoose.model(resourceType); + const modelUpdate = { $inc: { } }; + + const NOW = new Date(); + const CURRENT_DAY = new Date(NOW); CURRENT_DAY.setHours(0, 0, 0, 0); let uniqueKey = req.ip.toString().trim().toLowerCase(); @@ -48,20 +55,65 @@ class ResourceService extends SiteService { uniqueKey, }, { - $inc: { viewCount: 1 }, + $inc: { 'stats.visitCount': 1 }, }, { upsert: true }, ); - this.log.debug('resource view', { response }); + if (response.upsertedCount > 0) { - const Model = mongoose.model(resourceType); - await Model.updateOne( - { _id: resourceId }, - { - $inc: { 'stats.totalViewCount': 1 }, - }, - ); + modelUpdate.$inc['stats.uniqueViewCount'] = 1; + } + + /* + * Record the ResourceVisit + */ + const visit = new ResourceVisit(); + + visit.created = NOW; + visit.resourceType = resourceType; + visit.resource = resourceId; + + if (req.user) { + visit.user = req.user._id; } + + /* + * We geo-analyze (but do not store) the IP address. + */ + const ipAddress = req.ip; + const geo = geoip.lookup(ipAddress); + if (geo) { + visit.geoip = { + country: geo.country, + region: geo.region, + eu: geo.eu, + timezone: geo.timezone, + city: geo.city, + }; + if (Array.isArray(geo.ll) && (geo.ll.length === 2)) { + visit.geoip.location = { + type: 'Point', + coordinates: geo.ll, + }; + } + } + + await visit.save(); + + modelUpdate.$inc['stats.totalVisitCount'] = 1; + await Model.updateOne({ _id: resourceId }, modelUpdate); + } + + async remove (resourceType, resource) { + this.log.debug('removing resource view records', { resourceType, resourceId: resource._id }); + await ResourceView.deleteMany({ resource: resource._id }); + + this.log.debug('removing resource visit records', { resourceType, resourceId: resource._id }); + await ResourceVisit.deleteMany({ resource: resource._id }); + + this.log.debug('removing resource', { resourceType, resourceId: resource._id }); + const Model = mongoose.model(resourceType); + await Model.deleteOne({ _id: resource._id }); } } @@ -69,4 +121,4 @@ module.exports = { slug: 'resource', name: 'resource', create: (dtp) => { return new ResourceService(dtp); }, -}; \ No newline at end of file +}; diff --git a/app/services/user-notification.js b/app/services/user-notification.js new file mode 100644 index 0000000..bbdb6f3 --- /dev/null +++ b/app/services/user-notification.js @@ -0,0 +1,100 @@ +// user-notification.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); + +const mongoose = require('mongoose'); +const UserNotification = mongoose.model('UserNotification'); + +const pug = require('pug'); + +const { SiteService } = require('../../lib/site-lib'); + +class UserNotificationService extends SiteService { + + constructor (dtp) { + super(dtp, module.exports); + this.populateComment = [ + { + path: 'author', + select: '_id username username_lc displayName picture', + }, + { + path: 'replyTo', + }, + ]; + } + + async start ( ) { + this.templates = { }; + this.templates.notification = pug.compileFile(path.join(this.dtp.config.root, 'app', 'views', 'notification', 'components', 'notification-standalone.pug')); + } + + async create (user, notificationDefinition) { + const NOW = new Date(); + const notification = new UserNotification(); + + notification.created = NOW; + notification.user = user._id; + notification.source = notificationDefinition.source; + notification.message = notificationDefinition.message; + notification.status = 'new'; + notification.attachmentType = notificationDefinition.attachmentType; + notification.attachment = notificationDefinition.attachment; + + await notification.save(); + + return notification.toObject(); + } + + async getNewCountForUser (user) { + const result = await UserNotification.aggregate([ + { + $match: { + user: user._id, + status: 'new', + }, + }, + { + $group: { + _id: { user: 1 }, + count: { $sum: 1 }, + }, + }, + { + $project: { + _id: -1, + count: '$count', + }, + }, + ]); + this.log('getNewCountForUser', { result }); + return result[0].count; + } + + async getForUser (user, pagination) { + const notifications = await UserNotification + .find({ user: user._id }) + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .lean(); + const newNotifications = notifications.map((notif) => notif.status === 'new'); + if (newNotifications.length > 0) { + await UserNotification.updateMany( + { _id: { $in: newNotifications.map((notif) => notif._id) } }, + { $set: { stats: 'seen' } }, + ); + } + return notifications; + } +} + +module.exports = { + slug: 'user-notification', + name: 'userNotification', + create: (dtp) => { return new UserNotificationService(dtp); }, +}; \ No newline at end of file diff --git a/app/services/user.js b/app/services/user.js index 9c703d9..39cdf2d 100644 --- a/app/services/user.js +++ b/app/services/user.js @@ -5,7 +5,9 @@ 'use strict'; const mongoose = require('mongoose'); + const User = mongoose.model('User'); +const UserBlock = mongoose.model('UserBlock'); const passport = require('passport'); const PassportLocal = require('passport-local'); @@ -20,6 +22,15 @@ class UserService { constructor (dtp) { this.dtp = dtp; this.log = new SiteLog(dtp, `svc:${module.exports.slug}`); + + this.populateUser = [ + { + path: 'picture.large', + }, + { + path: 'picture.small', + }, + ]; } async start ( ) { @@ -47,6 +58,7 @@ class UserService { // 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(); + await this.checkUsername(username_lc); // test the email address for validity, blacklisting, etc. await mailService.checkEmailAddress(userDefinition.email); @@ -72,6 +84,7 @@ class UserService { user.email = userDefinition.email; user.username = userDefinition.username; user.username_lc = username_lc; + user.displayName = striptags(userDefinition.displayName || userDefinition.username); user.passwordSalt = passwordSalt; user.password = maskedPassword; @@ -84,6 +97,8 @@ class UserService { user.permissions = { canLogin: true, canChat: true, + canComment: true, + canReport: true, }; this.log.info('creating new user account', { email: userDefinition.email }); @@ -102,6 +117,7 @@ class UserService { const username_lc = userDefinition.username.toLowerCase(); userDefinition.displayName = striptags(userDefinition.displayName.trim()); + userDefinition.bio = striptags(userDefinition.bio.trim()); this.log.info('updating user', { userDefinition }); await User.updateOne( @@ -111,10 +127,13 @@ class UserService { username: userDefinition.username, username_lc, displayName: userDefinition.displayName, + bio: userDefinition.bio, 'flags.isAdmin': userDefinition.isAdmin === 'on', 'flags.isModerator': userDefinition.isModerator === 'on', 'permissions.canLogin': userDefinition.canLogin === 'on', 'permissions.canChat': userDefinition.canChat === 'on', + 'permissions.canComment': userDefinition.canComment === 'on', + 'permissions.canReport': userDefinition.canReport === 'on', }, }, ); @@ -126,6 +145,7 @@ class UserService { const username_lc = userDefinition.username.toLowerCase(); userDefinition.displayName = striptags(userDefinition.displayName.trim()); + userDefinition.bio = striptags(userDefinition.bio.trim()); this.log.info('updating user settings', { userDefinition }); await User.updateOne( @@ -135,6 +155,7 @@ class UserService { username: userDefinition.username, username_lc, displayName: userDefinition.displayName, + bio: userDefinition.bio, }, }, ); @@ -253,6 +274,7 @@ class UserService { const user = await User .findById(userId) .select('+email +flags +permissions') + .populate(this.populateUser) .lean(); if (!user) { throw new SiteError(404, 'Member account not found'); @@ -260,9 +282,13 @@ class UserService { return user; } - async getUserAccounts (pagination) { + async getUserAccounts (pagination, username) { + let search = { }; + if (username) { + search.username_lc = { $regex: `^${username.toLowerCase().trim()}` }; + } const users = await User - .find() + .find(search) .sort({ username_lc: 1 }) .select('+email +flags +permissions') .skip(pagination.skip) @@ -280,10 +306,36 @@ class UserService { } catch (error) { user = User.findOne({ username: userId }); } - user = await user.select('+email +flags +settings').lean(); + user = await user + .select('+email +flags +settings') + .populate(this.populateUser) + .lean(); + return user; + } + + async getPublicProfile (username) { + if (!username || (typeof username !== 'string') || (username.length === 0)) { + throw new SiteError(406, 'Invalid username'); + } + username = username.trim().toLowerCase(); + const user = await User + .findOne({ username_lc: username }) + .select('_id created username username_lc displayName bio picture header') + .populate(this.populateUser) + .lean(); return user; } + async getRecent (maxCount = 3) { + const users = User + .find() + .select('_id created username username_lc displayName bio picture') + .sort({ created: -1 }) + .limit(maxCount) + .lean(); + return users; + } + async setUserSettings (user, settings) { const { crypto: cryptoService, @@ -302,10 +354,7 @@ class UserService { 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'); - } + await this.checkUsername(update.username); } if (settings.email && (settings.email !== user.email)) { @@ -360,20 +409,128 @@ class UserService { 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; + async checkUsername (username) { + if (!username || (typeof username !== 'string') || (username.length === 0)) { + throw new SiteError(406, 'Invalid username'); + } + const reservedNames = [ + 'about', + 'admin', + 'amy', + 'auth', + 'digitaltelepresence', + 'dist', + 'dtp', + 'fontawesome', + 'fonts', + 'img', + 'image', + 'less', + 'manifest.json', + 'moment', + 'newsletter', + 'numeral', + 'rob', + 'socket.io', + 'uikit', + 'user', + 'welcome', + 'zack' + ]; + if (reservedNames.includes(username.trim().toLowerCase())) { + throw new SiteError(403, 'That username is reserved for system use'); } const user = await User.findOne({ username: username}).select('username').lean(); if (user) { this.log.alert('username is already registered', { username }); - return true; + throw new SiteError(403, 'Username is already registered'); } + } + + async recordProfileView (user, req) { + const { resource: resourceService } = this.dtp.services; + await resourceService.recordView(req, 'User', user._id); + } - return false; + async getTotalCount ( ) { + return await User.estimatedDocumentCount(); + } + + async updatePhoto (user, file) { + const { image: imageService } = this.dtp.services; + const images = [ + { + width: 512, + height: 512, + format: 'jpeg', + formatParameters: { + quality: 80, + }, + }, + { + width: 64, + height: 64, + format: 'jpeg', + formatParameters: { + conpressionLevel: 9, + }, + }, + ]; + await imageService.processImageFile(user, file, images); + await User.updateOne( + { _id: user._id }, + { + $set: { + 'picture.large': images[0].image._id, + 'picture.small': images[1].image._id, + }, + }, + ); + } + + async removePhoto (user) { + const { image: imageService } = this.dtp.services; + + this.log.info('remove profile photo', { user: user._id }); + user = await this.getUserAccount(user._id); + if (user.picture.large) { + await imageService.deleteImage(user.picture.large); + } + if (user.picture.small) { + await imageService.deleteImage(user.picture.small); + } + await User.updateOne({ _id: user._id }, { $unset: { 'picture': '' } }); + } + + async blockUser (userId, blockedUserId) { + userId = mongoose.Types.ObjectId(userId); + blockedUserId = mongoose.Types.ObjectId(blockedUserId); + if (userId.equals(blockedUserId)) { + throw new SiteError(406, "You can't block yourself"); + } + await UserBlock.updateOne( + { user: userId }, + { + $addToSet: { blockedUsers: blockedUserId }, + }, + { upsert: true }, + ); + } + + async unblockUser (userId, blockedUserId) { + userId = mongoose.Types.ObjectId(userId); + blockedUserId = mongoose.Types.ObjectId(blockedUserId); + if (userId.equals(blockedUserId)) { + throw new SiteError(406, "You can't un-block yourself"); + } + await UserBlock.updateOne( + { user: userId }, + { + $removeFromSet: { blockedUsers: blockedUserId }, + }, + { upsert: true }, + ); } } diff --git a/app/views/admin/components/menu.pug b/app/views/admin/components/menu.pug index 4689df3..d25db8e 100644 --- a/app/views/admin/components/menu.pug +++ b/app/views/admin/components/menu.pug @@ -6,6 +6,7 @@ ul.uk-nav.uk-nav-default span.nav-item-icon i.fas.fa-home span.uk-margin-small-left Home + li(class={ 'uk-active': (adminView === 'settings') }) a(href="/admin/settings") span.nav-item-icon i.fas.fa-cog @@ -29,6 +30,19 @@ ul.uk-nav.uk-nav-default i.fas.fa-newspaper span.uk-margin-small-left Newsletter + li.uk-nav-divider + + li(class={ 'uk-active': (adminView === 'user') }) + a(href="/admin/user") + span.nav-item-icon + i.fas.fa-user + span.uk-margin-small-left Users + li(class={ 'uk-active': (adminView === 'content-report') }) + a(href="/admin/content-report") + span.nav-item-icon + i.fas.fa-ban + span.uk-margin-small-left Content Reports + li.uk-nav-divider li(class={ 'uk-active': (adminView === 'host') }) @@ -41,6 +55,11 @@ ul.uk-nav.uk-nav-default span.nav-item-icon i.fas.fa-microchip span.uk-margin-small-left Jobs + li(class={ 'uk-active': (adminView === 'log') }) + a(href="/admin/log") + span.nav-item-icon + i.fas.fa-clipboard-list + span.uk-margin-small-left Logs li.uk-nav-divider @@ -48,4 +67,4 @@ ul.uk-nav.uk-nav-default a(href="/admin/user") span.nav-item-icon i.fas.fa-user - span.uk-margin-small-left Users \ No newline at end of file + span.uk-margin-small-left Users diff --git a/app/views/admin/content-report/index.pug b/app/views/admin/content-report/index.pug new file mode 100644 index 0000000..4c7605a --- /dev/null +++ b/app/views/admin/content-report/index.pug @@ -0,0 +1,31 @@ +extends ../layouts/main +block content + + .uk-margin + +renderSectionTitle('Content Reports') + + if Array.isArray(reports) && (reports.length > 0) + .uk-overflow-auto + table.uk-table.uk-table-small.uk-table-hover + thead + tr + th Created + th Category + th Reporter + th Reason + th Content Type + th Content Author + tbody + each report in reports + tr + td.uk-table-link + a(href=`/admin/content-report/${report._id}`)= moment(report.created).fromNow() + td= report.category + td.uk-table-link + a(href=`/admin/user/${report.user._id}`)= report.user.username + td.uk-table-expand.uk-table-truncate= report.reason + td= report.resourceType.toLowerCase() + td + a(href=`/admin/user/${report.resource.author._id}`)= report.resource.author.username + else + div There are no reports. \ No newline at end of file diff --git a/app/views/admin/content-report/view.pug b/app/views/admin/content-report/view.pug new file mode 100644 index 0000000..9e34671 --- /dev/null +++ b/app/views/admin/content-report/view.pug @@ -0,0 +1,48 @@ +extends ../layouts/main +block content + + include ../../comment/components/comment-review + + .uk-margin.uk-width-xlarge + fieldset + legend Reported #{report.resourceType} + +renderCommentReview(report.resource) + + .uk-margin + label(for="reason").uk-form-label.sr-only Reporter's note + .uk-card.uk-card-secondary.uk-card-small + #reason.uk-card-body= report.reason + div(uk-grid).uk-text-small.uk-text-muted + .uk-width-auto reporter: #[a(href=`/admin/user/${report.user._id}`)= report.user.username] + .uk-width-auto reported: #{moment(report.created).fromNow()} + .uk-width-auto author: #[a(href=`/admin/user/${report.resource.author._id}`)= report.resource.author.username] + .uk-width-auto category: #{report.category} + .uk-width-auto status: #{report.status} + + div(uk-grid) + .uk-width-expand + div(uk-grid) + .uk-width-auto + form(method="POST", action= `/admin/content-report/${report._id}/action`, onsubmit="return dtp.app.submitForm(event, 'remove content');") + input(type="hidden", name="verb", value="dismiss") + button( + type="submit", + title="Dismiss this content report with no action", + ).uk-button.dtp-button-primary + +renderLabeledIcon('fa-times', 'Dismiss') + .uk-width-auto + form(method="POST", action= `/admin/content-report/${report._id}/action`, onsubmit="return dtp.app.submitForm(event, 'remove content');") + input(type="hidden", name="verb", value="purge") + button( + type="submit", + title=`Dismiss this content report and purge all reports from ${report.user.username}`, + ).uk-button.dtp-button-primary + +renderLabeledIcon('fa-bicycle', 'Purge') + .uk-width-auto + form(method="POST", action= `/admin/content-report/${report._id}/action`, onsubmit="return dtp.app.submitForm(event, 'remove content');") + input(type="hidden", name="verb", value="remove") + button( + type="submit", + title=`Remove the reported ${report.resourceType}`, + ).uk-button.dtp-button-danger + +renderLabeledIcon('fa-trash', `Remove ${report.resourceType}`) \ No newline at end of file diff --git a/app/views/admin/host/index.pug b/app/views/admin/host/index.pug index 65eb9a3..81e5aa7 100644 --- a/app/views/admin/host/index.pug +++ b/app/views/admin/host/index.pug @@ -1,23 +1,35 @@ 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' \ No newline at end of file + mixin renderHostList (hosts) + if Array.isArray(hosts) && (hosts.length > 0) + 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(data-host-id= host._id) + 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' + else + div The host list is empty + + if Array.isArray(activeHosts) && (activeHosts.length > 0) + h2 Active hosts + +renderHostList(activeHosts) + + if Array.isArray(crashedHosts) && (crashedHosts.length > 0) + h2 Crashed hosts + +renderHostList(crashedHosts) diff --git a/app/views/admin/index.pug b/app/views/admin/index.pug index 904dbc9..cb2d13e 100644 --- a/app/views/admin/index.pug +++ b/app/views/admin/index.pug @@ -1,12 +1,22 @@ extends layouts/main block content - - div(uk-grid).uk-grid-small.uk-flex-between.uk-flex-middle + + .uk-margin + canvas(id="hourly-signups") + + div(uk-grid).uk-grid-small.uk-flex-middle .uk-width-auto +renderCell('Members', formatCount(stats.memberCount)) .uk-width-auto - +renderCell('Channels', formatCount(stats.channelCount)) + +renderCell('Posts', formatCount(stats.postCount)) .uk-width-auto - +renderCell('Streams', formatCount(stats.streamCount)) - .uk-width-auto - +renderCell('Viewers', formatCount(stats.viewerCount)) + +renderCell('Comments', formatCount(stats.commentCount)) + +block viewjs + script(src="/chart.js/chart.min.js") + script(src="/chartjs-adapter-moment/chartjs-adapter-moment.min.js") + script. + window.addEventListener('dtp-load', ( ) => { + const graphData = !{JSON.stringify(stats.userSignupHourly)}; + dtp.app.renderStatsGraph('#hourly-signups', 'Hourly Signups', graphData); + }); \ No newline at end of file diff --git a/app/views/admin/layouts/main.pug b/app/views/admin/layouts/main.pug index 1ce341c..812e6d3 100644 --- a/app/views/admin/layouts/main.pug +++ b/app/views/admin/layouts/main.pug @@ -1,4 +1,7 @@ extends ../../layouts/main +block vendorcss + link(rel="stylesheet", href="/highlight.js/styles/default.css") + block content-container block page-header @@ -16,4 +19,4 @@ block content-container include ../components/menu div(class="uk-width-1-1 uk-flex-first uk-width-expand@m").uk-width-expand - block content \ No newline at end of file + block content diff --git a/app/views/admin/log/index.pug b/app/views/admin/log/index.pug new file mode 100644 index 0000000..802b040 --- /dev/null +++ b/app/views/admin/log/index.pug @@ -0,0 +1,46 @@ +extends ../layouts/main +block content + + include ../../components/pagination-bar + + .uk-margin + form(method="GET", action="/admin/log").uk-form + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-expand + h1 Log #[span.uk-text-small.uk-text-muted #{numeral(totalLogCount).format('0,0')} records] + + .uk-width-auto + - + var urlParams = ''; + if (query.component) { + urlParams += `&component=${query.component}`; + } + +renderPaginationBar(`/admin/log`, totalLogCount, urlParams) + + .uk-width-auto + select(id="component", name="component").uk-select + each componentName in components + option(value= componentName, selected= (query.component === componentName))= componentName + .uk-width-auto + button(type="submit").uk-button.dtp-button-primary Filter + + if Array.isArray(logs) && (logs.length > 0) + table.uk-table.uk-table-small.uk-table-divider + thead + tr + th Timestamp + th Level + th Component + th Message + tbody + each log in logs + tr + td= moment(log.created).format('YYYY-MM-DD hh:mm:ss.SSS') + td= log.level + td= log.componentName + td + div= log.message + if log.metadata + .uk-text-small(style="font-family: Courier New;")!= hljs.highlightAuto(JSON.stringify(log.metadata, null, 1)).value + else + div There are no logs. \ No newline at end of file diff --git a/app/views/admin/page/editor.pug b/app/views/admin/page/editor.pug index d0f3f2c..f1ea69a 100644 --- a/app/views/admin/page/editor.pug +++ b/app/views/admin/page/editor.pug @@ -35,6 +35,10 @@ block content fieldset legend Menu + .uk-margin + label(for="menu-icon").uk-form-label Menu item icon + input(id="menu-icon", name="menuIcon", type="text", maxlength="80", placeholder="Enter icon class", value= page ? page.menu.icon : undefined).uk-input + .uk-text-small Visit #[a(href="https://fontawesome.com/v5.15/icons?d=gallery&p=2&q=blog&m=free", target="_blank") FontAwesome] for a list of usable icons. .uk-margin label(for="menu-label").uk-form-label Menu item label input(id="menu-label", name="menuLabel", type="text", maxlength="80", placeholder="Enter label", value= page ? page.menu.label : undefined).uk-input diff --git a/app/views/admin/settings/editor.pug b/app/views/admin/settings/editor.pug index 06cd950..be35576 100644 --- a/app/views/admin/settings/editor.pug +++ b/app/views/admin/settings/editor.pug @@ -2,14 +2,64 @@ extends ../layouts/main block content form(method="POST", action="/admin/settings").uk-form - .uk-margin - label(for="name").uk-form-label Site name - input(id="name", name="name", type="text", maxlength="200", placeholder="Enter site name", value= site.name).uk-input - .uk-margin - label(for="description").uk-form-label Site description - input(id="description", name="description", type="text", maxlength="500", placeholder="Enter site description", value= site.description).uk-input - .uk-margin - label(for="company").uk-form-label Company name - input(id="company", name="company", type="text", maxlength="200", placeholder="Enter company name", value= site.company).uk-input + fieldset + legend Site Information + .uk-margin + label(for="name").uk-form-label Site name + input(id="name", name="name", type="text", maxlength="200", placeholder="Enter site name", value= site.name).uk-input + .uk-margin + label(for="description").uk-form-label Site description + input(id="description", name="description", type="text", maxlength="500", placeholder="Enter site description", value= site.description).uk-input + .uk-margin + label(for="company").uk-form-label Company name + input(id="company", name="company", type="text", maxlength="200", placeholder="Enter company name", value= site.company).uk-input + + fieldset + legend Gab links + div(uk-grid).uk-grid-small + div(class="uk-width-1-1 uk-width-1-2@m uk-width-1-3@xl") + label(for="gab-url").uk-form-label Gab Social Profile + input(id="gab-url", name="gabUrl", type="url", placeholder="Enter Gab profile URL", value= site.gabUrl).uk-input + div(class="uk-width-1-1 uk-width-1-2@m uk-width-1-3@xl") + label(for="gabtv-url").uk-form-label Gab TV Channel + input(id="gabtv-url", name="gabtvUrl", type="url", placeholder="Enter Gab TV URL", value= site.gabtvUrl).uk-input + + fieldset + legend Social links + div(uk-grid).uk-grid-small + div(class="uk-width-1-1 uk-width-1-2@m uk-width-1-3@xl") + label(for="telegram-url").uk-form-label Telegram URL + input(id="telegram-url", name="telegramUrl", type="url", placeholder="Enter Telegram URL", value= site.telegramUrl).uk-input + div(class="uk-width-1-1 uk-width-1-2@m uk-width-1-3@xl") + label(for="twitter-url").uk-form-label Twitter URL + input(id="twitter-url", name="twitterUrl", type="url", placeholder="Enter Twitter URL", value= site.twitterUrl).uk-input + div(class="uk-width-1-1 uk-width-1-2@m uk-width-1-3@xl") + label(for="facebook-url").uk-form-label Facebook URL + input(id="facebook-url", name="facebookUrl", type="url", placeholder="Enter Facebook URL", value= site.facebookUrl).uk-input + div(class="uk-width-1-1 uk-width-1-2@m uk-width-1-3@xl") + label(for="instagram-url").uk-form-label Instagram URL + input(id="instagram-url", name="instagramUrl", type="url", placeholder="Enter Instagram URL", value= site.instagramUrl).uk-input + + fieldset + legend Social links + div(uk-grid).uk-grid-small + div(class="uk-width-1-1 uk-width-1-2@m uk-width-1-3@xl") + label(for="bitchute-url").uk-form-label BitChute URL + input(id="bitchute-url", name="bitchuteUrl", type="url", placeholder="Enter BitChute URL", value= site.bitchuteUrl).uk-input + div(class="uk-width-1-1 uk-width-1-2@m uk-width-1-3@xl") + label(for="odysee-url").uk-form-label Odysee URL + input(id="odysee-url", name="odyseeUrl", type="url", placeholder="Enter Odysee channel URL", value= site.odyseeUrl).uk-input + div(class="uk-width-1-1 uk-width-1-2@m uk-width-1-3@xl") + label(for="rumble-url").uk-form-label Rumble URL + input(id="rumble-url", name="rumbleUrl", type="url", placeholder="Enter Rumble channel URL", value= site.rumbleUrl).uk-input + div(class="uk-width-1-1 uk-width-1-2@m uk-width-1-3@xl") + label(for="twitch-url").uk-form-label Twitch URL + input(id="twitch-url", name="twitchUrl", type="url", placeholder="Enter Twitch channel URL", value= site.twitchUrl).uk-input + div(class="uk-width-1-1 uk-width-1-2@m uk-width-1-3@xl") + label(for="youtube-url").uk-form-label YouTube URL + input(id="youtube-url", name="youtubeUrl", type="url", placeholder="Enter YouTube channel URL", value= site.youtubeUrl).uk-input + div(class="uk-width-1-1 uk-width-1-2@m uk-width-1-3@xl") + label(for="dlive-url").uk-form-label DLive URL + input(id="dlive-url", name="dliveUrl", type="url", placeholder="Enter DLive channel URL", value= site.dliveUrl).uk-input button(type="submit").uk-button.dtp-button-primary Save Settings \ No newline at end of file diff --git a/app/views/admin/user/form.pug b/app/views/admin/user/form.pug index d45a495..9740cc0 100644 --- a/app/views/admin/user/form.pug +++ b/app/views/admin/user/form.pug @@ -33,5 +33,11 @@ block content label input(id="can-chat", name="canChat", type="checkbox", checked= userAccount.permissions.canChat) | Can Chat + label + input(id="can-comment", name="canComment", type="checkbox", checked= userAccount.permissions.canComment) + | Can Comment + label + input(id="can-report", name="canReport", type="checkbox", checked= userAccount.permissions.canReport) + | Can Report button(type="submit").uk-button.uk-button-primary Update User \ No newline at end of file diff --git a/app/views/admin/user/index.pug b/app/views/admin/user/index.pug index ff74877..5935971 100644 --- a/app/views/admin/user/index.pug +++ b/app/views/admin/user/index.pug @@ -1,6 +1,16 @@ extends ../layouts/main block content + include ../../components/pagination-bar + + .uk-margin + form(method="GET", action="/admin/user").uk-form + div(uk-grid).uk-grid-collapse + .uk-width-expand + input(id="username", name="u", placeholder="Enter username", value= query && query.u ? query.u : undefined).uk-input + .uk-width-auto + button(type="submit").uk-button.uk-button-secondary.uk-light Search + .uk-overflow-auto table.uk-table.uk-table-divider.uk-table-hover.uk-table-small.uk-table-justify thead @@ -19,4 +29,7 @@ block content else .uk-text-muted N/A td= moment(userAccount.created).format('YYYY-MM-DD hh:mm a') - td= userAccount._id \ No newline at end of file + td= userAccount._id + + .uk-margin + +renderPaginationBar("/admin/user", totalUserCount) diff --git a/app/views/comment/components/comment-list.pug b/app/views/comment/components/comment-list.pug new file mode 100644 index 0000000..c58d7c3 --- /dev/null +++ b/app/views/comment/components/comment-list.pug @@ -0,0 +1,11 @@ +include comment + +mixin renderCommentList (comments) + if Array.isArray(comments) && (comments.length > 0) + ul#post-comment-list.uk-list.uk-list-divider.uk-list-large + each comment in comments + li(data-comment-id= comment._id) + +renderComment(comment) + else + ul#post-comment-list.uk-list.uk-list-divider.uk-list-large + div There are no comments at this time. Please check back later. \ No newline at end of file diff --git a/app/views/comment/components/comment-review.pug b/app/views/comment/components/comment-review.pug new file mode 100644 index 0000000..0df8617 --- /dev/null +++ b/app/views/comment/components/comment-review.pug @@ -0,0 +1,13 @@ +mixin renderCommentReview (comment) + - var resourceId = comment.resource._id || comment.resource; + article.uk-comment.dtp-site-comment + header.uk-comment-header + div(uk-grid).uk-grid-medium.uk-flex-middle + .uk-width-auto + img(src="/img/default-member.png").site-profile-picture.sb-small.uk-comment-avatar + .uk-width-expand + h4.uk-comment-title.uk-margin-remove= comment.author.displayName || comment.author.username + .uk-comment-meta= moment(comment.created).fromNow() + + .uk-comment-body(class={ 'uk-text-muted': ['removed','mod-removed'].includes(comment.status) }) + .comment-content!= marked.parse(comment.content) \ No newline at end of file diff --git a/app/views/comment/components/comment.pug b/app/views/comment/components/comment.pug index 4f6a8e3..0ba7a4f 100644 --- a/app/views/comment/components/comment.pug +++ b/app/views/comment/components/comment.pug @@ -1,47 +1,114 @@ mixin renderComment (comment) - .uk-card.uk-card-secondary.uk-card-small.uk-border-rounded - .uk-card-body - div(uk-grid).uk-grid-small + - var resourceId = comment.resource._id || comment.resource; + article(data-comment-id= comment._id).uk-comment.dtp-site-comment + header.uk-comment-header + div(uk-grid).uk-grid-medium.uk-flex-middle .uk-width-auto - img(src="/img/default-member.png").site-profile-picture.sb-small + img(src="/img/default-member.png").site-profile-picture.sb-small.uk-comment-avatar + .uk-width-expand - div(uk-grid).uk-grid-small.uk-flex-middle.uk-text-small - if comment.author.displayName - .uk-width-auto - span= comment.author.displayName - .uk-width-auto= comment.author.username - .uk-width-auto= moment(comment.created).fromNow() - div!= marked.parse(comment.content) - div(uk-grid).uk-grid-small.uk-text-small - .uk-width-auto - button( - type="button", - data-comment-id= comment._id, - onclick="return dtp.app.upvoteComment(event);", - title="Upvote this comment", - ).uk-button.uk-button-link - +renderButtonIcon('fa-chevron-up', formatCount(comment.stats.upvoteCount)) - .uk-width-auto - button( - type="button", - data-comment-id= comment._id, - onclick="return dtp.app.downvoteComment(event);", - title="Downvote this comment", - ).uk-button.uk-button-link - +renderButtonIcon('fa-chevron-down', formatCount(comment.stats.downvoteCount)) - .uk-width-auto - button( - type="button", - data-comment-id= comment._id, - onclick="return dtp.app.openReplies(event);", - title="Load replies to this comment", - ).uk-button.uk-button-link - +renderButtonIcon('fa-comment', formatCount(comment.stats.replyCount)) - .uk-width-auto - button( - type="button", - data-comment-id= comment._id, - onclick="return dtp.app.openReplyComposer(event);", - title="Write a reply to this comment", - ).uk-button.uk-button-link - +renderButtonIcon('fa-reply', 'reply') \ No newline at end of file + h4.uk-comment-title.uk-margin-remove= comment.author.displayName || comment.author.username + .uk-comment-meta= moment(comment.created).fromNow() + + if user && (comment.status === 'published') + .uk-width-auto + button(type="button").uk-button.uk-button-link + span + i.fas.fa-ellipsis-h + div(data-comment-id= comment._id, uk-dropdown={ mode: 'click', pos: 'bottom-right' }) + ul.uk-nav.uk-dropdown-nav + if user && user._id.equals(comment.author._id) + li.uk-nav-header.no-select Author menu + li + a( + href="", + data-comment-id= comment._id, + onclick="return dtp.app.deleteComment(event);", + ) Delete + else if user + li.uk-nav-header.no-select Moderation menu + li + a( + href="", + data-resource-type= comment.resourceType, + data-resource-id= resourceId, + data-comment-id= comment._id, + onclick="return dtp.app.showReportCommentForm(event);", + ) Report + li + a( + href="", + data-resource-type= comment.resourceType, + data-resource-id= resourceId, + data-comment-id= comment._id, + onclick="return dtp.app.blockCommentAuthor(event);", + ) Block author + + .uk-comment-body + case comment.status + when 'published' + if comment.flags && comment.flags.isNSFW + div.uk-alert.uk-alert-info.uk-border-rounded + div(uk-grid).uk-grid-small.uk-text-small.uk-flex-middle + .uk-width-expand NSFW comment hidden by default. Use the eye to show/hide. + .uk-width-auto + button( + type="button", + uk-toggle={ target: `.comment-content[data-comment-id="${comment._id}"]` }, + title="Show/hide the comment text", + ).uk-button.uk-button-link + span + i.fas.fa-eye + .comment-content(data-comment-id= comment._id, hidden= comment.flags ? comment.flags.isNSFW : false)!= marked.parse(comment.content) + when 'removed' + .comment-content.uk-text-muted [comment removed] + when 'mod-warn' + alert + span A warning has been added to this comment. + button(type="button", uk-toggle={ target: `.comment-content[data-comment-id="${comment._id}"]` }) + .comment-content(data-comment-id= comment._id, hidden)!= marked.parse(comment.content) + when 'mod-removed' + .comment-content.uk-text-muted [comment removed] + + //- Comment meta bar + div(uk-grid).uk-grid-small + .uk-width-auto + button( + type="button", + data-comment-id= comment._id, + data-vote="up", + onclick="return dtp.app.submitCommentVote(event);", + title="Upvote this comment", + ).uk-button.uk-button-link + +renderLabeledIcon('fa-chevron-up', formatCount(comment.stats.upvoteCount)) + .uk-width-auto + button( + type="button", + data-comment-id= comment._id, + data-vote="down", + onclick="return dtp.app.submitCommentVote(event);", + title="Downvote this comment", + ).uk-button.uk-button-link + +renderLabeledIcon('fa-chevron-down', formatCount(comment.stats.downvoteCount)) + .uk-width-auto + button( + type="button", + data-comment-id= comment._id, + onclick="return dtp.app.openReplies(event);", + title="Load replies to this comment", + ).uk-button.uk-button-link + +renderLabeledIcon('fa-comment', formatCount(comment.stats.replyCount)) + .uk-width-auto + button( + type="button", + data-comment-id= comment._id, + onclick="return dtp.app.openReplyComposer(event);", + title="Write a reply to this comment", + ).uk-button.uk-button-link + +renderLabeledIcon('fa-reply', 'reply') + + //- Comment replies and reply composer + div(data-comment-id= comment._id) + if user && user.flags.canComment + .uk-margin + +renderCommentComposer(`/post`, { replyTo: comment._id }) \ No newline at end of file diff --git a/app/views/comment/components/composer.pug b/app/views/comment/components/composer.pug new file mode 100644 index 0000000..c4bc6bc --- /dev/null +++ b/app/views/comment/components/composer.pug @@ -0,0 +1,36 @@ +//- https://owenbenjamin.com/social-channels/ and https://gab.com/owenbenjamin +mixin renderCommentComposer (actionUrl) + form(method="POST", action= actionUrl, onsubmit="return dtp.app.submitForm(event, 'create-comment');").uk-form + .uk-card.uk-card-secondary.uk-card-small + .uk-card-body + textarea( + id="content", + name="content", + rows="4", + maxlength="3000", + placeholder="Enter comment", + oninput="return dtp.app.onCommentInput(event);", + ).uk-textarea.uk-resize-vertical + .uk-text-small + div(uk-grid).uk-flex-between + .uk-width-auto You are commenting as: #{user.username} + .uk-width-auto #[span#comment-character-count 0] of 3,000 + .uk-card-footer + div(uk-grid).uk-flex-between + .uk-width-expand + ul.uk-subnav + li + button( + type="button", + data-target-element="content", + title="Add an emoji", + onclick="return dtp.app.showEmojiPicker(event);", + ).uk-button.dtp-button-default + span + i.far.fa-smile + li(title="Not Safe For Work will hide your comment text by default") + label + input(id="is-nsfw", name="isNSFW", type="checkbox").uk-checkbox + | NSFW + .uk-width-auto + button(type="submit").uk-button.dtp-button-primary Post \ No newline at end of file diff --git a/app/views/comment/components/report-form.pug b/app/views/comment/components/report-form.pug new file mode 100644 index 0000000..1789f5d --- /dev/null +++ b/app/views/comment/components/report-form.pug @@ -0,0 +1,35 @@ +include ../../components/library +include comment-review +.uk-modal-body + h4.uk-modal-title Report Comment + form(method="POST", action=`/content-report/comment`, onsubmit="return dtp.app.submitDialogForm(event, 'report comment');").uk-form + + input(type="hidden", name="resourceType", value= params.resourceType) + input(type="hidden", name="resourceId", value= params.resourceId) + input(type="hidden", name="commentId", value= params.commentId) + + .uk-margin + +renderCommentReview(comment) + + .uk-margin + select(id="category", name="category").uk-select + option(value="none") --- Select category --- + option(value="spam") Spam + option(value="violence") Violence/Threats + option(value="porn") Porn + option(value="doxxing") Doxxing + option(value="other") Other + + .uk-margin + textarea(id="reason", name="reason", rows="4", placeholder="Enter additional notes here").uk-textarea.uk-resize-vertical + + .uk-margin + label + input(id="block-author", name="blockAuthor", type="checkbox", checked).uk-checkbox + | Also block #{comment.author.username} + + div(uk-grid).uk-grid-small.uk-flex-between + .uk-width-auto + button(type="button").uk-button.dtp-button-default.uk-modal-close Cancel + .uk-width-auto + button(type="submit").uk-button.dtp-button-primary Submit Report \ No newline at end of file diff --git a/app/views/components/file-upload-image.pug b/app/views/components/file-upload-image.pug index 762992b..54d9e87 100644 --- a/app/views/components/file-upload-image.pug +++ b/app/views/components/file-upload-image.pug @@ -1,6 +1,6 @@ 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 + form(method="POST", action= actionUrl, enctype="multipart/form-data", onsubmit= "return dtp.app.submitImageForm(event);").uk-form .uk-margin .uk-card.uk-card-default.uk-card-small .uk-card-body @@ -8,9 +8,9 @@ mixin renderFileUploadImage (actionUrl, containerId, imageId, imageClass, defaul 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 + img(id= imageId, src= `/image/${currentImage._id}`, class= imageClass).sb-large else - img(id= imageId, data-cropper-options= cropperOptions, src= defaultImage, class= imageClass).sb-large + img(id= imageId, src= defaultImage, class= imageClass).sb-large div(class="uk-width-1-1 uk-width-auto@m") .uk-text-small.uk-margin(hidden= !!currentImage) @@ -21,7 +21,6 @@ mixin renderFileUploadImage (actionUrl, containerId, imageId, imageClass, defaul 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, @@ -29,8 +28,7 @@ mixin renderFileUploadImage (actionUrl, containerId, imageId, imageClass, defaul data-file-size-element= "file-size", data-file-max-size= 15 * 1024000, data-image-id= imageId, - data-image-w= 512, - data-image-h= 512, + data-cropper-options= cropperOptions, onchange="return dtp.app.selectImageFile(event);", ) button(type="button", tabindex="-1").uk-button.dtp-button-default Select @@ -51,10 +49,11 @@ mixin renderFileUploadImage (actionUrl, containerId, imageId, imageClass, defaul #remove-btn(hidden= !currentImage).uk-width-auto button( type= "button", + data-image-type= imageId, 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 \ No newline at end of file + ).uk-button.uk-button-primary Save diff --git a/app/views/components/labeled-icon.pug b/app/views/components/labeled-icon.pug new file mode 100644 index 0000000..5fe1825 --- /dev/null +++ b/app/views/components/labeled-icon.pug @@ -0,0 +1,4 @@ +mixin renderLabeledIcon (iconClass, iconLabel) + span + i(class=`fas ${iconClass}`) + span.uk-margin-small-left.dtp-item-value= iconLabel \ No newline at end of file diff --git a/app/views/components/library.pug b/app/views/components/library.pug index 9574a8f..8e5409f 100644 --- a/app/views/components/library.pug +++ b/app/views/components/library.pug @@ -1,6 +1,7 @@ //- common routines for all views everywhere include button-icon +include labeled-icon include section-title - diff --git a/app/views/components/navbar.pug b/app/views/components/navbar.pug index 601345a..046c747 100644 --- a/app/views/components/navbar.pug +++ b/app/views/components/navbar.pug @@ -3,24 +3,38 @@ nav(uk-navbar).uk-navbar-container.uk-position-fixed.uk-position-top .uk-navbar-item button(type="button", uk-toggle="target: #dtp-offcanvas").uk-button.uk-button-link.uk-padding-small i.fas.fa-bars - + + div(class="uk-visible@m").uk-navbar-left //- Site icon - a(href="/", class="uk-visible@m").uk-navbar-item + a(href="/").uk-navbar-item img(src=`/img/icon/icon-48x48.png`) //- Site name - a(href="/", class="uk-visible@xl").uk-navbar-item.uk-logo - span= site.name + ul.uk-navbar-nav + li(class={ 'uk-active': currentView === 'home' }) + a(href="/", title= "Home") + +renderButtonIcon('fa-home', 'Home') + each menuItem in mainMenu + li(class={ 'uk-active': (pageSlug === menuItem.slug) }) + a(href= menuItem.url, title= menuItem.label) + +renderButtonIcon(menuItem.icon || 'fa-file', menuItem.label) - each menuItem in mainMenu - a(href= menuItem.url).uk-navbar-item= menuItem.label - - //- Center menu (visible only on tablet and mobile) div(class="uk-hidden@m").uk-navbar-center - a(href="/").uk-navbar-item - img(src=`/img/icon/icon-48x48.png`) + //- Site name + ul.uk-navbar-nav + li + a(href="/").uk-navbar-item + img(src=`/img/icon/icon-48x48.png`) + + each menuItem in mainMenu + li + a(href= menuItem.url, title= menuItem.label)= menuItem.label .uk-navbar-right + if user + ul.uk-navbar-nav + + .uk-navbar-item if user div.no-select @@ -36,7 +50,7 @@ nav(uk-navbar).uk-navbar-container.uk-position-fixed.uk-position-top ).site-profile-picture.sb-navbar div(uk-dropdown={ mode: 'click' }).uk-navbar-dropdown - ul.uk-nav.uk-navbar-dropdown-nav(style="z-index: 1024;") + ul.uk-nav.uk-navbar-dropdown-nav li.uk-nav-heading.uk-text-center= user.displayName || user.username li.uk-nav-divider if (user.channel) diff --git a/app/views/components/off-canvas.pug b/app/views/components/off-canvas.pug index f668f95..0df23ee 100644 --- a/app/views/components/off-canvas.pug +++ b/app/views/components/off-canvas.pug @@ -1,18 +1,29 @@ +mixin renderMenuItem (iconClass, label) + div(uk-grid).uk-grid-collapse + .uk-width-auto + .app-menu-icon + i(class=`fas ${iconClass}`) + .uk-width-expand= label + #dtp-offcanvas(uk-offcanvas="mode: slide; overlay: true; bg-close: true;") .uk-offcanvas-bar .uk-margin - a(href="/", style="color: white;").uk-display-block + a(href="/").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.uk-nav-header Site 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 + +renderMenuItem('fa-home', 'Home') + + each menuItem in mainMenu + li(class={ 'uk-active': (pageSlug === menuItem.slug) }) + a(href= menuItem.url, title= menuItem.label) + +renderMenuItem(menuItem.icon || 'fa-file', menuItem.label) if user li.uk-nav-header Member Menu diff --git a/app/views/components/page-footer.pug b/app/views/components/page-footer.pug index 418b082..52e395f 100644 --- a/app/views/components/page-footer.pug +++ b/app/views/components/page-footer.pug @@ -1,12 +1,71 @@ +mixin renderSocialIcon (iconClass, iconLabel, url) + a(href= url).dtp-social-link + span + i(class=`fab ${iconClass}`) + span.uk-margin-small-left= iconLabel + section.uk-section.uk-section-muted.uk-section-small.dtp-site-footer .uk-container.uk-text-small.uk-text-center ul.uk-subnav.uk-flex-center - each socialIcon in socialIcons + if site.gabUrl li - a(href=socialIcon.url).dtp-social-link + a(href= site.gabUrl).dtp-social-link span - i(class=`fab ${socialIcon.icon}`) - span.uk-margin-small-left= socialIcon.label + img(src="/img/gab-g.svg", style="width: auto; height: 1em;") + span.uk-margin-small-left Gab Social + if site.gabtvUrl + li + a(href= site.gabtvUrl).dtp-social-link + span + img(src="/img/gab-g.svg", style="width: auto; height: 1em;") + span.uk-margin-small-left Gab TV + + if site.telegramUrl + li + +renderSocialIcon('fa-telegram', 'Telegram', site.telegramUrl) + if site.twitterUrl + li + +renderSocialIcon('fa-twitter', 'Twitter', site.twitterUrl) + if site.facebookUrl + li + +renderSocialIcon('fa-facebook', 'Facebook', site.facebookUrl) + if site.instagramUrl + li + +renderSocialIcon('fa-instagram', 'Instagram', site.instagramUrl) + + if site.bitchuteUrl + li + a(href= site.bitchuteUrl).dtp-social-link + span + img(src="/img/social-icons/bitchute.svg", style="width: auto; height: 1em;") + span.uk-margin-small-left BitChute + if site.odyseeUrl + li + a(href= site.odyseeUrl).dtp-social-link + span + img(src="/img/social-icons/odysee.svg", style="width: auto; height: 1em;") + span.uk-margin-small-left Odysee + if site.rumbleUrl + li + a(href= site.odyseeUrl).dtp-social-link + span + img(src="/img/social-icons/rumble.svg", style="width: auto; height: 1em;") + span.uk-margin-small-left Rumble + if site.twitchUrl + li + +renderSocialIcon('fa-twitch', 'Twitch', site.twitchUrl) + if site.youtubeUrl + li + +renderSocialIcon('fa-youtube', 'YouTube', site.youtubeUrl) + if site.dliveUrl + li + a(href= site.dliveUrl).dtp-social-link + span + img(src="/img/social-icons/dlive.svg", style="width: auto; height: 1em;") + span.uk-margin-small-left DLive + .uk-width-medium.uk-margin-auto hr - div Copyright © 2021 #[+renderSiteLink()] \ No newline at end of file + + div Copyright © 2021 #[+renderSiteLink()] + div All Rights Reserved \ No newline at end of file diff --git a/app/views/components/page-sidebar.pug b/app/views/components/page-sidebar.pug index b799af1..02a97e0 100644 --- a/app/views/components/page-sidebar.pug +++ b/app/views/components/page-sidebar.pug @@ -25,7 +25,7 @@ mixin renderPageSidebar ( ) +renderSidebarEpisode(episode) //- Newsletter Signup - div(uk-sticky={ offset: 60, bottom: '#dtp-content-grid' }) + div(uk-sticky={ offset: 60, bottom: '#dtp-content-grid' }, style="z-index: initial;") +renderSectionTitle('Mailing List') .uk-margin form(method="post", action="/newsletter", onsubmit="return dtp.app.submitForm(event, 'Subscribe to newsletter');").uk-form diff --git a/app/views/components/social-card/facebook.pug b/app/views/components/social-card/facebook.pug index d017243..20cb5fa 100644 --- a/app/views/components/social-card/facebook.pug +++ b/app/views/components/social-card/facebook.pug @@ -1,8 +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:image', content= `https://${site.domain}/img/social-cards/${site.domainKey}.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}`) \ No newline at end of file + meta(property='og:title', content= pageTitle || site.name) + meta(property='og:description', content= pageDescription || site.description) + meta(property='og:image:alt', content= `${site.name} | ${site.description}`) diff --git a/app/views/components/social-card/twitter.pug b/app/views/components/social-card/twitter.pug index 796bea8..f3aab08 100644 --- a/app/views/components/social-card/twitter.pug +++ b/app/views/components/social-card/twitter.pug @@ -1,5 +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) \ No newline at end of file + meta(name='twitter:image' content= `https://${site.domain}/img/social-cards/${site.domainKey}.png?v=${pkg.version}`) + meta(name='twitter:title', content= pageTitle || site.name) + meta(name='twitter:description', content= pageDescription || site.description) diff --git a/app/views/error.pug b/app/views/error.pug index 077adc4..239a91e 100644 --- a/app/views/error.pug +++ b/app/views/error.pug @@ -3,9 +3,12 @@ block content section.uk-section.uk-section-default.uk-section-xsmall .uk-container + h1 Well, That's Not Right! .uk-text-large= message + //- if error.stack //- pre= error.stack + if error && error.status div.uk-text-small.uk-text-muted status:#{error.status} @@ -14,4 +17,4 @@ block content a(href="/").uk-button.uk-button-default.uk-border-rounded span.uk-margin-small-right i.fas.fa-home - span Home \ No newline at end of file + span Back to #{site.name} diff --git a/app/views/index.pug b/app/views/index.pug index 5dc5f4a..4362d55 100644 --- a/app/views/index.pug +++ b/app/views/index.pug @@ -18,15 +18,15 @@ block content 'uk-flex-last': ((postIndex % postIndexModulus) === 0), }) article.uk-article - h4(style="line-height: 1;").uk-article-title.uk-margin-remove= post.title - .uk-text-truncate= post.summary + h4(style="line-height: 1.1;").uk-article-title.uk-margin-small= post.title .uk-article-meta - div(uk-grid).uk-grid-small - .uk-width-auto published: #{moment(post.created).format("MMM DD YYYY HH:MM a")} - if post.updated - .uk-width-auto updated: #{moment(post.updated).format("MMM DD YYYY HH:MM a")} + if post.updated + span updated: #{moment(post.updated).format("MMM DD YYYY HH:MM a")} + else + span published: #{moment(post.created).format("MMM DD YYYY HH:MM a")} +renderSectionTitle('Featured') + .uk-margin div(style="position: relative; overflow: hidden; width: 100%; padding-top: 56.25%") iframe( @@ -47,7 +47,7 @@ block content ul.uk-list.uk-list-divider.uk-list-small each post in posts li - +renderBlogPostListItem(post, postIndex, 2) + +renderBlogPostListItem(post, postIndex, 4) - postIndex += 1; else div There are no posts at this time. Please check back later! \ No newline at end of file diff --git a/app/views/layouts/main-sidebar.pug b/app/views/layouts/main-sidebar.pug index 55e474a..acabacd 100644 --- a/app/views/layouts/main-sidebar.pug +++ b/app/views/layouts/main-sidebar.pug @@ -1,5 +1,4 @@ extends main - block content-container section.uk-section.uk-section-default .uk-container @@ -7,7 +6,4 @@ block content-container div(class="uk-width-1-1 uk-width-2-3@m") block content div(class="uk-width-1-1 uk-width-1-3@m") - +renderPageSidebar() - - block page-footer - include ../components/page-footer \ No newline at end of file + +renderPageSidebar() \ No newline at end of file diff --git a/app/views/layouts/main.pug b/app/views/layouts/main.pug index 4fef5b2..a99d95a 100644 --- a/app/views/layouts/main.pug +++ b/app/views/layouts/main.pug @@ -59,8 +59,9 @@ html(lang='en') block content-container block content - block page-footer - include ../components/page-footer + + block page-footer + include ../components/page-footer block dtp-navbar include ../components/navbar diff --git a/app/views/notification/components/notification-standalone.pug b/app/views/notification/components/notification-standalone.pug new file mode 100644 index 0000000..71fd78f --- /dev/null +++ b/app/views/notification/components/notification-standalone.pug @@ -0,0 +1,2 @@ +include notification ++renderUserNotification(notification) \ No newline at end of file diff --git a/app/views/notification/components/notification.pug b/app/views/notification/components/notification.pug new file mode 100644 index 0000000..cca4b70 --- /dev/null +++ b/app/views/notification/components/notification.pug @@ -0,0 +1,5 @@ +mixin renderUserNotification (userNotification) + div + div= moment(userNotification.created).fromNow() + div= userNotification.source + div= userNotification.message \ No newline at end of file diff --git a/app/views/page/view.pug b/app/views/page/view.pug index f05f27b..81b04f6 100644 --- a/app/views/page/view.pug +++ b/app/views/page/view.pug @@ -7,7 +7,10 @@ block content .uk-margin div(uk-grid) .uk-width-expand - h1.article-title= page.title + h1.article-title + span + i(class=`fas ${page.menu.icon}`) + span.uk-margin-left= page.title if user && user.flags.isAdmin .uk-width-auto a(href=`/admin/page/${page._id}`).uk-button.dtp-button-text EDIT diff --git a/app/views/post/view.pug b/app/views/post/view.pug index 027ac66..9257c2f 100644 --- a/app/views/post/view.pug +++ b/app/views/post/view.pug @@ -1,7 +1,8 @@ extends ../layouts/main-sidebar block content - include ../comment/components/comment + include ../comment/components/comment-list + include ../comment/components/composer article(dtp-post-id= post._id) .uk-margin @@ -37,43 +38,16 @@ block content +renderSectionTitle('Add a comment') .uk-margin - form(method="POST", action=`/post/${post._id}/comment`, onsubmit="return dtp.app.submitForm(event, 'create-comment');").uk-form - .uk-card.uk-card-secondary.uk-card-small - .uk-card-body - textarea( - id="content", - name="content", - rows="4", - maxlength="3000", - placeholder="Enter comment", - oninput="return dtp.app.onCommentInput(event);", - ).uk-textarea.uk-resize-vertical - .uk-text-small - div(uk-grid).uk-flex-between - .uk-width-auto You are commenting as: #{user.username} - .uk-width-auto #[span#comment-character-count 0] of 3,000 - .uk-card-footer - div(uk-grid).uk-flex-between - .uk-width-expand - button( - type="button", - data-target-element="content", - title="Add an emoji", - onclick="return dtp.app.showEmojiPicker(event);", - ).uk-button.dtp-button-default - span - i.far.fa-smile - .uk-width-auto - button(type="submit").uk-button.dtp-button-primary Post comment - - .uk-margin - +renderSectionTitle('Comments') + +renderCommentComposer(`/post/${post._id}/comment`) - .uk-margin - if Array.isArray(comments) && (comments.length > 0) - ul#post-comment-list.uk-list - each comment in comments - +renderComment(comment) - else - ul#post-comment-list.uk-list - div There are no comments at this time. Please check back later. \ No newline at end of file + if featuredComment + #featured-comment.uk-margin-large + .uk-margin + +renderSectionTitle('Linked Comment') + +renderComment(featuredComment) + + .uk-margin + +renderSectionTitle('Comments') + + .uk-margin + +renderCommentList(comments) \ No newline at end of file diff --git a/app/views/user/profile.pug b/app/views/user/profile.pug index c60bebf..2dbeec0 100644 --- a/app/views/user/profile.pug +++ b/app/views/user/profile.pug @@ -1,9 +1,26 @@ extends ../layouts/main block content + include ../comment/components/comment + section.uk-section.uk-section-default .uk-container h1= user.displayName || user.username || user.email - p Viewers do not have public profiles on #[+renderSiteLink()]. You must be logged in to view your profile. Only you can view your profile. + p People don't have public profiles on #[+renderSiteLink()]. You must be logged in to view your profile. And, only you can view your profile. + + p Your profile is where you edit your account settings, configure your commenting defaults, and otherwise manage how you use #[+renderSiteLink()]. + + section.uk-section.uk-section-default + .uk-container + .uk-margin + +renderSectionTitle('Comment History') - p Your profile is where you manage your channel subscriptions, edit account settings, configure your chat defaults, and otherwise manage how you use #[+renderSiteLink()]. \ No newline at end of file + if Array.isArray(commentHistory) && (commentHistory.length > 0) + ul.uk-list.uk-list-divider + each comment in commentHistory + li + .uk-margin-small + .uk-text-small commenting on #[a(href=`/post/${comment.resource.slug}?comment=${comment._id}#featured-comment`)= comment.resource.title] + +renderComment(comment) + else + div You haven't written any comments on posts. \ No newline at end of file diff --git a/app/views/user/settings.pug b/app/views/user/settings.pug index a6ff2fc..d3f7aee 100644 --- a/app/views/user/settings.pug +++ b/app/views/user/settings.pug @@ -14,11 +14,21 @@ block content div(uk-grid) div(class="uk-width-1-1 uk-width-1-3@m") - - var currentImage = null; + var currentProfile = null; if (user.picture && user.picture.large) { - currentImage = user.picture.large; + currentProfile = user.picture.large; } - +renderFileUploadImage(`/user/${user._id}/profile-photo`, 'test-image-upload', 'profile-picture-file', 'site-profile-picture', `/img/default-member.png`, currentImage) + .uk-margin + +renderFileUploadImage( + `/user/${user._id}/header-image`, + 'header-image-upload', + 'header-image-file', + 'header-image-picture', + `/img/default-header.png`, + user.header, + { aspectRatio: 1400 / 400 }, + ) + div(class="uk-width-1-1 uk-width-expand@m") form(method="POST", action=`/user/${user._id}/settings`, onsubmit="return dtp.app.submitForm(event, 'user account update');").uk-form .uk-margin @@ -28,4 +38,8 @@ block content label(for="display-name").uk-form-label Display Name input(id="display-name", name="displayName", type="text", placeholder="Enter display name", value= user.displayName).uk-input .uk-margin - button(type="submit").uk-button.dtp-button-primary Update account settings \ No newline at end of file + label(for="bio").uk-form-label Profile bio + textarea(id="bio", name="bio", rows="3", placeholder="Tell people about yourself").uk-textarea.uk-resize-vertival= user.bio + + .uk-margin + button(type="submit").uk-button.dtp-button-primary Update account settings diff --git a/app/views/welcome/index.pug b/app/views/welcome/index.pug index 4b7af06..282a815 100644 --- a/app/views/welcome/index.pug +++ b/app/views/welcome/index.pug @@ -3,7 +3,10 @@ block content section.uk-section.uk-section-secondary .uk-container.uk-text-center - h1 Welcome to #{site.name} + .uk-width-auto.uk-margin-auto + img(src="/img/icon/icon-256x256.png") + + h1= site.name .uk-text-lead= site.description .uk-margin-medium-top @@ -11,4 +14,4 @@ block content .uk-width-auto a(href="/welcome/signup").uk-button.dtp-button-primary Create Account .uk-width-auto - a(href="/welcome/login").uk-button.dtp-button-secondary Sign In \ No newline at end of file + a(href="/welcome/login").uk-button.dtp-button-secondary Sign In diff --git a/app/views/welcome/signup.pug b/app/views/welcome/signup.pug index 8bbb1c3..77e3789 100644 --- a/app/views/welcome/signup.pug +++ b/app/views/welcome/signup.pug @@ -2,7 +2,7 @@ extends ../layouts/main block content form(method="POST", action="/user").uk-form - section.uk-section.uk-section-muted.uk-section + section.uk-section.uk-section-default.uk-section-xsmall .uk-container.uk-container-small p You are creating a new member account on #[+renderSiteLink()]. If you have an account, please #[a(href="/welcome/login") log in here]. An account is required to comment on posts and use other site features. @@ -50,4 +50,4 @@ block content img(src='/welcome/signup/captcha', style="padding: 8px 0;").uk-display-block.uk-margin-auto input(id="captcha", name="captcha", type="text", placeholder="Enter captcha text").uk-input.uk-text-center .uk-margin-small - button(type="submit").uk-button.dtp-button-primary Create Account \ No newline at end of file + button(type="submit").uk-button.dtp-button-primary Create Account diff --git a/app/workers/reeeper.js b/app/workers/reeeper.js new file mode 100644 index 0000000..cb4a91b --- /dev/null +++ b/app/workers/reeeper.js @@ -0,0 +1,78 @@ +// host-services.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const path = require('path'); + +require('dotenv').config({ path: path.resolve(__dirname, '..', '..', '.env') }); + +const mongoose = require('mongoose'); + +const { + SitePlatform, + SiteLog, +} = require(path.join(__dirname, '..', '..', 'lib', 'site-lib')); + +const { CronJob } = require('cron'); + +const CRON_TIMEZONE = 'America/New_York'; + +module.pkg = require(path.resolve(__dirname, '..', '..', 'package.json')); +module.config = { + componentName: 'reeeper', + root: path.resolve(__dirname, '..', '..'), +}; + +module.log = new SiteLog(module, module.config.componentName); + +module.expireCrashedHosts = async ( ) => { + const NetHost = mongoose.model('NetHost'); + try { + await NetHost + .find({ status: 'crashed' }) + .select('_id hostname') + .lean() + .cursor() + .eachAsync(async (host) => { + module.log.info('deactivating crashed host', { hostname: host.hostname }); + await NetHost.updateOne({ _id: host._id }, { $set: { status: 'inactive' } }); + }); + } catch (error) { + module.log.error('failed to expire crashed hosts', { error }); + } +}; + +(async ( ) => { + try { + process.once('SIGINT', async ( ) => { + module.log.info('SIGINT received'); + module.log.info('requesting shutdown...'); + + const exitCode = await SitePlatform.shutdown(); + process.nextTick(( ) => { + process.exit(exitCode); + }); + }); + + /* + * Site Platform startup + */ + await SitePlatform.startPlatform(module); + + await module.expireCrashedHosts(); // first-run the expirations + + module.expireJob = new CronJob( + '*/5 * * * * *', + module.expireCrashedHosts, + null, true, CRON_TIMEZONE, + ); + + module.log.info(`${module.pkg.name} v${module.pkg.version} ${module.config.componentName} started`); + } catch (error) { + module.log.error('failed to start Host Cache worker', { error }); + process.exit(-1); + } + +})(); \ No newline at end of file diff --git a/client/img/social-icons/bitchute.svg b/client/img/social-icons/bitchute.svg new file mode 100644 index 0000000..3e52325 --- /dev/null +++ b/client/img/social-icons/bitchute.svg @@ -0,0 +1,62 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + diff --git a/client/img/social-icons/dlive.svg b/client/img/social-icons/dlive.svg new file mode 100644 index 0000000..ad82b90 --- /dev/null +++ b/client/img/social-icons/dlive.svg @@ -0,0 +1,69 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + diff --git a/client/img/social-icons/odysee.svg b/client/img/social-icons/odysee.svg new file mode 100644 index 0000000..04effe0 --- /dev/null +++ b/client/img/social-icons/odysee.svg @@ -0,0 +1,102 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + diff --git a/client/img/social-icons/rumble.svg b/client/img/social-icons/rumble.svg new file mode 100644 index 0000000..ad88349 --- /dev/null +++ b/client/img/social-icons/rumble.svg @@ -0,0 +1,95 @@ + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + diff --git a/client/js/site-app.js b/client/js/site-app.js index 72d1e68..f502f75 100644 --- a/client/js/site-app.js +++ b/client/js/site-app.js @@ -15,6 +15,14 @@ import Cropper from 'cropperjs'; import { EmojiButton } from '@joeattardi/emoji-button'; +const GRID_COLOR = 'rgb(64, 64, 64)'; +const GRID_TICK_COLOR = 'rgb(192,192,192)'; + +const AXIS_TICK_COLOR = 'rgb(192, 192, 192)'; + +const CHART_LINE_USER = 'rgb(0, 192, 0)'; +const CHART_FILL_USER = 'rgb(0, 128, 0)'; + export default class DtpSiteApp extends DtpApp { constructor (user) { @@ -39,6 +47,21 @@ export default class DtpSiteApp extends DtpApp { if (this.chat.input) { this.chat.input.addEventListener('keydown', this.onChatInputKeyDown.bind(this)); } + + this.charts = {/* will hold rendered charts */}; + + this.scrollToHash(); + } + + async scrollToHash ( ) { + const { hash } = window.location; + if (hash === '') { + return; + } + const target = document.getElementById(hash.slice(1)); + if (target && target.scrollIntoView) { + target.scrollIntoView({ behavior: 'smooth' }); + } } async connect ( ) { @@ -151,7 +174,7 @@ export default class DtpSiteApp extends DtpApp { } return false; } - + async submitForm (event, userAction) { event.preventDefault(); event.stopPropagation(); @@ -184,6 +207,51 @@ export default class DtpSiteApp extends DtpApp { return; } + async submitImageForm (event) { + event.preventDefault(); + event.stopPropagation(); + + const formElement = event.currentTarget || event.target; + const form = new FormData(formElement); + + this.cropper.getCroppedCanvas().toBlob(async (imageData) => { + try { + form.append('imageFile', imageData, 'profile.png'); + + this.log.info('submitImageForm', 'updating user settings', { event, action: formElement.action }); + const response = await fetch(formElement.action, { + method: formElement.method, + body: form, + }); + + if (!response.ok) { + let json; + try { + json = await response.json(); + } catch (error) { + throw new Error('Server error'); + } + throw new Error(json.message || 'Server error'); + } + + await this.processResponse(response); + window.location.reload(); + } catch (error) { + UIkit.modal.alert(`Failed to update profile photo: ${error.message}`); + } + }); + + return; + } + + async closeCurrentDialog ( ) { + if (!this.currentDialog) { + return; + } + this.currentDialog.hide(); + delete this.currentDialog; + } + async copyHtmlToText (event, textContentId) { const content = this.editor.getContent({ format: 'text' }); const text = document.getElementById(textContentId); @@ -196,7 +264,10 @@ export default class DtpSiteApp extends DtpApp { const imageId = event.target.getAttribute('data-image-id'); //z read the cropper options from the element on the page - const cropperOptions = event.target.getAttribute('data-cropper-options'); + let cropperOptions = event.target.getAttribute('data-cropper-options'); + if (cropperOptions) { + cropperOptions = JSON.parse(cropperOptions); + } this.log.debug('selectImageFile', 'cropper options', { cropperOptions }); //z remove when done const fileSelectContainerId = event.target.getAttribute('data-file-select-container'); @@ -238,20 +309,11 @@ export default class DtpSiteApp extends DtpApp { return; } - // const IMAGE_WIDTH = parseInt(event.target.getAttribute('data-image-w')); - // const IMAGE_HEIGHT = parseInt(event.target.getAttribute('data-image-h')); - const reader = new FileReader(); reader.onload = (e) => { const img = document.getElementById(imageId); img.onload = (e) => { console.log('image loaded', e, img.naturalWidth, img.naturalHeight); - // if (img.naturalWidth !== IMAGE_WIDTH || img.naturalHeight !== IMAGE_HEIGHT) { - // UIkit.modal.alert(`This image must be ${IMAGE_WIDTH}x${IMAGE_HEIGHT}`); - // img.setAttribute('hidden', ''); - // img.src = ''; - // return; - // } fileSelectContainer.querySelector('#file-name').textContent = selectedFile.name; fileSelectContainer.querySelector('#file-modified').textContent = moment(selectedFile.lastModifiedDate).fromNow(); @@ -268,16 +330,15 @@ export default class DtpSiteApp extends DtpApp { img.src = e.target.result; //z create cropper and set options here - this.createImageCropper(img); + this.createImageCropper(img, cropperOptions); }; // read in the file, which will trigger everything else in the event handler above. reader.readAsDataURL(selectedFile); } - async createImageCropper (img) { - this.log.info("createImageCropper", "Creating image cropper", { img }); - this.cropper = new Cropper(img, { + async createImageCropper (img, options) { + options = Object.assign({ aspectRatio: 1, dragMode: 'move', autoCropArea: 0.85, @@ -285,11 +346,13 @@ export default class DtpSiteApp extends DtpApp { guides: false, center: false, highlight: false, - cropBoxMovable: false, - cropBoxResizable: false, + cropBoxMovable: true, + cropBoxResizable: true, toggleDragModeOnDblclick: false, modal: true, - }); + }, options); + this.log.info("createImageCropper", "Creating image cropper", { img }); + this.cropper = new Cropper(img, options); } async attachTinyMCE (editor) { @@ -458,11 +521,8 @@ export default class DtpSiteApp extends DtpApp { let response; switch (imageType) { - case 'channel-app-icon': - const channelId = (event.target || event.currentTarget).getAttribute('data-channel-id'); - response = await fetch(`/channel/${channelId}/app-icon`, { - method: 'DELETE', - }); + case 'profile-picture-file': + response = await fetch(`/user/${this.user._id}/profile-photo`, { method: 'DELETE' }); break; default: @@ -474,11 +534,11 @@ export default class DtpSiteApp extends DtpApp { } await this.processResponse(response); + window.location.reload(); } catch (error) { UIkit.modal.alert(`Failed to remove image: ${error.message}`); } } - async onCommentInput (event) { const label = document.getElementById('comment-character-count'); label.textContent = numeral(event.target.value.length).format('0,0'); @@ -494,6 +554,193 @@ export default class DtpSiteApp extends DtpApp { async onEmojiSelected (selection) { this.emojiTargetElement.value += selection.emoji; } + + async showReportCommentForm (event) { + event.preventDefault(); + event.stopPropagation(); + + const resourceType = event.currentTarget.getAttribute('data-resource-type'); + const resourceId = event.currentTarget.getAttribute('data-resource-id'); + const commentId = event.currentTarget.getAttribute('data-comment-id'); + + this.closeCommentDropdownMenu(commentId); + + try { + const response = await fetch('/content-report/comment/form', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + resourceType, resourceId, commentId + }), + }); + if (!response.ok) { + throw new Error('failed to load report form'); + } + const html = await response.text(); + this.currentDialog = UIkit.modal.dialog(html); + } catch (error) { + this.log.error('reportComment', 'failed to process comment request', { resourceType, resourceId, commentId, error }); + UIkit.modal.alert(`Failed to report comment: ${error.message}`); + } + + return true; + } + + async deleteComment (event) { + event.preventDefault(); + event.stopPropagation(); + const commentId = (event.currentTarget || event.target).getAttribute('data-comment-id'); + try { + const response = fetch(`/comment/${commentId}`, { method: 'DELETE' }); + if (!response.ok) { + throw new Error('Server error'); + } + this.processResponse(response); + } catch (error) { + UIkit.modal.alert(`Failed to delete comment: ${error.message}`); + } + } + + async submitDialogForm (event, userAction) { + await this.submitForm(event, userAction); + await this.closeCurrentDialog(); + } + + async blockCommentAuthor (event) { + event.preventDefault(); + event.stopPropagation(); + + const resourceType = event.currentTarget.getAttribute('data-resource-type'); + const resourceId = event.currentTarget.getAttribute('data-resource-id'); + const commentId = event.currentTarget.getAttribute('data-comment-id'); + const actionUrl = this.getCommentActionUrl(resourceType, resourceId, commentId, 'block-author'); + + this.closeCommentDropdownMenu(commentId); + + try { + this.log.info('blockCommentAuthor', 'blocking comment author', { resourceType, resourceId, commentId }); + const response = await fetch(actionUrl, { method: 'POST'}); + await this.processResponse(response); + + } catch (error) { + this.log.error('reportComment', 'failed to process comment request', { resourceType, resourceId, commentId, error }); + UIkit.modal.alert(`Failed to block comment author: ${error.message}`); + } + + return true; + } + + closeCommentDropdownMenu (commentId) { + const dropdown = document.querySelector(`[data-comment-id="${commentId}"][uk-dropdown]`); + UIkit.dropdown(dropdown).hide(false); + } + + getCommentActionUrl (resourceType, resourceId, commentId, action) { + switch (resourceType) { + case 'Newsletter': + return `/newsletter/${resourceId}/comment/${commentId}/${action}`; + case 'Page': + return `/page/${resourceId}/comment/${commentId}/${action}`; + case 'Post': + return `/post/${resourceId}/comment/${commentId}/${action}`; + default: + break; + } + throw new Error('Invalid resource type for comment operation'); + } + + async submitCommentVote (event) { + const target = (event.currentTarget || event.target); + const commentId = target.getAttribute('data-comment-id'); + const vote = target.getAttribute('data-vote'); + try { + const response = await fetch(`/comment/${commentId}/vote`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ vote }), + }); + await this.processResponse(response); + } catch (error) { + UIkit.modal.alert(`Failed to submit vote: ${error.message}`); + } + } + + async renderStatsGraph (selector, title, data) { + try { + const canvas = document.querySelector(selector); + const ctx = canvas.getContext('2d'); + this.charts.profileStats = new Chart(ctx, { + type: 'bar', + data: { + labels: data.map((item) => new Date(item.date)), + datasets: [ + { + label: title, + data: data.map((item) => item.count), + borderColor: CHART_LINE_USER, + borderWidth: 1, + backgroundColor: CHART_FILL_USER, + tension: 0, + }, + ], + }, + options: { + scales: { + yAxis: { + display: true, + ticks: { + color: AXIS_TICK_COLOR, + callback: (value) => { + return numeral(value).format(value > 1000 ? '0,0.0a' : '0,0'); + }, + }, + grid: { + color: GRID_COLOR, + tickColor: GRID_TICK_COLOR, + }, + }, + + x: { + type: 'time', + }, + xAxis: { + display: false, + grid: { + color: GRID_COLOR, + tickColor: GRID_TICK_COLOR, + }, + }, + }, + plugins: { + title: { display: false }, + subtitle: { display: false }, + legend: { display: false }, + }, + maintainAspectRatio: true, + aspectRatio: 16.0 / 9.0, + onResize: (chart, event) => { + if (event.width >= 960) { + chart.config.options.aspectRatio = 16.0 / 5.0; + } + else if (event.width >= 640) { + chart.config.options.aspectRatio = 16.0 / 9.0; + } else if (event.width >= 480) { + chart.config.options.aspectRatio = 16.0 / 12.0; + } else { + chart.config.options.aspectRatio = 16.0 / 16.0; + } + }, + }, + }); + } catch (error) { + this.log.error('renderStatsGraph', 'failed to render stats graph', { title, error }); + UIkit.modal.alert(`Failed to render chart: ${error.message}`); + } + } } dtp.DtpSiteApp = DtpSiteApp; \ No newline at end of file diff --git a/client/less/site/button.less b/client/less/site/button.less index b2ec990..b7e0b10 100644 --- a/client/less/site/button.less +++ b/client/less/site/button.less @@ -1,3 +1,28 @@ +button.dtp-link-button, +a.dtp-link-button { + display: inline-block; + padding: 6px; + line-height: 18px; + + font-size: 14px; + text-transform: uppercase; + text-decoration: none; + text-align: center; + + background: none; + outline: none; + border: solid 2px @global-primary-background; + color: @global-color; + + transition: background-color 0.2s, color 0.2s; + + &:hover { + background-color: @global-primary-background; + color: @global-inverse-color; + text-decoration: none; + } +} + .share-button { background: #00d178; color: white; diff --git a/client/less/site/comment.less b/client/less/site/comment.less new file mode 100644 index 0000000..be7c02a --- /dev/null +++ b/client/less/site/comment.less @@ -0,0 +1,61 @@ +.dtp-site-comment { + + .uk-dropdown { + background-color: #e8e8e8; + color: #1a1a1a; + } + + .uk-dropdown-nav .uk-nav-header, + .uk-dropdown-nav .uk-nav-header { + color: #1a1a1a; + } + + .uk-dropdown-nav li a { + color: #1a1a1a; + + &:hover { + color: #808080; + text-decoration: underline; + } + } + + .comment-content { + padding-right: 4px; + max-height: 320px; + overflow: auto; + + p:last-child { + margin-bottom: none; + } + + blockquote { + padding-top: 20px; + padding-bottom: 20px; + padding-left: 20px; + + border-left: solid 2px @global-color; + border-radius: 4px; + + font-size: inherit; + color: inherit; + } + + em { + color: inherit; + } + + &::-webkit-scrollbar { + width: 10px; + } + &::-webkit-scrollbar-track { + background: none; + } + &::-webkit-scrollbar-thumb { + background: #4a4a4a; + border-radius: 4px; + } + &::-webkit-scrollbar-thumb:hover { + background: #e8e8e8; + } + } +} \ No newline at end of file diff --git a/client/less/site/dashboard.less b/client/less/site/dashboard.less index dd75332..8dca97b 100644 --- a/client/less/site/dashboard.less +++ b/client/less/site/dashboard.less @@ -1,5 +1,10 @@ .dtp-dashboard-cluster { + canvas.visit-graph { + width: 960px; + height: 360px; + } + fieldset { border-color: #9e9e9e; color: #c8c8c8; @@ -78,4 +83,4 @@ } } } -} \ No newline at end of file +} diff --git a/client/less/site/image.less b/client/less/site/image.less index 3de1f83..1faa6e5 100644 --- a/client/less/site/image.less +++ b/client/less/site/image.less @@ -44,15 +44,12 @@ img.site-channel-icon, img.site-profile-picture { &.sb-xxsmall { max-width: 32px; - border-radius: 4px; } &.sb-xsmall { max-width: 48px; - border-radius: 6px; } &.sb-list-item { max-width: 48px; - border-radius: 6px; } &.sb-navbar { max-width: 48px; @@ -62,14 +59,11 @@ img.site-profile-picture { } &.sb-medium { max-width: 128px; - border-radius: 12px; } &.sb-large { max-width: 256px; - border-radius: 16px; } &.sb-full { max-width: 512px; - border-radius: 20px; } } \ No newline at end of file diff --git a/client/less/style.less b/client/less/style.less index 219a1b5..c7c81a1 100644 --- a/client/less/style.less +++ b/client/less/style.less @@ -5,6 +5,7 @@ @import "site/main.less"; @import "site/border.less"; +@import "site/comment.less"; @import "site/image.less"; @import "site/figure.less"; @import "site/header-section.less"; diff --git a/config/limiter.js b/config/limiter.js index 0639224..f57a61e 100644 --- a/config/limiter.js +++ b/config/limiter.js @@ -46,6 +46,30 @@ module.exports = { }, }, + comment: { + deleteComment: { + total: 1, + expire: ONE_MINUTE, + message: 'You are deleting comments too quickly', + }, + }, + + /* + * ContentReportController + */ + contentReport: { + postCommentReportForm: { + total: 5, + expire: ONE_MINUTE, + message: 'You are reporting comments too quickly', + }, + postCommentReport: { + total: 1, + expire: ONE_MINUTE, + message: 'You are reporting comments too quickly', + }, + }, + /* * CryptoExchangeController */ @@ -171,6 +195,11 @@ module.exports = { expire: ONE_MINUTE, message: 'You are creating accounts too quickly', }, + postProfilePhoto: { + total: 5, + expire: ONE_MINUTE * 5, + message: 'You are updating your profile photo too quickly', + }, postUpdateSettings: { total: 4, expire: ONE_MINUTE, @@ -186,6 +215,11 @@ module.exports = { expire: ONE_MINUTE, message: 'You are requesting user profiles too quickly', }, + deleteProfilePhoto: { + total: 5, + expire: ONE_MINUTE * 5, + message: 'You are deleting your profile photo too quickly', + }, }, welcome: { @@ -193,4 +227,4 @@ module.exports = { expire: ONE_MINUTE, message: 'You are loading these pages too quickly', }, -}; \ No newline at end of file +}; diff --git a/deploy b/deploy new file mode 100755 index 0000000..b38e0de --- /dev/null +++ b/deploy @@ -0,0 +1,8 @@ +#!/bin/sh + +git pull origin master +yarn --production=false +gulp build + +./stop-production +./start-production \ No newline at end of file diff --git a/lib/client/js/dtp-display-engine.js b/lib/client/js/dtp-display-engine.js index 1be3f98..b0fc9dd 100644 --- a/lib/client/js/dtp-display-engine.js +++ b/lib/client/js/dtp-display-engine.js @@ -209,6 +209,10 @@ export default class DtpDisplayEngine { } async showModal (displayList, command) { - UIkit.modal.dialog(command.html); + UIkit.modal.dialog(command.params.html); + } + + async navigateTo (displayList, command) { + window.location = command.params.href; } } \ No newline at end of file diff --git a/lib/site-controller.js b/lib/site-controller.js index ce6a589..84196d4 100644 --- a/lib/site-controller.js +++ b/lib/site-controller.js @@ -18,7 +18,6 @@ class SiteController extends SiteCommon { } async loadChild (filename) { - this.log.info('loading child controller', { script: filename }); let child = await require(filename)(this.dtp); return await child.start(); } @@ -38,10 +37,15 @@ class SiteController extends SiteCommon { return pagination; } + createDisplayList (name) { + const { displayEngine: displayEngineService } = this.dtp.services; + return displayEngineService.createDisplayList(name); + } + async createCsrfToken (req, name) { const { csrfToken } = this.dtp.platform.services; return csrfToken.create(req, { name }); } } -module.exports.SiteController = SiteController; \ No newline at end of file +module.exports.SiteController = SiteController; diff --git a/lib/site-log.js b/lib/site-log.js index 8f6789d..825721e 100644 --- a/lib/site-log.js +++ b/lib/site-log.js @@ -8,6 +8,8 @@ const util = require('util'); const moment = require('moment'); const rfs = require('rotating-file-stream'); +const color = require('ansicolor'); + var LogModel, LogStream; if (process.env.DTP_LOG_FILE === 'enabled') { @@ -74,16 +76,47 @@ class SiteLog { async writeLog (level, message, metadata) { const NOW = new Date(); const { componentName } = this; + if (process.env.DTP_LOG_CONSOLE === 'enabled') { + let clevel = level.padEnd(5); + switch (level) { + case 'debug': + clevel = color.black(clevel); + break; + case 'info': + clevel = color.green(clevel); + break; + case 'warn': + clevel = color.yellow(clevel); + break; + case 'alert': + clevel = color.red(clevel); + break; + case 'error': + clevel = color.bgRed.white(clevel); + break; + case 'crit': + clevel = color.bgRed.yellow(clevel); + break; + case 'fatal': + clevel = color.bgRed.black(clevel); + break; + } + + const ctimestamp = color.black(moment(NOW).format('YYYY-MM-DD HH:mm:ss.SSS')); + const ccomponentName = color.cyan(componentName); + const cmessage = color.darkGray(message); if (metadata) { - console.log(`${moment(NOW).format('YYYY-MM-DD HH:mm:ss.SSS')} ${componentName} ${level} ${message}`, util.inspect(metadata, false, Infinity, true)); + console.log(`${ctimestamp} ${clevel} ${ccomponentName} ${cmessage}`, util.inspect(metadata, false, Infinity, true)); } else { - console.log(`${moment(NOW).format('YYYY-MM-DD HH:mm:ss.SSS')} ${componentName} ${level} ${message}`); + console.log(`${ctimestamp} ${clevel} ${ccomponentName} ${cmessage}`); } } + if (LogModel && (process.env.DTP_LOG_MONGODB === 'enabled')) { await LogModel.create({ created: NOW, level, componentName, message, metadata }); } + if (LogStream && (process.env.DTP_LOG_FILE === 'enabled')) { const logEntry = { t: NOW, c: componentName, l: level, m: message, d: metadata, @@ -93,4 +126,4 @@ class SiteLog { } } -module.exports.SiteLog = SiteLog; \ No newline at end of file +module.exports.SiteLog = SiteLog; diff --git a/lib/site-platform.js b/lib/site-platform.js index 8c496b0..329dfbf 100644 --- a/lib/site-platform.js +++ b/lib/site-platform.js @@ -57,11 +57,29 @@ module.loadModels = async (dtp) => { module.log.error('model name collision', { name: model.modelName }); process.exit(-1); } - module.log.info('data model loaded', { - name: model.modelName, - collection: model.collection.collectionName, + module.models.push(model); + }); +}; + +module.exports.resetIndexes = async (dtp) => { + await SiteAsync.each(dtp.models, module.resetIndex); +}; + +module.resetIndex = async (model) => { + return new Promise(async (resolve, reject) => { + module.log.info('dropping model indexes', { model: model.modelName }); + model.collection.dropIndexes((err) => { + if (err) { + return reject(err); + } + module.log.info('creating model indexes', { model: model.modelName }); + model.ensureIndexes((err) => { + if (err) { + return reject(err); + } + return resolve(model); + }); }); - module.models[model.modelName] = model; }); }; @@ -109,12 +127,10 @@ module.loadServices = async (dtp) => { const scripts = glob.sync(path.join(dtp.config.root, 'app', 'services', '*.js')); const inits = [ ]; await SiteAsync.each(scripts, async (script) => { - module.log.info('service', { script }); const service = await require(script); module.services[service.name] = service.create(dtp); module.services[service.name].__dtp_service_name = service.name; inits.push(module.services[service.name]); - module.log.info('service loaded', { name: service.name }); }); await SiteAsync.each(inits, async (service) => { await service.start(); @@ -124,13 +140,43 @@ module.loadServices = async (dtp) => { module.loadControllers = async (dtp) => { const scripts = glob.sync(path.join(dtp.config.root, 'app', 'controllers', '*.js')); const inits = [ ]; + + dtp.controllers = { }; + await SiteAsync.each(scripts, async (script) => { - module.log.info('controller', { script }); - const controller = await require(script)(dtp); + const controller = await require(script); + controller.instance = await controller.create(dtp); + + dtp.controllers[controller.name] = controller; + inits.push(controller); }); + await SiteAsync.each(inits, async (controller) => { - await controller.start(); + if (controller.isHome) { + return; // must run last + } + await controller.instance.start(); + }); + + /* + * Start the Home controller + */ + await dtp.controllers.home.instance.start(); + + /* + * Default error handler + */ + module.log.info('registering ExpressJS error handler'); + dtp.app.use((error, req, res, next) => { // jshint ignore:line + res.locals.errorCode = error.statusCode || error.status || error.code || 500; + module.log.error('ExpressJS error', { url: req.url, error }); + res.status(res.locals.errorCode).render('error', { + message: error.message, + error, + errorCode: res.locals.errorCode, + }); + // return next(error); }); }; @@ -166,11 +212,13 @@ module.exports.startWebServer = async (dtp) => { * Expose useful modules and information */ module.app.locals.DTP_SCRIPT_DEBUG = (process.env.NODE_ENV === 'local'); + module.app.locals.dtp = dtp; module.app.locals.pkg = require(path.join(dtp.config.root, 'package.json')); module.app.locals.moment = require('moment'); module.app.locals.numeral = require('numeral'); module.app.locals.phoneNumberJS = require('libphonenumber-js'); module.app.locals.anchorme = require('anchorme').default; + module.app.locals.hljs = require('highlight.js'); /* * Set up the protected markdown renderer that will refuse to process links and images @@ -211,12 +259,14 @@ module.exports.startWebServer = async (dtp) => { */ module.app.use('/uikit', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'uikit', 'dist'))); module.app.use('/chart.js', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'chart.js', 'dist'))); + module.app.use('/chartjs-adapter-moment', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'chartjs-adapter-moment', 'dist'))); module.app.use('/cropperjs', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'cropperjs', 'dist'))); module.app.use('/fontawesome', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', '@fortawesome', 'fontawesome-free'))); module.app.use('/moment', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'moment', 'min'))); module.app.use('/mpegts', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'mpegts.js', 'dist'))); module.app.use('/numeral', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'numeral', 'min'))); module.app.use('/tinymce', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'tinymce'))); + module.app.use('/highlight.js', cacheOneDay, express.static(path.join(dtp.config.root, 'node_modules', 'highlight.js'))); /* * ExpressJS middleware @@ -274,26 +324,14 @@ module.exports.startWebServer = async (dtp) => { res.locals.dtp = { request: req, }; - res.locals.socialIcons = [ - { - url: 'https://facebook.com', - label: 'Facebook', - icon: 'fa-facebook' - }, - { - url: 'https://twitter.com', - label: 'Twitter', - icon: 'fa-twitter' - }, - { - url: 'https://instagram.com', - label: 'Instagram', - icon: 'fa-instagram' - }, - ]; const settingsKey = `settings:${dtp.config.site.domainKey}:site`; - res.locals.site = (await cacheService.getObject(settingsKey)) || dtp.config.site; + res.locals.site = Object.assign({ }, dtp.config.site); + + const settings = await cacheService.getObject(settingsKey); + if (settings) { + res.locals.site = Object.assign(res.locals.site, settings); + } return next(); } catch (error) { module.log.error('failed to populate general request data', { error }); @@ -341,4 +379,4 @@ module.exports.startWebServer = async (dtp) => { module.exports.shutdown = async ( ) => { module.log.info('platform shutting down'); -}; \ No newline at end of file +}; diff --git a/lib/site-service.js b/lib/site-service.js index 024fd4b..16fb463 100644 --- a/lib/site-service.js +++ b/lib/site-service.js @@ -19,12 +19,12 @@ class SiteService extends SiteCommon { } async start ( ) { - this.log.info(`starting ${this.name} service`); + this.log.debug(`starting ${this.name} service`); } async stop ( ) { - this.log.info(`stopping ${this.name} service`); + this.log.debug(`stopping ${this.name} service`); } } -module.exports.SiteService = SiteService; \ No newline at end of file +module.exports.SiteService = SiteService; diff --git a/package.json b/package.json index 45dabd2..a809333 100644 --- a/package.json +++ b/package.json @@ -15,9 +15,11 @@ "@joeattardi/emoji-button": "^4.6.2", "@socket.io/redis-adapter": "^7.1.0", "anchorme": "^2.1.2", + "ansicolor": "^1.1.95", "argv": "^0.0.2", "bull": "^4.1.1", "chart.js": "^3.6.2", + "chartjs-adapter-moment": "^1.0.0", "compression": "^1.7.4", "connect-redis": "^6.0.0", "cookie-parser": "^1.4.6", @@ -70,6 +72,7 @@ "tinymce": "^5.10.2", "uikit": "^3.9.4", "uniqid": "^5.4.0", + "url-validation": "^2.1.0", "uuid": "^8.3.2", "zxcvbn": "^4.4.2" }, diff --git a/sites-start-local b/start-local similarity index 100% rename from sites-start-local rename to start-local diff --git a/start-production b/start-production new file mode 100755 index 0000000..965260a --- /dev/null +++ b/start-production @@ -0,0 +1,12 @@ +#!/bin/bash + +# +# WORKERS +# +forever start --killSignal=SIGINT app/workers/reeeper.js +forever start --killSignal=SIGINT app/workers/host-services.js + +# +# APP HOSTS +# +forever start --killSignal=SIGINT dtp-libertylinks.js \ No newline at end of file diff --git a/stop-production b/stop-production new file mode 100755 index 0000000..692e5b2 --- /dev/null +++ b/stop-production @@ -0,0 +1,12 @@ +#!/bin/bash + +# +# APP HOSTS +# +forever stop dtp-libertylinks.js + +# +# WORKERS +# +forever stop app/workers/host-services.js +forever stop app/workers/reeeper.js \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 484a3e3..eb949ee 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1424,6 +1424,11 @@ ansi-wrap@0.1.0, ansi-wrap@^0.1.0: resolved "https://registry.yarnpkg.com/ansi-wrap/-/ansi-wrap-0.1.0.tgz#a82250ddb0015e9a27ca82e82ea603bbfa45efaf" integrity sha1-qCJQ3bABXponyoLoLqYDu/pF768= +ansicolor@^1.1.95: + version "1.1.95" + resolved "https://registry.yarnpkg.com/ansicolor/-/ansicolor-1.1.95.tgz#978c494f04793d6c58115ba13a50f56593f736c6" + integrity sha512-R4yTmrfQZ2H9Wr5TZoM2iOz0+T6TNHqztpld7ZToaN8EaUj/06NG4r5UHQfegA9/+K/OY3E+WumprcglbcTMRA== + anymatch@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-2.0.0.tgz#bcb24b4f37934d9aa7ac17b4adaf89e7c76ef2eb" @@ -2121,6 +2126,11 @@ chart.js@^3.6.2: resolved "https://registry.yarnpkg.com/chart.js/-/chart.js-3.6.2.tgz#47342c551f688ffdda2cd53b534cb7e461ecec33" integrity sha512-Xz7f/fgtVltfQYWq0zL1Xbv7N2inpG+B54p3D5FSvpCdy3sM+oZhbqa42eNuYXltaVvajgX5UpKCU2GeeJIgxg== +chartjs-adapter-moment@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/chartjs-adapter-moment/-/chartjs-adapter-moment-1.0.0.tgz#9174b1093c68bcfe285aff24f7388ad60d44e8f7" + integrity sha512-PqlerEvQcc5hZLQ/NQWgBxgVQ4TRdvkW3c/t+SUEQSj78ia3hgLkf2VZ2yGJtltNbEEFyYGm+cA6XXevodYvWA== + chokidar@^2.0.0: version "2.1.8" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-2.1.8.tgz#804b3a7b6a99358c3c5c61e71d8728f041cff917" @@ -8147,6 +8157,11 @@ url-parse-lax@^3.0.0: dependencies: prepend-http "^2.0.0" +url-validation@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/url-validation/-/url-validation-2.1.0.tgz#7c61b96bc8d215c040c3cddadbfd81f2bd3f3853" + integrity sha512-DGEik6FuB31DEXnpGRDtDr6Re8GIzsWeXOCtN8lQP9bS0a9sa7MfOf5LDdKRSzipVckyU+DsEOJ3dIow+Gd/dA== + use@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/use/-/use-3.1.1.tgz#d50c8cac79a19fbc20f2911f56eb973f4e10070f"