diff --git a/ghost/core/core/server/api/endpoints/comments-members.js b/ghost/core/core/server/api/endpoints/comments-members.js index 49cb949882..8a7c025535 100644 --- a/ghost/core/core/server/api/endpoints/comments-members.js +++ b/ghost/core/core/server/api/endpoints/comments-members.js @@ -1,18 +1,7 @@ -const Promise = require('bluebird'); -const tpl = require('@tryghost/tpl'); -const errors = require('@tryghost/errors'); -const models = require('../../models'); const commentsService = require('../../services/comments'); const ALLOWED_INCLUDES = ['member', 'replies', 'replies.member', 'replies.count.likes', 'replies.liked', 'count.replies', 'count.likes', 'liked', 'post', 'parent']; const UNSAFE_ATTRS = ['status']; -const messages = { - commentNotFound: 'Comment could not be found', - memberNotFound: 'Unable to find member', - likeNotFound: 'Unable to find like', - alreadyLiked: 'This comment was liked already' -}; - module.exports = { docName: 'comments', @@ -157,27 +146,7 @@ module.exports = { }, permissions: true, async query(frame) { - // TODO: move to likes service - if (frame.options?.context?.member?.id) { - const data = { - member_id: frame.options.context.member.id, - comment_id: frame.options.id - }; - - const existing = await models.CommentLike.findOne(data, frame.options); - - if (existing) { - throw new errors.BadRequestError({ - message: tpl(messages.alreadyLiked) - }); - } - - return await models.CommentLike.add(data, frame.options); - } else { - throw new errors.NotFoundError({ - message: tpl(messages.memberNotFound) - }); - } + return await commentsService.controller.like(frame); } }, @@ -188,31 +157,8 @@ module.exports = { ], validation: {}, permissions: true, - query(frame) { - // TODO: move to likes service - if (frame.options?.context?.member?.id) { - return models.CommentLike.destroy({ - ...frame.options, - destroyBy: { - member_id: frame.options.context.member.id, - comment_id: frame.options.id - }, - require: true - }).then(() => null) - .catch((err) => { - if (err instanceof models.CommentLike.NotFoundError) { - return Promise.reject(new errors.NotFoundError({ - message: tpl(messages.likeNotFound) - })); - } - - throw err; - }); - } else { - return Promise.reject(new errors.NotFoundError({ - message: tpl(messages.memberNotFound) - })); - } + async query(frame) { + return await commentsService.controller.unlike(frame); } }, @@ -224,13 +170,7 @@ module.exports = { validation: {}, permissions: true, async query(frame) { - if (!frame.options?.context?.member?.id) { - return Promise.reject(new errors.UnauthorizedError({ - message: tpl(messages.memberNotFound) - })); - } - - await commentsService.api.reportComment(frame.options.id, frame.options?.context?.member); + await commentsService.controller.report(frame); } } }; diff --git a/ghost/core/core/server/services/comments/controller.js b/ghost/core/core/server/services/comments/controller.js index 30e14d2372..18f9182a2c 100644 --- a/ghost/core/core/server/services/comments/controller.js +++ b/ghost/core/core/server/services/comments/controller.js @@ -1,4 +1,5 @@ const _ = require('lodash'); +const errors = require('@tryghost/errors'); /** * @typedef {import('../../api/shared/frame')} Frame @@ -8,7 +9,8 @@ const {MethodNotAllowedError} = require('@tryghost/errors'); const tpl = require('@tryghost/tpl'); const messages = { - cannotDestroyComments: 'You cannot destroy comments.' + cannotDestroyComments: 'You cannot destroy comments.', + memberNotFound: 'Unable to find member' }; module.exports = class CommentsController { @@ -21,6 +23,14 @@ module.exports = class CommentsController { this.stats = stats; } + #checkMember(frame) { + if (!frame.options?.context?.member?.id) { + throw new errors.UnauthorizedError({ + message: tpl(messages.memberNotFound) + }); + } + } + /** * @param {Frame} frame */ @@ -46,6 +56,8 @@ module.exports = class CommentsController { * @param {Frame} frame */ async edit(frame) { + this.#checkMember(frame); + if (frame.data.comments[0].status === 'deleted') { return await this.service.deleteComment( frame.options.id, @@ -66,6 +78,7 @@ module.exports = class CommentsController { * @param {Frame} frame */ async add(frame) { + this.#checkMember(frame); const data = frame.data.comments[0]; if (data.parent_id) { @@ -98,4 +111,42 @@ module.exports = class CommentsController { return await this.stats.getCountsByPost(frame.data.ids); } + + /** + * @param {Frame} frame + */ + async like(frame) { + this.#checkMember(frame); + + return await this.service.likeComment( + frame.options.id, + frame.options?.context?.member, + frame.options + ); + } + + /** + * @param {Frame} frame + */ + async unlike(frame) { + this.#checkMember(frame); + + return await this.service.unlikeComment( + frame.options.id, + frame.options?.context?.member, + frame.options + ); + } + + /** + * @param {Frame} frame + */ + async report(frame) { + this.#checkMember(frame); + + return await this.service.reportComment( + frame.options.id, + frame.options?.context?.member + ); + } }; diff --git a/ghost/core/core/server/services/comments/service.js b/ghost/core/core/server/services/comments/service.js index cd1d4463c9..3bfba50087 100644 --- a/ghost/core/core/server/services/comments/service.js +++ b/ghost/core/core/server/services/comments/service.js @@ -87,6 +87,58 @@ class CommentsService { } } + async likeComment(commentId, member, options = {}) { + this.checkEnabled(); + + const memberModel = await this.models.Member.findOne({ + id: member.id + }, { + require: true, + ...options, + withRelated: ['products'] + }); + + this.checkCommentAccess(memberModel); + + const data = { + member_id: memberModel.id, + comment_id: commentId + }; + + const existing = await this.models.CommentLike.findOne(data, options); + + if (existing) { + throw new errors.BadRequestError({ + message: tpl(messages.alreadyLiked) + }); + } + + return await this.models.CommentLike.add(data, options); + } + + async unlikeComment(commentId, member, options = {}) { + this.checkEnabled(); + + try { + await this.models.CommentLike.destroy({ + ...options, + destroyBy: { + member_id: member.id, + comment_id: commentId + }, + require: true + }); + } catch (err) { + if (err instanceof this.models.CommentLike.NotFoundError) { + return Promise.reject(new errors.NotFoundError({ + message: tpl(messages.likeNotFound) + })); + } + + throw err; + } + } + async reportComment(commentId, reporter) { this.checkEnabled(); const comment = await this.models.Comment.findOne({id: commentId}, {require: true}); diff --git a/ghost/core/test/e2e-api/members-comments/__snapshots__/comments.test.js.snap b/ghost/core/test/e2e-api/members-comments/__snapshots__/comments.test.js.snap index b843912e49..52bb618838 100644 --- a/ghost/core/test/e2e-api/members-comments/__snapshots__/comments.test.js.snap +++ b/ghost/core/test/e2e-api/members-comments/__snapshots__/comments.test.js.snap @@ -3093,6 +3093,96 @@ Object { } `; +exports[`Comments API when commenting enabled for all when not authenticated cannot comment on a post 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "Unable to find member", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Authorisation error, cannot save comment.", + "property": null, + "type": "UnauthorizedError", + }, + ], +} +`; + +exports[`Comments API when commenting enabled for all when not authenticated cannot comment on a post 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "*", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "250", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Comments API when commenting enabled for all when not authenticated cannot like a comment 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": null, + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Unable to find member", + "property": null, + "type": "UnauthorizedError", + }, + ], +} +`; + +exports[`Comments API when commenting enabled for all when not authenticated cannot like a comment 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "*", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "211", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Comments API when commenting enabled for all when not authenticated cannot reply on a post 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "Unable to find member", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Authorisation error, cannot save comment.", + "property": null, + "type": "UnauthorizedError", + }, + ], +} +`; + +exports[`Comments API when commenting enabled for all when not authenticated cannot reply on a post 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "*", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "250", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Encoding", + "x-powered-by": "Express", +} +`; + exports[`Comments API when commenting enabled for all when not authenticated cannot report a comment 1: [body] 1`] = ` Object { "errors": Array [ @@ -3123,6 +3213,36 @@ Object { } `; +exports[`Comments API when commenting enabled for all when not authenticated cannot unlike a comment 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": null, + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Unable to find member", + "property": null, + "type": "UnauthorizedError", + }, + ], +} +`; + +exports[`Comments API when commenting enabled for all when not authenticated cannot unlike a comment 2: [headers] 1`] = ` +Object { + "access-control-allow-origin": "*", + "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", + "content-length": "211", + "content-type": "application/json; charset=utf-8", + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Encoding", + "x-powered-by": "Express", +} +`; + exports[`Comments API when not authenticated but enabled Can browse all comments of a post 1: [body] 1`] = ` Object { "comments": Array [ diff --git a/ghost/core/test/e2e-api/members-comments/comments.test.js b/ghost/core/test/e2e-api/members-comments/comments.test.js index 0315427631..2a140da383 100644 --- a/ghost/core/test/e2e-api/members-comments/comments.test.js +++ b/ghost/core/test/e2e-api/members-comments/comments.test.js @@ -142,14 +142,14 @@ async function testCanReply(member, emailMatchers = {}) { should.notEqual(member.get('last_commented_at').getTime(), date.getTime(), 'Should update `last_commented_at` property after posting a comment.'); } -async function testCannotCommentOnPost() { +async function testCannotCommentOnPost(status = 403) { await membersAgent .post(`/api/comments/`) .body({comments: [{ post_id: postId, html: '
This is a message
New line
' }]}) - .expectStatus(403) + .expectStatus(status) .matchHeaderSnapshot({ etag: anyEtag }) @@ -160,7 +160,7 @@ async function testCannotCommentOnPost() { }); } -async function testCannotReply() { +async function testCannotReply(status = 403) { await membersAgent .post(`/api/comments/`) .body({comments: [{ @@ -168,7 +168,7 @@ async function testCannotReply() { parent_id: fixtureManager.get('comments', 0).id, html: 'This is a reply' }]}) - .expectStatus(403) + .expectStatus(status) .matchHeaderSnapshot({ etag: anyEtag }) @@ -228,6 +228,14 @@ describe('Comments API', function () { comments: [commentMatcherWithReplies({replies: 1})] }); }); + + it('cannot comment on a post', async function () { + await testCannotCommentOnPost(401); + }); + + it('cannot reply on a post', async function () { + await testCannotReply(401); + }); it('cannot report a comment', async function () { commentId = fixtureManager.get('comments', 0).id; @@ -245,6 +253,36 @@ describe('Comments API', function () { }] }); }); + + it('cannot like a comment', async function () { + // Create a temporary comment + await membersAgent + .post(`/api/comments/${commentId}/like/`) + .expectStatus(401) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + errors: [{ + id: anyUuid + }] + }); + }); + + it('cannot unlike a comment', async function () { + // Create a temporary comment + await membersAgent + .delete(`/api/comments/${commentId}/like/`) + .expectStatus(401) + .matchHeaderSnapshot({ + etag: anyEtag + }) + .matchBodySnapshot({ + errors: [{ + id: anyUuid + }] + }); + }); }); describe('when authenticated', function () {