Ghost/core/server/services/route-settings/validate.js
Naz 95706aa37e Moved validate&yaml-parser modules to backend
refs https://linear.app/tryghost/issue/CORE-35/refactor-route-and-redirect-settings

- These modules should be colocated along with the rest of routes.yaml related modules
- They will later be extracted into external modules
2021-09-28 04:59:41 +13:00

441 lines
16 KiB
JavaScript

const _ = require('lodash');
const debug = require('@tryghost/debug')('frontend:services:settings:validate');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const bridge = require('../../../bridge');
const _private = {};
let RESOURCE_CONFIG;
const messages = {
validationError: `The following definition "{at}" is invalid: {reason}`
};
_private.validateTemplate = function validateTemplate(object) {
// CASE: /about/: about
if (typeof object === 'string') {
return {
templates: [object]
};
}
if (!Object.prototype.hasOwnProperty.call(object, 'template')) {
object.templates = [];
return object;
}
if (_.isArray(object.template)) {
object.templates = object.template;
} else {
object.templates = [object.template];
}
delete object.template;
return object;
};
_private.validateData = function validateData(object) {
if (!Object.prototype.hasOwnProperty.call(object, 'data')) {
return object;
}
const shortToLongForm = (shortForm, options = {}) => {
let longForm = {
query: {},
router: {}
};
if (!shortForm.match(/.*\..*/)) {
throw new errors.ValidationError({
message: tpl(messages.validationError, {
at: shortForm,
reason: 'Incorrect Format. Please use e.g. tag.recipes'
})
});
}
let [resourceKey, slug] = shortForm.split('.');
if (!RESOURCE_CONFIG.QUERY[resourceKey] ||
(Object.prototype.hasOwnProperty.call(RESOURCE_CONFIG.QUERY[resourceKey], 'internal') && RESOURCE_CONFIG.QUERY[resourceKey].internal === true)) {
throw new errors.ValidationError({
message: `Resource key not supported. ${resourceKey}`,
help: 'Please use: tag, user, post or page.'
});
}
longForm.query[options.resourceKey || resourceKey] = {};
longForm.query[options.resourceKey || resourceKey] = _.cloneDeep(_.omit(RESOURCE_CONFIG.QUERY[resourceKey], 'resourceAlias'));
// redirect is enabled by default when using the short form
longForm.router = {
[RESOURCE_CONFIG.QUERY[resourceKey].resourceAlias || RESOURCE_CONFIG.QUERY[resourceKey].resource]: [{
slug: slug,
redirect: true
}]
};
longForm.query[options.resourceKey || resourceKey].options.slug = slug;
return longForm;
};
// CASE: short form e.g. data: tag.recipes (expand to long form)
if (typeof object.data === 'string') {
object.data = shortToLongForm(object.data);
} else {
const requiredQueryFields = ['type', 'resource'];
const allowedQueryValues = {
type: ['read', 'browse'],
resource: _.map(RESOURCE_CONFIG.QUERY, 'resource')
};
const allowedQueryOptions = ['limit', 'order', 'filter', 'include', 'slug', 'visibility', 'status', 'page'];
const allowedRouterOptions = ['redirect', 'slug'];
const defaultRouterOptions = {
redirect: true
};
let data = {
query: {},
router: {}
};
_.each(object.data, (value, key) => {
// CASE: a name is required to define the data longform
if (['resource', 'type', 'limit', 'order', 'include', 'filter', 'status', 'visibility', 'slug', 'redirect'].indexOf(key) !== -1) {
throw new errors.ValidationError({
message: 'Please wrap the data definition into a custom name.',
help: 'Example:\n data:\n my-tag:\n resource: tags\n ...\n'
});
}
// @NOTE: We disallow author, because {{author}} is deprecated.
if (key === 'author') {
throw new errors.ValidationError({
message: 'Please choose a different name. We recommend not using author.'
});
}
// CASE: short form used with custom names, resolve to longform and return
if (typeof object.data[key] === 'string') {
const longForm = shortToLongForm(object.data[key], {resourceKey: key});
data.query = _.merge(data.query, longForm.query);
_.each(Object.keys(longForm.router), (routerKey) => {
if (data.router[routerKey]) {
data.router[routerKey] = data.router[routerKey].concat(longForm.router[routerKey]);
} else {
data.router[routerKey] = longForm.router[routerKey];
}
});
return;
}
data.query[key] = {
options: {}
};
_.each(requiredQueryFields, (option) => {
if (!Object.prototype.hasOwnProperty.call(object.data[key], option)) {
throw new errors.ValidationError({
message: tpl(messages.validationError, {
at: JSON.stringify(object.data[key]),
reason: `${option} is required.`
})
});
}
if (allowedQueryValues[option] && allowedQueryValues[option].indexOf(object.data[key][option]) === -1) {
throw new errors.ValidationError({
message: tpl(messages.validationError, {
at: JSON.stringify(object.data[key]),
reason: `${object.data[key][option]} not supported. Please use ${_.uniq(allowedQueryValues[option])}.`
})
});
}
data.query[key][option] = object.data[key][option];
});
const DEFAULT_RESOURCE = _.find(RESOURCE_CONFIG.QUERY, {resource: data.query[key].resource});
data.query[key].resource = DEFAULT_RESOURCE.resource;
data.query[key] = _.defaults(data.query[key], _.omit(DEFAULT_RESOURCE, ['options', 'resourceAlias']));
data.query[key].options = _.pick(object.data[key], allowedQueryOptions);
if (data.query[key].type === 'read') {
data.query[key].options = _.defaults(data.query[key].options, DEFAULT_RESOURCE.options);
}
if (!Object.prototype.hasOwnProperty.call(data.router, DEFAULT_RESOURCE.resourceAlias || DEFAULT_RESOURCE.resource)) {
data.router[DEFAULT_RESOURCE.resourceAlias || DEFAULT_RESOURCE.resource] = [];
}
// CASE: we do not allowed redirects for type browse
if (data.query[key].type === 'read') {
let entry = _.pick(object.data[key], allowedRouterOptions);
entry = _.defaults(entry, defaultRouterOptions);
data.router[DEFAULT_RESOURCE.resourceAlias || DEFAULT_RESOURCE.resource].push(entry);
} else {
data.router[DEFAULT_RESOURCE.resourceAlias || DEFAULT_RESOURCE.resource].push(defaultRouterOptions);
}
});
object.data = data;
}
return object;
};
_private.validateRoutes = function validateRoutes(routes) {
if (routes.constructor !== Object) {
throw new errors.ValidationError({
message: tpl(messages.validationError, {
at: routes,
reason: '`routes` must be a YAML map.'
})
});
}
_.each(routes, (routingTypeObject, routingTypeObjectKey) => {
// CASE: we hard-require trailing slashes for the index route
if (!routingTypeObjectKey.match(/\/$/)) {
throw new errors.ValidationError({
message: tpl(messages.validationError, {
at: routingTypeObjectKey,
reason: 'A trailing slash is required.'
})
});
}
// CASE: we hard-require leading slashes for the index route
if (!routingTypeObjectKey.match(/^\//)) {
throw new errors.ValidationError({
message: tpl(messages.validationError, {
at: routingTypeObjectKey,
reason: 'A leading slash is required.'
})
});
}
// CASE: you define /about/:
if (!routingTypeObject) {
throw new errors.ValidationError({
message: tpl(messages.validationError, {
at: routingTypeObjectKey,
reason: 'Please define a template.'
}),
help: 'e.g. /about/: about'
});
}
routes[routingTypeObjectKey] = _private.validateTemplate(routingTypeObject);
routes[routingTypeObjectKey] = _private.validateData(routes[routingTypeObjectKey]);
});
return routes;
};
_private.validateCollections = function validateCollections(collections) {
if (collections.constructor !== Object) {
throw new errors.ValidationError({
message: tpl(messages.validationError, {
at: collections,
reason: '`collections` must be a YAML map.'
})
});
}
_.each(collections, (routingTypeObject, routingTypeObjectKey) => {
// CASE: we hard-require trailing slashes for the collection index route
if (!routingTypeObjectKey.match(/\/$/)) {
throw new errors.ValidationError({
message: tpl(messages.validationError, {
at: routingTypeObjectKey,
reason: 'A trailing slash is required.'
})
});
}
// CASE: we hard-require leading slashes for the collection index route
if (!routingTypeObjectKey.match(/^\//)) {
throw new errors.ValidationError({
message: tpl(messages.validationError, {
at: routingTypeObjectKey,
reason: 'A leading slash is required.'
})
});
}
if (!Object.prototype.hasOwnProperty.call(routingTypeObject, 'permalink')) {
throw new errors.ValidationError({
message: tpl(messages.validationError, {
at: routingTypeObjectKey,
reason: 'Please define a permalink route.'
}),
help: 'e.g. permalink: /{slug}/'
});
}
// CASE: validate permalink key
if (!routingTypeObject.permalink) {
throw new errors.ValidationError({
message: tpl(messages.validationError, {
at: routingTypeObjectKey,
reason: 'Please define a permalink route.'
}),
help: 'e.g. permalink: /{slug}/'
});
}
// CASE: we hard-require trailing slashes for the value/permalink route
if (!routingTypeObject.permalink.match(/\/$/)) {
throw new errors.ValidationError({
message: tpl(messages.validationError, {
at: routingTypeObject.permalink,
reason: 'A trailing slash is required.'
})
});
}
// CASE: we hard-require leading slashes for the value/permalink route
if (!routingTypeObject.permalink.match(/^\//)) {
throw new errors.ValidationError({
message: tpl(messages.validationError, {
at: routingTypeObject.permalink,
reason: 'A leading slash is required.'
})
});
}
// CASE: notation /:slug/ or /:primary_author/ is not allowed. We only accept /{{...}}/.
if (routingTypeObject.permalink && routingTypeObject.permalink.match(/\/:\w+/)) {
throw new errors.ValidationError({
message: tpl(messages.validationError, {
at: routingTypeObject.permalink,
reason: 'Please use the following notation e.g. /{slug}/.'
})
});
}
// CASE: transform {.*} into :\w+ notation. This notation is our internal notation e.g. see permalink
// replacement in our UrlService utility.
if (routingTypeObject.permalink.match(/{.*}/)) {
routingTypeObject.permalink = routingTypeObject.permalink.replace(/{(\w+)}/g, ':$1');
}
collections[routingTypeObjectKey] = _private.validateTemplate(routingTypeObject);
collections[routingTypeObjectKey] = _private.validateData(collections[routingTypeObjectKey]);
});
return collections;
};
_private.validateTaxonomies = function validateTaxonomies(taxonomies) {
if (taxonomies.constructor !== Object) {
throw new errors.ValidationError({
message: tpl(messages.validationError, {
at: taxonomies,
reason: '`taxonomies` must be a YAML map.'
})
});
}
const validRoutingTypeObjectKeys = Object.keys(RESOURCE_CONFIG.TAXONOMIES);
_.each(taxonomies, (routingTypeObject, routingTypeObjectKey) => {
if (!routingTypeObject) {
throw new errors.ValidationError({
message: tpl(messages.validationError, {
at: routingTypeObjectKey,
reason: 'Please define a taxonomy permalink route.'
}),
help: 'e.g. tag: /tag/{slug}/'
});
}
if (!validRoutingTypeObjectKeys.includes(routingTypeObjectKey)) {
throw new errors.ValidationError({
message: tpl(messages.validationError, {
at: routingTypeObjectKey,
reason: 'Unknown taxonomy.'
})
});
}
// CASE: we hard-require trailing slashes for the taxonomie permalink route
if (!routingTypeObject.match(/\/$/)) {
throw new errors.ValidationError({
message: tpl(messages.validationError, {
at: routingTypeObject,
reason: 'A trailing slash is required.'
})
});
}
// CASE: we hard-require leading slashes for the value/permalink route
if (!routingTypeObject.match(/^\//)) {
throw new errors.ValidationError({
message: tpl(messages.validationError, {
at: routingTypeObject,
reason: 'A leading slash is required.'
})
});
}
// CASE: notation /:slug/ or /:primary_author/ is not allowed. We only accept /{{...}}/.
if (routingTypeObject && routingTypeObject.match(/\/:\w+/)) {
throw new errors.ValidationError({
message: tpl(messages.validationError, {
at: routingTypeObject,
reason: 'Please use the following notation e.g. /{slug}/.'
})
});
}
// CASE: transform {.*} into :\w+ notation. This notation is our internal notation e.g. see permalink
// replacement in our UrlService utility.
if (routingTypeObject && routingTypeObject.match(/{.*}/)) {
routingTypeObject = routingTypeObject.replace(/{(\w+)}/g, ':$1');
taxonomies[routingTypeObjectKey] = routingTypeObject;
}
});
return taxonomies;
};
/**
* Validate and sanitize the routing object.
* NOTE: mutates the object even if it's a valid configuration
*/
module.exports = function validate(object) {
if (!object) {
object = {};
}
if (!object.routes) {
object.routes = {};
}
if (!object.collections) {
object.collections = {};
}
if (!object.taxonomies) {
object.taxonomies = {};
}
const apiVersion = bridge.getFrontendApiVersion();
debug('api version', apiVersion);
// TODO: extract this config outta here! the config should be passed into this module
RESOURCE_CONFIG = require(`../../../frontend/services/routing/config/${apiVersion}`);
object.routes = _private.validateRoutes(object.routes);
object.collections = _private.validateCollections(object.collections);
object.taxonomies = _private.validateTaxonomies(object.taxonomies);
return object;
};