mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-18 16:01:40 +03:00
c6a95c6478
no issue - this commit cleans up the usages of `include` and `withRelated`. ### API layer (`include`) - as request parameter e.g. `?include=roles,tags` - as theme API parameter e.g. `{{get .... include="author"}}` - as internal API access e.g. `api.posts.browse({include: 'author,tags'})` - the `include` notation is more readable than `withRelated` - and it allows us to use a different easier format (comma separated list) - the API utility transforms these more readable properties into model style (or into Ghost style) ### Model access (`withRelated`) - e.g. `models.Post.findPage({withRelated: ['tags']})` - driven by bookshelf --- Commits explained. * Reorder the usage of `convertOptions` - 1. validation - 2. options convertion - 3. permissions - the reason is simple, the permission layer access the model layer - we have to prepare the options before talking to the model layer - added `convertOptions` where it was missed (not required, but for consistency reasons) * Use `withRelated` when accessing the model layer and use `include` when accessing the API layer * Change `convertOptions` API utiliy - API Usage - ghost.api(..., {include: 'tags,authors'}) - `include` should only be used when calling the API (either via request or via manual usage) - `include` is only for readability and easier format - Ghost (Model Layer Usage) - models.Post.findOne(..., {withRelated: ['tags', 'authors']}) - should only use `withRelated` - model layer cannot read 'tags,authors` - model layer has no idea what `include` means, speaks a different language - `withRelated` is bookshelf - internal usage * include-count plugin: use `withRelated` instead of `include` - imagine you outsource this plugin to git and publish it to npm - `include` is an unknown option in bookshelf * Updated `permittedOptions` in base model - `include` is no longer a known option * Remove all occurances of `include` in the model layer * Extend `filterOptions` base function - this function should be called as first action - we clone the unfiltered options - check if you are using `include` (this is a protection which could help us in the beginning) - check for permitted and (later on default `withRelated`) options - the usage is coming in next commit * Ensure we call `filterOptions` as first action - use `ghostBookshelf.Model.filterOptions` as first action - consistent naming pattern for incoming options: `unfilteredOptions` - re-added allowed options for `toJSON` - one unsolved architecture problem: - if you override a function e.g. `edit` - then you should call `filterOptions` as first action - the base implementation of e.g. `edit` will call it again - future improvement * Removed `findOne` from Invite model - no longer needed, the base implementation is the same
343 lines
13 KiB
JavaScript
343 lines
13 KiB
JavaScript
// # API Utils
|
|
// Shared helpers for working with the API
|
|
var Promise = require('bluebird'),
|
|
_ = require('lodash'),
|
|
permissions = require('../services/permissions'),
|
|
validation = require('../data/validation'),
|
|
common = require('../lib/common'),
|
|
utils;
|
|
|
|
utils = {
|
|
// ## Default Options
|
|
// Various default options for different types of endpoints
|
|
|
|
// ### Auto Default Options
|
|
// Handled / Added automatically by the validate function
|
|
// globalDefaultOptions - valid for every api endpoint
|
|
globalDefaultOptions: ['context', 'include'],
|
|
// dataDefaultOptions - valid for all endpoints which take object as well as options
|
|
dataDefaultOptions: ['data'],
|
|
|
|
// ### Manual Default Options
|
|
// These must be provided by the endpoint
|
|
// browseDefaultOptions - valid for all browse api endpoints
|
|
browseDefaultOptions: ['page', 'limit', 'fields', 'filter', 'order', 'debug'],
|
|
// idDefaultOptions - valid whenever an id is valid
|
|
idDefaultOptions: ['id'],
|
|
|
|
/**
|
|
* ## Validate
|
|
* Prepare to validate the object and options passed to an endpoint
|
|
* @param {String} docName
|
|
* @param {Object} extras
|
|
* @returns {Function} doValidate
|
|
*/
|
|
validate: function validate(docName, extras) {
|
|
/**
|
|
* ### Do Validate
|
|
* Validate the object and options passed to an endpoint
|
|
* @argument {...*} [arguments] object or object and options hash
|
|
*/
|
|
return function doValidate() {
|
|
var object, options, permittedOptions;
|
|
|
|
if (arguments.length === 2) {
|
|
object = arguments[0];
|
|
options = _.clone(arguments[1]) || {};
|
|
} else if (arguments.length === 1) {
|
|
options = _.clone(arguments[0]) || {};
|
|
} else {
|
|
options = {};
|
|
}
|
|
|
|
// Setup permitted options, starting with the global defaults
|
|
permittedOptions = utils.globalDefaultOptions;
|
|
|
|
// Add extra permitted options if any are passed in
|
|
if (extras && extras.opts) {
|
|
permittedOptions = permittedOptions.concat(extras.opts);
|
|
}
|
|
|
|
// This request will have a data key added during validation
|
|
if ((extras && extras.attrs) || object) {
|
|
permittedOptions = permittedOptions.concat(utils.dataDefaultOptions);
|
|
}
|
|
|
|
// If an 'attrs' object is passed, we use this to pick from options and convert them to data
|
|
if (extras && extras.attrs) {
|
|
options.data = _.pick(options, extras.attrs);
|
|
options = _.omit(options, extras.attrs);
|
|
}
|
|
|
|
/**
|
|
* ### Check Options
|
|
* Ensure that the options provided match exactly with what is permitted
|
|
* - incorrect option keys are sanitized
|
|
* - incorrect option values are validated
|
|
* @param {object} options
|
|
* @returns {Promise<options>}
|
|
*/
|
|
function checkOptions(options) {
|
|
// @TODO: should we throw an error if there are incorrect options provided?
|
|
options = _.pick(options, permittedOptions);
|
|
|
|
var validationErrors = utils.validateOptions(options);
|
|
|
|
if (_.isEmpty(validationErrors)) {
|
|
return Promise.resolve(options);
|
|
}
|
|
|
|
// For now, we can only handle showing the first validation error
|
|
return Promise.reject(validationErrors[0]);
|
|
}
|
|
|
|
// If we got an object, check that too
|
|
if (object) {
|
|
return utils.checkObject(object, docName, options.id).then(function (data) {
|
|
options.data = data;
|
|
|
|
return checkOptions(options);
|
|
});
|
|
}
|
|
|
|
// Otherwise just check options and return
|
|
return checkOptions(options);
|
|
};
|
|
},
|
|
|
|
validateOptions: function validateOptions(options) {
|
|
var globalValidations = {
|
|
id: {matches: /^[a-f\d]{24}$|^1$|me/i},
|
|
uuid: {isUUID: true},
|
|
slug: {isSlug: true},
|
|
page: {matches: /^\d+$/},
|
|
limit: {matches: /^\d+|all$/},
|
|
from: {isDate: true},
|
|
to: {isDate: true},
|
|
fields: {matches: /^[\w, ]+$/},
|
|
order: {matches: /^[a-z0-9_,\. ]+$/i},
|
|
name: {},
|
|
email: {isEmail: true}
|
|
},
|
|
// these values are sanitised/validated separately
|
|
noValidation = ['data', 'context', 'include', 'filter', 'forUpdate', 'transacting', 'formats'],
|
|
errors = [];
|
|
|
|
_.each(options, function (value, key) {
|
|
// data is validated elsewhere
|
|
if (noValidation.indexOf(key) === -1) {
|
|
if (globalValidations[key]) {
|
|
errors = errors.concat(validation.validate(value, key, globalValidations[key]));
|
|
} else {
|
|
// all other keys should be alpha-numeric with dashes/underscores, like tag, author, status, etc
|
|
errors = errors.concat(validation.validate(value, key, globalValidations.slug));
|
|
}
|
|
}
|
|
});
|
|
|
|
return errors;
|
|
},
|
|
|
|
/**
|
|
* ## Detect Public Context
|
|
* Calls parse context to expand the options.context object
|
|
* @param {Object} options
|
|
* @returns {Boolean}
|
|
*/
|
|
detectPublicContext: function detectPublicContext(options) {
|
|
options.context = permissions.parseContext(options.context);
|
|
return options.context.public;
|
|
},
|
|
/**
|
|
* ## Apply Public Permissions
|
|
* Update the options object so that the rules reflect what is permitted to be retrieved from a public request
|
|
* @param {String} docName
|
|
* @param {String} method (read || browse)
|
|
* @param {Object} options
|
|
* @returns {Object} options
|
|
*/
|
|
applyPublicPermissions: function applyPublicPermissions(docName, method, options) {
|
|
return permissions.applyPublicRules(docName, method, options);
|
|
},
|
|
|
|
/**
|
|
* ## Handle Public Permissions
|
|
* @param {String} docName
|
|
* @param {String} method (read || browse)
|
|
* @returns {Function}
|
|
*/
|
|
handlePublicPermissions: function handlePublicPermissions(docName, method) {
|
|
var singular = docName.replace(/s$/, '');
|
|
|
|
/**
|
|
* Check if this is a public request, if so use the public permissions, otherwise use standard canThis
|
|
* @param {Object} options
|
|
* @returns {Object} options
|
|
*/
|
|
return function doHandlePublicPermissions(options) {
|
|
var permsPromise;
|
|
|
|
if (utils.detectPublicContext(options)) {
|
|
permsPromise = utils.applyPublicPermissions(docName, method, options);
|
|
} else {
|
|
permsPromise = permissions.canThis(options.context)[method][singular](options.data);
|
|
}
|
|
|
|
return permsPromise.then(function permissionGranted() {
|
|
return options;
|
|
});
|
|
};
|
|
},
|
|
|
|
/**
|
|
* ## Handle Permissions
|
|
* @param {String} docName
|
|
* @param {String} method (browse || read || edit || add || destroy)
|
|
* * @param {Array} unsafeAttrNames - attribute names (e.g. post.status) that could change the outcome
|
|
* @returns {Function}
|
|
*/
|
|
handlePermissions: function handlePermissions(docName, method, unsafeAttrNames) {
|
|
var singular = docName.replace(/s$/, '');
|
|
|
|
/**
|
|
* ### Handle Permissions
|
|
* We need to be an authorised user to perform this action
|
|
* @param {Object} options
|
|
* @returns {Object} options
|
|
*/
|
|
return function doHandlePermissions(options) {
|
|
var unsafeAttrObject = unsafeAttrNames && _.has(options, 'data.[' + docName + '][0]') ? _.pick(options.data[docName][0], unsafeAttrNames) : {},
|
|
permsPromise = permissions.canThis(options.context)[method][singular](options.id, unsafeAttrObject);
|
|
|
|
return permsPromise.then(function permissionGranted(result) {
|
|
/*
|
|
* Allow the permissions function to return a list of excluded attributes.
|
|
* If it does, omit those attrs from the data passed through
|
|
*
|
|
* NOTE: excludedAttrs differ from unsafeAttrs in that they're determined by the model's permissible function,
|
|
* and the attributes are simply excluded rather than throwing a NoPermission exception
|
|
*
|
|
* TODO: This is currently only needed because of the posts model and the contributor role. Once we extend the
|
|
* contributor role to be able to edit existing tags, this concept can be removed.
|
|
*/
|
|
if (result && result.excludedAttrs && _.has(options, 'data.[' + docName + '][0]')) {
|
|
options.data[docName][0] = _.omit(options.data[docName][0], result.excludedAttrs);
|
|
}
|
|
|
|
return options;
|
|
}).catch(function handleNoPermissionError(err) {
|
|
if (err instanceof common.errors.NoPermissionError) {
|
|
err.message = common.i18n.t('errors.api.utils.noPermissionToCall', {
|
|
method: method,
|
|
docName: docName
|
|
});
|
|
return Promise.reject(err);
|
|
}
|
|
|
|
return Promise.reject(new common.errors.GhostError({
|
|
err: err
|
|
}));
|
|
});
|
|
};
|
|
},
|
|
|
|
trimAndLowerCase: function trimAndLowerCase(params) {
|
|
params = params || '';
|
|
if (_.isString(params)) {
|
|
params = params.split(',');
|
|
}
|
|
|
|
return _.map(params, function (item) {
|
|
return item.trim().toLowerCase();
|
|
});
|
|
},
|
|
|
|
prepareInclude: function prepareInclude(include, allowedIncludes) {
|
|
return _.intersection(this.trimAndLowerCase(include), allowedIncludes);
|
|
},
|
|
|
|
prepareFields: function prepareFields(fields) {
|
|
return this.trimAndLowerCase(fields);
|
|
},
|
|
|
|
prepareFormats: function prepareFormats(formats, allowedFormats) {
|
|
return _.intersection(this.trimAndLowerCase(formats), allowedFormats);
|
|
},
|
|
|
|
/**
|
|
* ## Convert Options
|
|
* @param {Array} allowedIncludes
|
|
* @returns {Function} doConversion
|
|
*/
|
|
convertOptions: function convertOptions(allowedIncludes, allowedFormats) {
|
|
/**
|
|
* Convert our options from API-style to Model-style
|
|
* @param {Object} options
|
|
* @returns {Object} options
|
|
*/
|
|
return function doConversion(options) {
|
|
if (options.include) {
|
|
options.withRelated = utils.prepareInclude(options.include, allowedIncludes);
|
|
delete options.include;
|
|
}
|
|
|
|
if (options.fields) {
|
|
options.columns = utils.prepareFields(options.fields);
|
|
delete options.fields;
|
|
}
|
|
|
|
if (options.formats) {
|
|
options.formats = utils.prepareFormats(options.formats, allowedFormats);
|
|
}
|
|
|
|
if (options.formats && options.columns) {
|
|
options.columns = options.columns.concat(options.formats);
|
|
}
|
|
|
|
return options;
|
|
};
|
|
},
|
|
/**
|
|
* ### Check Object
|
|
* Check an object passed to the API is in the correct format
|
|
*
|
|
* @param {Object} object
|
|
* @param {String} docName
|
|
* @returns {Promise(Object)} resolves to the original object if it checks out
|
|
*/
|
|
checkObject: function checkObject(object, docName, editId) {
|
|
if (_.isEmpty(object) || _.isEmpty(object[docName]) || _.isEmpty(object[docName][0])) {
|
|
return Promise.reject(new common.errors.BadRequestError({
|
|
message: common.i18n.t('errors.api.utils.noRootKeyProvided', {docName: docName})
|
|
}));
|
|
}
|
|
|
|
// convert author property to author_id to match the name in the database
|
|
if (docName === 'posts') {
|
|
if (object.posts[0].hasOwnProperty('author')) {
|
|
object.posts[0].author_id = object.posts[0].author;
|
|
delete object.posts[0].author;
|
|
}
|
|
}
|
|
|
|
// will remove unwanted null values
|
|
_.each(object[docName], function (value, index) {
|
|
if (!_.isObject(object[docName][index])) {
|
|
return;
|
|
}
|
|
|
|
object[docName][index] = _.omitBy(object[docName][index], _.isNull);
|
|
});
|
|
|
|
if (editId && object[docName][0].id && editId !== object[docName][0].id) {
|
|
return Promise.reject(new common.errors.BadRequestError({
|
|
message: common.i18n.t('errors.api.utils.invalidIdProvided')
|
|
}));
|
|
}
|
|
|
|
return Promise.resolve(object);
|
|
}
|
|
};
|
|
|
|
module.exports = utils;
|