array( 'href' => true, 'target' => true, 'rel' => true, ), 'strong' => array(), 'em' => array(), 'br' => array(), ); } /** * Clean field definitions from the block attributes. * * @param mixed $fields Raw data. * @return array[] */ public static function normalize( $fields ) { $out = array(); $seen = array(); foreach ( array_values( (array) $fields ) as $i => $f ) { if ( ! is_array( $f ) ) { continue; } $type = isset( $f['type'] ) && in_array( $f['type'], self::types(), true ) ? $f['type'] : 'text'; $id = isset( $f['id'] ) ? substr( sanitize_key( $f['id'] ), 0, 40 ) : ''; if ( '' === $id || isset( $seen[ $id ] ) ) { $suffix = $i + 1; do { $id = 'field_' . $suffix++; } while ( isset( $seen[ $id ] ) ); } $seen[ $id ] = true; $options = array(); if ( self::has_options( $type ) ) { $raw = isset( $f['options'] ) ? $f['options'] : array(); $raw = is_array( $raw ) ? $raw : preg_split( '/\r\n|\r|\n/', (string) $raw ); $options = array_values( array_filter( array_map( 'sanitize_text_field', $raw ), 'strlen' ) ); } $condition = null; if ( ! empty( $f['condition'] ) && is_array( $f['condition'] ) && ! empty( $f['condition']['field'] ) && 'step' !== $type ) { $op = isset( $f['condition']['op'] ) && in_array( $f['condition']['op'], self::operators(), true ) ? $f['condition']['op'] : 'is'; $condition = array( 'field' => substr( sanitize_key( $f['condition']['field'] ), 0, 40 ), 'op' => $op, 'value' => sanitize_text_field( isset( $f['condition']['value'] ) ? (string) $f['condition']['value'] : '' ), ); } $field = array( 'id' => $id, 'type' => $type, 'label' => 'consent' === $type ? wp_kses( isset( $f['label'] ) ? (string) $f['label'] : '', self::consent_kses() ) : sanitize_text_field( isset( $f['label'] ) ? $f['label'] : '' ), 'required' => 'consent' === $type ? true : ( 'step' === $type ? false : ! empty( $f['required'] ) ), 'placeholder' => sanitize_text_field( isset( $f['placeholder'] ) ? $f['placeholder'] : '' ), 'help' => sanitize_text_field( isset( $f['help'] ) ? $f['help'] : '' ), 'width' => isset( $f['width'] ) && 'half' === $f['width'] ? 'half' : 'full', 'options' => $options, 'condition' => $condition, ); if ( 'file' === $type ) { $accept = isset( $f['accept'] ) && in_array( $f['accept'], array( 'documents', 'images', 'both', 'custom' ), true ) ? $f['accept'] : 'documents'; $field['accept'] = $accept; $field['extensions'] = sanitize_text_field( isset( $f['extensions'] ) ? $f['extensions'] : '' ); $field['max_size'] = max( 1, min( 1024, (int) ( isset( $f['max_size'] ) ? $f['max_size'] : 5 ) ) ); $field['max_files'] = max( 1, min( Files::MAX_FILES, (int) ( isset( $f['max_files'] ) ? $f['max_files'] : 1 ) ) ); } $out[] = $field; } // Conditions may only refer to earlier, real input fields. $earlier = array(); foreach ( $out as $k => $field ) { if ( $field['condition'] && ( ! isset( $earlier[ $field['condition']['field'] ] ) ) ) { $out[ $k ]['condition'] = null; } if ( self::is_input( $field ) && 'file' !== $field['type'] ) { $earlier[ $field['id'] ] = true; } } return $out; } /** * Split fields into steps. Without "step" items there is exactly one step. * * @param array[] $fields Normalized fields. * @param string $first_title Title of the first step. * @return array[] List of array( 'title' => string, 'fields' => array[] ). */ public static function steps( array $fields, $first_title = '' ) { $steps = array( array( 'title' => $first_title, 'fields' => array(), ), ); foreach ( $fields as $field ) { if ( 'step' === $field['type'] ) { $steps[] = array( 'title' => $field['label'], 'fields' => array(), ); continue; } $steps[ count( $steps ) - 1 ]['fields'][] = $field; } // Drop empty steps (e.g. a step break at the very beginning or end). return array_values( array_filter( $steps, static function ( $s ) { return ! empty( $s['fields'] ); } ) ); } /** * Which fields are visible given the submitted values? * Must match the logic in view.js. * * @param array[] $fields Normalized fields. * @param array $input Raw input (field ID => value). * @return array field ID => bool */ public static function visibility( array $fields, array $input ) { $visible = array(); foreach ( $fields as $field ) { if ( ! self::is_input( $field ) ) { continue; } $show = true; if ( $field['condition'] ) { $ref = $field['condition']['field']; $value = ( ! empty( $visible[ $ref ] ) && isset( $input[ $ref ] ) ) ? $input[ $ref ] : ''; $show = self::matches( $field['condition'], $value ); } $visible[ $field['id'] ] = $show; } return $visible; } /** * Evaluate a condition against a value. * * @param array $condition Condition. * @param mixed $value String or list of strings. * @return bool */ public static function matches( array $condition, $value ) { $values = array_values( array_filter( array_map( static function ( $v ) { return self::lower( trim( (string) $v ) ); }, is_array( $value ) ? $value : array( $value ) ), 'strlen' ) ); $needle = self::lower( trim( (string) $condition['value'] ) ); switch ( $condition['op'] ) { case 'filled': return (bool) $values; case 'empty': return ! $values; case 'is_not': return ! in_array( $needle, $values, true ); case 'contains': foreach ( $values as $v ) { if ( '' !== $needle && false !== strpos( $v, $needle ) ) { return true; } } return false; default: return in_array( $needle, $values, true ); } } /** * Lower-case a string (multibyte-safe if mbstring is available). * * @param string $s String. * @return string */ private static function lower( $s ) { return function_exists( 'mb_strtolower' ) ? mb_strtolower( $s, 'UTF-8' ) : strtolower( $s ); } /** * Translated validation messages (shared by server and browser). * * @return array */ public static function messages() { return array( /* translators: %s: field label */ 'required' => __( '"%s" is required.', 'alpeg-forms' ), /* translators: %s: field label */ 'choose' => __( 'Please choose an option for "%s".', 'alpeg-forms' ), /* translators: %s: field label */ 'choose_one' => __( 'Please choose at least one option for "%s".', 'alpeg-forms' ), 'consent' => __( 'Please agree to the processing of your data so that we can handle your request.', 'alpeg-forms' ), 'email' => __( 'Please enter a valid email address.', 'alpeg-forms' ), 'tel' => __( 'Please enter a valid phone number.', 'alpeg-forms' ), 'url' => __( 'Please enter a valid web address.', 'alpeg-forms' ), /* translators: %s: field label */ 'number' => __( 'Please enter only a number in "%s".', 'alpeg-forms' ), 'date' => __( 'Please choose a valid date.', 'alpeg-forms' ), /* translators: %s: field label */ 'file_required' => __( 'Please attach a file to "%s".', 'alpeg-forms' ), /* translators: 1: file name, 2: max size, e.g. "5 MB" */ 'file_size' => __( '"%1$s" is too large. Maximum file size: %2$s.', 'alpeg-forms' ), /* translators: 1: file name, 2: list of allowed file types */ 'file_type' => __( '"%1$s" has a file type that is not allowed. Allowed: %2$s.', 'alpeg-forms' ), /* translators: 1: max number of files, 2: field label */ 'file_count' => __( 'Please attach at most %1$d file(s) to "%2$s".', 'alpeg-forms' ), 'summary' => __( 'Please check the highlighted fields.', 'alpeg-forms' ), ); } /** * Validate and clean a value (not for file fields). * * @param array $field Field. * @param mixed $raw Input (unslashed). * @return array [string $value, string|null $error] */ public static function validate( array $field, $raw ) { $type = $field['type']; $label = $field['label']; $m = self::messages(); // Multiple choice. if ( 'checkbox' === $type ) { $values = array_values( array_intersect( array_map( 'strval', (array) $raw ), $field['options'] ) ); if ( $field['required'] && ! $values ) { return array( '', sprintf( $m['choose_one'], $label ) ); } return array( implode( ', ', $values ), null ); } $value = is_scalar( $raw ) ? trim( (string) $raw ) : ''; if ( 'consent' === $type ) { if ( '1' !== $value ) { return array( '', $m['consent'] ); } return array( __( 'Yes', 'alpeg-forms' ), null ); } if ( '' === $value ) { if ( $field['required'] ) { return array( '', sprintf( in_array( $type, array( 'select', 'radio' ), true ) ? $m['choose'] : $m['required'], $label ) ); } return array( '', null ); } switch ( $type ) { case 'email': $value = sanitize_email( $value ); if ( ! is_email( $value ) ) { return array( '', $m['email'] ); } break; case 'tel': $value = sanitize_text_field( $value ); if ( ! preg_match( '/^[0-9+()\/.\-\s]{3,40}$/', $value ) || ! preg_match( '/\d{3}/', preg_replace( '/\D/', '', $value ) ) ) { return array( '', $m['tel'] ); } break; case 'url': if ( ! preg_match( '#^https?://#i', $value ) ) { $value = 'https://' . $value; } $value = esc_url_raw( $value, array( 'http', 'https' ) ); $host = (string) wp_parse_url( $value, PHP_URL_HOST ); if ( '' === $value || false === filter_var( $value, FILTER_VALIDATE_URL ) || false === strpos( $host, '.' ) ) { return array( '', $m['url'] ); } break; case 'number': $value = str_replace( ',', '.', $value ); if ( ! is_numeric( $value ) ) { return array( '', sprintf( $m['number'], $label ) ); } break; case 'date': $d = \DateTime::createFromFormat( '!Y-m-d', $value ); if ( ! $d || $d->format( 'Y-m-d' ) !== $value ) { return array( '', $m['date'] ); } break; case 'select': case 'radio': if ( ! in_array( $value, $field['options'], true ) ) { return array( '', sprintf( $m['choose'], $label ) ); } break; case 'textarea': $value = sanitize_textarea_field( $value ); if ( mb_strlen( $value ) > 10000 ) { return array( '', __( 'Your message is too long (max. 10,000 characters).', 'alpeg-forms' ) ); } break; default: $value = sanitize_text_field( $value ); if ( mb_strlen( $value ) > 500 ) { /* translators: %s: field label */ return array( '', sprintf( __( 'The input in "%s" is too long.', 'alpeg-forms' ), $label ) ); } } return array( $value, null ); } }