Extracted Bookshelf sanitization code to plugins

no issue

- this commit extracts all options + data sanitization code from the Base
  model into a plugin
This commit is contained in:
Daniel Lockyer 2021-06-16 13:16:28 +01:00
parent f4f31027b7
commit 763d368c6e
3 changed files with 180 additions and 165 deletions

View File

@ -50,6 +50,8 @@ ghostBookshelf.plugin(require('./events'));
ghostBookshelf.plugin(require('./raw-knex'));
ghostBookshelf.plugin(require('./sanitize'));
// Manages nested updates (relationships)
ghostBookshelf.plugin('bookshelf-relations', {
allowedOptions: ['context', 'importing', 'migrating'],

View File

@ -19,11 +19,6 @@ const tpl = require('@tryghost/tpl');
const ghostBookshelf = require('./bookshelf');
const messages = {
missingContext: 'missing context',
invalidDate: 'Date format for `{key}` is invalid.'
};
let proto;
// Cache an instance of the base model prototype
@ -41,12 +36,6 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
return false;
},
// Ghost option handling - get permitted attributes from server/data/schema.js, where the DB schema is defined
permittedAttributes: function permittedAttributes() {
return _.keys(schema.tables[this.tableName])
.filter(key => key.indexOf('@@') === -1);
},
// Ghost ordering handling, allows to order by permitted attributes by default and can be overriden on specific model level
orderAttributes: function orderAttributes() {
return Object.keys(schema.tables[this.tableName])
@ -338,160 +327,6 @@ ghostBookshelf.Model = ghostBookshelf.Model.extend({
return id === ghostBookshelf.Model.externalUser || id === ghostBookshelf.Model.externalUser.toString();
},
/**
* Returns an array of keys permitted in every method's `options` hash.
* Can be overridden and added to by a model's `permittedOptions` method.
*
* importing: is used when import a JSON file or when migrating the database
*
* @return {Object} Keys allowed in the `options` hash of every model's method.
*/
permittedOptions: function permittedOptions(methodName) {
const baseOptions = ['context', 'withRelated'];
const extraOptions = ['transacting', 'importing', 'forUpdate', 'migrating'];
switch (methodName) {
case 'toJSON':
return baseOptions.concat('shallow', 'columns', 'previous');
case 'destroy':
return baseOptions.concat(extraOptions, ['id', 'destroyBy', 'require']);
case 'edit':
return baseOptions.concat(extraOptions, ['id', 'require']);
case 'findOne':
return baseOptions.concat(extraOptions, ['columns', 'require', 'mongoTransformer']);
case 'findAll':
return baseOptions.concat(extraOptions, ['filter', 'columns', 'mongoTransformer']);
case 'findPage':
return baseOptions.concat(extraOptions, ['filter', 'order', 'autoOrder', 'page', 'limit', 'columns', 'mongoTransformer']);
default:
return baseOptions.concat(extraOptions);
}
},
/**
* Filters potentially unsafe model attributes, so you can pass them to Bookshelf / Knex.
* This filter should be called before each insert/update operation.
*
* @param {Object} data Has keys representing the model's attributes/fields in the database.
* @return {Object} The filtered results of the passed in data, containing only what's allowed in the schema.
*/
filterData: function filterData(data) {
const permittedAttributes = this.prototype.permittedAttributes();
const filteredData = _.pick(data, permittedAttributes);
const sanitizedData = this.sanitizeData(filteredData);
return sanitizedData;
},
/**
* `sanitizeData` ensures that client data is in the correct format for further operations.
*
* Dates:
* - client dates are sent as ISO 8601 format (moment(..).format())
* - server dates are in JS Date format
* >> when bookshelf fetches data from the database, all dates are in JS Dates
* >> see `parse`
* - Bookshelf updates the model with the new client data via the `set` function
* - Bookshelf uses a simple `isEqual` function from lodash to detect real changes
* - .previous(attr) and .get(attr) returns false obviously
* - internally we use our `hasDateChanged` if we have to compare previous dates
* - but Bookshelf is not in our control for this case
*
* @IMPORTANT
* Before the new client data get's inserted again, the dates get's re-transformed into
* proper strings, see `format`.
*
* @IMPORTANT
* Sanitize relations.
*/
sanitizeData: function sanitizeData(data) {
const tableName = _.result(this.prototype, 'tableName');
let date;
_.each(data, (value, property) => {
if (value !== null
&& Object.prototype.hasOwnProperty.call(schema.tables[tableName], property)
&& schema.tables[tableName][property].type === 'dateTime'
&& typeof value === 'string'
) {
date = new Date(value);
// CASE: client sends `0000-00-00 00:00:00`
if (isNaN(date)) {
throw new errors.ValidationError({
message: tpl(messages.invalidDate, {key: property}),
code: 'DATE_INVALID'
});
}
data[property] = moment(value).toDate();
}
if (this.prototype.relationships && this.prototype.relationships.indexOf(property) !== -1) {
let relations = data[property];
// CASE: 1:1 relation will have single data point
if (!_.isArray(data[property])) {
relations = [data[property]];
}
_.each(relations, (relation, indexInArr) => {
_.each(relation, (relationValue, relationProperty) => {
if (relationValue !== null
&& Object.prototype.hasOwnProperty.call(schema.tables[this.prototype.relationshipBelongsTo[property]], relationProperty)
&& schema.tables[this.prototype.relationshipBelongsTo[property]][relationProperty].type === 'dateTime'
&& typeof relationValue === 'string'
) {
date = new Date(relationValue);
// CASE: client sends `0000-00-00 00:00:00`
if (isNaN(date)) {
throw new errors.ValidationError({
message: tpl(messages.invalidDate, {key: relationProperty}),
code: 'DATE_INVALID'
});
}
data[property][indexInArr][relationProperty] = moment(relationValue).toDate();
}
});
});
}
});
return data;
},
/**
* Filters potentially unsafe `options` in a model method's arguments, so you can pass them to Bookshelf / Knex.
* @param {Object} unfilteredOptions Represents options to filter in order to be passed to the Bookshelf query.
* @param {String} methodName The name of the method to check valid options for.
* @return {Object} The filtered results of `options`.
*/
filterOptions: function filterOptions(unfilteredOptions, methodName, filterConfig) {
unfilteredOptions = unfilteredOptions || {};
filterConfig = filterConfig || {};
if (Object.prototype.hasOwnProperty.call(unfilteredOptions, 'include')) {
throw new errors.IncorrectUsageError({
message: 'The model layer expects using `withRelated`.'
});
}
let options = _.cloneDeep(unfilteredOptions);
const extraAllowedProperties = filterConfig.extraAllowedProperties || [];
let permittedOptions;
permittedOptions = this.permittedOptions(methodName, options);
permittedOptions = _.union(permittedOptions, extraAllowedProperties);
options = _.pick(options, permittedOptions);
if (this.defaultRelations) {
options = this.defaultRelations(methodName, options);
}
return options;
},
// ## Model Data Functions
getFilteredCollection: function getFilteredCollection(options) {

View File

@ -0,0 +1,178 @@
const _ = require('lodash');
const errors = require('@tryghost/errors');
const moment = require('moment');
const tpl = require('@tryghost/tpl');
const schema = require('../../data/schema');
const messages = {
missingContext: 'missing context',
invalidDate: 'Date format for `{key}` is invalid.'
};
/**
* @param {import('bookshelf')} Bookshelf
*/
module.exports = function (Bookshelf) {
Bookshelf.Model = Bookshelf.Model.extend({
// Ghost option handling - get permitted attributes from server/data/schema.js, where the DB schema is defined
permittedAttributes: function permittedAttributes() {
return _.keys(schema.tables[this.tableName])
.filter(key => key.indexOf('@@') === -1);
}
}, {
/**
* Returns an array of keys permitted in every method's `options` hash.
* Can be overridden and added to by a model's `permittedOptions` method.
*
* importing: is used when import a JSON file or when migrating the database
*
* @return {Object} Keys allowed in the `options` hash of every model's method.
*/
permittedOptions: function permittedOptions(methodName) {
const baseOptions = ['context', 'withRelated'];
const extraOptions = ['transacting', 'importing', 'forUpdate', 'migrating'];
switch (methodName) {
case 'toJSON':
return baseOptions.concat('shallow', 'columns', 'previous');
case 'destroy':
return baseOptions.concat(extraOptions, ['id', 'destroyBy', 'require']);
case 'edit':
return baseOptions.concat(extraOptions, ['id', 'require']);
case 'findOne':
return baseOptions.concat(extraOptions, ['columns', 'require', 'mongoTransformer']);
case 'findAll':
return baseOptions.concat(extraOptions, ['filter', 'columns', 'mongoTransformer']);
case 'findPage':
return baseOptions.concat(extraOptions, ['filter', 'order', 'autoOrder', 'page', 'limit', 'columns', 'mongoTransformer']);
default:
return baseOptions.concat(extraOptions);
}
},
/**
* Filters potentially unsafe model attributes, so you can pass them to Bookshelf / Knex.
* This filter should be called before each insert/update operation.
*
* @param {Object} data Has keys representing the model's attributes/fields in the database.
* @return {Object} The filtered results of the passed in data, containing only what's allowed in the schema.
*/
filterData: function filterData(data) {
const permittedAttributes = this.prototype.permittedAttributes();
const filteredData = _.pick(data, permittedAttributes);
const sanitizedData = this.sanitizeData(filteredData);
return sanitizedData;
},
/**
* `sanitizeData` ensures that client data is in the correct format for further operations.
*
* Dates:
* - client dates are sent as ISO 8601 format (moment(..).format())
* - server dates are in JS Date format
* >> when bookshelf fetches data from the database, all dates are in JS Dates
* >> see `parse`
* - Bookshelf updates the model with the new client data via the `set` function
* - Bookshelf uses a simple `isEqual` function from lodash to detect real changes
* - .previous(attr) and .get(attr) returns false obviously
* - internally we use our `hasDateChanged` if we have to compare previous dates
* - but Bookshelf is not in our control for this case
*
* @IMPORTANT
* Before the new client data get's inserted again, the dates get's re-transformed into
* proper strings, see `format`.
*
* @IMPORTANT
* Sanitize relations.
*/
sanitizeData: function sanitizeData(data) {
const tableName = _.result(this.prototype, 'tableName');
let date;
_.each(data, (value, property) => {
if (value !== null
&& Object.prototype.hasOwnProperty.call(schema.tables[tableName], property)
&& schema.tables[tableName][property].type === 'dateTime'
&& typeof value === 'string'
) {
date = new Date(value);
// CASE: client sends `0000-00-00 00:00:00`
if (isNaN(date)) {
throw new errors.ValidationError({
message: tpl(messages.invalidDate, {key: property}),
code: 'DATE_INVALID'
});
}
data[property] = moment(value).toDate();
}
if (this.prototype.relationships && this.prototype.relationships.indexOf(property) !== -1) {
let relations = data[property];
// CASE: 1:1 relation will have single data point
if (!_.isArray(data[property])) {
relations = [data[property]];
}
_.each(relations, (relation, indexInArr) => {
_.each(relation, (relationValue, relationProperty) => {
if (relationValue !== null
&& Object.prototype.hasOwnProperty.call(schema.tables[this.prototype.relationshipBelongsTo[property]], relationProperty)
&& schema.tables[this.prototype.relationshipBelongsTo[property]][relationProperty].type === 'dateTime'
&& typeof relationValue === 'string'
) {
date = new Date(relationValue);
// CASE: client sends `0000-00-00 00:00:00`
if (isNaN(date)) {
throw new errors.ValidationError({
message: tpl(messages.invalidDate, {key: relationProperty}),
code: 'DATE_INVALID'
});
}
data[property][indexInArr][relationProperty] = moment(relationValue).toDate();
}
});
});
}
});
return data;
},
/**
* Filters potentially unsafe `options` in a model method's arguments, so you can pass them to Bookshelf / Knex.
* @param {Object} unfilteredOptions Represents options to filter in order to be passed to the Bookshelf query.
* @param {String} methodName The name of the method to check valid options for.
* @return {Object} The filtered results of `options`.
*/
filterOptions: function filterOptions(unfilteredOptions, methodName, filterConfig) {
unfilteredOptions = unfilteredOptions || {};
filterConfig = filterConfig || {};
if (Object.prototype.hasOwnProperty.call(unfilteredOptions, 'include')) {
throw new errors.IncorrectUsageError({
message: 'The model layer expects using `withRelated`.'
});
}
let options = _.cloneDeep(unfilteredOptions);
const extraAllowedProperties = filterConfig.extraAllowedProperties || [];
let permittedOptions;
permittedOptions = this.permittedOptions(methodName, options);
permittedOptions = _.union(permittedOptions, extraAllowedProperties);
options = _.pick(options, permittedOptions);
if (this.defaultRelations) {
options = this.defaultRelations(methodName, options);
}
return options;
}
});
};