gocodebox/lifterlms

View on GitHub
includes/forms/class-llms-form-validator.php

Summary

Maintainability
C
7 hrs
Test Coverage
A
100%
<?php
/**
 * Handles data sanitization & validation for the LLMS_Form_Handler class
 *
 * @package LifterLMS/Classes
 *
 * @since 5.0.0
 * @version 5.1.0
 */

defined( 'ABSPATH' ) || exit;

/**
 * LLMS_Form_Handler class.
 *
 * @since 5.0.0
 */
class LLMS_Form_Validator {

    /**
     * Filters a list of fields down to only the required fields.
     *
     * @since 5.0.0
     *
     * @param array[] $fields Array of LifterLMS Form Field settings arrays.
     * @return array[]
     */
    public function get_required_fields( $fields ) {
        return array_values(
            array_filter(
                $fields,
                function( $field ) {
                    return ! empty( $field['required'] );
                }
            )
        );
    }

    /**
     * Sanitize a single field according to its type
     *
     * @since 5.0.0
     *
     * @param mixed $posted_value User-submitted (dirty) value.
     * @param array $field        LifterLMS field settings.
     * @return mixed
     */
    public function sanitize_field( $posted_value, $field ) {

        $map = array(
            'email'    => 'sanitize_email',
            'number'   => array( $this, 'sanitize_field_number' ),
            'tel'      => array( $this, 'sanitize_field_tel' ),
            'textarea' => 'sanitize_textarea_field',
            'url'      => 'esc_url_raw',
        );

        $func = isset( $map[ $field['type'] ] ) ? $map[ $field['type'] ] : 'sanitize_text_field';

        // Turn the submitted value into array, so to unify sanitization of scalar and array posted values.
        $to_sanitize = is_array( $posted_value ) ? $posted_value : array( $posted_value );
        $sanitized   = array();

        foreach ( $to_sanitize as $value ) {
            $sanitized[] = trim( call_user_func( $func, $value ) );
        }

        return is_array( $posted_value ) ? $sanitized : $sanitized[0];

    }

    /**
     * Sanitize a number field
     *
     * @since 5.0.0
     *
     * @param string $posted_value User-submitted (dirty) value.
     * @return string
     */
    protected function sanitize_field_number( $posted_value ) {
        return preg_replace( '/[^0-9.,]/', '', $posted_value );
    }

    /**
     * Sanitize a telephone field
     *
     * @since 5.0.0
     *
     * @param string $posted_value User-submitted (dirty) value.
     * @return string
     */
    protected function sanitize_field_tel( $posted_value ) {
        return preg_replace( '/[^\s\#0-9\-\+\(\)\.]/', '', $posted_value );

    }

    /**
     * Sanitize all user-submitted data according to field settings
     *
     * @since 5.0.0
     *
     * @param array   $posted_data User-submitted form data.
     * @param array[] $fields      LifterLMS form fields settings.
     * @return array
     */
    public function sanitize_fields( $posted_data, $fields ) {

        foreach ( $fields as $field ) {

            if ( empty( $field['name'] ) || ! isset( $posted_data[ $field['name'] ] ) ) {
                continue;
            }

            $posted_data[ $field['name'] ] = $this->sanitize_field( $posted_data[ $field['name'] ], $field );

        }

        return $posted_data;

    }

