diff --git a/client/components/comments.vue b/client/components/comments.vue index 0974d64c..f6188856 100644 --- a/client/components/comments.vue +++ b/client/components/comments.vue @@ -76,12 +76,42 @@ span.white--text.title {{cm.initials}} v-card.elevation-1 v-card-text - .comments-post-actions(v-if='permissions.manage && !isBusy') + .comments-post-actions(v-if='permissions.manage && !isBusy && commentEditId === 0') v-icon.mr-3(small, @click='editComment(cm)') mdi-pencil v-icon(small, @click='deleteCommentConfirm(cm)') mdi-delete .comments-post-name.caption: strong {{cm.authorName}} .comments-post-date.overline.grey--text {{cm.createdAt | moment('from') }} #[em(v-if='cm.createdAt !== cm.updatedAt') - modified {{cm.updatedAt | moment('from') }}] - .comments-post-content.mt-3(v-html='cm.render') + .comments-post-content.mt-3(v-if='commentEditId !== cm.id', v-html='cm.render') + .comments-post-editcontent.mt-3(v-else) + v-textarea( + outlined + flat + auto-grow + dense + rows='3' + hide-details + v-model='commentEditContent' + color='blue-grey darken-2' + :background-color='$vuetify.theme.dark ? `grey darken-5` : `white`' + ) + .d-flex.align-center.pt-3 + v-spacer + v-btn.mr-3( + dark + color='blue-grey darken-2' + @click='editCommentCancel' + outlined + ) + v-icon(left) mdi-close + span.text-none Cancel + v-btn( + dark + color='blue-grey darken-2' + @click='updateComment' + depressed + ) + v-icon(left) mdi-comment + span.text-none Update Comment .pt-5.text-center.body-2.blue-grey--text(v-else-if='permissions.write') Be the first to comment. .text-center.body-2.blue-grey--text(v-else) No comments yet. @@ -113,6 +143,8 @@ export default { guestName: '', guestEmail: '', commentToDelete: {}, + commentEditId: 0, + commentEditContent: null, deleteCommentDialogShown: false, isBusy: false, scrollOpts: { @@ -286,9 +318,118 @@ export default { }) } }, + /** + * Show Comment Editing Form + */ async editComment (cm) { - + this.$store.commit(`loadingStart`, 'comments-edit') + this.isBusy = true + try { + const results = await this.$apollo.query({ + query: gql` + query ($id: Int!) { + comments { + single(id: $id) { + content + } + } + } + `, + variables: { + id: cm.id + }, + fetchPolicy: 'network-only' + }) + this.commentEditContent = _.get(results, 'data.comments.single.content', null) + if (this.commentEditContent === null) { + throw new Error('Failed to load comment content.') + } + } catch (err) { + console.warn(err) + this.$store.commit('showNotification', { + style: 'red', + message: err.message, + icon: 'alert' + }) + } + this.commentEditId = cm.id + this.isBusy = false + this.$store.commit(`loadingStop`, 'comments-edit') }, + /** + * Cancel Comment Edit + */ + editCommentCancel () { + this.commentEditId = 0 + this.commentEditContent = null + }, + /** + * Update Comment with new content + */ + async updateComment () { + this.$store.commit(`loadingStart`, 'comments-edit') + this.isBusy = true + try { + if (this.commentEditContent.length < 2) { + throw new Error('Comment is empty or too short!') + } + const resp = await this.$apollo.mutate({ + mutation: gql` + mutation ( + $id: Int! + $content: String! + ) { + comments { + update ( + id: $id, + content: $content + ) { + responseResult { + succeeded + errorCode + slug + message + } + render + } + } + } + `, + variables: { + id: this.commentEditId, + content: this.commentEditContent + } + }) + + if (_.get(resp, 'data.comments.update.responseResult.succeeded', false)) { + this.$store.commit('showNotification', { + style: 'success', + message: 'Comment was updated successfully.', + icon: 'check' + }) + + const cm = _.find(this.comments, ['id', this.commentEditId]) + cm.render = _.get(resp, 'data.comments.update.render', '-- Failed to load updated comment --') + cm.updatedAt = (new Date()).toISOString() + + this.editCommentCancel() + } else { + throw new Error(_.get(resp, 'data.comments.delete.responseResult.message', 'An unexpected error occured.')) + } + } catch (err) { + console.warn(err) + this.$store.commit('showNotification', { + style: 'red', + message: err.message, + icon: 'alert' + }) + } + this.isBusy = false + this.$store.commit(`loadingStop`, 'comments-edit') + }, + /** + * Show Delete Comment Confirmation Dialog + */ deleteCommentConfirm (cm) { this.commentToDelete = cm this.deleteCommentDialogShown = true diff --git a/server/graph/resolvers/comment.js b/server/graph/resolvers/comment.js index f5c1edbe..ff30b55c 100644 --- a/server/graph/resolvers/comment.js +++ b/server/graph/resolvers/comment.js @@ -40,13 +40,10 @@ module.exports = { * Fetch list of comments for a page */ async list (obj, args, context) { - const page = await WIKI.models.pages.getPage(args) + const page = await WIKI.models.pages.query().select('id').findOne({ localeCode: args.locale, path: args.path }) if (page) { - if (WIKI.auth.checkAccess(context.req.user, ['read:comments'], { - path: page.path, - locale: page.localeCode - })) { - const comments = await WIKI.models.comments.query().where('pageId', page.id) + if (WIKI.auth.checkAccess(context.req.user, ['read:comments'], args)) { + const comments = await WIKI.models.comments.query().where('pageId', page.id).orderBy('createdAt') return comments.map(c => ({ ...c, authorName: c.name, @@ -54,11 +51,39 @@ module.exports = { authorIP: c.ip })) } else { - throw new WIKI.Error.PageViewForbidden() + throw new WIKI.Error.CommentViewForbidden() } } else { return [] } + }, + /** + * Fetch a single comment + */ + async single (obj, args, context) { + const cm = await WIKI.data.commentProvider.getCommentById(args.id) + if (!cm || !cm.pageId) { + throw new WIKI.Error.CommentNotFound() + } + const page = await WIKI.models.pages.query().select('localeCode', 'path').findById(cm.pageId) + if (page) { + if (WIKI.auth.checkAccess(context.req.user, ['read:comments'], { + path: page.path, + locale: page.localeCode + })) { + return { + ...cm, + authorName: cm.name, + authorEmail: cm.email, + authorIP: cm.ip + } + } else { + throw new WIKI.Error.CommentViewForbidden() + } + } else { + WIKI.logger.warn(`Comment #${cm.id} is linked to a page #${cm.pageId} that doesn't exist! [ERROR]`) + throw new WIKI.Error.CommentGenericError() + } } }, CommentMutation: { @@ -80,6 +105,24 @@ module.exports = { return graphHelper.generateError(err) } }, + /** + * Update an Existing Comment + */ + async update (obj, args, context) { + try { + const cmRender = await WIKI.models.comments.updateComment({ + ...args, + user: context.req.user, + ip: context.req.ip + }) + return { + responseResult: graphHelper.generateSuccess('Comment updated successfully'), + render: cmRender + } + } catch (err) { + return graphHelper.generateError(err) + } + }, /** * Delete an Existing Comment */ diff --git a/server/graph/schemas/comment.graphql b/server/graph/schemas/comment.graphql index 9e35bd86..df73496a 100644 --- a/server/graph/schemas/comment.graphql +++ b/server/graph/schemas/comment.graphql @@ -47,7 +47,7 @@ type CommentMutation { update( id: Int! content: String! - ): DefaultResponse @auth(requires: ["write:comments", "manage:comments", "manage:system"]) + ): CommentUpdateResponse @auth(requires: ["write:comments", "manage:comments", "manage:system"]) delete( id: Int! @@ -77,7 +77,7 @@ input CommentProviderInput { type CommentPost { id: Int! - content: String! + content: String! @auth(requires: ["write:comments", "manage:comments", "manage:system"]) render: String! authorId: Int! authorName: String! @@ -91,3 +91,8 @@ type CommentCreateResponse { responseResult: ResponseStatus id: Int } + +type CommentUpdateResponse { + responseResult: ResponseStatus + render: String +} diff --git a/server/helpers/error.js b/server/helpers/error.js index d7093a2a..1c052019 100644 --- a/server/helpers/error.js +++ b/server/helpers/error.js @@ -97,18 +97,14 @@ module.exports = { message: 'Too many attempts! Try again later.', code: 1008 }), - CommentGenericError: CustomError('CommentGenericError', { - message: 'An unexpected error occured.', - code: 8001 - }), - CommentPostForbidden: CustomError('CommentPostForbidden', { - message: 'You are not authorized to post a comment on this page.', - code: 8002 - }), CommentContentMissing: CustomError('CommentContentMissing', { message: 'Comment content is missing or too short.', code: 8003 }), + CommentGenericError: CustomError('CommentGenericError', { + message: 'An unexpected error occured.', + code: 8001 + }), CommentManageForbidden: CustomError('CommentManageForbidden', { message: 'You are not authorized to manage comments on this page.', code: 8004 @@ -117,6 +113,14 @@ module.exports = { message: 'This comment does not exist.', code: 8005 }), + CommentPostForbidden: CustomError('CommentPostForbidden', { + message: 'You are not authorized to post a comment on this page.', + code: 8002 + }), + CommentViewForbidden: CustomError('CommentViewForbidden', { + message: 'You are not authorized to view comments for this page.', + code: 8006 + }), InputInvalid: CustomError('InputInvalid', { message: 'Input data is invalid.', code: 1012 diff --git a/server/models/comments.js b/server/models/comments.js index 228b18af..aeaa94db 100644 --- a/server/models/comments.js +++ b/server/models/comments.js @@ -123,6 +123,39 @@ module.exports = class Comment extends Model { }) } + /** + * Update an Existing Comment + */ + static async updateComment ({ id, content, user, ip }) { + // -> Load Page + const pageId = await WIKI.data.commentProvider.getPageIdFromCommentId(id) + if (!pageId) { + throw new WIKI.Error.CommentNotFound() + } + const page = await WIKI.models.pages.getPageFromDb(pageId) + if (page) { + if (!WIKI.auth.checkAccess(user, ['manage:comments'], { + path: page.path, + locale: page.localeCode + })) { + throw new WIKI.Error.CommentManageForbidden() + } + } else { + throw new WIKI.Error.PageNotFound() + } + + // -> Process by comment provider + return WIKI.data.commentProvider.update({ + id, + content, + page, + user: { + ...user, + ip + } + }) + } + /** * Delete an Existing Comment */ diff --git a/server/modules/comments/default/comment.js b/server/modules/comments/default/comment.js index cdc7512a..53b05bf1 100644 --- a/server/modules/comments/default/comment.js +++ b/server/modules/comments/default/comment.js @@ -13,6 +13,17 @@ const DOMPurify = createDOMPurify(window) let akismetClient = null +const mkdown = md({ + html: false, + breaks: true, + linkify: true, + highlight(str, lang) { + return `
${_.escape(str)}
` + } +}) + +mkdown.use(mdEmoji) + // ------------------------------------ // Default Comment Provider // ------------------------------------ @@ -51,18 +62,6 @@ module.exports = { * Create New Comment */ async create ({ page, replyTo, content, user }) { - // -> Render Markdown - const mkdown = md({ - html: false, - breaks: true, - linkify: true, - highlight(str, lang) { - return `
${_.escape(str)}
` - } - }) - - mkdown.use(mdEmoji) - // -> Build New Comment const newComment = { content, @@ -121,13 +120,20 @@ module.exports = { // -> Return Comment ID return cm.id }, - async update ({ id, content, user, ip }) { - + /** + * Update an existing comment + */ + async update ({ id, content, user }) { + const renderedContent = DOMPurify.sanitize(mkdown.render(content)) + await WIKI.models.comments.query().findById(id).patch({ + render: renderedContent + }) + return renderedContent }, /** * Delete an existing comment by ID */ - async remove ({ id, user, ip }) { + async remove ({ id, user }) { return WIKI.models.comments.query().findById(id).delete() }, /** @@ -137,6 +143,12 @@ module.exports = { const result = await WIKI.models.comments.query().select('pageId').findById(id) return (result) ? result.pageId : false }, + /** + * Get a comment by ID + */ + async getCommentById (id) { + return WIKI.models.comments.query().findById(id) + }, /** * Get the total comments count for a page ID */