Ghost/core/server/models/relations/authors.js

366 lines
15 KiB
JavaScript
Raw Normal View History

✨ Multiple authors (#9426) no issue This PR adds the server side logic for multiple authors. This adds the ability to add multiple authors per post. We keep and support single authors (maybe till the next major - this is still in discussion) ### key notes - `authors` are not fetched by default, only if we need them - the migration script iterates over all posts and figures out if an author_id is valid and exists (in master we can add invalid author_id's) and then adds the relation (falls back to owner if invalid) - ~~i had to push a fork of bookshelf to npm because we currently can't bump bookshelf + the two bugs i discovered are anyway not yet merged (https://github.com/kirrg001/bookshelf/commits/master)~~ replaced by new bookshelf release - the implementation of single & multiple authors lives in a single place (introduction of a new concept: model relation) - if you destroy an author, we keep the behaviour for now -> remove all posts where the primary author id matches. furthermore, remove all relations in posts_authors (e.g. secondary author) - we make re-use of the `excludeAttrs` concept which was invented in the contributors PR (to protect editing authors as author/contributor role) -> i've added a clear todo that we need a logic to make a diff of the target relation -> both for tags and authors - `authors` helper available (same as `tags` helper) - `primary_author` computed field available - `primary_author` functionality available (same as `primary_tag` e.g. permalinks, prev/next helper etc)
2018-03-27 17:16:15 +03:00
'use strict';
const _ = require('lodash'),
Promise = require('bluebird'),
common = require('../../lib/common/index');
/**
* 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 2.0
*/
model.unset('author');
// 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: 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: 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 2.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.
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 tag
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,
result = {};
// 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();
}
// @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) {
result.excludedAttrs = ['authors'];
}
if (hasUserPermission && hasAppPermission) {
return Post.permissible.call(
this,
postModelOrId,
action, context,
unsafeAttrs,
loadedPermissions,
hasUserPermission,
hasAppPermission,
result
);
}
return Promise.reject(new common.errors.NoPermissionError({
message: common.i18n.t('errors.models.post.notEnoughPermission')
}));
}
});
return Model;
};