    /**
     * Validate a posted value
     *
     * @since 5.0.0
     *
     * @param mixed $posted_value Posted data.
     * @param array $field        LifterLMS Form Field settings array.
     * @return WP_Error|true
     */
    public function validate_field( $posted_value, $field ) {

        // Validate field by type.
        $type_map = array(
            'email'  => array( $this, 'validate_field_email' ),
            'number' => array( $this, 'validate_field_number' ),
            'tel'    => array( $this, 'validate_field_tel' ),
            'url'    => array( $this, 'validate_field_url' ),
        );

        // Turn the submitted value into array, so to unify validation of scalar and array posted values.
        $to_validate = is_array( $posted_value ) ? $posted_value : array( $posted_value );

        foreach ( $to_validate as $value ) {

            $valid = isset( $type_map[ $field['type'] ] ) ? call_user_func( $type_map[ $field['type'] ], $value, $field ) : true;
            if ( is_wp_error( $valid ) ) { // Return as soon as a field is not valid.
                return $valid;
            }

            // HTML Attribute Validations.
            if ( ! empty( $field['attributes']['minlength'] ) ) {
                $valid = $this->validate_field_attribute_minlength( $value, $field['attributes']['minlength'], $field );
                if ( is_wp_error( $valid ) ) {
                    return $valid;
                }
            }
        }

        // Perform special validations for special field types (scalar by their nature).
        $extra_map = array(
            'llms_voucher'     => array( $this, 'validate_field_voucher' ),
            'password_current' => array( $this, 'validate_field_current_password' ),
            'user_email'       => array( $this, 'validate_field_user_email' ),
            'user_login'       => array( $this, 'validate_field_user_login' ),
        );
        $valid     = isset( $extra_map[ $field['id'] ] ) ? call_user_func( $extra_map[ $field['id'] ], $posted_value ) : true;
        if ( is_wp_error( $valid ) ) {
            return $valid;
        }

        return true;

    }

    /**
     * Validates the html input minlength attribute
     *
     * Used by the User Password field.
     *
     * @since 5.0.0
     *
     * @param string $posted_value User-submitted value.
     * @param int    $minlength    The minimum string length as parsed from the field block.
     * @param array  $field        LifterLMS Form Field settings array.
     * @return WP_Error|boolean Returns `true` for a valid value, otherwise an error.
     */
    protected function validate_field_attribute_minlength( $posted_value, $minlength, $field ) {

        if ( strlen( $posted_value ) < $minlength ) {
            return new WP_Error(
                'llms-form-field-invalid',
                sprintf(
                    __( 'The %1$s must be at least %2$d characters in length.', 'lifterlms' ),
                    isset( $field['label'] ) ? $field['label'] : $field['name'],
                    $minlength
                )
            );
        }

        return true;

    }

    /**
     * Validate an email field
     *
     * @since 5.0.0
     *
     * @param string $posted_value User-submitted (dirty) value.
     * @return WP_Error|boolean Returns `true` for a valid submission, otherwise an error.
     */
    protected function validate_field_email( $posted_value ) {

        if ( ! is_email( $posted_value ) ) {
            // Translators: %s user submitted value.
            return new WP_Error( 'llms-form-field-invalid', sprintf( __( 'The email address "%s" is not valid.', 'lifterlms' ), $posted_value ) );
        }

        return true;

    }

    /**
     * Validate a number field
     *
     * Ensures the posted valued is numeric and, where applicable, ensures that the number falls
     * within minimum and maximum value requirements.
     *
     * @since 5.0.0
     *
     * @param string $posted_value User-submitted (dirty) value.
     * @param array  $field        The LLMS_Form_Field settings array.
     * @return WP_Error|boolean Returns `true` for a valid submission, otherwise an error.
     */
    protected function validate_field_number( $posted_value, $field ) {

        $temp_value = str_replace( ',', '', $posted_value );
        if ( ! is_numeric( $temp_value ) ) {
            // Translators: %1$s field label or name; %2$s = user submitted value.
            return new WP_Error( 'llms-form-field-invalid', sprintf( __( 'The %1$s "%2$s" is not valid number.', 'lifterlms' ), isset( $field['label'] ) ? $field['label'] : $field['name'], $posted_value ) );
        } elseif ( isset( $field['attributes'] ) ) {
            if ( ( ! empty( $field['attributes']['min'] ) || ( isset( $field['attributes']['min'] ) && '0' === $field['attributes']['min'] ) ) && $temp_value < $field['attributes']['min'] ) {
                // Translators: %1$s = field label or name; %2$s = user submitted value; %3$d = minimum allowed number.
                return new WP_Error( 'llms-form-field-invalid', sprintf( __( 'The %1$s "%2$s" must be greater than or equal to %3$d.', 'lifterlms' ), isset( $field['label'] ) ? $field['label'] : $field['name'], $posted_value, $field['attributes']['min'] ) );
            } elseif ( ( ! empty( $field['attributes']['max'] ) || ( isset( $field['attributes']['max'] ) && '0' === $field['attributes']['max'] ) ) && $temp_value > $field['attributes']['max'] ) {
                // Translators: %1$s = field label or name; %2$s = user submitted value; %3$d = maximum allowed number.
                return new WP_Error( 'llms-form-field-invalid', sprintf( __( 'The %1$s "%2$s" must be less than or equal to %3$d.', 'lifterlms' ), isset( $field['label'] ) ? $field['label'] : $field['name'], $posted_value, $field['attributes']['max'] ) );
            }
        }

        return true;

    }

