278 lines
9.7 KiB
JavaScript
278 lines
9.7 KiB
JavaScript
|
const ghostBookshelf = require('./base');
|
||
|
const _ = require('lodash');
|
||
|
const errors = require('@tryghost/errors');
|
||
|
const tpl = require('@tryghost/tpl');
|
||
|
const {ValidationError} = require('@tryghost/errors');
|
||
|
|
||
|
const messages = {
|
||
|
emptyComment: 'The body of a comment cannot be empty',
|
||
|
commentNotFound: 'Comment could not be found',
|
||
|
notYourCommentToEdit: 'You may only edit your own comments',
|
||
|
notYourCommentToDestroy: 'You may only delete your own comments'
|
||
|
};
|
||
|
|
||
|
/**
|
||
|
* Remove empty paragraps from the start and end
|
||
|
* + remove duplicate empty paragrapsh (only one empty line allowed)
|
||
|
*/
|
||
|
function trimParagraphs(str) {
|
||
|
const paragraph = '<p></p>';
|
||
|
const escapedParagraph = '<p>\\s*?</p>';
|
||
|
|
||
|
const startReg = new RegExp('^(' + escapedParagraph + ')+');
|
||
|
const endReg = new RegExp('(' + escapedParagraph + ')+$');
|
||
|
const duplicates = new RegExp('(' + escapedParagraph + ')+');
|
||
|
return str.replace(startReg, '').replace(endReg, '').replace(duplicates, paragraph);
|
||
|
}
|
||
|
|
||
|
const Comment = ghostBookshelf.Model.extend({
|
||
|
tableName: 'comments',
|
||
|
|
||
|
defaults: function defaults() {
|
||
|
return {
|
||
|
status: 'published'
|
||
|
};
|
||
|
},
|
||
|
|
||
|
post() {
|
||
|
return this.belongsTo('Post', 'post_id');
|
||
|
},
|
||
|
|
||
|
member() {
|
||
|
return this.belongsTo('Member', 'member_id');
|
||
|
},
|
||
|
|
||
|
parent() {
|
||
|
return this.belongsTo('Comment', 'parent_id');
|
||
|
},
|
||
|
|
||
|
likes() {
|
||
|
return this.hasMany('CommentLike', 'comment_id');
|
||
|
},
|
||
|
|
||
|
replies() {
|
||
|
return this.hasMany('Comment', 'parent_id', 'id')
|
||
|
.query('orderBy', 'created_at', 'ASC')
|
||
|
// Note: this limit is not working
|
||
|
.query('limit', 3);
|
||
|
},
|
||
|
|
||
|
emitChange: function emitChange(event, options) {
|
||
|
const eventToTrigger = 'comment' + '.' + event;
|
||
|
ghostBookshelf.Model.prototype.emitChange.bind(this)(this, eventToTrigger, options);
|
||
|
},
|
||
|
|
||
|
onSaving() {
|
||
|
ghostBookshelf.Model.prototype.onSaving.apply(this, arguments);
|
||
|
|
||
|
if (this.hasChanged('html')) {
|
||
|
const sanitizeHtml = require('sanitize-html');
|
||
|
|
||
|
const html = trimParagraphs(
|
||
|
sanitizeHtml(this.get('html'), {
|
||
|
allowedTags: ['p', 'br', 'a', 'blockquote'],
|
||
|
allowedAttributes: {
|
||
|
a: ['href', 'target', 'rel']
|
||
|
},
|
||
|
selfClosing: ['br'],
|
||
|
// Enforce _blank and safe URLs
|
||
|
transformTags: {
|
||
|
a: sanitizeHtml.simpleTransform('a', {
|
||
|
target: '_blank',
|
||
|
rel: 'ugc noopener noreferrer nofollow'
|
||
|
})
|
||
|
}
|
||
|
})
|
||
|
).trim();
|
||
|
|
||
|
if (html.length === 0) {
|
||
|
throw new ValidationError({
|
||
|
message: tpl(messages.emptyComment)
|
||
|
});
|
||
|
}
|
||
|
this.set('html', html);
|
||
|
}
|
||
|
},
|
||
|
|
||
|
onCreated: function onCreated(model, options) {
|
||
|
ghostBookshelf.Model.prototype.onCreated.apply(this, arguments);
|
||
|
|
||
|
model.emitChange('added', options);
|
||
|
},
|
||
|
|
||
|
enforcedFilters: function enforcedFilters(options) {
|
||
|
// Convenience option to merge all filters with parent_id:null filter
|
||
|
if (options.parentId !== undefined) {
|
||
|
if (options.parentId === null) {
|
||
|
return 'parent_id:null';
|
||
|
}
|
||
|
return 'parent_id:\'' + options.parentId + '\'';
|
||
|
}
|
||
|
|
||
|
return null;
|
||
|
}
|
||
|
}, {
|
||
|
destroy: function destroy(unfilteredOptions) {
|
||
|
let options = this.filterOptions(unfilteredOptions, 'destroy', {extraAllowedProperties: ['id']});
|
||
|
|
||
|
const softDelete = () => {
|
||
|
return ghostBookshelf.Model.edit.call(this, {status: 'deleted'}, options);
|
||
|
};
|
||
|
|
||
|
if (!options.transacting) {
|
||
|
return ghostBookshelf.transaction((transacting) => {
|
||
|
options.transacting = transacting;
|
||
|
return softDelete();
|
||
|
});
|
||
|
}
|
||
|
|
||
|
return softDelete();
|
||
|
},
|
||
|
|
||
|
async permissible(commentModelOrId, action, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasApiKeyPermission, hasMemberPermission) {
|
||
|
const self = this;
|
||
|
|
||
|
if (hasUserPermission) {
|
||
|
return true;
|
||
|
}
|
||
|
|
||
|
if (_.isString(commentModelOrId)) {
|
||
|
// Grab the original args without the first one
|
||
|
const origArgs = _.toArray(arguments).slice(1);
|
||
|
|
||
|
// Get the actual comment model
|
||
|
return this.findOne({
|
||
|
id: commentModelOrId
|
||
|
}).then(function then(foundCommentModel) {
|
||
|
if (!foundCommentModel) {
|
||
|
throw new errors.NotFoundError({
|
||
|
message: tpl(messages.commentNotFound)
|
||
|
});
|
||
|
}
|
||
|
|
||
|
// Build up the original args but substitute with actual model
|
||
|
const newArgs = [foundCommentModel].concat(origArgs);
|
||
|
|
||
|
return self.permissible.apply(self, newArgs);
|
||
|
});
|
||
|
}
|
||
|
|
||
|
if (action === 'edit' && commentModelOrId.get('member_id') !== context.member.id) {
|
||
|
return Promise.reject(new errors.NoPermissionError({
|
||
|
message: tpl(messages.notYourCommentToEdit)
|
||
|
}));
|
||
|
}
|
||
|
|
||
|
if (action === 'destroy' && commentModelOrId.get('member_id') !== context.member.id) {
|
||
|
return Promise.reject(new errors.NoPermissionError({
|
||
|
message: tpl(messages.notYourCommentToDestroy)
|
||
|
}));
|
||
|
}
|
||
|
|
||
|
return hasMemberPermission;
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* We have to ensure consistency. If you listen on model events (e.g. `member.added`), you can expect that you always
|
||
|
* receive all fields including relations. Otherwise you can't rely on a consistent flow. And we want to avoid
|
||
|
* that event listeners have to re-fetch a resource. This function is used in the context of inserting
|
||
|
* and updating resources. We won't return the relations by default for now.
|
||
|
*/
|
||
|
defaultRelations: function defaultRelations(methodName, options) {
|
||
|
// @todo: the default relations are not working for 'add' when we add it below
|
||
|
if (['findAll', 'findPage', 'edit', 'findOne', 'destroy'].indexOf(methodName) !== -1) {
|
||
|
if (!options.withRelated || options.withRelated.length === 0) {
|
||
|
if (options.parentId) {
|
||
|
// Do not include replies for replies
|
||
|
options.withRelated = [
|
||
|
// Relations
|
||
|
'member', 'count.likes', 'count.liked'
|
||
|
];
|
||
|
} else {
|
||
|
options.withRelated = [
|
||
|
// Relations
|
||
|
'member', 'count.replies', 'count.likes', 'count.liked',
|
||
|
// Replies (limited to 3)
|
||
|
'replies', 'replies.member' , 'replies.count.likes', 'replies.count.liked'
|
||
|
];
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
return options;
|
||
|
},
|
||
|
|
||
|
async findPage(options) {
|
||
|
const {withRelated} = this.defaultRelations('findPage', options);
|
||
|
|
||
|
const relationsToLoadIndividually = [
|
||
|
'replies',
|
||
|
'replies.member',
|
||
|
'replies.count.likes',
|
||
|
'replies.count.liked'
|
||
|
].filter(relation => withRelated.includes(relation));
|
||
|
|
||
|
const result = await ghostBookshelf.Model.findPage.call(this, options);
|
||
|
|
||
|
for (const model of result.data) {
|
||
|
await model.load(relationsToLoadIndividually, _.omit(options, 'withRelated'));
|
||
|
}
|
||
|
|
||
|
return result;
|
||
|
},
|
||
|
|
||
|
countRelations() {
|
||
|
return {
|
||
|
replies(modelOrCollection) {
|
||
|
modelOrCollection.query('columns', 'comments.*', (qb) => {
|
||
|
qb.count('replies.id')
|
||
|
.from('comments AS replies')
|
||
|
.whereRaw('replies.parent_id = comments.id')
|
||
|
.as('count__replies');
|
||
|
});
|
||
|
},
|
||
|
likes(modelOrCollection) {
|
||
|
modelOrCollection.query('columns', 'comments.*', (qb) => {
|
||
|
qb.count('comment_likes.id')
|
||
|
.from('comment_likes')
|
||
|
.whereRaw('comment_likes.comment_id = comments.id')
|
||
|
.as('count__likes');
|
||
|
});
|
||
|
},
|
||
|
liked(modelOrCollection, options) {
|
||
|
modelOrCollection.query('columns', 'comments.*', (qb) => {
|
||
|
if (options.context && options.context.member && options.context.member.id) {
|
||
|
qb.count('comment_likes.id')
|
||
|
.from('comment_likes')
|
||
|
.whereRaw('comment_likes.comment_id = comments.id')
|
||
|
.where('comment_likes.member_id', options.context.member.id)
|
||
|
.as('count__liked');
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// Return zero
|
||
|
qb.select(ghostBookshelf.knex.raw('0')).as('count__liked');
|
||
|
});
|
||
|
}
|
||
|
};
|
||
|
},
|
||
|
|
||
|
/**
|
||
|
* Returns an array of keys permitted in a method's `options` hash, depending on the current method.
|
||
|
* @param {String} methodName The name of the method to check valid options for.
|
||
|
* @return {Array} Keys allowed in the `options` hash of the model's method.
|
||
|
*/
|
||
|
permittedOptions: function permittedOptions(methodName) {
|
||
|
let options = ghostBookshelf.Model.permittedOptions.call(this, methodName);
|
||
|
|
||
|
// The comment model additionally supports having a parentId option
|
||
|
options.push('parentId');
|
||
|
|
||
|
return options;
|
||
|
}
|
||
|
});
|
||
|
|
||
|
module.exports = {
|
||
|
Comment: ghostBookshelf.model('Comment', Comment)
|
||
|
};
|