diff --git a/app/controllers/admin.js b/app/controllers/admin.js index 3800970..809feae 100644 --- a/app/controllers/admin.js +++ b/app/controllers/admin.js @@ -51,6 +51,7 @@ class AdminController extends SiteController { router.use('/log', await this.loadChild(path.join(__dirname, 'admin', 'log'))); router.use('/newsletter', await this.loadChild(path.join(__dirname, 'admin', 'newsletter'))); router.use('/newsroom', await this.loadChild(path.join(__dirname, 'admin', 'newsroom'))); + router.use('/otp', await this.loadChild(path.join(__dirname, 'admin', 'otp'))); 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'))); @@ -89,6 +90,7 @@ class AdminController extends SiteController { }; res.locals.channels = await venueService.getChannels(); + res.locals.pageTitle = `Admin Dashbord for ${this.dtp.config.site.name}`; res.render('admin/index'); } diff --git a/app/controllers/admin/otp.js b/app/controllers/admin/otp.js new file mode 100644 index 0000000..0363aa9 --- /dev/null +++ b/app/controllers/admin/otp.js @@ -0,0 +1,55 @@ +// admin/otp.js +// Copyright (C) 2021 Digital Telepresence, LLC +// License: Apache-2.0 + +'use strict'; + +const express = require('express'); +// const multer = require('multer'); + +const { SiteController, SiteError } = require('../../../lib/site-lib'); + +class OtpAdminController extends SiteController { + + constructor (dtp) { + super(dtp, module.exports); + } + + async start ( ) { + // const upload = multer({ dest: `/tmp/${this.dtp.config.site.domainKey}/uploads/${module.exports.slug}` }); + + const router = express.Router(); + router.use(async (req, res, next) => { + res.locals.currentView = 'admin'; + res.locals.adminView = 'otp'; + return next(); + }); + + // router.param('otp', this.populateOtp.bind(this)); + + router.get('/', this.getIndex.bind(this)); + + return router; + } + + async getIndex (req, res, next) { + try { + const { otpAuth: otpAuthService } = this.dtp.services; + if (!req.user) { + throw new SiteError(402, "Error getting user"); + } + res.locals.tokens = await otpAuthService.getBackupTokens(req.user, "Admin"); + res.render('admin/otp/index'); + } catch (error) { + this.log.error('failed to get tokens', { error }); + return next(error); + } + } + +} + +module.exports = { + name: 'adminOtp', + slug: 'admin-opt', + create: async (dtp) => { return new OtpAdminController(dtp); }, +}; \ No newline at end of file diff --git a/app/controllers/admin/settings.js b/app/controllers/admin/settings.js index e5eefcb..87b0ab1 100644 --- a/app/controllers/admin/settings.js +++ b/app/controllers/admin/settings.js @@ -16,6 +16,13 @@ class SettingsController extends SiteController { async start ( ) { const router = express.Router(); + + const imageUpload = this.createMulter('uploads', { + limits: { + fileSize: 1024 * 1000 * 12, + }, + }); + router.use(async (req, res, next) => { res.locals.currentView = 'admin'; res.locals.adminView = 'settings'; @@ -23,9 +30,15 @@ class SettingsController extends SiteController { }); router.post('/', this.postUpdateSettings.bind(this)); + + router.post('/images/updateSiteIcon', imageUpload.single('imageFile'), this.postUpdateSiteIcon.bind(this)); + + router.post('/images/updatePostImage', imageUpload.single('imageFile'), this.postUpdatePostImage.bind(this)); router.get('/', this.getSettingsView.bind(this)); + router.get('/images', this.getImageSettings.bind(this)); + return router; } @@ -47,6 +60,68 @@ class SettingsController extends SiteController { return next(error); } } + + async getImageSettings (req, res, next) { + const { image: imageService } = this.dtp.services; + res.locals.adminView = 'image-settings'; + res.locals.pageTitle = `Image settings for ${this.dtp.config.site.name}`; + try { + res.locals.siteIcon = await imageService.getSiteIconInfo(); + res.locals.postImage = await imageService.getPostImageInfo(); + res.render('admin/settings/images'); + } catch (error) { + return next(error); + } + } + + async postUpdateSiteIcon (req, res) { + const { image: imageService } = this.dtp.services; + try { + const displayList = this.createDisplayList('site-icon'); + await imageService.updateSiteIcon(req.body, req.file); + displayList.showNotification( + 'Site Icon updated successfully.', + 'success', + 'bottom-center', + 2000, + ); + res.status(200).json({ + success: true, + displayList, + }); + } catch (error) { + this.log.error('failed to update site icon', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + + async postUpdatePostImage (req, res) { + const { image: imageService } = this.dtp.services; + try { + const displayList = this.createDisplayList('site-post-image'); + await imageService.updatePostImage(req.body, req.file); + displayList.showNotification( + 'Post Image updated successfully.', + 'success', + 'bottom-center', + 2000, + ); + res.status(200).json({ + success: true, + displayList, + }); + } catch (error) { + this.log.error('failed to update site icon', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } + } module.exports = { diff --git a/app/controllers/author.js b/app/controllers/author.js index 07b6696..f3f6983 100644 --- a/app/controllers/author.js +++ b/app/controllers/author.js @@ -50,9 +50,14 @@ class AuthorController extends SiteController { router.use(checkPermissions); - router.get('/post', + router.get('/posts', limiterService.createMiddleware(authorLimiter.getPostIndex), - this.getPostHome.bind(this), + this.getPublishedPostHome.bind(this), + ); + + router.get('/drafts', + limiterService.createMiddleware(authorLimiter.getPostIndex), + this.getDraftsHome.bind(this), ); router.get('/', @@ -61,15 +66,34 @@ class AuthorController extends SiteController { ); } - async getPostHome (req, res, next) { + async getPublishedPostHome (req, res, next) { const { post: postService } = this.dtp.services; try { - res.locals.drafts = await postService.getForAuthor(req.user, ['draft'], { skip: 0, cpp: 5 }); - res.locals.archive = await postService.getForAuthor(req.user, ['archived'], { skip: 0, cpp: 5 }); - + const isAdmin = req.user.flags.isAdmin; + const canAuthor = req.user.permissions.canAuthorPosts; + const canPublish = req.user.permissions.canPublishPosts; res.locals.pagination = this.getPaginationParameters(req, 20); - res.locals.published = await postService.getForAuthor(req.user, ['published'], res.locals.pagination); - this.log.debug('published posts for author', { count: res.locals.published.totalPostCount }); + + if(canAuthor) { + + if ( canPublish ) { + + res.locals.published = await postService.getPosts( { skip: 0, cpp: 5 }, ['published'], true ); + + res.locals.allPosts = true; + + res.locals.published.all = true; + + } else { + + res.locals.published = await postService.getForAuthor( req.user, ['published'], { skip: 0, cpp: 5 } ); + + } + } + else if ( canPublish || isAdmin ) { + res.locals.published = await postService.getPosts( { skip: 0, cpp: 5 }, ['published'], true ); + res.locals.published.all = true; + } res.render('author/post/index'); } catch (error) { @@ -78,6 +102,43 @@ class AuthorController extends SiteController { } } + async getDraftsHome (req, res, next) { + const { post: postService } = this.dtp.services; + try { + const isAdmin = req.user.flags.isAdmin; + const canAuthor = req.user.permissions.canAuthorPosts; + const canPublish = req.user.permissions.canPublishPosts; + res.locals.pagination = this.getPaginationParameters(req, 20); + const status = ['draft']; + + if(canAuthor) { + + if ( canPublish ) { + + res.locals.drafts = await postService.getPosts( { skip: 0, cpp: 5 }, status, true ); + + res.locals.allPosts = true; + + res.locals.drafts.all = true; + + } else { + + res.locals.drafts = await postService.getForAuthor( req.user, status, { skip: 0, cpp: 5 } ); + + } + } + else if ( canPublish || isAdmin ) { + res.locals.drafts = await postService.getPosts( { skip: 0, cpp: 5 }, status, true ); + res.locals.drafts.all = true; + } + + res.render('author/draft/index'); + } catch (error) { + this.log.error('failed to render Author dashboard', { error }); + return next(error); + } + } + async getAuthorHome (req, res, next) { const { /*comment: commentService,*/ post: postService } = this.dtp.services; try { @@ -86,9 +147,9 @@ class AuthorController extends SiteController { const canAuthor = req.user.permissions.canAuthorPosts; const canPublish = req.user.permissions.canPublishPosts; - if(canAuthor) { + if(canAuthor || isAdmin) { - if(canPublish) { + if(canPublish || isAdmin) { res.locals.published = await postService.getPosts({ skip: 0, cpp: 5 }); res.locals.drafts = await postService.getPosts({ skip: 0, cpp: 5 }, ['draft']); @@ -105,7 +166,7 @@ class AuthorController extends SiteController { res.locals.authorComments = await postService.getCommentsForAuthor(req.user, res.locals.pagination); } } - else if (canPublish || isAdmin) { + else if (canPublish) { res.locals.posts = await postService.getPosts({ skip: 0, cpp: 5 }, ['draft', 'published', 'archived']); res.locals.posts.all = true; } diff --git a/app/controllers/post.js b/app/controllers/post.js index faddb5f..9d1b587 100644 --- a/app/controllers/post.js +++ b/app/controllers/post.js @@ -30,6 +30,9 @@ class PostController extends SiteController { dtp.app.use('/post', router); async function requireAuthorPrivileges (req, res, next) { + if (req.user && req.user.flags.isAdmin) { + return next(); + } if (!req.user || !req.user.permissions.canAuthorPages) { return next(new SiteError(403, 'Author privileges are required')); } @@ -40,10 +43,12 @@ class PostController extends SiteController { res.locals.currentView = 'home'; return next(); }); - + + router.param('username', this.populateUsername.bind(this)); router.param('postSlug', this.populatePostSlug.bind(this)); router.param('postId', this.populatePostId.bind(this)); - + router.param('tagSlug', this.populateTagSlug.bind(this)); + router.param('commentId', commentService.populateCommentId.bind(commentService)); router.post('/:postSlug/comment/:commentId/block-author', authRequired, upload.none(), this.postBlockCommentAuthor.bind(this)); @@ -52,6 +57,8 @@ class PostController extends SiteController { router.post('/:postId/image', requireAuthorPrivileges, upload.single('imageFile'), this.postUpdateImage.bind(this)); router.post('/:postId', requireAuthorPrivileges, this.postUpdatePost.bind(this)); + router.post('/:postId/tags', requireAuthorPrivileges, this.postUpdatePostTags.bind(this)); + router.post('/', requireAuthorPrivileges, this.postCreatePost.bind(this)); router.get('/:postId/edit', requireAuthorPrivileges, this.getEditor.bind(this)); @@ -62,6 +69,18 @@ class PostController extends SiteController { this.getComments.bind(this), ); + router.get('/author/:username', + limiterService.createMiddleware(limiterService.config.post.getIndex), + this.getAuthorView.bind(this), + ); + + router.get('/authors', + limiterService.createMiddleware(limiterService.config.post.getAllAuthorsView), + this.getAllAuthorsView.bind(this), + ); + + router.get('/tags', this.getTagIndex.bind(this)); + router.get('/:postSlug', limiterService.createMiddleware(limiterService.config.post.getView), this.getView.bind(this), @@ -84,6 +103,24 @@ class PostController extends SiteController { requireAuthorPrivileges, this.deletePost.bind(this), ); + + + + router.get('/tag/:tagSlug', this.getTagSearchView.bind(this)); + } + + async populateUsername (req, res, next, username) { + const { user: userService } = this.dtp.services; + try { + res.locals.author = await userService.lookup(username); + if (!res.locals.author) { + throw new SiteError(404, 'User not found'); + } + return next(); + } catch (error) { + this.log.error('failed to populate username', { username, error }); + return next(error); + } } async populatePostSlug (req, res, next, postSlug) { @@ -201,17 +238,46 @@ class PostController extends SiteController { async postUpdatePost (req, res, next) { const { post: postService } = this.dtp.services; try { - if (!req.user._id.equals(res.locals.post.author._id) && - !req.user.permissions.canPublishPosts) { - throw new SiteError(403, 'This is not your post'); + if(!req.user.flags.isAdmin){ + if (!req.user._id.equals(res.locals.post.author._id) || + !req.user.permissions.canPublishPosts) { + throw new SiteError(403, 'This is not your post'); + } } await postService.update(req.user, res.locals.post, req.body); res.redirect(`/post/${res.locals.post.slug}`); } catch (error) { - this.log.error('failed to update post', { newletterId: res.locals.post._id, error }); + this.log.error('failed to update post', { postId: res.locals.post._id, error }); return next(error); } } + + async postUpdatePostTags (req, res) { + const { post: postService } = this.dtp.services; + try { + if(!req.user.flags.isAdmin) + { + if (!req.user._id.equals(res.locals.post.author._id)) { + throw new SiteError(403, 'Only authors or admins can update tags.'); + } + } + await postService.updateTags(req.user, res.locals.post, req.body); + const displayList = this.createDisplayList(); + 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 post tags', { error }); + return res.status(error.statusCode || 500).json({ + success: false, + message: error.message, + }); + } + } async postCreatePost (req, res, next) { const { post: postService } = this.dtp.services; @@ -258,7 +324,7 @@ class PostController extends SiteController { res.status(200).json({ success: true, displayList }); } catch (error) { - this.log.error('failed to fetch more commnets', { postId: res.locals.post._id, error }); + this.log.error('failed to fetch more comments', { postId: res.locals.post._id, error }); return res.status(error.statusCode || 500).json({ success: false, message: error.message, @@ -280,7 +346,6 @@ class PostController extends SiteController { if (res.locals.post.status === 'published') { await resourceService.recordView(req, 'Post', res.locals.post._id); } - res.locals.countPerPage = 20; res.locals.pagination = this.getPaginationParameters(req, res.locals.countPerPage); @@ -294,6 +359,10 @@ class PostController extends SiteController { res.locals.pagination, ); res.locals.pageTitle = `${res.locals.post.title} on ${this.dtp.config.site.name}`; + res.locals.pageDescription = `${res.locals.post.summary}`; + if (res.locals.post.image) { + res.locals.shareImage = `https://${this.dtp.config.site.domain}/image/${res.locals.post.image._id}`; + } res.render('post/view'); } catch (error) { this.log.error('failed to service post view', { postId: res.locals.post._id, error }); @@ -327,18 +396,47 @@ class PostController extends SiteController { } } + async getAuthorView (req, res, next) { + const { post: postService } = this.dtp.services; + try { + res.locals.pagination = this.getPaginationParameters(req, 20); + const {posts, totalPostCount} = await postService.getForAuthor(res.locals.author, ['published'], res.locals.pagination); + res.locals.posts = posts; + res.locals.totalPostCount = totalPostCount; + res.render('post/author/view'); + } catch (error) { + return next(error); + } + } + + async getAllAuthorsView (req, res, next) { + const { user: userService } = this.dtp.services; + try { + res.locals.pagination = this.getPaginationParameters(req, 20); + + const {authors , totalAuthorCount } = await userService.getAuthors(res.locals.pagination); + res.locals.authors = authors; + res.locals.totalAuthorCount = totalAuthorCount; + res.render('post/author/all'); + } catch (error) { + return next(error); + } + } + async deletePost (req, res) { const { post: postService } = this.dtp.services; try { - if (!req.user._id.equals(res.locals.post.author._id) || - !req.user.permissions.canPublishPosts) { - throw new SiteError(403, 'This is not your post'); + // only give admins and the author permission to delete + if (!req.user.flags.isAdmin) { + if (!req.user._id.equals(res.locals.post.author._id)) { + throw new SiteError(403, 'This is not your post'); + } } await postService.deletePost(res.locals.post); const displayList = this.createDisplayList('add-recipient'); - displayList.reload(); + displayList.navigateTo('/'); res.status(200).json({ success: true, displayList }); } catch (error) { @@ -349,6 +447,55 @@ class PostController extends SiteController { }); } } + + async populateTagSlug (req, res, next, tagSlug) { + const { post: postService } = this.dtp.services; + try { + var allPosts = false; + var statusArray = ['published']; + if (req.user) { + if (req.user.flags.isAdmin) { + statusArray.push('draft'); + allPosts = true; + } + } + res.locals.allPosts = allPosts; + res.locals.tagSlug = tagSlug; + tagSlug = tagSlug.replace("_", " "); + res.locals.pagination = this.getPaginationParameters(req, 20); + res.locals.posts = await postService.getByTags(tagSlug, res.locals.pagination, statusArray); + res.locals.tag = tagSlug; + return next(); + + } catch (error) { + this.log.error('failed to populate tagSlug', { tagSlug, error }); + return next(error); + } + } + + async getTagSearchView (req, res) { + try { + res.locals.pageTitle = `Tag ${res.locals.tag} on ${this.dtp.config.site.name}`; + + res.render('post/tag/view'); + } catch (error) { + this.log.error('failed to service post view', { postId: res.locals.post._id, error }); + throw SiteError("Error getting tag view:", error ); + } + } + + + async getTagIndex (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); + res.render('post/tag/index'); + } catch (error) { + return next(error); + } + } + } module.exports = { diff --git a/app/models/post.js b/app/models/post.js index 736338f..3ba931a 100644 --- a/app/models/post.js +++ b/app/models/post.js @@ -23,6 +23,7 @@ const PostSchema = new Schema({ author: { type: Schema.ObjectId, required: true, index: 1, refPath: 'authorType' }, image: { type: Schema.ObjectId, ref: 'Image' }, title: { type: String, required: true }, + tags: { type: [String], lowercase: true }, slug: { type: String, required: true, lowercase: true, unique: true }, summary: { type: String }, content: { type: String, select: false }, diff --git a/app/services/image.js b/app/services/image.js index ea46e51..560106e 100644 --- a/app/services/image.js +++ b/app/services/image.js @@ -16,7 +16,7 @@ const { SiteService, SiteAsync } = require('../../lib/site-lib'); class ImageService extends SiteService { - constructor (dtp) { + constructor(dtp) { super(dtp, module.exports); this.populateImage = [ { @@ -26,20 +26,20 @@ class ImageService extends SiteService { ]; } - async start ( ) { + async start() { await super.start(); await fs.promises.mkdir(process.env.DTP_IMAGE_WORK_PATH, { recursive: true }); } - async create (owner, imageDefinition, file) { + async create(owner, imageDefinition, file) { const NOW = new Date(); const { minio: minioService } = this.dtp.services; try { this.log.debug('processing uploaded image', { imageDefinition, file }); - - const sharpImage = await sharp(file.path); + + const sharpImage = sharp(file.path); const metadata = await sharpImage.metadata(); - + // create an Image model instance, but leave it here in application memory. // we don't persist it to the db until MinIO accepts the binary data. const image = new SiteImage(); @@ -49,12 +49,12 @@ class ImageService extends SiteService { image.size = file.size; image.file.bucket = process.env.MINIO_IMAGE_BUCKET; image.metadata = this.makeImageMetadata(metadata); - + const imageId = image._id.toString(); const ownerId = owner._id.toString(); const fileKey = `/${ownerId.slice(0, 3)}/${ownerId}/${imageId.slice(0, 3)}/${imageId}`; image.file.key = fileKey; - + // upload the image file to MinIO const response = await minioService.uploadFile({ bucket: image.file.bucket, @@ -65,13 +65,13 @@ class ImageService extends SiteService { 'Content-Length': file.size, }, }); - + // store the eTag from MinIO in the Image model image.file.etag = response.etag; - + // save the Image model to the db await image.save(); - + this.log.info('processed uploaded image', { ownerId, imageId, fileKey }); return image.toObject(); } catch (error) { @@ -83,14 +83,14 @@ class ImageService extends SiteService { } } - async getImageById (imageId) { + async getImageById(imageId) { const image = await SiteImage .findById(imageId) .populate(this.populateImage); return image; } - async getRecentImagesForOwner (owner) { + async getRecentImagesForOwner(owner) { const images = await SiteImage .find({ owner: owner._id }) .sort({ created: -1 }) @@ -100,7 +100,7 @@ class ImageService extends SiteService { return images; } - async deleteImage (image) { + async deleteImage(image) { const { minio: minioService } = this.dtp.services; this.log.debug('removing image from storage', { bucket: image.file.bucket, key: image.file.key }); @@ -110,13 +110,13 @@ class ImageService extends SiteService { await SiteImage.deleteOne({ _id: image._id }); } - async processImageFile (owner, file, outputs, options) { + async processImageFile(owner, file, outputs, options) { this.log.debug('processing image file', { owner, file, outputs }); const sharpImage = sharp(file.path); return this.processImage(owner, sharpImage, outputs, options); } - async processImage (owner, sharpImage, outputs, options) { + async processImage(owner, sharpImage, outputs, options) { const NOW = new Date(); const service = this; const { minio: minioService } = this.dtp.services; @@ -128,7 +128,7 @@ class ImageService extends SiteService { const imageWorkPath = process.env.DTP_IMAGE_WORK_PATH || '/tmp'; const metadata = await sharpImage.metadata(); - async function processOutputImage (output) { + async function processOutputImage(output) { const outputMetadata = service.makeImageMetadata(metadata); outputMetadata.width = output.width; outputMetadata.height = output.height; @@ -149,7 +149,7 @@ class ImageService extends SiteService { height: output.height, options: output.resizeOptions, }) - ; + ; chain = chain[output.format](output.formatParameters); output.filePath = path.join(imageWorkPath, `${image._id}.${output.width}x${output.height}.${output.format}`); @@ -165,11 +165,11 @@ class ImageService extends SiteService { const imageId = image._id.toString(); const ownerId = owner._id.toString(); const fileKey = `/${ownerId.slice(0, 3)}/${ownerId}/images/${imageId.slice(0, 3)}/${imageId}.${output.format}`; - + image.file.bucket = process.env.MINIO_IMAGE_BUCKET; image.file.key = fileKey; image.size = output.stat.size; - + // upload the image file to MinIO const response = await minioService.uploadFile({ bucket: image.file.bucket, @@ -180,13 +180,13 @@ class ImageService extends SiteService { 'Content-Length': output.stat.size, }, }); - + // store the eTag from MinIO in the Image model image.file.etag = response.etag; - + // save the Image model to the db await image.save(); - + service.log.info('processed uploaded image', { ownerId, imageId, fileKey }); if (options.removeWorkFiles) { @@ -216,7 +216,124 @@ class ImageService extends SiteService { await SiteAsync.each(outputs, processOutputImage, 4); } - makeImageMetadata (metadata) { + async getSiteIconInfo() { + const siteDomain = this.dtp.config.site.domainKey; + + const siteImagesDir = path.join(this.dtp.config.root, 'client', 'img'); + const siteIconDir = path.join(siteImagesDir, 'icon', siteDomain); + + let icon; + + try { + + await fs.promises.access(siteIconDir); + const iconMetadata = await sharp(path.join(siteIconDir, 'icon-512x512.png')).metadata(); + icon = { + metadata: iconMetadata, + path: `/img/icon/${siteDomain}/icon-512x512.png`, + }; + + } catch (error) { + + icon = null; + } + + return icon; + } + + async getPostImageInfo() { + + const siteImagesDir = path.join(this.dtp.config.root, 'client', 'img'); + + let icon; + + try { + + await fs.promises.access(siteImagesDir); + const iconMetadata = await sharp(path.join(siteImagesDir, 'default-poster.jpg')).metadata(); + icon = { + metadata: iconMetadata, + path: `/img/default-poster.jpg`, + }; + + } catch (error) { + + icon = null; + } + + return icon; + } + + async updatePostImage(imageDefinition, file) { + + this.log.debug('updating site icon', { imageDefinition, file }); + try { + + + + const siteImagesDir = path.join(this.dtp.config.root, 'client', 'img'); + + const sourceIconFilePath = file.path; + + + await sharp(sourceIconFilePath).resize({ + fit: sharp.fit.inside, + width: 540, + height: 960, + }).jpeg() + .toFile(path.join(siteImagesDir, `default-poster.jpg`)); + + return path.join(siteImagesDir, 'default-poster.jpg'); + } catch (error) { + this.log.error('failed to update site icon', { error }); + throw error; + } finally { + this.log.info('removing uploaded image from local file system', { file: file.path }); + await fs.promises.rm(file.path); + } + + } + + async updateSiteIcon(imageDefinition, file) { + + this.log.debug('updating site icon', { imageDefinition, file }); + try { + + + const siteDomain = this.dtp.config.site.domainKey; + + const siteImagesDir = path.join(this.dtp.config.root, 'client', 'img'); + + const siteIconDir = path.join(siteImagesDir, 'icon', siteDomain); + + const sourceIconFilePath = file.path; + + const sizes = [16, 32, 36, 48, 57, 60, 70, 72, 76, 96, 114, 120, 144, 150, 152, 180, 192, 256, 310, 384, 512]; + + await fs.promises.mkdir(siteIconDir, { force: true, recursive: true }); + + for (var size of sizes) { + await sharp(sourceIconFilePath).resize({ + fit: sharp.fit.inside, + width: size, + height: size, + }).png() + .toFile(path.join(siteIconDir, `icon-${size}x${size}.png`)); + } + + await fs.promises.cp(sourceIconFilePath, path.join(siteImagesDir, 'social-cards', `${siteDomain}.png`)); + return path.join(siteIconDir, 'icon-512x512.png'); + } catch (error) { + this.log.error('failed to update site icon', { error }); + throw error; + } finally { + this.log.info('removing uploaded image from local file system', { file: file.path }); + await fs.promises.rm(file.path); + } + + } + + makeImageMetadata(metadata) { return { format: metadata.format, size: metadata.size, diff --git a/app/services/otp-auth.js b/app/services/otp-auth.js index 85fb0b4..9ba4c07 100644 --- a/app/services/otp-auth.js +++ b/app/services/otp-auth.js @@ -209,6 +209,11 @@ class OtpAuthService extends SiteService { return true; } + async destroyOtpSession (req, serviceName) { + delete req.session.otp[serviceName]; + await this.saveSession(req); + } + async isUserProtected (user, serviceName) { const account = await OtpAccount.findOne({ user: user._id, service: serviceName }); if (!account) { @@ -217,8 +222,15 @@ class OtpAuthService extends SiteService { return true; } - async removeForUser (user) { - return await OtpAccount.deleteMany({ user: user }); + async removeForUser (user, serviceName) { + return await OtpAccount.findOneAndDelete({ user: user, service: serviceName }); + } + + async getBackupTokens (user, serviceName) { + const tokens = await OtpAccount.findOne({ user: user._id, service: serviceName }) + .select('+backupTokens') + .lean(); + return tokens.backupTokens; } } diff --git a/app/services/post.js b/app/services/post.js index d2e1b8c..538851b 100644 --- a/app/services/post.js +++ b/app/services/post.js @@ -42,8 +42,10 @@ class PostService extends SiteService { async createPlaceholder (author) { const NOW = new Date(); - if (!author.permissions.canAuthorPosts) { - throw new SiteError(403, 'You are not permitted to author posts'); + if (!author.flags.isAdmin){ + if (!author.permissions.canAuthorPosts) { + throw new SiteError(403, 'You are not permitted to author posts'); + } } let post = new Post(); @@ -63,11 +65,19 @@ class PostService extends SiteService { async create (author, postDefinition) { const NOW = new Date(); - if (!author.permissions.canAuthorPosts) { - throw new SiteError(403, 'You are not permitted to author posts'); + if (!author.flags.isAdmin){ + if (!author.permissions.canAuthorPosts) { + throw new SiteError(403, 'You are not permitted to author posts'); + } + if ((postDefinition.status === 'published') && !author.permissions.canPublishPosts) { + throw new SiteError(403, 'You are not permitted to publish posts'); + } } - if ((postDefinition.status === 'published') && !author.permissions.canPublishPosts) { - throw new SiteError(403, 'You are not permitted to publish posts'); + + if (postDefinition.tags) { + postDefinition.tags = postDefinition.tags.split(',').map((tag) => striptags(tag.trim())); + } else { + postDefinition.tags = [ ]; } const post = new Post(); @@ -78,6 +88,7 @@ class PostService extends SiteService { post.slug = this.createPostSlug(post._id, post.title); post.summary = striptags(postDefinition.summary.trim()); post.content = postDefinition.content.trim(); + post.tags = postDefinition.tags; post.status = postDefinition.status || 'draft'; post.flags = { enableComments: postDefinition.enableComments === 'on', @@ -92,10 +103,11 @@ class PostService extends SiteService { async update (user, post, postDefinition) { const { coreNode: coreNodeService } = this.dtp.services; - if (!user.permissions.canAuthorPosts) { - throw new SiteError(403, 'You are not permitted to author posts'); + if (!user.flags.isAdmin){ + if (!user.permissions.canAuthorPosts) { + throw new SiteError(403, 'You are not permitted to author posts'); + } } - const NOW = new Date(); const updateOp = { $setOnInsert: { @@ -125,13 +137,20 @@ class PostService extends SiteService { if (postDefinition.content) { updateOp.$set.content = postDefinition.content.trim(); } + + await this.updateTags(post._id, postDefinition.tags); + if (!postDefinition.status) { throw new SiteError(406, 'Must include post status'); } - if (post.status !== 'published' && postDefinition.status === 'published') { - if (!user.permissions.canPublishPosts) { - throw new SiteError(403, 'You are not permitted to publish posts'); + // const postWillBeUnpublished = post.status === 'published' && postDefinition.status !== 'published'; + const postWillBePublished = post.status !== 'published' && postDefinition.status === 'published'; + + if (postWillBePublished) { + + if (!user.flags.isAdmin && !user.permissions.canPublishPosts) { + throw new SiteError(403, 'You are not permitted to publish posts'); } } updateOp.$set.status = striptags(postDefinition.status.trim()); @@ -166,6 +185,42 @@ class PostService extends SiteService { } } + // pass the post._id and its tags to function + async updateTags (id, tags) { + if (tags) { + tags = tags.split(',').map((tag) => striptags(tag.trim().toLowerCase())); + } else { + tags = [ ]; + } + + const NOW = new Date(); + const updateOp = { + $setOnInsert: { + created: NOW, + }, + $set: { + updated: NOW, + }, + }; + updateOp.$set.tags = tags; + await Post.findOneAndUpdate( + { _id: id }, + updateOp, + ); + } + + async getByTags (tag, pagination, status = ['published']) { + if (!Array.isArray(status)) { + status = [status]; + } + const posts = await Post.find( { status: { $in: status }, tags: tag } ) + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populatePost); + return posts; + } + async updateImage (user, post, file) { const { image: imageService } = this.dtp.services; @@ -194,12 +249,22 @@ class PostService extends SiteService { ); } - async getPosts (pagination, status = ['published']) { + async getPosts (pagination, status = ['published'], count = false) { if (!Array.isArray(status)) { status = [status]; } + + var search = { + status: { $in: status }, + 'flags.isFeatured': false + }; + if ( count ) { + search = { + status: { $in: status }, + }; + } const posts = await Post - .find({ status: { $in: status }, 'flags.isFeatured': false }) + .find(search) .sort({ created: -1 }) .skip(pagination.skip) .limit(pagination.cpp) @@ -208,6 +273,11 @@ class PostService extends SiteService { posts.forEach((post) => { post.author.type = post.authorType; }); + if (count) { + const totalPostCount = await Post + .countDocuments(search); + return { posts, totalPostCount }; + } return posts; } diff --git a/app/services/user.js b/app/services/user.js index afba7f6..467ec53 100644 --- a/app/services/user.js +++ b/app/services/user.js @@ -367,7 +367,7 @@ class UserService extends SiteService { '_id', 'created', 'username', 'username_lc', 'displayName', 'picture', - 'flags', 'permissions', + 'flags', 'permissions', 'bio', ]; if (options.withEmail) { selects.push('email'); @@ -528,6 +528,19 @@ class UserService extends SiteService { return users.map((user) => { user.type = 'User'; return user; }); } + + async getAuthors (pagination) { + const authors = await User + .find({ 'permissions.canAuthorPosts': true }) + .sort({ displayName: 1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populateUser) + .lean(); + const totalAuthorCount = await User.countDocuments( {'permissions.canAuthorPosts': true } ); + + return {authors, totalAuthorCount}; + } async getUserProfile (userId) { let user; diff --git a/app/views/admin/components/file-upload-image.pug b/app/views/admin/components/file-upload-image.pug new file mode 100644 index 0000000..3ca78f6 --- /dev/null +++ b/app/views/admin/components/file-upload-image.pug @@ -0,0 +1,51 @@ +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.submitImageForm(event);").uk-form + .uk-margin + .uk-card.uk-card-default.uk-card-small + .uk-card-body + div(uk-grid).uk-flex-middle.uk-flex-center + div(class="uk-width-1-1 uk-width-auto@m") + .upload-image-container.size-512 + if !!currentImage + img(id= imageId, src= currentImage.path, class= imageClass).sb-large + else + img(id= imageId, src= defaultImage, class= imageClass) + + div(class="uk-width-1-1 uk-width-auto@m") + .uk-text-small.uk-margin + #file-select + .uk-margin(class="uk-text-center uk-text-left@m") + span.uk-text-middle Select an image + div(uk-form-custom).uk-margin-small-left + input( + type="file", + formenctype="multipart/form-data", + accept=".jpg,.png,image/jpeg,image/png", + data-file-select-container= containerId, + data-file-select="test-image-upload", + data-file-size-element= "file-size", + data-file-max-size= 15 * 1024000, + data-image-id= imageId, + data-cropper-options= cropperOptions, + onchange="return dtp.app.selectImageFile(event);", + ) + button(type="button", tabindex="-1").uk-button.uk-button-default Select + + #file-info(class="uk-text-center uk-text-left@m", hidden) + #file-name.uk-text-bold + if currentImage + div resolution: #[span#image-resolution-w= numeral(currentImage.metadata.width).format('0,0')]x#[span#image-resolution-h= numeral(currentImage.metadata.height).format('0,0')] + div size: #[span#file-size= numeral(currentImage.metadata.size).format('0,0.00b')] + div last modified: #[span#file-modified= moment(currentImage.created).format('MMM DD, YYYY')] + else + div resolution: #[span#image-resolution-w 512]x#[span#image-resolution-h 512] + div size: #[span#file-size N/A] + div last modified: #[span#file-modified N/A] + + .uk-card-footer + div(class="uk-flex-center", uk-grid) + #file-save-btn(hidden).uk-width-auto + button( + type="submit", + ).uk-button.uk-button-primary Save diff --git a/app/views/admin/components/menu.pug b/app/views/admin/components/menu.pug index 54df834..9573dbb 100644 --- a/app/views/admin/components/menu.pug +++ b/app/views/admin/components/menu.pug @@ -12,6 +12,18 @@ ul(uk-nav).uk-nav-default span.nav-item-icon i.fas.fa-cog span.uk-margin-small-left Settings + + li(class={ 'uk-active': (adminView === 'image-settings') }) + a(href="/admin/settings/images") + span.nav-item-icon + i.fas.fa-image + span.uk-margin-small-left Image Settings + + li(class={ 'uk-active': (adminView === 'otp') }) + a(href="/admin/otp") + span.nav-item-icon + i.fas.fa-cog + span.uk-margin-small-left Otp Settings li.uk-nav-divider diff --git a/app/views/admin/otp/index.pug b/app/views/admin/otp/index.pug new file mode 100644 index 0000000..bb8f872 --- /dev/null +++ b/app/views/admin/otp/index.pug @@ -0,0 +1,28 @@ +extends ../layouts/main +block content + + + div(uk-grid).uk-flex-middle + .uk-width-expand + h1.margin-remove Tokens + section.uk-section.uk-section-default.uk-section-xsmall + .uk-container + .uk-text-small + h4 This is where you will regenerate OTP tokens for your admin account and destroy your old OTP account. + //- .uk-width-auto + button( + type="button", + data-user= user._id, + onclick="return dtp.adminApp.generateOTPTokens(event);", + ).uk-button.dtp-button-danger + +renderButtonIcon('fa-repeat', 'Generate OTP Tokens') + //- regenerate route should set this so tokens can be viewed once. + if otpRegen + section.uk-section.uk-section-default.uk-section-xsmall + .uk-container + h3 You should save these tokens in a safe place. This is the only time you will see them. + p These tokens should be saved in a safe place so you can get into your account should you lose your 2FA device + each token of tokens + ul.uk-list.uk-list-divider + li + .uk-text-small= token.token diff --git a/app/views/admin/post/index.pug b/app/views/admin/post/index.pug index e4a1800..33b5ff5 100644 --- a/app/views/admin/post/index.pug +++ b/app/views/admin/post/index.pug @@ -23,10 +23,12 @@ block content .uk-text-small div(uk-grid).uk-grid-small .uk-width-auto - span published: #{moment(post.created).format('MMM DD, YYYY [at] hh:mm:ss a')} + span published: + span(data-dtp-timestamp= post.created) if post.updated .uk-width-auto - span last update: #{moment(post.updated).format('MMM DD, YYYY [at] hh:mm:ss a')} + span last update: + span(data-dtp-timestamp= post.updated) .uk-width-auto span by a(href=`/admin/user/${post.author._id}`)=` ${post.author.username}` diff --git a/app/views/admin/settings/images.pug b/app/views/admin/settings/images.pug new file mode 100644 index 0000000..9aa60be --- /dev/null +++ b/app/views/admin/settings/images.pug @@ -0,0 +1,47 @@ +extends ../layouts/main +block vendorcss + link(rel='stylesheet', href=`/cropperjs/cropper.min.css?v=${pkg.version}`) +block vendorjs + script(src=`/cropperjs/cropper.min.js?v=${pkg.version}`) +block content + + include ../components/file-upload-image + + //- h2 Add or replace your site images here + div(uk-grid).uk-flex-middle + .uk-width-expand + fieldset + legend Site Icon + .uk-margin + if siteIcon + p.uk-card-title Replace your site icon below. + else + p.uk-card-title You do not currently have a site icon. Add one below. + +renderFileUploadImage( + `/admin/settings/images/updateSiteIcon`, + 'site-icon-upload', + 'site-icon-file', + 'site-icon-picture', + `/img/icon/dtp-sites.png`, + siteIcon, + { aspectRatio: 1 }, + ) + + div(uk-grid).uk-flex-middle + .uk-width-expand + fieldset + legend Default poster + .uk-margin + if postImage + p.uk-card-title Replace your default post image below. + else + p.uk-card-title You do not currently have a default post image. Add one below. + +renderFileUploadImage( + `/admin/settings/images/updatePostImage`, + 'site-post-upload', + 'site-post-file', + 'site-post-picture', + `/img/default-poster.jpg`, + postImage, + { aspectRatio: 16/9 }, + ) \ No newline at end of file diff --git a/app/views/author/components/draft-list.pug b/app/views/author/components/draft-list.pug new file mode 100644 index 0000000..37d3ecc --- /dev/null +++ b/app/views/author/components/draft-list.pug @@ -0,0 +1,64 @@ +mixin renderPostDraftList (posts) + if Array.isArray(posts) && (posts.length > 0) + ul.uk-list.uk-list-divider + each draft in posts + li + a(href=`/post/${draft.slug}`, title="Preview draft")= draft.title + .uk-article-meta + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-expand + .uk-article-meta + div(uk-grid).uk-grid-small.uk-text-small + .uk-width-expand + a(href=`/post/${draft.slug}`, title="Edit draft")= moment(draft.created).fromNow() + if drafts.all + span by + a(href=`/user/${draft.author.username}`)=` ${draft.author.username}` + .uk-width-auto + a(href=`/post/${draft._id}/edit`).uk-display-block + +renderButtonIcon('fa-pen', 'edit') + + .uk-width-auto + a( + href="", + title="Delete draft", + data-post-id= draft._id, + data-post-title= draft.title, + onclick="return dtp.app.deletePost(event);", + ).uk-text-danger + +renderButtonIcon('fa-trash', 'delete') + else + .uk-margin-small You have no drafts. + +mixin renderFullDraftList (posts) + if Array.isArray(posts) && (posts.length > 0) + ul.uk-list.uk-list-divider + each draft in posts + li + a(href=`/post/${draft.slug}`, title="Preview draft")= draft.title + .uk-article-meta + div(uk-grid).uk-grid-medium.uk-flex-middle + .uk-width-expand + .uk-article-meta + div(uk-grid).uk-grid-medium.uk-text-medium + .uk-width-expand + a(href=`/post/${draft.slug}`, title="Edit draft")= moment(draft.created).fromNow() + if drafts.all + span by + a(href=`/user/${draft.author.username}`)=` ${draft.author.username}` + .uk-width-auto + a(href=`/post/${draft._id}/edit`).uk-display-block + +renderButtonIcon('fa-pen', 'edit') + + .uk-width-auto + a( + href="", + title="Delete draft", + data-post-id= draft._id, + data-post-title= draft.title, + onclick="return dtp.app.deletePost(event);", + ).uk-text-danger + +renderButtonIcon('fa-trash', 'delete') + +renderPaginationBar('/author/drafts', posts.totalPostCount) + else + .uk-margin-small You have no drafts. \ No newline at end of file diff --git a/app/views/author/components/list-item.pug b/app/views/author/components/list-item.pug new file mode 100644 index 0000000..dcf11f5 --- /dev/null +++ b/app/views/author/components/list-item.pug @@ -0,0 +1,24 @@ +mixin renderBlogPostListItem (post, postIndex = 1, postIndexModulus = 3) + a(href=`/post/${post.slug}`).uk-display-block.uk-link-reset + div(uk-grid).uk-grid-small + + div(class="uk-width-1-1 uk-width-1-3@s uk-flex-first", class={ + 'uk-flex-first@m': ((postIndex % postIndexModulus) === 0), + 'uk-flex-last@m': ((postIndex % postIndexModulus) !== 0), + }) + if post.image + img(src= `/image/${post.image._id}`).responsive + else + img(src="/img/default-poster.jpg").responsive + + div(class='uk-width-1-1 uk-width-2-3@s', class="uk-flex-last", class={ + 'uk-flex-first@m': ((postIndex % postIndexModulus) !== 0), + 'uk-flex-last@m': ((postIndex % postIndexModulus) === 0), + }) + article.uk-article + 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 author: #{post.author.displayName || post.author.username} + .uk-width-auto + span published: #{moment(post.created).format("MMM DD YYYY HH:MM a")} \ No newline at end of file diff --git a/app/views/author/components/list.pug b/app/views/author/components/list.pug new file mode 100644 index 0000000..b56fb49 --- /dev/null +++ b/app/views/author/components/list.pug @@ -0,0 +1,70 @@ +mixin renderPostList (posts) + if Array.isArray(posts) && (posts.length > 0) + ul.uk-list.uk-list-divider + each post in posts + li + a(href=`/post/${post.slug}`).uk-display-block + div= post.title + + .uk-article-meta + div(uk-grid).uk-grid-small.uk-text-small + .uk-width-expand + a(href=`/post/${post.slug}`)= moment(post.created).fromNow() + if posts.all + span by + a(href=`/user/${post.author.username}`)=` ${post.author.username}` + .uk-width-auto + a(href=`/post/${post._id}/edit`).uk-display-block + +renderButtonIcon('fa-pen', 'edit') + + .uk-width-auto + a( + href="", + data-post-id= post._id, + data-post-title= post.title, + onclick="return dtp.app.deletePost(event);", + ).uk-display-block.uk-text-danger + +renderButtonIcon('fa-trash', 'delete') + + div(style="width: 65px;") + span + i.fas.fa-eye + span.uk-margin-small-left= formatCount(post.stats.totalVisitCount) + else + .uk-margin-small There are no posts. + +mixin renderPublishedPostList (posts) + if Array.isArray(posts) && (posts.length > 0) + ul.uk-list.uk-list-divider + each post in posts + li + a(href=`/post/${post.slug}`).uk-display-block + div= post.title + + .uk-article-meta + div(uk-grid).uk-grid-small.uk-text-small + .uk-width-expand + a(href=`/post/${post.slug}`)= moment(post.created).fromNow() + if posts.all + span by + a(href=`/user/${post.author.username}`)=` ${post.author.username}` + .uk-width-auto + a(href=`/post/${post._id}/edit`).uk-display-block + +renderButtonIcon('fa-pen', 'edit') + + .uk-width-auto + a( + href="", + data-post-id= post._id, + data-post-title= post.title, + onclick="return dtp.app.deletePost(event);", + ).uk-display-block.uk-text-danger + +renderButtonIcon('fa-trash', 'delete') + + div(style="width: 65px;") + span + i.fas.fa-eye + span.uk-margin-small-left= formatCount(post.stats.totalVisitCount) + +renderPaginationBar('/author/posts', posts.totalPostCount) + else + .uk-margin-small No published posts. \ No newline at end of file diff --git a/app/views/author/draft/index.pug b/app/views/author/draft/index.pug new file mode 100644 index 0000000..668d06a --- /dev/null +++ b/app/views/author/draft/index.pug @@ -0,0 +1,28 @@ +extends ../../layouts/main +block content + + include ../components/draft-list + include ../components/list + include ../../post/components/summary + + include ../../components/pagination-bar + + section.uk-section.uk-section-default.uk-section-xsmall + .uk-container.uk-container-expand + div(uk-grid).uk-flex-middle + .uk-width-expand + h2.uk-margin-remove Drafts + .uk-width-auto + if user.permissions.canAuthorPosts || user.flags.isAdmin + a(href= "/post/compose").uk-button.uk-button-primary.uk-border-rounded + span + i.fas.fa-plus + span.uk-margin-small-left.uk-text-bold Create Post + .uk-width-medium + a(href= "/author").uk-button.uk-button-primary.uk-border-rounded + span.uk-margin-small-middle.uk-text-bold Author Dashboard + div(uk-grid) + div(class="uk-width-1-1 uk-width-3-3@m") + +renderSectionTitle('Drafts') + +renderFullDraftList(drafts.posts) + diff --git a/app/views/author/index.pug b/app/views/author/index.pug index 91cce08..1de8881 100644 --- a/app/views/author/index.pug +++ b/app/views/author/index.pug @@ -3,13 +3,13 @@ block content include ../components/pagination-bar - include ../post/components/draft-list - include ../post/components/list + include components/draft-list + include components/list include ../post/components/summary include ../comment/components/comment - if (user.permissions.canAuthorPosts && user.permissions.canPublishPosts) + if (user.permissions.canAuthorPosts && user.permissions.canPublishPosts || user.flags.isAdmin) section.uk-section.uk-section-default.uk-section-xsmall .uk-container.uk-container-expand div(uk-grid).uk-flex-middle @@ -42,12 +42,20 @@ block content div(class="uk-width-1-1 uk-width-1-3@m") .uk-margin - +renderSectionTitle('Drafts') - +renderPostDraftList(drafts) + if (drafts && drafts.length > 0) + +renderSectionTitle('Drafts', { url: '/author/drafts', title: 'See All', label: 'SEE ALL' }) + +renderPostDraftList(drafts) + else + +renderSectionTitle('Drafts') + +renderPostDraftList(drafts) .uk-margin - +renderSectionTitle('Recent Posts', { title: 'View All', label: 'View All', url: '/author/post' }) - +renderPostList(published) + if (published && published.length > 0) + +renderSectionTitle('Posts', { url: '/author/posts', title: 'See All', label: 'SEE ALL' }) + +renderPostList(published) + else + +renderSectionTitle('Posts') + +renderPostList(published) else if user.permissions.canAuthorPosts section.uk-section.uk-section-default.uk-section-xsmall @@ -82,20 +90,33 @@ block content div(class="uk-width-1-1 uk-width-1-3@m") .uk-margin - +renderSectionTitle('Drafts') - +renderPostDraftList(drafts.posts) + if (drafts && drafts.length > 0) + +renderSectionTitle('Drafts', { url: '/author/drafts', title: 'See All', label: 'SEE ALL' }) + +renderPostDraftList(drafts) + else + +renderSectionTitle('Drafts') + +renderPostDraftList(drafts) .uk-margin - +renderSectionTitle('Recent Posts', { title: 'View All', label: 'View All', url: '/author/post' }) - +renderPostList(published.posts) + if (published && published.length > 0) + +renderSectionTitle('Posts', { url: '/author/posts', title: 'See All', label: 'SEE ALL' }) + +renderPostList(published) + else + +renderSectionTitle('Posts') + +renderPostList(published) - else if user.permissions.canPublishPosts || user.flags.isAdmin + else if user.permissions.canPublishPosts section.uk-section.uk-section-default.uk-section-xsmall .uk-container.uk-container-expand div(uk-grid).uk-flex-middle .uk-width-expand h1.uk-margin-remove Author Dashboard .uk-width-auto + a(href= "/author/drafts").uk-button.uk-button-primary.uk-border-rounded + .uk-margin-small-middle.uk-text-bold View Drafts + .uk-width-medium + a(href= "/author/posts").uk-button.uk-button-primary.uk-border-rounded + .uk-margin-small-middle.uk-text-bold View Posts .uk-margin div(class="uk-width-1-1 uk-width-3-3@m") .uk-margin diff --git a/app/views/author/post/index.pug b/app/views/author/post/index.pug index 9c505a5..7ccc4b0 100644 --- a/app/views/author/post/index.pug +++ b/app/views/author/post/index.pug @@ -1,58 +1,27 @@ extends ../../layouts/main block content - include ../../post/components/draft-list - include ../../post/components/list + include ../components/draft-list + include ../components/list include ../../post/components/summary include ../../components/pagination-bar - section.uk-section.uk-section-default.uk-section-small + section.uk-section.uk-section-default.uk-section-xsmall .uk-container.uk-container-expand - h1 Post Author Dashboard + div(uk-grid).uk-flex-middle + .uk-width-expand + h2.uk-margin-remove Published Posts + if user.permissions.canAuthorPosts + .uk-width-auto + a(href= "/post/compose").uk-button.uk-button-primary.uk-border-rounded + span + i.fas.fa-plus + span.uk-margin-small-left.uk-text-bold Create Post + .uk-width-medium + a(href= "/author").uk-button.uk-button-primary.uk-border-rounded + span.uk-margin-small-left.uk-text-bold Author Dashboard div(uk-grid) - .uk-width-2-3 - .uk-margin - +renderSectionTitle('Your Posts') - .content-block - if published && Array.isArray(published.posts) && (published.posts.length > 0) - .uk-margin - ul.uk-list.uk-list-divider - each post in published.posts - li - a(href=`/post/${post.slug}`).uk-display-block - div= post.title - .uk-article-meta - div(uk-grid).uk-grid-small.uk-text-small - .uk-width-expand - a(href=`/post/${post.slug}`)= moment(post.created).fromNow() - - .uk-width-auto - a(href=`/post/${post._id}/edit`).uk-display-block - +renderButtonIcon('fa-pen', 'edit') - - .uk-width-auto - a( - href="", - data-post-id= post._id, - data-post-title= post.title, - onclick="return dtp.app.deletePost(event);", - ).uk-display-block.uk-text-danger - +renderButtonIcon('fa-trash', 'delete') - - div(style="width: 65px;") - span - i.fas.fa-eye - span.uk-margin-small-left= formatCount(post.stats.totalVisitCount) - +renderPaginationBar('/author/post', published.totalPostCount) - else - div You have no published posts. - - .uk-width-1-3 - .uk-margin - +renderSectionTitle('Your Drafts') - +renderPostDraftList(drafts.posts) - - .uk-margin - +renderSectionTitle('Archived') - +renderPostList(archive.posts) \ No newline at end of file + div(class="uk-width-1-1 uk-width-3-3@m") + +renderSectionTitle('Posts') + +renderPublishedPostList(published.posts) \ No newline at end of file diff --git a/app/views/components/off-canvas.pug b/app/views/components/off-canvas.pug index 689900e..34e67ee 100644 --- a/app/views/components/off-canvas.pug +++ b/app/views/components/off-canvas.pug @@ -32,12 +32,21 @@ mixin renderMenuItem (iconClass, label) li(class={ "uk-active": (currentView === 'announcement') }) a(href='/announcement').uk-display-block +renderMenuItem('fa-bullhorn', 'Announcements') + + li(class={ "uk-active": (currentView === 'authors') }) + a(href=`/post/authors`).uk-display-block + div(uk-grid).uk-grid-collapse + .uk-width-auto + .app-menu-icon + i.fas.fa-user + .uk-width-expand All Authors - each menuItem in mainMenu + if Array.isArray(mainMenu) li.uk-nav-header Pages - li(class={ 'uk-active': (pageSlug === menuItem.slug) }) - a(href= menuItem.url, title= menuItem.label) - +renderMenuItem(menuItem.icon || 'fa-file', menuItem.label) + 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-sidebar.pug b/app/views/components/page-sidebar.pug index 1a04b41..1e8c711 100644 --- a/app/views/components/page-sidebar.pug +++ b/app/views/components/page-sidebar.pug @@ -3,6 +3,7 @@ include ../newsroom/components/feed-entry-list-item include ../venue/components/channel-card include ../venue/components/channel-list-item +include ../post/components/author-credit - var isLive = !!shingChannelStatus && shingChannelStatus.isLive && !!shingChannelStatus.liveEpisode; @@ -116,6 +117,13 @@ mixin renderPageSidebar ( ) +renderNewsroomFeedEntryListItem(entry) //- + //- Author credit + //- + if author && posts.length > 0 + .uk-card.uk-card-default.uk-card-small + .uk-card-body + +renderPostAuthorCredit(author) + //- //- Newsletter Signup //- div(uk-sticky={ offset: 60, bottom: '#dtp-content-grid' }, style="z-index: initial;").uk-margin-medium diff --git a/app/views/layouts/main.pug b/app/views/layouts/main.pug index b344cac..3c5879e 100644 --- a/app/views/layouts/main.pug +++ b/app/views/layouts/main.pug @@ -118,4 +118,8 @@ html(lang='en') else script(src=`/dist/js/dtpsites-admin.min.js?v=${pkg.version}`, type="module") - block viewjs \ No newline at end of file + block viewjs + script. + window.addEventListener('dtp-load', function () { + return dtp.app.updatePostTimestamps(); + }); \ No newline at end of file diff --git a/app/views/otp/new-account.pug b/app/views/otp/new-account.pug index 4f089c4..c50f6f9 100644 --- a/app/views/otp/new-account.pug +++ b/app/views/otp/new-account.pug @@ -5,7 +5,18 @@ block content .uk-container h1 2FA Setup Successful + section.uk-section.uk-section-default.uk-section-xsmall + .uk-container + h3 You should save these tokens in a safe place. This is the only time you will see them. + p These tokens should be saved in a safe place so you can get into your account should you lose your 2FA device + each token of otpAccount.backupTokens + ul.uk-list.uk-list-divider + li + .uk-text-small= token.token + + section.uk-section.uk-section-default.uk-section-xsmall .uk-container - p Your account is now enabled with access to #{site.name} #{otpServiceName}. - a(href= otpRedirectURL, title="Continue").uk-button.uk-button-primary.uk-border-pill Continue \ No newline at end of file + p Your account is now enabled with access to #{site.name} #{otpAccount.service}. + a(href= otpRedirectURL, title="Continue").uk-button.uk-button-primary.uk-border-pill Continue + diff --git a/app/views/post/author/all.pug b/app/views/post/author/all.pug new file mode 100644 index 0000000..15be7d8 --- /dev/null +++ b/app/views/post/author/all.pug @@ -0,0 +1,16 @@ +extends ../../layouts/main-sidebar +block content + + include ../../components/pagination-bar + include components/credit + + div(uk-grid).uk-flex-expand + .uk-width-expand + h3.uk-margin-remove= `Author List` + if Array.isArray(authors) && (authors.length > 0) + ul.uk-list.uk-list-divider + each author in authors + li + +renderAuthorCredit(author) + .uk-card-footer + +renderPaginationBar(`/post/authors`, totalAuthorCount ) \ No newline at end of file diff --git a/app/views/post/author/components/credit.pug b/app/views/post/author/components/credit.pug new file mode 100644 index 0000000..120a508 --- /dev/null +++ b/app/views/post/author/components/credit.pug @@ -0,0 +1,42 @@ +mixin renderAuthorCredit (author) + div(uk-grid).uk-grid-small + .uk-width-auto + +renderProfileIcon(author) + .uk-width-expand + .uk-margin-small + div(uk-grid).uk-flex-middle + .uk-width-expand + - var userUrl = !!author.coreUserId ? `/user/core/${author._id}` : `/user/${author.username}`; + a(href= userUrl, title="View member profile") + .uk-text-bold(style="line-height: 1em;")= author.displayName || author.username + .uk-width-auto + .uk-text-small.uk-text-muted(style="line-height: 1em;") + if author.coreUserId + a(href=`${process.env.DTP_CORE_AUTH_SCHEME}://${author.core.meta.domain}/user/${author.coreUserId}`)= author.core.meta.name + else if !Array.isArray(posts) + a(href= `/post/author/${author.username}`)= `View posts by author` + .uk-text-small= author.bio + +mixin renderUserIcon (user, title, size = "small") + if user.coreUserId + img( + src=`http://${user.core.meta.domain}/core/user/${user.coreUserId}/picture?s=${sizeMap[size]}`, + class= "site-profile-picture", + class= `sb-${size}`, + title= title, + ) + else + if user.picture && user.picture.small + img( + src= `/image/${user.picture[sizeMap[size]]._id}`, + class= "site-profile-picture", + class= `sb-${size}`, + title= title, + ) + else + img( + src= "/img/default-member.png", + class= "site-profile-picture", + class= `sb-${size}`, + title= title, + ) diff --git a/app/views/post/author/components/list.pug b/app/views/post/author/components/list.pug new file mode 100644 index 0000000..cc142b7 --- /dev/null +++ b/app/views/post/author/components/list.pug @@ -0,0 +1,15 @@ +mixin renderPostSummaryFull (post) + div(uk-grid).uk-grid-small + if post.image + .uk-width-auto + img(src= `/image/${post.image._id}`).uk-width-small + else + .uk-width-auto + img(src="/img/default-poster.jpg").uk-width-small + .uk-width-expand + .uk-text-large.uk-text-bold(style="line-height: 1em;") + a(href=`/post/${post.slug}`)= `${post.title}` + .uk-text-small.uk-text-muted + div + div= moment(post.created).fromNow() + div= post.summary \ No newline at end of file diff --git a/app/views/post/author/view.pug b/app/views/post/author/view.pug new file mode 100644 index 0000000..76e9b8e --- /dev/null +++ b/app/views/post/author/view.pug @@ -0,0 +1,35 @@ +extends ../../layouts/main +block content + + include ../../components/pagination-bar + include components/list + include components/credit + + + + section(class="uk-section uk-section-default uk-section-small") + div(class="uk-container uk-container-expand") + div(class="uk-margin-medium") + h2= `Author page for ${author.username}` + div(class="uk-margin") + div(class="uk-grid").uk-grid + div(class="uk-width-1-1 uk-width-1-4@m uk-first-column") + +renderUserIcon(author, author.displayName || author.username, 'large') + if author.bio + .uk-margin + .uk-card.uk-card-default.uk-card-small + .uk-card-header.uk-text-center + h1.uk-card-title Bio + .uk-card-body + p.uk-flex-wrap= author.bio + + div(class="uk-width-1-1 uk-width-expand@m") + div.uk-container + h3.uk-margin-medium= `Posts` + if Array.isArray(posts) && (posts.length > 0) + ul.uk-list.uk-list-divider + each post in posts + li + +renderPostSummaryFull(post) + .uk-card-footer + +renderPaginationBar(`/post/author/${author.username}`, posts.totalPostCount ) diff --git a/app/views/post/components/author-credit.pug b/app/views/post/components/author-credit.pug index 1f2d600..fe2dda5 100644 --- a/app/views/post/components/author-credit.pug +++ b/app/views/post/components/author-credit.pug @@ -6,13 +6,13 @@ mixin renderPostAuthorCredit (author) .uk-margin-small div(uk-grid).uk-flex-middle .uk-width-expand - - var userUrl = !!author.coreUserId ? `/user/core/${author._id}` : `/user/${author._id}`; + - var userUrl = !!author.coreUserId ? `/user/core/${author._id}` : `/user/${author.username}`; a(href= userUrl, title="View member profile") .uk-text-bold(style="line-height: 1em;")= author.displayName || author.username .uk-width-auto .uk-text-small.uk-text-muted(style="line-height: 1em;") if author.coreUserId a(href=`${process.env.DTP_CORE_AUTH_SCHEME}://${author.core.meta.domain}/user/${author.coreUserId}`)= author.core.meta.name - else - a(href= "/")= site.name + else if !Array.isArray(posts) + a(href= `/post/author/${author.username}`)= `View posts by author` .uk-text-small= author.bio \ No newline at end of file diff --git a/app/views/post/components/featured-item.pug b/app/views/post/components/featured-item.pug index 9d958e5..0522701 100644 --- a/app/views/post/components/featured-item.pug +++ b/app/views/post/components/featured-item.pug @@ -10,6 +10,9 @@ mixin renderBlogPostFeaturedItem (post) h3(style="line-height: 1;", uk-tooltip= post.title).uk-margin-remove.uk-text-truncate= post.title .uk-article-meta if post.updated - span updated: #{moment(post.updated).format("MMM DD YYYY HH:MM a")} + span updated: + span(data-dtp-timestamp= post.updated) else - span published: #{moment(post.created).format("MMM DD YYYY HH:MM a")} \ No newline at end of file + span published: + span(data-dtp-timestamp= post.created) + \ No newline at end of file diff --git a/app/views/post/components/list-item.pug b/app/views/post/components/list-item.pug index dcf11f5..b51021b 100644 --- a/app/views/post/components/list-item.pug +++ b/app/views/post/components/list-item.pug @@ -21,4 +21,5 @@ mixin renderBlogPostListItem (post, postIndex = 1, postIndexModulus = 3) div(uk-grid).uk-grid-small .uk-width-auto author: #{post.author.displayName || post.author.username} .uk-width-auto - span published: #{moment(post.created).format("MMM DD YYYY HH:MM a")} \ No newline at end of file + span published: + span(data-dtp-timestamp= post.created) \ No newline at end of file diff --git a/app/views/post/components/list.pug b/app/views/post/components/list.pug index 7deab63..9097030 100644 --- a/app/views/post/components/list.pug +++ b/app/views/post/components/list.pug @@ -31,4 +31,4 @@ mixin renderPostList (posts) i.fas.fa-eye span.uk-margin-small-left= formatCount(post.stats.totalVisitCount) else - div You have authored posts. \ No newline at end of file + div You have no authored posts. \ No newline at end of file diff --git a/app/views/post/editor.pug b/app/views/post/editor.pug index c37f5a5..56b97e3 100644 --- a/app/views/post/editor.pug +++ b/app/views/post/editor.pug @@ -33,12 +33,15 @@ block content } input(id="slug", name="slug", type="text", placeholder= "Enter post URL slug", value= post ? postSlug : undefined).uk-input .uk-text-small The slug is used in the link to the page https://#{site.domain}/post/#{post ? post.slug : 'your-slug-here'} + .uk-margin + label(for="tags").uk-form-label Post tags + input(id="tags", name="tags", placeholder= "Enter a comma-separated list of tags", value= (post.tags || [ ]).join(', ')).uk-input .uk-margin label(for="summary").uk-form-label Post summary textarea(id="summary", name="summary", rows="4", placeholder= "Enter post summary (text only, no HTML)").uk-textarea= post ? post.summary : undefined div(uk-grid) .uk-width-auto - button(type="submit").uk-button.uk-button-primary= post ? 'Update post' : 'Create post' + button(type="submit").uk-button.uk-button-primary= 'Update post' .uk-margin label(for="status").uk-form-label Status select(id="status", name="status").uk-select diff --git a/app/views/post/tag/components/list.pug b/app/views/post/tag/components/list.pug new file mode 100644 index 0000000..534d976 --- /dev/null +++ b/app/views/post/tag/components/list.pug @@ -0,0 +1,19 @@ +mixin renderPostSummaryFull (post) + div(uk-grid).uk-grid-small + if post.image + .uk-width-auto + img(src= `/image/${post.image._id}`).uk-width-medium + else + .uk-width-auto + img(src="/img/default-poster.jpg").uk-width-medium + .uk-width-expand + .uk-text-large.uk-text-bold(style="line-height: 1em;") + a(href=`/post/${post.slug}`)= `${post.title}` + .uk-text-small.uk-text-muted + div + div= moment(post.created).fromNow() + span by + a(href=`/user/${post.author.username}`)=` ${post.author.username}` + if user && allPosts + div= `Status: ${post.status}` + div= post.summary \ No newline at end of file diff --git a/app/views/post/tag/view.pug b/app/views/post/tag/view.pug new file mode 100644 index 0000000..b2aefb6 --- /dev/null +++ b/app/views/post/tag/view.pug @@ -0,0 +1,37 @@ +extends ../../layouts/main-sidebar + +include components/list + +include ../../components/pagination-bar + +block content + + + if Array.isArray(posts) && (posts.length > 0) + h3= `Posts with the tag ${tag}.` + ul.uk-list.uk-list-divider + each post in posts + li + +renderPostSummaryFull(post) + + .uk-card-footer + +renderPaginationBar(`/post/tag/${tagSlug}`, posts.total ) + //- li + if post.image + img(src= `/image/${post.image._id}`, href=`/post/${post.slug}`, style="max-height: 350px; object-fit: cover; vertical-align:middle;margin:0px 20px;").responsive + else + img(src="/img/default-poster.jpg", href=`/post/${post.slug}`, style="max-height: 350px; object-fit: cover; vertical-align:middle;margin:0px 20px;").responsive + a(href=`/post/${post.slug}`).uk-display-block + div.h2= post.title + + .uk-article-meta + div(uk-grid).uk-grid-small.uk-text-small + .uk-width-expand + a(href=`/post/${post.slug}`)= moment(post.created).fromNow() + span by + a(href=`/user/${post.author.username}`)=` ${post.author.username}` + .uk-width-expand + div= post.summary + + else + h3= `There are no posts with the tag ${tag}.` \ No newline at end of file diff --git a/app/views/post/view.pug b/app/views/post/view.pug index a340c3c..57d3d40 100644 --- a/app/views/post/view.pug +++ b/app/views/post/view.pug @@ -24,16 +24,25 @@ block content div(uk-grid) .uk-width-auto +renderGabShareButton(`https://${site.domainKey}/post/${post.slug}`, `${post.title} - ${post.summary}`) - + +renderSectionTitle('Post tags') + if Array.isArray(post.tags) && (post.tags.length > 0) + div(uk-grid).uk-grid-small + each tag in post.tags + - + var tagSlug; + tagSlug = tag.replace(" ", "_") + a(href=`/post/tag/${tagSlug}`).uk-display-block.uk-link-reset.uk-margin-small= tag + .uk-margin .uk-article-meta div(uk-grid).uk-grid-small.uk-flex-top .uk-width-expand - div #{moment(post.created).format('MMM DD, YYYY, hh:mm a')}, by #[a(href=`/user/${post.author._id}`)= post.author.displayName || post.author.username] + uk-text-small(data-dtp-timestamp= post.created) + span by #[a(href=`/user/${post.author.username}`)= post.author.displayName || post.author.username] if user && user.hasAuthorDashboard .uk-width-auto= post.status - if post.author._id.equals(user._id) || user.permissions.canPublishPosts + if post.author._id.equals(user._id) || user.hasAuthorDashboard .uk-width-auto a(href=`/post/${post._id}/edit`).uk-display-block +renderButtonIcon('fa-pen', 'edit') @@ -54,13 +63,14 @@ block content +renderButtonIcon('fa-chevron-down', displayIntegerValue(post.stats.downvoteCount)) .uk-width-auto +renderButtonIcon('fa-comment', displayIntegerValue(post.stats.commentCount)) - .uk-margin div!= post.content if post.updated .uk-margin - .uk-article-meta This post was updated on #{moment(post.updated).format('MMMM DD, YYYY, [at] hh:mm a')}. + .uk-article-meta This post was updated on + uk-text-small(data-dtp-timestamp= post.updated) + .content-block .uk-margin diff --git a/app/views/welcome/core-home.pug b/app/views/welcome/core-home.pug index bb313d9..7298915 100644 --- a/app/views/welcome/core-home.pug +++ b/app/views/welcome/core-home.pug @@ -3,26 +3,34 @@ block content section.uk-section.uk-section-default .uk-container - - .uk-card.uk-card-default - .uk-card-header - h1.uk-card-title Select Community - - .uk-card-body - div(uk-grid).uk-grid-small - each core in connectedCores - div(class="uk-width-1-1 uk-width-1-2@m uk-width-1-3@xl") - //- pre= JSON.stringify(connectedCores, null, 2) - a(href=`/auth/core/${core._id}`).uk-display-block.uk-link-reset - .dtp-core-list-item.uk-border-rounded - div(uk-grid).uk-grid-small.uk-flex-middle - .uk-width-auto - img(src=`http://${core.meta.domain}/img/icon/dtp-core.svg`, style="width: 48px; height: auto;") - .uk-width-expand - .core-name= core.meta.name - .core-description= core.meta.description - + + if Array.isArray(hosts) && (hosts.length > 0) + .uk-card.uk-card-default + .uk-card-header + h1.uk-card-title Select Community + .uk-card-body + div(uk-grid).uk-grid-small + each core in connectedCores + div(class="uk-width-1-1 uk-width-1-2@m uk-width-1-3@xl") + //- pre= JSON.stringify(connectedCores, null, 2) + a(href=`/auth/core/${core._id}`).uk-display-block.uk-link-reset + .dtp-core-list-item.uk-border-rounded + div(uk-grid).uk-grid-small.uk-flex-middle + .uk-width-auto + img(src=`http://${core.meta.domain}/img/icon/dtp-core.svg`, style="width: 48px; height: auto;") + .uk-width-expand + .core-name= core.meta.name + .core-description= core.meta.description + .uk-card-footer div(uk-grid).uk-grid-small .uk-width-expand - +renderBackButton() \ No newline at end of file + +renderBackButton() + else + .uk-card.uk-card-default + .uk-card-header + h1.uk-card-title There are no communities connected to this site + .uk-card-footer + div(uk-grid).uk-grid-small + .uk-width-expand + +renderBackButton() \ No newline at end of file diff --git a/client/js/site-admin-app.js b/client/js/site-admin-app.js index ff6bb1f..89e608a 100644 --- a/client/js/site-admin-app.js +++ b/client/js/site-admin-app.js @@ -507,6 +507,43 @@ export default class DtpSiteAdminHostStatsApp extends DtpApp { return false; } + + 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, 'icon.png'); + + this.log.info('submitImageForm', 'updating site image', { 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 site image: ${error.message}`); + } + }); + + return; + } } dtp.DtpSiteAdminHostStatsApp = DtpSiteAdminHostStatsApp; \ No newline at end of file diff --git a/client/js/site-app.js b/client/js/site-app.js index 247cbf0..2f2289a 100644 --- a/client/js/site-app.js +++ b/client/js/site-app.js @@ -546,6 +546,17 @@ export default class DtpSiteApp extends DtpApp { return false; } + + updatePostTimestamps () { + const timestamps = document.querySelectorAll('[data-dtp-timestamp]'); + + // console.log(timestamps); + timestamps.forEach((timestamp) => { + const postTime = timestamp.getAttribute('data-dtp-timestamp'); + const format = timestamp.getAttribute('data-dtp-time-format'); + timestamp.textContent = moment(postTime).format(format || 'MMM DD, YYYY, hh:mm a'); + }); + } } dtp.DtpSiteApp = DtpSiteApp; \ No newline at end of file diff --git a/config/limiter.js b/config/limiter.js index 1c1b788..a7c417d 100644 --- a/config/limiter.js +++ b/config/limiter.js @@ -330,6 +330,11 @@ module.exports = { expire: ONE_MINUTE, message: 'You are reading posts too quickly', }, + getAllAuthorsView: { + total: 20, + expire: ONE_MINUTE, + message: 'You are loading pages too quickly', + }, getIndex: { total: 60, expire: ONE_MINUTE,