From 3b8a832cad7cd1d42ce40adf58c0256e6af8964f Mon Sep 17 00:00:00 2001 From: rob Date: Fri, 16 Jun 2023 20:56:56 -0400 Subject: [PATCH] admin/management --- app/controllers/admin.js | 2 + app/controllers/admin/attachment.js | 138 ++++++++++++++++++ app/controllers/admin/image.js | 107 ++++++++++++++ app/controllers/image.js | 5 +- app/controllers/user.js | 12 +- app/services/attachment.js | 17 +++ app/services/image.js | 12 ++ app/services/logan.js | 18 +-- app/services/user.js | 38 ++++- app/views/admin/attachment/index.pug | 21 +++ app/views/admin/components/menu.pug | 12 ++ app/views/admin/image/index.pug | 45 ++++++ app/views/admin/user/components/list-item.pug | 3 +- client/js/site-admin-app.js | 7 + 14 files changed, 421 insertions(+), 16 deletions(-) create mode 100644 app/controllers/admin/attachment.js create mode 100644 app/controllers/admin/image.js create mode 100644 app/views/admin/attachment/index.pug create mode 100644 app/views/admin/image/index.pug diff --git a/app/controllers/admin.js b/app/controllers/admin.js index 71c1d2d..ee0d400 100644 --- a/app/controllers/admin.js +++ b/app/controllers/admin.js @@ -43,10 +43,12 @@ class AdminController extends SiteController { ); router.use('/announcement', await this.loadChild(path.join(__dirname, 'admin', 'announcement'))); + router.use('/attachment', await this.loadChild(path.join(__dirname, 'admin', 'attachment'))); router.use('/content-report', await this.loadChild(path.join(__dirname, 'admin', 'content-report'))); router.use('/core-node', await this.loadChild(path.join(__dirname, 'admin', 'core-node'))); router.use('/core-user', await this.loadChild(path.join(__dirname, 'admin', 'core-user'))); router.use('/host', await this.loadChild(path.join(__dirname, 'admin', 'host'))); + router.use('/image', await this.loadChild(path.join(__dirname, 'admin', 'image'))); 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'))); diff --git a/app/controllers/admin/attachment.js b/app/controllers/admin/attachment.js new file mode 100644 index 0000000..27163eb --- /dev/null +++ b/app/controllers/admin/attachment.js @@ -0,0 +1,138 @@ +// admin/attachment.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const express = require('express'); + +const { SiteController } = require('../../../lib/site-lib'); + +class AttachmentAdminController extends SiteController { + + constructor (dtp) { + super(dtp, module.exports); + } + + async start ( ) { + const router = express.Router(); + router.use(async (req, res, next) => { + res.locals.currentView = 'admin'; + res.locals.adminView = 'attachment'; + return next(); + }); + + router.param('attachmentId', this.populateAttachmentId.bind(this)); + + router.post('/:attachmentId', this.postUpdateAttachment.bind(this)); + + router.get('/create', this.getAttachmentEditor.bind(this)); + router.get('/:attachmentId', this.getAttachmentEditor.bind(this)); + + router.get('/', this.getDashboard.bind(this)); + + router.delete('/:attachmentId', this.deleteAttachment.bind(this)); + + return router; + } + + async populateAttachmentId (req, res, next, attachmentId) { + const { + attachment: attachmentService, + logan: loganService, + } = this.dtp.services; + try { + res.locals.attachment = await attachmentService.getById(attachmentId); + return next(); + } catch (error) { + loganService.sendRequestEvent(module.exports, req, { + level: 'error', + event: 'populateAttachmentId', + message: `failed to populate attachment: ${error.message}`, + data: { attachmentId, error }, + }); + return next(error); + } + } + + async postUpdateAttachment (req, res, next) { + const { + attachment: attachmentService, + logan: loganService, + } = this.dtp.services; + try { + await attachmentService.update(res.locals.attachment, req.body); + loganService.sendRequestEvent(module.exports, req, { + level: 'info', + event: 'postUpdateAttachment', + data: { + attachment: { + _id: res.locals.attachment._id, + }, + }, + }); + res.redirect('/admin/attachment'); + } catch (error) { + loganService.sendRequestEvent(module.exports, req, { + level: 'error', + event: 'postUpdateAttachment', + message: `failed to update attachment: ${error.message}`, + data: { error }, + }); + return next(error); + } + } + + async getAttachmentEditor (req, res) { + res.render('admin/attachment/editor'); + } + + async getDashboard (req, res, next) { + const { attachment: attachmentService } = this.dtp.services; + try { + res.locals.pagination = this.getPaginationParameters(req, 20); + res.locals.attachments = await attachmentService.getRecent(res.locals.pagination); + res.render('admin/attachment/index'); + } catch (error) { + return next(error); + } + } + + async deleteAttachment (req, res) { + const { + attachment: attachmentService, + logan: loganService, + } = this.dtp.services; + try { + const displayList = this.createDisplayList('delete-attachment'); + await attachmentService.remove(res.locals.attachment); + displayList.reload(); + + res.status(200).json({ success: true, displayList }); + + loganService.sendRequestEvent(module.exports, req, { + level: 'info', + event: 'deleteAttachment', + data: { attachment: { _id: res.locals.attachment._id } }, + }); + } catch (error) { + loganService.sendRequestEvent(module.exports, req, { + level: 'error', + event: 'deleteAttachment', + message: `failed to delete attachment: ${error.message}`, + data: { error }, + }); + res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } +} + +module.exports = { + name: 'adminAttachment', + slug: 'adminAttachment', + className: 'AttachmentAdminController', + create: async (dtp) => { return new AttachmentAdminController(dtp); }, +}; \ No newline at end of file diff --git a/app/controllers/admin/image.js b/app/controllers/admin/image.js new file mode 100644 index 0000000..3e2df35 --- /dev/null +++ b/app/controllers/admin/image.js @@ -0,0 +1,107 @@ +// admin/image.js +// Copyright (C) 2022 DTP Technologies, LLC +// License: Apache-2.0 + +'use strict'; + +const express = require('express'); + +const { SiteController } = require('../../../lib/site-lib'); + +class ImageAdminController extends SiteController { + + constructor (dtp) { + super(dtp, module.exports); + } + + async start ( ) { + const router = express.Router(); + router.use(async (req, res, next) => { + res.locals.currentView = 'admin'; + res.locals.adminView = 'image'; + return next(); + }); + + router.param('imageId', this.populateImageId.bind(this)); + + router.get('/:imageId', this.getImageView.bind(this)); + + router.get('/', this.getDashboard.bind(this)); + + router.delete('/:imageId', this.deleteImage.bind(this)); + + return router; + } + + async populateImageId (req, res, next, imageId) { + const { + image: imageService, + logan: loganService, + } = this.dtp.services; + try { + res.locals.image = await imageService.getImageById(imageId); + return next(); + } catch (error) { + loganService.sendRequestEvent(module.exports, req, { + level: 'error', + event: 'populateImageId', + message: `failed to populate image: ${error.message}`, + data: { imageId, error }, + }); + return next(error); + } + } + + async getImageView (req, res) { + res.render('admin/image/view'); + } + + async getDashboard (req, res, next) { + const { image: imageService } = this.dtp.services; + try { + res.locals.pagination = this.getPaginationParameters(req, 20); + res.locals.images = await imageService.getRecentImages(res.locals.pagination); + res.render('admin/image/index'); + } catch (error) { + return next(error); + } + } + + async deleteImage (req, res) { + const { + image: imageService, + logan: loganService, + } = this.dtp.services; + try { + const displayList = this.createDisplayList('delete-image'); + await imageService.deleteImage(res.locals.image); + displayList.reload(); + + res.status(200).json({ success: true, displayList }); + + loganService.sendRequestEvent(module.exports, req, { + level: 'info', + event: 'deleteImage', + data: { image: { _id: res.locals.image._id } }, + }); + } catch (error) { + loganService.sendRequestEvent(module.exports, req, { + level: 'error', + event: 'deleteImage', + message: `failed to delete image: ${error.message}`, + data: { error }, + }); + res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } +} + +module.exports = { + name: 'adminImage', + slug: 'adminImage', + className: 'ImageAdminController', + create: async (dtp) => { return new ImageAdminController(dtp); }, +}; \ No newline at end of file diff --git a/app/controllers/image.js b/app/controllers/image.js index 83a38f9..c8bb9ce 100644 --- a/app/controllers/image.js +++ b/app/controllers/image.js @@ -9,7 +9,7 @@ const fs = require('fs'); const express = require('express'); const mongoose = require('mongoose'); -const { SiteController/*, SiteError*/ } = require('../../lib/site-lib'); +const { SiteController, SiteError } = require('../../lib/site-lib'); class ImageController extends SiteController { @@ -60,6 +60,9 @@ class ImageController extends SiteController { try { res.locals.imageId = mongoose.Types.ObjectId(imageId); res.locals.image = await this.dtp.services.image.getImageById(res.locals.imageId); + if (!res.locals.image) { + throw new SiteError(404, 'Image not found'); + } return next(); } catch (error) { this.log.error('failed to populate image', { error }); diff --git a/app/controllers/user.js b/app/controllers/user.js index bbcedf7..e6a9fea 100644 --- a/app/controllers/user.js +++ b/app/controllers/user.js @@ -119,13 +119,13 @@ class UserController extends SiteController { ); router.get( - '/:userId/otp-setup', + '/:localUserId/otp-setup', limiterService.createMiddleware(limiterService.config.user.getOtpSetup), otpSetup, this.getOtpSetup.bind(this), ); router.get( - '/:userId/otp-disable', + '/:localUserId/otp-disable', limiterService.createMiddleware(limiterService.config.user.getOtpDisable), authRequired, this.getOtpDisable.bind(this), @@ -148,7 +148,7 @@ class UserController extends SiteController { ); router.delete( - '/:userId/profile-photo', + '/:localUserId/profile-photo', limiterService.createMiddleware(limiterService.config.user.deleteProfilePhoto), authRequired, checkProfileOwner, @@ -334,6 +334,12 @@ class UserController extends SiteController { }); } catch (error) { this.log.error('failed to create new user', { error }); + loganService.sendRequestEvent(module.exports, req, { + level: 'error', + event: 'postCreateUser', + message: `failed to create user account: ${error.message}`, + data: { definition: req.body, error }, + }); return next(error); } } diff --git a/app/services/attachment.js b/app/services/attachment.js index 8c086f9..4d9f1f9 100644 --- a/app/services/attachment.js +++ b/app/services/attachment.js @@ -126,6 +126,23 @@ class AttachmentService extends SiteService { return attachments; } + /** + * Access all attachments sorted by most recent with pagination. This is for + * use by Admin tools. + * @param {*} pagination required pagination parameters (skip and cpp) + * @returns Array of attachments + */ + async getRecent (pagination) { + const attachments = await Attachment + .find() + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateAttachment) + .lean(); + return attachments; + } + /** * * @param {mongoose.Types.ObjectId} attachmentId The ID of the attachment diff --git a/app/services/image.js b/app/services/image.js index 23eeab0..bc32251 100644 --- a/app/services/image.js +++ b/app/services/image.js @@ -100,6 +100,18 @@ class ImageService extends SiteService { return images; } + async getRecentImages (pagination) { + const images = await SiteImage + .find() + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateImage) + .lean(); + const totalImageCount = await SiteImage.estimatedDocumentCount(); + return { images, totalImageCount }; + } + async deleteImage(image) { const { minio: minioService } = this.dtp.services; diff --git a/app/services/logan.js b/app/services/logan.js index 94e78ef..8b22ef0 100644 --- a/app/services/logan.js +++ b/app/services/logan.js @@ -19,9 +19,6 @@ class LoganService extends SiteService { } async sendRequestEvent (component, req, event) { - if (process.env.DTP_LOGAN !== 'enabled') { - return; - } if (req.user) { event.data = event.data || { }; event.data.user = { @@ -34,20 +31,21 @@ class LoganService extends SiteService { } async sendEvent (component, event) { - if (process.env.DTP_LOGAN !== 'enabled') { - return; - } try { - const loganScheme = process.env.DTP_LOGAN_SCHEME || 'http'; - const loganUrl = `${loganScheme}://${process.env.DTP_LOGAN_HOST}/api/event`; - event.host = os.hostname(); event['component.slug'] = component.slug; event['component.name'] = component.className || component.name; - this.log[event.level]('sending Logan event', { event }); + this.log[event.level]('application event', { event }); + if (process.env.DTP_LOGAN !== 'enabled') { + return; + } + + const loganScheme = process.env.DTP_LOGAN_SCHEME || 'http'; + const loganUrl = `${loganScheme}://${process.env.DTP_LOGAN_HOST}/api/event`; const payload = JSON.stringify(event); + const response = await fetch(loganUrl, { method: 'POST', headers: { diff --git a/app/services/user.js b/app/services/user.js index 03f4a0f..7348d33 100644 --- a/app/services/user.js +++ b/app/services/user.js @@ -69,6 +69,7 @@ class UserService extends SiteService { } = this.dtp.services; try { + this.checkRestrictedKeys('create', userDefinition); userDefinition.email = userDefinition.email.trim().toLowerCase(); // strip characters we don't want to allow in username @@ -205,6 +206,8 @@ class UserService extends SiteService { throw SiteError(403, 'Invalid user account operation'); } + this.checkRestrictedKeys('create', userDefinition); + userDefinition.username = striptags(userDefinition.username.trim().replace(/[^A-Za-z0-9\-_]/gi, '')); const username_lc = userDefinition.username.toLowerCase(); @@ -683,7 +686,18 @@ class UserService extends SiteService { const { image: imageService } = this.dtp.services; this.log.info('remove profile photo', { user: user._id }); - user = await this.getUserAccount(user._id); + switch (user.type) { + case 'User': + user = await this.getLocalUserAccount(user._id); + break; + + case 'CoreUser': + user = await this.getCoreUserAccount(user._id); + break; + + default: + throw new SiteError(400, 'Invalid User type'); + } if (user.picture.large) { await imageService.deleteImage(user.picture.large); } @@ -834,6 +848,28 @@ class UserService extends SiteService { await stickerService.removeForUser(user); await userNotificationService.removeForUser(user); } + + checkRestrictedKeys (method, definition) { + const { logan: loganService } = this.dtp.services; + const restrictedKeys = [ + 'isAdmin', 'isModerator', 'isEmailVerified', + 'canLogin', 'canChat', 'canComment', 'canReport', + 'optInSystem', 'optInMarketing', + ]; + + const keys = Object.keys(definition); + for (const restrictedKey of restrictedKeys) { + if (keys.includes(restrictedKey)) { + loganService.sendEvent(module.exports, { + level: 'alert', + event: method, + message: 'malicious fields detected', + data: { definition }, + }); + throw new SiteError(403, 'invalid request'); + } + } + } } module.exports = { diff --git a/app/views/admin/attachment/index.pug b/app/views/admin/attachment/index.pug new file mode 100644 index 0000000..5120f13 --- /dev/null +++ b/app/views/admin/attachment/index.pug @@ -0,0 +1,21 @@ +extends ../layouts/main +block content + + h1 Attachments + + if Array.isArray(attachments) && (attachments.length > 0) + ul.uk-list.uk-list-divider + each attachment in attachments + li + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-expand + //- had to abort while writing the renderer for an attachment. + //- will be back to finish this and have an attachment browser/manager. + pre= JSON.stringify(attachment, null, 2) + + .uk-width-auto + button(type="button", data-attachment-id= attachment._id, onclick="return dtp.adminApp.deleteAttachment(event);").uk-button.dtp-button-danger.uk-border-rounded + span + i.fas.fa-trash + else + div There are no attachments. \ No newline at end of file diff --git a/app/views/admin/components/menu.pug b/app/views/admin/components/menu.pug index 2adc469..9f6b90a 100644 --- a/app/views/admin/components/menu.pug +++ b/app/views/admin/components/menu.pug @@ -33,6 +33,18 @@ ul(uk-nav).uk-nav-default i.fas.fa-bullhorn span.uk-margin-small-left Announcements + li(class={ 'uk-active': (adminView === 'attachment') }) + a(href="/admin/attachment") + span.nav-item-icon + i.fas.fa-file + span.uk-margin-small-left Attachments + + li(class={ 'uk-active': (adminView === 'image') }) + a(href="/admin/image") + span.nav-item-icon + i.fas.fa-image + span.uk-margin-small-left Images + li(class={ 'uk-active': (adminView === 'user') }) a(href="/admin/user") span.nav-item-icon diff --git a/app/views/admin/image/index.pug b/app/views/admin/image/index.pug new file mode 100644 index 0000000..81f40d7 --- /dev/null +++ b/app/views/admin/image/index.pug @@ -0,0 +1,45 @@ +extends ../layouts/main +block content + + include ../user/components/list-item + include ../../components/pagination-bar + + h1.uk-text-center Image Manager + + if Array.isArray(images.images) && (images.images.length > 0) + div(uk-grid).uk-flex-center + each image in images.images + .uk-width-medium + .uk-margin-small(uk-lightbox) + a(href=`/image/${image._id}`, data-type="image", data-caption=`id: ${image._id}`) + div + img(src= `/image/${image._id}`).responsive + + if image.owner + .uk-margin-small + +renderUserListItem(image.owner) + + .uk-margin-small.uk-text-center + button(type="button").uk-button.uk-button-default.uk-button-small.uk-border-rounded + span Image Menu + + div(uk-drop={ mode: 'click', pos: 'top-center' }).uk-card.uk-card-default.uk-card-small.uk-border-rounded + .uk-card-header + .uk-text-small.uk-text-muted.uk-text-center id:#{image._id} + .uk-card-body + ul.uk-nav.uk-dropdown-nav + li + a(href="#", data-image-id= image._id, onclick="dtp.adminApp.deleteImage(event);") + span + i.fas.fa-trash + span.uk-margin-small-left Delete image + li + a(href=`/admin/image/${image._id}/archive-user`).uk-text-truncate + span + i.fas.fa-file-archive + span.uk-margin-small-left Archive and ban #[span.uk-text-bold= image.owner.username] + + +renderPaginationBar('/admin/image', images.totalImageCount) + + else + .uk-text-center There are no images. \ No newline at end of file diff --git a/app/views/admin/user/components/list-item.pug b/app/views/admin/user/components/list-item.pug index 123f74b..ebdd631 100644 --- a/app/views/admin/user/components/list-item.pug +++ b/app/views/admin/user/components/list-item.pug @@ -7,7 +7,8 @@ mixin renderUserListItem (user) .uk-text-small.uk-text-muted a(href= getUserProfileUrl(user))= user.username .uk-text-small.uk-text-truncate= user.bio - .uk-text-small.uk-text-muted created #{moment(user.created).fromNow()} + if user.created + .uk-text-small.uk-text-muted created #{moment(user.created).fromNow()} .uk-width-auto a(href=`/admin/user/local/${user._id}`, uk-tooltip={ title: 'Manage user account' }).uk-button.uk-button-default.uk-button-small.uk-border-rounded span diff --git a/client/js/site-admin-app.js b/client/js/site-admin-app.js index 67d729f..6ba76b5 100644 --- a/client/js/site-admin-app.js +++ b/client/js/site-admin-app.js @@ -452,6 +452,13 @@ export default class DtpSiteAdminHostStatsApp extends DtpApp { return; } + + async deleteImage (event) { + const target = event.currentTarget || event.target; + const imageId = target.getAttribute('data-image-id'); + const response = await fetch(`/admin/image/${imageId}`, { method: 'DELETE' }); + return this.processResponse(response); + } } dtp.DtpSiteAdminHostStatsApp = DtpSiteAdminHostStatsApp; \ No newline at end of file