Browse Source

Link URL validation and Admin link moderation tools

master
Rob Colbert 2 years ago
parent
commit
fb6f946898
  1. 5
      app/controllers/admin.js
  2. 111
      app/controllers/admin/link.js
  3. 135
      app/controllers/admin/page.js
  4. 124
      app/controllers/admin/post.js
  5. 47
      app/services/link.js
  6. 16
      app/services/otp-auth.js
  7. 13
      app/views/admin/components/menu.pug
  8. 6
      app/views/admin/index.pug
  9. 26
      app/views/admin/link/index.pug
  10. 22
      dtp-libertylinks-cli.js
  11. 1
      package.json
  12. 5
      yarn.lock

5
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');
}

111
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;
};

135
app/controllers/admin/page.js

@ -1,135 +0,0 @@
// admin/page.js
// Copyright (C) 2021 Digital Telepresence, LLC
// License: Apache-2.0
'use strict';
const DTP_COMPONENT_NAME = 'admin:page';
const express = require('express');
const { SiteController, SiteError } = require('../../../lib/site-lib');
class PageController extends SiteController {
constructor (dtp) {
super(dtp, DTP_COMPONENT_NAME);
}
async start ( ) {
const router = express.Router();
router.use(async (req, res, next) => {
res.locals.currentView = 'admin';
res.locals.adminView = 'page';
return next();
});
router.param('pageId', this.populatePageId.bind(this));
router.post('/:pageId', this.pageUpdatePage.bind(this));
router.post('/', this.pageCreatePage.bind(this));
router.get('/compose', this.getComposer.bind(this));
router.get('/:pageId', this.getComposer.bind(this));
router.get('/', this.getIndex.bind(this));
router.delete('/:pageId', this.deletePage.bind(this));
return router;
}
async populatePageId (req, res, next, pageId) {
const { page: pageService } = this.dtp.services;
try {
res.locals.page = await pageService.getById(pageId);
if (!res.locals.page) {
throw new SiteError(404, 'Page not found');
}
return next();
} catch (error) {
this.log.error('failed to populate pageId', { pageId, error });
return next(error);
}
}
async pageUpdatePage (req, res, next) {
const { page: pageService } = this.dtp.services;
try {
await pageService.update(res.locals.page, req.body);
res.redirect('/admin/page');
} catch (error) {
this.log.error('failed to update page', { newletterId: res.locals.page._id, error });
return next(error);
}
}
async pageCreatePage (req, res, next) {
const { page: pageService } = this.dtp.services;
try {
await pageService.create(req.user, req.body);
res.redirect('/admin/page');
} catch (error) {
this.log.error('failed to create page', { error });
return next(error);
}
}
async getComposer (req, res, next) {
const { page: pageService } = this.dtp.services;
try {
let excludedPages;
if (res.locals.page) {
excludedPages = [res.locals.page._id];
}
res.locals.availablePages = await pageService.getAvailablePages(excludedPages);
res.render('admin/page/editor');
} catch (error) {
this.log.error('failed to serve page editor', { error });
return next(error);
}
}
async getIndex (req, res, next) {
const { page: pageService } = this.dtp.services;
try {
res.locals.pagination = this.getPaginationParameters(req, 20);
res.locals.pages = await pageService.getPages(res.locals.pagination, ['draft', 'published', 'archived']);
res.render('admin/page/index');
} catch (error) {
this.log.error('failed to fetch pages', { error });
return next(error);
}
}
async deletePage (req, res) {
const { page: pageService, 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;
};

124
app/controllers/admin/post.js

@ -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;
};

47
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 = {

16
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 = {

13
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

6
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))

26
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)

22
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}`);
}

1
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"
},

5
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"

Loading…
Cancel
Save