Ghost/core/server/models/relations/authors.js
Fabien O'Carroll 23e9a02ff1
Updated Post and Author model permissible method (#9966)
refs #9865

Both the Post and the Author model implement the permissible method,
however the Post model does not abide by the signature of the
permissible method and add their own parameter "result" at the end.

This makes changes to the permissible method difficult as we have to
take into account multiple signatures.

This changes the Post model permissible method to the correct signature,
but still retains the current functionality. This will make it easier to
break up future permission related PR's so they can be reviwed easier
and faster!
2018-10-09 18:03:13 +07:00

366 lines
15 KiB
JavaScript

const _ = require('lodash'),
Promise = require('bluebird'),
common = require('../../lib/common');
/**
* Why and when do we have to fetch `authors` by default?
*
* # CASE 1
* We fetch the `authors` relations when you either request `withRelated=['authors']` or `withRelated=['author`].
* The old `author` relation was removed, but we still have to support this case.
*
* # CASE 2
* We fetch when editing a post.
* Imagine you change `author_id` and you have 3 existing `posts_authors`.
* We now need to set `author_id` as primary author `post.authors[0]`.
* Furthermore, we now longer have a `author` relationship.
*
* # CASE 3:
* If you request `include=author`, we have to fill this object with `post.authors[0]`.
* Otherwise we can't return `post.author = User`.
*
* ---
*
* It's impossible to implement a default `withRelated` feature nicely at the moment, because we can't hook into bookshelf
* and support all model queries and collection queries (e.g. fetchAll). The hardest part is to remember
* if the user requested the `authors` or not. Overriding `sync` does not work for collections.
* And overriding the sync method of Collection does not trigger sync - probably a bookshelf bug, i have
* not investigated.
*
* That's why we remember `_originalOptions` for now - only specific to posts.
*
* NOTE: If we fetch the multiple authors manually on the events, we run into the same problem. We have to remember
* the original options. Plus: we would fetch the authors twice in some cases.
*/
module.exports.extendModel = function extendModel(Post, Posts, ghostBookshelf) {
const proto = Post.prototype;
const Model = Post.extend({
_handleOptions: function _handleOptions(fnName) {
const self = this;
return function innerHandleOptions(model, attrs, options) {
model._originalOptions = _.cloneDeep(_.pick(options, ['withRelated']));
if (!options.withRelated) {
options.withRelated = [];
}
if (options.withRelated.indexOf('author') !== -1) {
options.withRelated.splice(options.withRelated.indexOf('author'), 1);
options.withRelated.push('authors');
}
if (options.forUpdate &&
['onFetching', 'onFetchingCollection'].indexOf(fnName) !== -1 &&
options.withRelated.indexOf('authors') === -1) {
options.withRelated.push('authors');
}
return proto[fnName].call(self, model, attrs, options);
};
},
onFetching: function onFetching(model, attrs, options) {
return this._handleOptions('onFetching')(model, attrs, options);
},
onFetchingCollection: function onFetchingCollection(collection, attrs, options) {
return this._handleOptions('onFetchingCollection')(collection, attrs, options);
},
onFetchedCollection: function (collection, attrs, options) {
_.each(collection.models, ((model) => {
model._originalOptions = collection._originalOptions;
}));
return proto.onFetchingCollection.call(this, collection, attrs, options);
},
// NOTE: sending `post.author = {}` was always ignored [unsupported]
onCreating: function onCreating(model, attrs, options) {
if (!model.get('author_id')) {
model.set('author_id', this.contextUser(options));
}
if (!model.get('authors')) {
model.set('authors', [{
id: model.get('author_id')
}]);
}
return this._handleOptions('onCreating')(model, attrs, options);
},
onUpdating: function onUpdating(model, attrs, options) {
return this._handleOptions('onUpdating')(model, attrs, options);
},
// NOTE: `post.author` was always ignored [unsupported]
onSaving: function (model, attrs, options) {
/**
* @deprecated: `author`, will be removed in Ghost 3.0
*/
model.unset('author');
// CASE: you can't delete all authors
if (model.get('authors') && !model.get('authors').length) {
throw new common.errors.ValidationError({
message: 'At least one author is required.'
});
}
// CASE: `post.author_id` has changed
if (model.hasChanged('author_id')) {
// CASE: you don't send `post.authors`
// SOLUTION: we have to update the primary author
if (!model.get('authors')) {
let existingAuthors = model.related('authors').toJSON();
// CASE: override primary author
existingAuthors[0] = {
id: model.get('author_id')
};
model.set('authors', existingAuthors);
} else {
// CASE: you send `post.authors` next to `post.author_id`
if (model.get('authors')[0].id !== model.get('author_id')) {
model.set('author_id', model.get('authors')[0].id);
}
}
}
// CASE: if you change `post.author_id`, we have to update the primary author
// CASE: if the `author_id` has change and you pass `posts.authors`, we already check above that
// the primary author id must be equal
if (model.hasChanged('author_id') && !model.get('authors')) {
let existingAuthors = model.related('authors').toJSON();
// CASE: override primary author
existingAuthors[0] = {
id: model.get('author_id')
};
model.set('authors', existingAuthors);
} else if (model.get('authors') && model.get('authors').length) {
// ensure we update the primary author id
model.set('author_id', model.get('authors')[0].id);
}
return proto.onSaving.call(this, model, attrs, options);
},
serialize: function serialize(options) {
const authors = this.related('authors');
let attrs = proto.serialize.call(this, options);
// CASE: e.g. you stub model response in the test
// CASE: you delete a model without fetching before
if (!this._originalOptions) {
this._originalOptions = {};
}
/**
* CASE: `author` was requested, `posts.authors` must exist
* @deprecated: `author`, will be removed in Ghost 3.0
*/
if (this._originalOptions.withRelated && this._originalOptions.withRelated && this._originalOptions.withRelated.indexOf('author') !== -1) {
if (!authors.models.length) {
throw new common.errors.ValidationError({
message: 'The target post has no primary author.'
});
}
attrs.author = attrs.authors[0];
delete attrs.author_id;
} else {
// CASE: we return `post.author=id` with or without requested columns.
// @NOTE: this serialization should be moved into api layer, it's not being moved as it's deprecated
if (!options.columns || (options.columns && options.columns.indexOf('author') !== -1)) {
attrs.author = attrs.author_id;
delete attrs.author_id;
}
}
// CASE: `posts.authors` was not requested, but fetched in specific cases (see top)
if (!this._originalOptions || !this._originalOptions.withRelated || this._originalOptions.withRelated.indexOf('authors') === -1) {
delete attrs.authors;
}
// If the current column settings allow it...
if (!options.columns || (options.columns && options.columns.indexOf('primary_author') > -1)) {
// ... attach a computed property of primary_author which is the first author
if (attrs.authors && attrs.authors.length) {
attrs.primary_author = attrs.authors[0];
} else {
attrs.primary_author = null;
}
}
return attrs;
}
}, {
/**
* ### destroyByAuthor
* @param {[type]} options has context and id. Context is the user doing the destroy, id is the user to destroy
*/
destroyByAuthor: function destroyByAuthor(unfilteredOptions) {
let options = this.filterOptions(unfilteredOptions, 'destroyByAuthor', {extraAllowedProperties: ['id']}),
postCollection = Posts.forge(),
authorId = options.id;
if (!authorId) {
return Promise.reject(new common.errors.NotFoundError({
message: common.i18n.t('errors.models.post.noUserFound')
}));
}
// CASE: if you are the primary author of a post, the whole post and it's relations get's deleted.
// `posts_authors` are automatically removed (bookshelf-relations)
// CASE: if you are the secondary author of a post, you are just deleted as author.
// must happen manually
const destroyPost = (() => {
return postCollection
.query('where', 'author_id', '=', authorId)
.fetch(options)
.call('invokeThen', 'destroy', options)
.then(function (response) {
return (options.transacting || ghostBookshelf.knex)('posts_authors')
.where('author_id', authorId)
.del()
.return(response);
})
.catch((err) => {
throw new common.errors.GhostError({err: err});
});
});
if (!options.transacting) {
return ghostBookshelf.transaction((transacting) => {
options.transacting = transacting;
return destroyPost();
});
}
return destroyPost();
},
permissible: function permissible(postModelOrId, action, context, unsafeAttrs, loadedPermissions, hasUserPermission, hasAppPermission) {
var self = this,
postModel = postModelOrId,
origArgs, isContributor, isAuthor, isEdit, isAdd, isDestroy;
// If we passed in an id instead of a model, get the model
// then check the permissions
if (_.isNumber(postModelOrId) || _.isString(postModelOrId)) {
// Grab the original args without the first one
origArgs = _.toArray(arguments).slice(1);
// Get the actual post model
return this.findOne({id: postModelOrId, status: 'all'}, {withRelated: ['authors']})
.then(function then(foundPostModel) {
if (!foundPostModel) {
throw new common.errors.NotFoundError({
level: 'critical',
message: common.i18n.t('errors.models.post.postNotFound')
});
}
// Build up the original args but substitute with actual model
const newArgs = [foundPostModel].concat(origArgs);
return self.permissible.apply(self, newArgs);
});
}
isContributor = loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Contributor'});
isAuthor = loadedPermissions.user && _.some(loadedPermissions.user.roles, {name: 'Author'});
isEdit = (action === 'edit');
isAdd = (action === 'add');
isDestroy = (action === 'destroy');
function isChanging(attr) {
return unsafeAttrs[attr] && unsafeAttrs[attr] !== postModel.get(attr);
}
function isChangingAuthors() {
if (!unsafeAttrs.authors) {
return false;
}
if (!unsafeAttrs.authors.length) {
return true;
}
return unsafeAttrs.authors[0].id !== postModel.related('authors').models[0].id;
}
function isOwner() {
let isCorrectOwner = true;
if (!unsafeAttrs.author_id && !unsafeAttrs.authors) {
return false;
}
if (unsafeAttrs.author_id) {
isCorrectOwner = unsafeAttrs.author_id && unsafeAttrs.author_id === context.user;
}
if (unsafeAttrs.authors) {
isCorrectOwner = isCorrectOwner && unsafeAttrs.authors.length && unsafeAttrs.authors[0].id === context.user;
}
return isCorrectOwner;
}
function isCurrentOwner() {
return context.user === postModel.related('authors').models[0].id;
}
if (isContributor && isEdit) {
hasUserPermission = !isChanging('author_id') && !isChangingAuthors() && isCurrentOwner();
} else if (isContributor && isAdd) {
hasUserPermission = isOwner();
} else if (isContributor && isDestroy) {
hasUserPermission = isCurrentOwner();
} else if (isAuthor && isEdit) {
hasUserPermission = isCurrentOwner() && !isChanging('author_id') && !isChangingAuthors();
} else if (isAuthor && isAdd) {
hasUserPermission = isOwner();
} else if (postModel) {
hasUserPermission = hasUserPermission || isCurrentOwner();
}
if (hasUserPermission && hasAppPermission) {
return Post.permissible.call(
this,
postModelOrId,
action, context,
unsafeAttrs,
loadedPermissions,
hasUserPermission,
hasAppPermission
).then(({excludedAttrs}) => {
// @TODO: we need a concept for making a diff between incoming authors and existing authors
// @TODO: for now we simply re-use the new concept of `excludedAttrs`
// We only check the primary author of `authors`, any other change will be ignored.
// By this we can deprecate `author_id` more easily.
if (isContributor || isAuthor) {
return {
excludedAttrs: ['authors'].concat(excludedAttrs)
};
}
return {excludedAttrs};
});
}
return Promise.reject(new common.errors.NoPermissionError({
message: common.i18n.t('errors.models.post.notEnoughPermission')
}));
}
});
return Model;
};