Moved (un)like endpoint code to comments service (#15371)

fixes https://github.com/TryGhost/Team/issues/1861

- Moved like and unlike endpoint handling to comments service and controller
- Moved small part of report logic to comments controller
- Added proper 401 authentication error when not authenticated as member
This commit is contained in:
Simon Backx 2022-09-06 17:20:55 +02:00 committed by GitHub
parent 914775d55f
commit 8b4d5504e8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 270 additions and 69 deletions

View File

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

View File

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

View File

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

View File

@ -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 [

View File

@ -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: '<div></div><p></p><p>This is a <strong>message</strong></p><p></p><p></p><p>New line</p><p></p>'
}]})
.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 () {