rittenhop-ghost/versions/5.94.2/core/server/models/comment.js

278 lines
9.7 KiB
JavaScript
Raw Normal View History

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