mirror of
https://github.com/TryGhost/Ghost.git
synced 2024-12-21 09:52:06 +03:00
e9c1aff418
refs https://linear.app/tryghost/issue/CORE-35/refactor-route-and-redirect-settings - i18n is an old pattern we are getting rid of. By removing it here we get rid of an extra dependancy on frontend's "proxy" module
440 lines
16 KiB
JavaScript
440 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);
|
|
|
|
RESOURCE_CONFIG = require(`../routing/config/${apiVersion}`);
|
|
|
|
object.routes = _private.validateRoutes(object.routes);
|
|
object.collections = _private.validateCollections(object.collections);
|
|
object.taxonomies = _private.validateTaxonomies(object.taxonomies);
|
|
|
|
return object;
|
|
};
|