Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

✨Comment importer endpoint #21723

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions ghost/core/core/server/api/endpoints/comments.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,27 @@ const controller = {
return result;
}
},
import: {
headers: {
cacheInvalidate: false
},
options: [
'id',
'post_id'
],
validation: {
options: {
post_id: {
required: true
}
}
},
permissions: true,
async query(frame) {
const result = await commentsService.controller.adminAdd(frame);
return result;
}
},
browse: {
headers: {
cacheInvalidate: false
Expand Down
35 changes: 35 additions & 0 deletions ghost/core/core/server/services/comments/CommentsController.js
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,42 @@ module.exports = class CommentsController {

return result;
}
async adminAdd(frame) {
const data = frame.data.comments[0];
let result;
if (data.parent_id) {
result = await this.service.importReplyToComment(
data.parent_id,
data.in_reply_to_id,
data.member,
data.html,
data.id,
data.created_at,
frame.options
);
} else {
result = await this.service.importCommentOnPost(
data.post_id,
data.member,
data.html,
data.id,
data.created_at,
frame.options
);
}

if (result) {
const postId = result.get('post_id');
const parentId = result.get('parent_id');
const pathsToInvalidate = [
postId ? `/api/members/comments/post/${postId}/` : null,
parentId ? `/api/members/comments/${parentId}/replies/` : null
].filter(path => path !== null);
frame.setHeader('X-Cache-Invalidate', pathsToInvalidate.join(', '));
}

return result;
}
async destroy() {
throw new MethodNotAllowedError({
message: tpl(messages.cannotDestroyComments)
Expand Down
117 changes: 116 additions & 1 deletion ghost/core/core/server/services/comments/CommentsService.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ const messages = {
replyToReply: 'Can not reply to a reply',
commentsNotEnabled: 'Comments are not enabled for this site.',
cannotCommentOnPost: 'You do not have permission to comment on this post.',
cannotEditComment: 'You do not have permission to edit comments'
cannotEditComment: 'You do not have permission to edit comments',
postNotFound: 'Post not found. Cannot attach comment to non-existent post.'
};

class CommentsService {
Expand Down Expand Up @@ -400,6 +401,120 @@ class CommentsService {

return model;
}

/**
* @param {string} post - The ID of the Post to comment on
* @param {string} member - The member object. May contain id, uuid, email, etc for matching.
* @param {string} comment - The HTML content of the Comment
* @param {string} actualCommentId - The ID of the comment. Not to be confused with the comment_id column of the post!
* @param {string} date - The date of the comment creation
* @param {any} options
*/

async importCommentOnPost(post, member, comment, actualCommentId, date, options) {
this.checkEnabled();
const memberModel = await this.models.Member.findOne(member, {
require: true,
...options
});
const postModel = await this.models.Post.findOne({
comment_id: post
// we're using the post's comment_id field to get the right post, because it will contain the old
// (imported) post ID.
}, {
require: true,
...options
});
if (!postModel) {
throw new errors.NotFoundError({
message: tpl(messages.postNotFound)
});
}
const model = await this.models.Comment.add({
id: actualCommentId,
post_id: post,
member_id: memberModel.id,
parent_id: null,
html: comment,
status: 'published',
created_at: date
}, options);

// Instead of returning the model, fetch it again, so we have all the relations properly fetched
return await this.models.Comment.findOne({id: model.id}, {...options, require: true});
}

/**
* @param {string} parent - The ID of the Comment to reply to
* @param {string} inReplyTo - The ID of the Reply to reply to
* @param {string} member - The member object. May contain id, uuid, email, etc for matching.
* @param {string} comment - The HTML content of the Comment
* @param {string} actualCommentId - The ID of the comment. Not to be confused with the comment_id column of the post!
* @param {string} date - The date of the comment creation
* @param {any} options
*/

async importReplyToComment(parent, inReplyTo, member, comment, actualCommentId, date, options) {
this.checkEnabled();
const memberModel = await this.models.Member.findOne(member, {
require: true,
...options
});

const parentComment = await this.getCommentByID(parent, options);
if (!parentComment) {
throw new errors.BadRequestError({
message: tpl(messages.commentNotFound)
});
}

if (parentComment.get('parent_id') !== null) {
throw new errors.BadRequestError({
message: tpl(messages.replyToReply)
});
}

const postModel = await this.models.Post.findOne({
id: parentComment.get('post_id')
}, {
require: true,
...options
});
if (!postModel) {
throw new errors.NotFoundError({
message: tpl(messages.postNotFound)
});
}
let inReplyToComment;
if (parent && inReplyTo) {
inReplyToComment = await this.getCommentByID(inReplyTo, options);

// we only allow references to published comments to avoid leaking
// hidden data via the snippet included in API responses
if (inReplyToComment && inReplyToComment.get('status') !== 'published') {
inReplyToComment = null;
}

// we don't allow in_reply_to references across different parents
if (inReplyToComment && inReplyToComment.get('parent_id') !== parent) {
inReplyToComment = null;
}
}

const model = await this.models.Comment.add({
post_id: parentComment.get('post_id'),
member_id: memberModel.id,
parent_id: parentComment.id,
in_reply_to_id: inReplyToComment && inReplyToComment.get('id'),
html: comment,
status: 'published',
id: actualCommentId,
created_at: date
}, options);

// Instead of returning the model, fetch it again, so we have all the relations properly fetched
return await this.models.Comment.findOne({id: model.id}, {...options, require: true});
}
}

module.exports = CommentsService;
5 changes: 2 additions & 3 deletions ghost/core/core/server/web/api/endpoints/admin/middleware.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,13 @@ const notImplemented = function notImplemented(req, res, next) {
media: ['POST'],
db: ['POST'],
settings: ['GET'],
oembed: ['GET']
oembed: ['GET'],
comments: ['POST']
};

const match = req.url.match(/^\/(\w+)\/?/);

if (match) {
const entity = match[1];

if (allowlisted[entity] && allowlisted[entity].includes(req.method)) {
return next();
}
Expand Down
3 changes: 2 additions & 1 deletion ghost/core/core/server/web/api/endpoints/admin/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,8 @@ module.exports = function apiRoutes() {
router.post('/posts/:id/copy', mw.authAdminApi, http(api.posts.copy));

router.get('/mentions', mw.authAdminApi, http(api.mentions.browse));


router.post('/comments/import/:post_id/', mw.authAdminApi, http(api.comments.import));
router.get('/comments/:id/replies', mw.authAdminApi, http(api.commentReplies.browse));
router.get('/comments/post/:post_id', mw.authAdminApi, http(api.comments.browse));
router.put('/comments/:id', mw.authAdminApi, http(api.comments.edit));
Expand Down
Loading