    /**
     * Validate a logged-in users current password
     *
     * @since 5.0.0
     *
     * @param string $posted_value User-submitted (dirty) value.
     * @return WP_Error|boolean Returns `true` for a valid submission, otherwise an error.
     */
    protected function validate_field_current_password( $posted_value ) {

        if ( ! is_user_logged_in() ) {
            return new WP_Error( 'llms-form-field-invalid-no-user', __( 'You must be logged in to update your password.', 'lifterlms' ), $posted_value );
        }

        $user = wp_get_current_user();
        if ( ! wp_check_password( $posted_value, $user->user_pass ) ) {
            return new WP_Error( 'llms-form-field-invalid', __( 'The submitted password was not correct.', 'lifterlms' ), $posted_value );
        }

        return true;
    }

    /**
     * Validate a telephone field
     *
     * @since 5.0.0
     *
     * @param string $posted_value User-submitted (dirty) value.
     * @return WP_Error|boolean Returns `true` for a valid submission, otherwise an error.
     */
    protected function validate_field_tel( $posted_value ) {

        if ( 0 < strlen( trim( preg_replace( '/[\s\#0-9\-\+\(\)\.]/', '', $posted_value ) ) ) ) {
            // Translators: %s = user submitted value.
            return new WP_Error( 'llms-form-field-invalid', sprintf( __( 'The phone number "%s" is not valid.', 'lifterlms' ), $posted_value ) );
        }

        return true;

    }

    /**
     * Validate a url field
     *
     * @since 5.0.0
     *
     * @param string $posted_value User-submitted (dirty) value.
     * @return WP_Error|boolean Returns `true` for a valid submission, otherwise an error.
     */
    protected function validate_field_url( $posted_value ) {

        if ( ! filter_var( $posted_value, FILTER_VALIDATE_URL ) ) {
            // Translators: %s = user submitted value.
            return new WP_Error( 'llms-form-field-invalid', sprintf( __( 'The URL "%s" is not valid.', 'lifterlms' ), $posted_value ) );
        }

        return true;

    }

    /**
     * Validate a user-email field
     *
     * User emails must be unique.
     *
     * @since 5.0.0
     *
     * @param string $posted_value User-submitted (dirty) value.
     * @return WP_Error|boolean Returns `true` for a valid submission, otherwise an error.
     */
    protected function validate_field_user_email( $posted_value ) {
        if ( email_exists( $posted_value ) ) {
            return new WP_Error( 'llms-form-field-not-unique', sprintf( __( 'An account with the email address "%s" already exists.', 'lifterlms' ), $posted_value ) );
        }

        return true;
    }

    /**
     * Validate a user-login field
     *
     * Ensures that a username isn't found in the LifterLMS username blocklist, that it meets the default
     * WP core username criteria and that the username doesn't already exist.
     *
     * @since 5.0.0
     *
     * @param string $posted_value User-submitted (dirty) value.
     * @return WP_Error|boolean Returns `true` for a valid submission, otherwise an error.
     */
    protected function validate_field_user_login( $posted_value ) {
        if ( in_array( $posted_value, llms_get_usernames_blocklist(), true ) || ! validate_username( $posted_value ) ) {
            return new WP_Error( 'llms-form-field-invalid', sprintf( __( 'The username "%s" is invalid, please try a different username.', 'lifterlms' ), $posted_value ), $posted_value );
        } elseif ( username_exists( $posted_value ) ) {
            return new WP_Error( 'llms-form-field-not-unique', sprintf( __( 'An account with the username "%s" already exists.', 'lifterlms' ), $posted_value ), $posted_value );
        }

        return true;
    }

