TryGhost/Ghost

View on GitHub
ghost/api-framework/lib/validators/input/all.js

Summary

Maintainability
B
6 hrs
Test Coverage
const debug = require('@tryghost/debug')('validators:input:all');
const _ = require('lodash');
const tpl = require('@tryghost/tpl');
const {BadRequestError, ValidationError} = require('@tryghost/errors');
const validator = require('@tryghost/validator');

const messages = {
    validationFailed: 'Validation ({validationName}) failed for {key}',
    noRootKeyProvided: 'No root key (\'{docName}\') provided.',
    invalidIdProvided: 'Invalid id provided.'
};

const GLOBAL_VALIDATORS = {
    id: {matches: /^[a-f\d]{24}$|^1$|me/i},
    page: {matches: /^\d+$/},
    limit: {matches: /^\d+|all$/},
    from: {isDate: true},
    to: {isDate: true},
    columns: {matches: /^[\w, ]+$/},
    order: {matches: /^[a-z0-9_,. ]+$/i},
    uuid: {isUUID: true},
    slug: {isSlug: true},
    name: {},
    email: {isEmail: true},
    filter: false,
    context: false,
    forUpdate: false,
    transacting: false,
    include: false,
    formats: false
};

const validate = (config, attrs) => {
    let errors = [];

    _.each(config, (value, key) => {
        if (value.required && !attrs[key]) {
            errors.push(new ValidationError({
                message: tpl(messages.validationFailed, {
                    validationName: 'FieldIsRequired',
                    key: key
                })
            }));
        }
    });

    _.each(attrs, (value, key) => {
        debug(key, value);

        if (GLOBAL_VALIDATORS[key]) {
            debug('global validation');
            errors = errors.concat(validator.validate(value, key, GLOBAL_VALIDATORS[key]));
        }

        if (config?.[key]) {
            const allowedValues = Array.isArray(config[key]) ? config[key] : config[key].values;

            if (allowedValues) {
                debug('ctrl validation');

                // CASE: we allow e.g. `formats=`
                if (!value || !value.length) {
                    return;
                }

                const valuesAsArray = Array.isArray(value) ? value : value.trim().toLowerCase().split(',');
                const unallowedValues = _.filter(valuesAsArray, (valueToFilter) => {
                    return !allowedValues.includes(valueToFilter);
                });

                if (unallowedValues.length) {
                    // CASE: we do not error for invalid includes, just silently remove
                    if (key === 'include') {
                        attrs.include = valuesAsArray.filter(x => allowedValues.includes(x));
                        return;
                    }

                    errors.push(new ValidationError({
                        message: tpl(messages.validationFailed, {
                            validationName: 'AllowedValues',
                            key: key
                        })
                    }));
                }
            }
        }
    });

    return errors;
};

module.exports = {
    /**
     * @param {object} apiConfig
     * @param {import('@tryghost/api-framework').Frame} frame
     */
    all(apiConfig, frame) {
        debug('validate all');

        let validationErrors = validate(apiConfig.options, frame.options);

        if (!_.isEmpty(validationErrors)) {
            return Promise.reject(validationErrors[0]);
        }

        return Promise.resolve();
    },

    /**
     * @param {object} apiConfig
     * @param {import('@tryghost/api-framework').Frame} frame
     */
    browse(apiConfig, frame) {
        debug('validate browse');

        let validationErrors = [];

        if (frame.data) {
            validationErrors = validate(apiConfig.data, frame.data);
        }

        if (!_.isEmpty(validationErrors)) {
            return Promise.reject(validationErrors[0]);
        }
    },

    read() {
        debug('validate read');
        return this.browse(...arguments);
    },

    /**
     * @param {object} apiConfig
     * @param {import('@tryghost/api-framework').Frame} frame
     */
    add(apiConfig, frame) {
        debug('validate add');

        // NOTE: this block should be removed completely once JSON Schema validations
        //       are introduced for all of the endpoints
        if (!['posts', 'tags'].includes(apiConfig.docName)) {
            if (_.isEmpty(frame.data) || _.isEmpty(frame.data[apiConfig.docName]) || _.isEmpty(frame.data[apiConfig.docName][0])) {
                return Promise.reject(new BadRequestError({
                    message: tpl(messages.noRootKeyProvided, {docName: apiConfig.docName})
                }));
            }
        }

        const jsonpath = require('jsonpath');

        if (apiConfig.data) {
            const missedDataProperties = [];
            const nilDataProperties = [];

            _.each(apiConfig.data, (value, key) => {
                if (jsonpath.query(frame.data[apiConfig.docName][0], key).length === 0) {
                    missedDataProperties.push(key);
                } else if (_.isNil(frame.data[apiConfig.docName][0][key])) {
                    nilDataProperties.push(key);
                }
            });

            if (missedDataProperties.length) {
                return Promise.reject(new ValidationError({
                    message: tpl(messages.validationFailed, {
                        validationName: 'FieldIsRequired',
                        key: JSON.stringify(missedDataProperties)
                    })
                }));
            }

            if (nilDataProperties.length) {
                return Promise.reject(new ValidationError({
                    message: tpl(messages.validationFailed, {
                        validationName: 'FieldIsInvalid',
                        key: JSON.stringify(nilDataProperties)
                    })
                }));
            }
        }
    },

    /**
     * @param {object} apiConfig
     * @param {import('@tryghost/api-framework').Frame} frame
     */
    edit(apiConfig, frame) {
        debug('validate edit');
        const result = this.add(...arguments);

        if (result instanceof Promise) {
            return result;
        }

        // NOTE: this block should be removed completely once JSON Schema validations
        //       are introduced for all of the endpoints. `id` property is currently
        //       stripped from the request body and only the one provided in `options`
        //       is used in later logic
        if (!['posts', 'tags'].includes(apiConfig.docName)) {
            if (frame.options.id && frame.data[apiConfig.docName][0].id
                && frame.options.id !== frame.data[apiConfig.docName][0].id) {
                return Promise.reject(new BadRequestError({
                    message: tpl(messages.invalidIdProvided)
                }));
            }
        }
    },

    changePassword() {
        debug('validate changePassword');
        return this.add(...arguments);
    },

    resetPassword() {
        debug('validate resetPassword');
        return this.add(...arguments);
    },

    setup() {
        debug('validate setup');
        return this.add(...arguments);
    },

    publish() {
        debug('validate schedule');
        return this.browse(...arguments);
    }
};