From fb6f9468983cb869b40aec2159d7c1d82fe3d1d5 Mon Sep 17 00:00:00 2001 From: rob Date: Sat, 18 Dec 2021 19:01:29 -0500 Subject: [PATCH] Link URL validation and Admin link moderation tools --- app/controllers/admin.js | 5 +- app/controllers/admin/link.js | 111 +++++++++++++++++++++++ app/controllers/admin/page.js | 135 ---------------------------- app/controllers/admin/post.js | 124 ------------------------- app/services/link.js | 47 +++++++++- app/services/otp-auth.js | 16 ++-- app/views/admin/components/menu.pug | 13 +-- app/views/admin/index.pug | 6 +- app/views/admin/link/index.pug | 26 ++++++ dtp-libertylinks-cli.js | 22 +++-- package.json | 1 + yarn.lock | 5 ++ 12 files changed, 221 insertions(+), 290 deletions(-) create mode 100644 app/controllers/admin/link.js delete mode 100644 app/controllers/admin/page.js delete mode 100644 app/controllers/admin/post.js create mode 100644 app/views/admin/link/index.pug diff --git a/app/controllers/admin.js b/app/controllers/admin.js index 77ee48a..51c2fbe 100644 --- a/app/controllers/admin.js +++ b/app/controllers/admin.js @@ -46,9 +46,8 @@ class AdminController extends SiteController { 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('/link',await this.loadChild(path.join(__dirname, 'admin', 'link'))); 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'))); @@ -58,8 +57,10 @@ class AdminController extends SiteController { } async getHomeView (req, res) { + const { link: linkService } = this.dtp.services; res.locals.stats = { memberCount: await User.estimatedDocumentCount(), + linkCount: await linkService.getTotalCount(), }; res.render('admin/index'); } diff --git a/app/controllers/admin/link.js b/app/controllers/admin/link.js new file mode 100644 index 0000000..48ff9a8 --- /dev/null +++ b/app/controllers/admin/link.js @@ -0,0 +1,111 @@ +// admin/link.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const DTP_COMPONENT_NAME = 'admin:link'; +const express = require('express'); + +const { SiteController, SiteError } = require('../../../lib/site-lib'); + +class LinkController 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 = 'link'; + return next(); + }); + + router.param('linkId', this.populateLinkId.bind(this)); + + router.post('/:linkId', this.postUpdateLink.bind(this)); + + router.get('/:linkId', this.getLinkView.bind(this)); + router.get('/', this.getIndex.bind(this)); + + router.delete('/:linkId', this.deleteLink.bind(this)); + + return router; + } + + async populateLinkId (req, res, next, linkId) { + const { link: linkService } = this.dtp.services; + try { + res.locals.link = await linkService.getById(linkId); + if (!res.locals.link) { + throw new SiteError(404, 'Link not found'); + } + return next(); + } catch (error) { + this.log.error('failed to populate linkId', { linkId, error }); + return next(error); + } + } + + async postUpdateLink (req, res, next) { + const { link: linkService } = this.dtp.services; + try { + await linkService.update(res.locals.link, req.body); + res.redirect('/admin/link'); + } catch (error) { + this.log.error('failed to update link', { linkId: res.locals.link._id, error }); + return next(error); + } + } + + async getLinkView (req, res) { + res.render('admin/link/view'); + } + + async getIndex (req, res, next) { + const { link: linkService } = this.dtp.services; + try { + res.locals.totalLinkCount = await linkService.getTotalCount(); + res.locals.pagination = this.getPaginationParameters(req, 50); + res.locals.links = await linkService.getAdmin(res.locals.pagination); + res.render('admin/link/index'); + } catch (error) { + this.log.error('failed to fetch links', { error }); + return next(error); + } + } + + async deleteLink (req, res) { + const { link: linkService, displayEngine: displayEngineService } = this.dtp.services; + try { + const displayList = displayEngineService.createDisplayList('delete-link'); + + await linkService.remove(res.locals.link); + + displayList.removeElement(`li[data-link-id="${res.locals.link._id}"]`); + displayList.showNotification( + `Link "${res.locals.link.title}" deleted`, + 'success', + 'bottom-center', + 3000, + ); + res.status(200).json({ success: true, displayList }); + } catch (error) { + this.log.error('failed to delete link', { + linkId: res.local.link._id, + error, + }); + res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } +} + +module.exports = async (dtp) => { + let controller = new LinkController(dtp); + return controller; +}; \ No newline at end of file diff --git a/app/controllers/admin/page.js b/app/controllers/admin/page.js deleted file mode 100644 index 6ff84f9..0000000 --- a/app/controllers/admin/page.js +++ /dev/null @@ -1,135 +0,0 @@ -// admin/page.js -// Copyright (C) 2021 Digital Telepresence, LLC -// License: Apache-2.0 - -'use strict'; - -const DTP_COMPONENT_NAME = 'admin:page'; -const express = require('express'); - -const { SiteController, SiteError } = require('../../../lib/site-lib'); - -class PageController extends SiteController { - - constructor (dtp) { - super(dtp, DTP_COMPONENT_NAME); - } - - async start ( ) { - const router = express.Router(); - router.use(async (req, res, next) => { - res.locals.currentView = 'admin'; - res.locals.adminView = 'page'; - return next(); - }); - - router.param('pageId', this.populatePageId.bind(this)); - - router.post('/:pageId', this.pageUpdatePage.bind(this)); - router.post('/', this.pageCreatePage.bind(this)); - - router.get('/compose', this.getComposer.bind(this)); - router.get('/:pageId', this.getComposer.bind(this)); - - router.get('/', this.getIndex.bind(this)); - - router.delete('/:pageId', this.deletePage.bind(this)); - - return router; - } - - async populatePageId (req, res, next, pageId) { - const { page: pageService } = this.dtp.services; - try { - res.locals.page = await pageService.getById(pageId); - if (!res.locals.page) { - throw new SiteError(404, 'Page not found'); - } - return next(); - } catch (error) { - this.log.error('failed to populate pageId', { pageId, error }); - return next(error); - } - } - - async pageUpdatePage (req, res, next) { - const { page: pageService } = this.dtp.services; - try { - await pageService.update(res.locals.page, req.body); - res.redirect('/admin/page'); - } catch (error) { - this.log.error('failed to update page', { newletterId: res.locals.page._id, error }); - return next(error); - } - } - - async pageCreatePage (req, res, next) { - const { page: pageService } = this.dtp.services; - try { - await pageService.create(req.user, req.body); - res.redirect('/admin/page'); - } catch (error) { - this.log.error('failed to create page', { error }); - return next(error); - } - } - - async getComposer (req, res, next) { - const { page: pageService } = this.dtp.services; - try { - let excludedPages; - if (res.locals.page) { - excludedPages = [res.locals.page._id]; - } - res.locals.availablePages = await pageService.getAvailablePages(excludedPages); - res.render('admin/page/editor'); - } catch (error) { - this.log.error('failed to serve page editor', { error }); - return next(error); - } - } - - async getIndex (req, res, next) { - const { page: pageService } = this.dtp.services; - try { - res.locals.pagination = this.getPaginationParameters(req, 20); - res.locals.pages = await pageService.getPages(res.locals.pagination, ['draft', 'published', 'archived']); - res.render('admin/page/index'); - } catch (error) { - this.log.error('failed to fetch pages', { error }); - return next(error); - } - } - - async deletePage (req, res) { - const { page: pageService, displayEngine: displayEngineService } = this.dtp.services; - try { - const displayList = displayEngineService.createDisplayList('delete-page'); - - await pageService.deletePage(res.locals.page); - - displayList.removeElement(`li[data-page-id="${res.locals.page._id}"]`); - displayList.showNotification( - `Page "${res.locals.page.title}" deleted`, - 'success', - 'bottom-center', - 3000, - ); - res.status(200).json({ success: true, displayList }); - } catch (error) { - this.log.error('failed to delete page', { - pageId: res.local.page._id, - error, - }); - res.status(error.statusCode || 500).json({ - success: false, - message: error.message, - }); - } - } -} - -module.exports = async (dtp) => { - let controller = new PageController(dtp); - return controller; -}; \ No newline at end of file diff --git a/app/controllers/admin/post.js b/app/controllers/admin/post.js deleted file mode 100644 index 143e282..0000000 --- a/app/controllers/admin/post.js +++ /dev/null @@ -1,124 +0,0 @@ -// admin/post.js -// Copyright (C) 2021 Digital Telepresence, LLC -// License: Apache-2.0 - -'use strict'; - -const DTP_COMPONENT_NAME = 'admin:post'; -const express = require('express'); - -const { SiteController, SiteError } = require('../../../lib/site-lib'); - -class PostController 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 = 'post'; - return next(); - }); - - router.param('postId', this.populatePostId.bind(this)); - - router.post('/:postId', this.postUpdatePost.bind(this)); - router.post('/', this.postCreatePost.bind(this)); - - router.get('/compose', this.getComposer.bind(this)); - router.get('/:postId', this.getComposer.bind(this)); - - router.get('/', this.getIndex.bind(this)); - - router.delete('/:postId', this.deletePost.bind(this)); - - return router; - } - - async populatePostId (req, res, next, postId) { - const { post: postService } = this.dtp.services; - try { - res.locals.post = await postService.getById(postId); - if (!res.locals.post) { - throw new SiteError(404, 'Post not found'); - } - return next(); - } catch (error) { - this.log.error('failed to populate postId', { postId, error }); - return next(error); - } - } - - async postUpdatePost (req, res, next) { - const { post: postService } = this.dtp.services; - try { - await postService.update(res.locals.post, req.body); - res.redirect('/admin/post'); - } catch (error) { - this.log.error('failed to update post', { newletterId: res.locals.post._id, error }); - return next(error); - } - } - - async postCreatePost (req, res, next) { - const { post: postService } = this.dtp.services; - try { - await postService.create(req.user, req.body); - res.redirect('/admin/post'); - } catch (error) { - this.log.error('failed to create post', { error }); - return next(error); - } - } - - async getComposer (req, res) { - res.render('admin/post/editor'); - } - - async getIndex (req, res, next) { - const { post: postService } = this.dtp.services; - try { - res.locals.pagination = this.getPaginationParameters(req, 20); - res.locals.posts = await postService.getPosts(res.locals.pagination, ['draft', 'published', 'archived']); - res.render('admin/post/index'); - } catch (error) { - this.log.error('failed to fetch posts', { error }); - return next(error); - } - } - - async deletePost (req, res) { - const { post: postService, displayEngine: displayEngineService } = this.dtp.services; - try { - const displayList = displayEngineService.createDisplayList('delete-post'); - - await postService.deletePost(res.locals.post); - - displayList.removeElement(`li[data-post-id="${res.locals.post._id}"]`); - displayList.showNotification( - `Post "${res.locals.post.title}" deleted`, - 'success', - 'bottom-center', - 3000, - ); - res.status(200).json({ success: true, displayList }); - } catch (error) { - this.log.error('failed to delete post', { - postId: res.local.post._id, - error, - }); - res.status(error.statusCode || 500).json({ - success: false, - message: error.message, - }); - } - } -} - -module.exports = async (dtp) => { - let controller = new PostController(dtp); - return controller; -}; \ No newline at end of file diff --git a/app/services/link.js b/app/services/link.js index 0ba51e4..e360c28 100644 --- a/app/services/link.js +++ b/app/services/link.js @@ -15,7 +15,9 @@ const LinkVisit = mongoose.model('LinkVisit'); const geoip = require('geoip-lite'); const striptags = require('striptags'); -const { SiteService } = require('../../lib/site-lib'); +const isUrlValid = require('url-validation'); + +const { SiteService, SiteError } = require('../../lib/site-lib'); class LinkService extends SiteService { @@ -42,6 +44,8 @@ class LinkService extends SiteService { } async create (user, linkDefinition) { + this.validateUrl(linkDefinition.href); + const NOW = new Date(); const link = new Link(); @@ -51,11 +55,12 @@ class LinkService extends SiteService { link.href = striptags(linkDefinition.href.trim()); await link.save(); - return link.toObject(); } async update (link, linkDefinition) { + this.validateUrl(linkDefinition.href); + const updateOp = { $set: { } }; if (linkDefinition.label) { @@ -112,6 +117,26 @@ class LinkService extends SiteService { return links; } + async getAdmin (pagination) { + const links = await Link + .find() + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate([ + { + path: 'user', + select: '_id username username_lc displayName picture', + }, + ]) + .lean(); + return links; + } + + async getTotalCount ( ) { + return await Link.estimatedDocumentCount(); + } + async remove (link) { this.log.debug('removing link visit records', { link: link._id }); await LinkVisit.deleteMany({ link: link._id }); @@ -163,6 +188,24 @@ class LinkService extends SiteService { await Link.updateOne({ _id: link._id }, { $set: { order: link.order } }); } } + + validateUrl (href) { + if (!isUrlValid(href)) { + throw new SiteError(406, 'Invalid link URL'); + } + + const urlTest = href.toLowerCase().trim(); + this.log.debug('testing URL for validity', { urlTest }); + + if (!urlTest.startsWith('https://') && !urlTest.startsWith('http://')) { + throw new SiteError(406, 'Invalid link URL'); + } + + if (urlTest.startsWith('https://libertylinks') || urlTest.startsWith('http://libertylinks') || + urlTest.startsWith('https://www.libertylinks') || urlTest.startsWith('http://libertylinks')) { + throw new SiteError(406, 'Invalid link URL'); + } + } } module.exports = { diff --git a/app/services/otp-auth.js b/app/services/otp-auth.js index 0f02ec5..15063e5 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,6 +212,10 @@ class OtpAuthService extends SiteService { return true; } + + async removeForUser (user) { + return await OtpAccount.deleteMany({ user: user }); + } } module.exports = { diff --git a/app/views/admin/components/menu.pug b/app/views/admin/components/menu.pug index 4689df3..00aeb19 100644 --- a/app/views/admin/components/menu.pug +++ b/app/views/admin/components/menu.pug @@ -13,16 +13,11 @@ ul.uk-nav.uk-nav-default li.uk-nav-divider - li(class={ 'uk-active': (adminView === 'post') }) - a(href="/admin/post") + li(class={ 'uk-active': (adminView === 'link') }) + a(href="/admin/link") span.nav-item-icon - i.fas.fa-pen - span.uk-margin-small-left Posts - li(class={ 'uk-active': (adminView === 'page') }) - a(href="/admin/page") - span.nav-item-icon - i.fas.fa-file - span.uk-margin-small-left Pages + i.fas.fa-link + span.uk-margin-small-left Links li(class={ 'uk-active': (adminView === 'newsletter') }) a(href="/admin/newsletter") span.nav-item-icon diff --git a/app/views/admin/index.pug b/app/views/admin/index.pug index 904dbc9..2f6bddb 100644 --- a/app/views/admin/index.pug +++ b/app/views/admin/index.pug @@ -5,8 +5,4 @@ block content .uk-width-auto +renderCell('Members', formatCount(stats.memberCount)) .uk-width-auto - +renderCell('Channels', formatCount(stats.channelCount)) - .uk-width-auto - +renderCell('Streams', formatCount(stats.streamCount)) - .uk-width-auto - +renderCell('Viewers', formatCount(stats.viewerCount)) + +renderCell('Links', formatCount(stats.linkCount)) \ No newline at end of file diff --git a/app/views/admin/link/index.pug b/app/views/admin/link/index.pug new file mode 100644 index 0000000..419b52e --- /dev/null +++ b/app/views/admin/link/index.pug @@ -0,0 +1,26 @@ +extends ../layouts/main +block content + + include ../../components/pagination-bar + + .uk-margin + if Array.isArray(links) && (links.length > 0) + ul.uk-list + each link in links + li + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-expand + div + span.uk-margin-small-right= link.label + span.uk-margin-small-right #[a(href= `/${link.user.username}`).uk-text-truncate= link.user.username] + a(href= link.href)= link.href + .uk-width-auto + form(method="POST", action=`/admin/link/${link._id}`, onsubmit="").uk-form + button(type="submit").uk-button.dtp-button-danger.uk-button-small + span + i.fas.fa-trash + else + div There are no links + + .uk-margin + +renderPaginationBar('/admin/link', totalLinkCount) \ No newline at end of file diff --git a/dtp-libertylinks-cli.js b/dtp-libertylinks-cli.js index cc8f2bd..fd56119 100644 --- a/dtp-libertylinks-cli.js +++ b/dtp-libertylinks-cli.js @@ -79,10 +79,16 @@ module.revokePermission = async (target, permission) => { } }; -module.dvrIngest = async (episodeId) => { - const jobQueue = module.services.jobQueue.getJobQueue('dvr-ingest', module.config.jobQueues['dvr-ingest']); - const job = await jobQueue.add({ episodeId }); - module.log.info('job created', { id: job.id }); +module.deleteOtpAccount = async (target) => { + const { otpAuth: otpAuthService } = module.services; + const User = mongoose.model('User'); + try { + const user = await User.findOne({ email: target }).lean(); + const response = await otpAuthService.removeForUser(user); + module.log.info('OTP accounts removed', { userId: user._id, response }); + } catch (error) { + module.log.error('failed to remove OTP account', { target, error }); + } }; /* @@ -130,11 +136,11 @@ module.dvrIngest = async (episodeId) => { case 'revoke': await module.revokePermission(target, module.app.options.permission); break; - - case 'dvr-ingest': - await module.dvrIngest(target); - break; + case 'delete-otp': + await module.deleteOtpAccount(target); + break; + default: throw new Error(`invalid action: ${module.app.options.action}`); } diff --git a/package.json b/package.json index 12867e8..fc28c8b 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,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/yarn.lock b/yarn.lock index 85d5086..e5b8401 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8152,6 +8152,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"