*/ private array $values = array(); /** * Errors, keyed by field name. * * @var array */ private array $errors = array(); /** * Accepted uploads, field name => $_FILES entry. * * @var array> */ private array $uploads = array(); /** * Fields skipped because their [if] group is inactive. * * @var array */ private array $inactive = array(); /** * Accepted uploads (checked, not yet moved). * * @return array> */ public function uploads(): array { return $this->uploads; } /** * Names of fields skipped by conditions. * * @return string[] */ public function inactive_fields(): array { return array_keys( $this->inactive ); } /** * Whether a field's [if] conditions are all met, judged on the submitted * values of the controlling fields (which must themselves be active). * * @param string $name Field name. * @param int $depth Recursion guard. */ public function is_active( string $name, int $depth = 0 ): bool { $conditions = $this->form->conditions()[ $name ] ?? array(); if ( array() === $conditions ) { return true; } if ( $depth > 10 ) { return false; // Circular conditions: treat as hidden. } $fields = $this->form->input_fields(); foreach ( $conditions as $condition ) { $controller = $condition->field; $values = array(); if ( isset( $fields[ $controller ] ) && $this->is_active( $controller, $depth + 1 ) ) { $raw = $this->input[ $controller ] ?? array(); $values = array_map( static fn( $v ): string => is_scalar( $v ) ? sanitize_text_field( (string) $v ) : '', is_array( $raw ) ? $raw : array( $raw ) ); } if ( ! $condition->matches( $values ) ) { return false; } } return true; } /** * Validates a file field. * * @param Field $field File field. * @param mixed $file $_FILES entry or null. */ private function validate_file( Field $field, mixed $file ): void { $name = $field->name; $this->values[ $name ] = ''; // An array of files where one is expected means a tampered request. if ( is_array( $file ) && ( is_array( $file['name'] ?? null ) || is_array( $file['error'] ?? null ) || is_array( $file['tmp_name'] ?? null ) ) ) { $this->errors[ $name ] = $this->form->message( 'upload_failed' ); return; } $missing = ! is_array( $file ) || UPLOAD_ERR_NO_FILE === (int) ( $file['error'] ?? UPLOAD_ERR_NO_FILE ) || '' === (string) ( $file['name'] ?? '' ); if ( $missing ) { if ( $field->required ) { $this->errors[ $name ] = $this->form->message( 'required' ); } return; } $problem = Uploads::check( $field, $file ); if ( null !== $problem ) { $this->errors[ $name ] = $this->form->message( $problem ); return; } $this->uploads[ $name ] = $file; $this->values[ $name ] = Uploads::clean_name( (string) $file['name'] ); } /** * Raw input of the current validation. * * @var array */ private array $input = array(); /** * Key used in values() for the "Other" text of a free_text field. It can * never collide with a field name, which must start with a letter. * * @param string $name Field name. */ public static function free_text_key( string $name ): string { return '__free:' . $name; } /** * Key used in values() for the separate parts of a name or address field * (part => text), so the form can be filled in again after an error. * * @param string $name Field name. */ public static function parts_key( string $name ): string { return '__parts:' . $name; } /** * Hidden input that tells the server which quiz question was shown. * * @param string $name Quiz field name. */ public static function quiz_index_field( string $name ): string { return 'tflexforms_quiz_' . $name; } /** * Normalizes quiz answers: case, surrounding and repeated whitespace are ignored. * * @param string $answer Answer. */ public static function normalize_answer( string $answer ): string { return mb_strtolower( trim( preg_replace( '/\s+/u', ' ', $answer ) ?? '' ), 'UTF-8' ); } /** * Constructor. * * @param Form $form Form being validated. */ public function __construct( private readonly Form $form ) {} /** * Validates raw (already unslashed) input. Unknown keys are ignored, so a * forged request cannot add fields the form does not define. * * @param array $input Raw request data. * @param array> $files Uploaded files ($_FILES shape), keyed by field name. */ public function validate( array $input, array $files = array() ): bool { $this->values = array(); $this->errors = array(); $this->input = $input; $this->uploads = array(); $this->inactive = array(); foreach ( $this->form->input_fields() as $name => $field ) { $raw = $input[ $name ] ?? null; // Fields inside an [if] group whose condition is false are ignored: // not required, not mailed, not stored. if ( ! $this->is_active( $name ) ) { $this->inactive[ $name ] = true; $this->values[ $name ] = $field->is_multiple() ? array() : ''; continue; } $custom = Extensions::field_type( $field->type ); if ( null !== $custom ) { $this->validate_custom( $field, $custom, $raw ); } elseif ( 'file' === $field->type ) { $this->validate_file( $field, $files[ $name ] ?? null ); } elseif ( $field->is_composite() ) { $this->validate_parts( $field, $raw ); } elseif ( $field->is_multiple() ) { $this->validate_multiple( $field, $raw ); } else { $this->validate_single( $field, $raw ); } } /** * Filters validation errors. Return field name => message pairs. * * @param array $errors Current errors. * @param array $values Sanitized values. * @param Form $form Form. */ $errors = apply_filters( 'tflexforms_validation_errors', $this->errors, $this->values, $this->form ); $this->errors = is_array( $errors ) ? array_map( 'strval', $errors ) : $this->errors; return array() === $this->errors; } /** * Sanitized values. * * @return array */ public function values(): array { return $this->values; } /** * Errors keyed by field name. * * @return array */ public function errors(): array { return $this->errors; } /** * Validates a field that accepts one value. * * @param Field $field Field. * @param mixed $raw Raw value. */ private function validate_single( Field $field, mixed $raw ): void { $name = $field->name; // Arrays where a scalar is expected are a tampered request. if ( is_array( $raw ) ) { $this->values[ $name ] = ''; $this->errors[ $name ] = $this->form->message( 'invalid_choice' ); return; } $raw = is_scalar( $raw ) ? (string) $raw : ''; if ( 'acceptance' === $field->type ) { $accepted = '1' === $raw; $this->values[ $name ] = $accepted ? '1' : ''; if ( $field->required && ! $accepted ) { $this->errors[ $name ] = $this->form->message( 'acceptance' ); } return; } $value = 'textarea' === $field->type ? sanitize_textarea_field( $raw ) : sanitize_text_field( $raw ); if ( '' === $value ) { $this->values[ $name ] = ''; if ( $field->required ) { $this->errors[ $name ] = $this->form->message( 'required' ); } return; } $error = $this->check_length( $field, $value ) ?? match ( $field->type ) { 'email' => $this->check_email( $value ), 'url' => $this->check_url( $value ), 'tel' => $this->check_tel( $value ), 'number' => $this->check_number( $field, $value ), 'range' => $this->check_number( $field, $value, '0', '100' ), 'quiz' => $this->check_quiz( $field, $value ), 'date' => $this->check_date( $field, $value ), 'select', 'radio' => in_array( $value, $field->choices(), true ) ? null : $this->form->message( 'invalid_choice' ), default => null, }; if ( 'email' === $field->type && null === $error ) { $value = sanitize_email( $value ); } if ( 'url' === $field->type && null === $error ) { $value = esc_url_raw( $value, array( 'http', 'https' ) ); } $this->values[ $name ] = $value; if ( null === $error && 'radio' === $field->type ) { $this->capture_free_text( $field, array( $value ) ); } if ( null !== $error ) { $this->errors[ $name ] = $error; } } /** * Validates a field of an add-on type with its own callbacks. The value * is always stored as a string, capped like any single-line field. * * @param Field $field Field. * @param array $type Registered type (see Extensions). * @param mixed $raw Raw value. */ private function validate_custom( Field $field, array $type, mixed $raw ): void { $name = $field->name; $value = null !== $type['sanitize'] ? (string) call_user_func( $type['sanitize'], $raw, $field ) : ( is_scalar( $raw ) ? sanitize_text_field( (string) $raw ) : '' ); $this->values[ $name ] = $value; if ( '' === $value ) { if ( $field->required ) { $this->errors[ $name ] = $this->form->message( 'required' ); } return; } $error = $this->check_length( $field, $value ); if ( null === $error && null !== $type['validate'] ) { $result = call_user_func( $type['validate'], $value, $field, $this->form ); $error = is_string( $result ) && '' !== $result ? $result : null; } if ( null !== $error ) { $this->errors[ $name ] = $error; } } /** * Validates a field with several inputs (name[first], address[city], ...). * The value is the parts joined into one text; a required field needs * every part marked as required. * * @param Field $field Composite field. * @param mixed $raw Raw value: part => text. */ private function validate_parts( Field $field, mixed $raw ): void { $name = $field->name; $raw = is_array( $raw ) ? $raw : array(); $parts = array(); $error = null; foreach ( $field->parts() as $key => $part ) { $text = isset( $raw[ $key ] ) && is_scalar( $raw[ $key ] ) ? sanitize_text_field( (string) $raw[ $key ] ) : ''; $parts[ $key ] = $text; if ( mb_strlen( $text, 'UTF-8' ) > Field::PART_MAX ) { $error ??= $this->form->message( 'too_long' ); } elseif ( '' === $text && $part['required'] && $field->required ) { $error ??= $this->form->message( 'required' ); } } $this->values[ $name ] = null === $error ? $field->join_parts( $parts ) : ''; $this->values[ self::parts_key( $name ) ] = $parts; if ( null !== $error ) { $this->errors[ $name ] = $error; } } /** * Validates checkbox groups and multi-selects. * * @param Field $field Field. * @param mixed $raw Raw value. */ private function validate_multiple( Field $field, mixed $raw ): void { $name = $field->name; $raw = is_array( $raw ) ? $raw : ( null === $raw || '' === $raw ? array() : array( $raw ) ); $choices = $field->choices(); $selected = array(); foreach ( $raw as $item ) { if ( ! is_scalar( $item ) ) { continue; } $item = sanitize_text_field( (string) $item ); if ( '' === $item ) { continue; } if ( ! in_array( $item, $choices, true ) ) { $this->values[ $name ] = array(); $this->errors[ $name ] = $this->form->message( 'invalid_choice' ); return; } $selected[ $item ] = true; } // Keep the admin-defined order and drop duplicates. $this->values[ $name ] = array_values( array_filter( $choices, static fn( string $c ) => isset( $selected[ $c ] ) ) ); if ( $field->has_flag( 'exclusive' ) && count( $this->values[ $name ] ) > 1 ) { $this->errors[ $name ] = $this->form->message( 'invalid_choice' ); return; } if ( $field->required && array() === $this->values[ $name ] ) { $this->errors[ $name ] = $this->form->message( 'required' ); return; } $this->capture_free_text( $field, $this->values[ $name ] ); } /** * Stores the "Other" text when the free_text choice was picked. * * @param Field $field Field. * @param string[] $selected Selected labels. */ private function capture_free_text( Field $field, array $selected ): void { $choice = $field->free_text_choice(); if ( '' === $choice || ! in_array( $choice, $selected, true ) ) { return; } $raw = $this->input[ $field->name . '_free' ] ?? ''; $text = is_string( $raw ) ? sanitize_text_field( $raw ) : ''; if ( mb_strlen( $text, 'UTF-8' ) > 200 ) { $this->errors[ $field->name ] = $this->form->message( 'too_long' ); return; } $this->values[ self::free_text_key( $field->name ) ] = $text; } /** * Checks a quiz answer against the question that was shown. The index * comes from the browser, but every question's answer is checked on the * server, so changing it only swaps one question for another. * * @param Field $field Field. * @param string $value Answer. */ private function check_quiz( Field $field, string $value ): ?string { $questions = $field->quiz_questions(); $index = $this->input[ self::quiz_index_field( $field->name ) ] ?? ''; if ( ! is_string( $index ) || ! ctype_digit( $index ) || ! isset( $questions[ (int) $index ] ) ) { return $this->form->message( 'quiz_wrong' ); } $answers = array_map( array( self::class, 'normalize_answer' ), $questions[ (int) $index ]['answers'] ); return in_array( self::normalize_answer( $value ), $answers, true ) ? null : $this->form->message( 'quiz_wrong' ); } /** * Enforces minlength/maxlength (and a default cap). * * @param Field $field Field. * @param string $value Value. */ private function check_length( Field $field, string $value ): ?string { $length = mb_strlen( $value, 'UTF-8' ); $default = 'textarea' === $field->type ? self::DEFAULT_MAX_TEXT : self::DEFAULT_MAX_LINE; $max = null !== $field->option( 'maxlength' ) ? (int) $field->option( 'maxlength' ) : $default; $min = (int) ( $field->option( 'minlength' ) ?? 0 ); if ( $length > min( $max, $default ) ) { return $this->form->message( 'too_long' ); } if ( $min > 0 && $length < $min ) { return $this->form->message( 'too_short' ); } return null; } /** * Validates an email address. * * @param string $value Value. */ private function check_email( string $value ): ?string { return is_email( $value ) && sanitize_email( $value ) === $value ? null : $this->form->message( 'invalid_email' ); } /** * Validates an http(s) URL. * * @param string $value Value. */ private function check_url( string $value ): ?string { $scheme = strtolower( (string) wp_parse_url( $value, PHP_URL_SCHEME ) ); $host = (string) wp_parse_url( $value, PHP_URL_HOST ); $ok = false !== filter_var( $value, FILTER_VALIDATE_URL ) && in_array( $scheme, array( 'http', 'https' ), true ) && '' !== $host; return $ok ? null : $this->form->message( 'invalid_url' ); } /** * Validates a phone number (digits, spaces and common punctuation). * * @param string $value Value. */ private function check_tel( string $value ): ?string { $digits = preg_replace( '/\D/', '', $value ) ?? ''; $ok = 1 === preg_match( '/^\+?[0-9\s().\-]{5,30}$/', $value ) && strlen( $digits ) >= 5 && strlen( $digits ) <= 20; return $ok ? null : $this->form->message( 'invalid_tel' ); } /** * Validates a number against min, max and step. * * @param Field $field Field. * @param string $value Value. * @param string|null $default_min Minimum when the tag sets none (range sliders). * @param string|null $default_max Maximum when the tag sets none (range sliders). */ private function check_number( Field $field, string $value, ?string $default_min = null, ?string $default_max = null ): ?string { if ( 1 !== preg_match( '/^-?\d{1,15}(\.\d{1,10})?$/', $value ) ) { return $this->form->message( 'invalid_number' ); } $number = (float) $value; $min = $field->option( 'min' ) ?? $default_min; $max = $field->option( 'max' ) ?? $default_max; if ( null !== $min && is_numeric( $min ) && $number < (float) $min ) { return $this->form->message( 'number_too_small' ); } if ( null !== $max && is_numeric( $max ) && $number > (float) $max ) { return $this->form->message( 'number_too_large' ); } $step = $field->option( 'step' ) ?? '1'; if ( 'any' !== $step && (float) $step > 0 ) { $base = null !== $min && is_numeric( $min ) ? (float) $min : 0.0; $steps = ( $number - $base ) / (float) $step; if ( abs( $steps - round( $steps ) ) > 1e-9 ) { return $this->form->message( 'invalid_number' ); } } return null; } /** * Validates a YYYY-MM-DD date against min and max. * * @param Field $field Field. * @param string $value Value. */ private function check_date( Field $field, string $value ): ?string { $date = \DateTimeImmutable::createFromFormat( '!Y-m-d', $value ); if ( false === $date || $date->format( 'Y-m-d' ) !== $value ) { return $this->form->message( 'invalid_date' ); } // ISO dates compare correctly as strings. $min = $field->option( 'min' ); $max = $field->option( 'max' ); if ( null !== $min && 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $min ) && $value < $min ) { return $this->form->message( 'date_too_early' ); } if ( null !== $max && 1 === preg_match( '/^\d{4}-\d{2}-\d{2}$/', $max ) && $value > $max ) { return $this->form->message( 'date_too_late' ); } return null; } }