    /**
     * Validate a voucher field ensuring it's a valid and usable voucher code
     *
     * @since 5.0.0
     *
     * @param string $posted_value User-submitted (dirty) value.
     * @return WP_Error|boolean Returns `true` for a valid submission, otherwise an error.
     */
    protected function validate_field_voucher( $posted_value ) {

        $voucher = new LLMS_Voucher();
        $check   = $voucher->check_voucher( $posted_value );
        if ( is_wp_error( $check ) ) {
            return new WP_Error( 'llms-form-field-invalid', $check->get_error_message(), array( $posted_value, $check ) );
        }

        return true;

    }

    /**
     * Validate submitted field values.
     *
     * @since 5.0.0
     * @since 5.1.0 Don't validate form with no user input only if the form is not empty itself (e.g. contains only invisible fields).
     *
     * @param array   $posted_data Array of posted data.
     * @param array[] $fields      Array of LifterLMS Form Fields.
     * @return WP_Error|true
     */
    public function validate_fields( $posted_data, $fields ) {

        if ( empty( $posted_data ) && ! empty( $fields ) ) {
            return new WP_Error( 'llms-form-no-input', __( 'Cannot validate a form with no user input.', 'lifterlms' ) );
        }

        $err      = new WP_Error();
        $err_data = array();
        foreach ( $fields as $field ) {

            if ( empty( $field['name'] ) || empty( $posted_data[ $field['name'] ] ) ) {
                continue;
            }

            $valid = $this->validate_field( $posted_data[ $field['name'] ], $field );
            if ( is_wp_error( $valid ) ) {
                $err->add( $valid->get_error_code(), $valid->get_error_message() );
                $err_data[ $field['name'] ] = $field;
            }
        }

        if ( $err->errors ) {
            $err->add_data( $err_data );
            return $err;
        }

        return true;

    }

    /**
     * Ensure matching fields match one another.
     *
     * @since 5.0.0
     *
     * @param array   $posted_data Array of posted data.
     * @param array[] $fields      Array of LifterLMS form fields.
     * @return WP_Error|true
     */
    public function validate_matching_fields( $posted_data, $fields ) {

        $err      = new WP_Error();
        $err_data = array();

        $matches = array();
        foreach ( $fields as $field ) {

            // Field doesn't have a match to check or it was already checked by it's match.
            if ( empty( $field['match'] ) || in_array( $field['id'], $matches, true ) ) {
                continue;
            }

            $field_name = isset( $field['label'] ) ? $field['label'] : $field['name'];

            $name        = $field['name'];
            $match_field = LLMS_Forms::instance()->get_field_by( $fields, 'id', $field['match'] );
            if ( ! $match_field ) {
                continue;
            }

            $match = $match_field['name'];

            $val   = isset( $posted_data[ $name ] ) ? $posted_data[ $name ] : '';
            $match = isset( $posted_data[ $match ] ) ? $posted_data[ $match ] : '';

            if ( $val !== $match ) {

                $match_name = isset( $match_field['label'] ) ? $match_field['label'] : $match_field['name'];
                $err->add( 'llms-form-field-not-matched', sprintf( __( '%1$s must match %2$s.', 'lifterlms' ), $field_name, $match_name ) );
                $err_data[] = array( $field, $match_field );

            }

            // Fields reference each other so we only need to check the pair one time.
            $matches[] = $match_field['id'];

        }

        if ( $err->errors ) {
            $err->add_data( $err_data, 'llms-form-field-not-matched' );
            return $err;
        }

        return true;

    }

    /**
     * Ensure that all of the forms required fields are present in the submitted data.
     *
     * @since 5.0.0
     *
     * @param array   $posted_data User data (likely from $_POST).
     * @param array[] $fields      Array of LifterLMS form fields.
     * @return WP_Error|true
     */
    public function validate_required_fields( $posted_data, $fields ) {

        // Ensure all required fields have been submitted.
        $err      = new WP_Error();
        $err_data = array();
        foreach ( $this->get_required_fields( $fields ) as $field ) {

            if ( empty( $posted_data[ $field['name'] ] ) ) {
                // Translators: %s = field label or name.
                $err->add( 'llms-form-missing-required', sprintf( __( '%s is a required field.', 'lifterlms' ), isset( $field['label'] ) ? $field['label'] : $field['name'] ) );
                $err_data[ $field['name'] ] = $field;
            }
        }

        if ( $err->errors ) {
            $err->add_data( $err_data, 'llms-form-missing-required' );
            return $err;
        }

        return true;

    }

}