}> */ public readonly array $fields; /** * Normalised settings. * * @var array{to: string, subject: string, button: string, store: bool, captcha: bool, retention_days: int, css: string, cc: string, from_name: string, from_email: string, reply_to: string, blocklist: string[], confirmation: array{type: string, message: string, url: string, page_id: int}, ext: array} */ public readonly array $settings; /** * Problems found while reading the definition. Empty when it is usable. * * @var string[] */ public readonly array $problems; /** * Reads a definition from stored or submitted data. Never trusts it: unknown types, bad ids * and duplicates are reported in $problems and left out. * * @param array $raw Array with "fields" and "settings". * @phpstan-param array $raw */ public function __construct( array $raw ) { $problems = array(); $fields = array(); $seen = array(); $types = self::types(); foreach ( array_slice( (array) ( $raw['fields'] ?? array() ), 0, self::MAX_FIELDS ) as $index => $field ) { $field = is_array( $field ) ? $field : array(); $id = strtolower( trim( (string) ( $field['id'] ?? '' ) ) ); $type = (string) ( $field['type'] ?? '' ); $label = self::text( (string) ( $field['label'] ?? '' ), 200 ); if ( 1 !== preg_match( '/^[a-z][a-z0-9_]{0,39}$/', $id ) ) { /* translators: %d: position of the field. */ $problems[] = sprintf( __( 'Field %d has no valid id (letters, digits and underscores, starting with a letter).', 'forensix-digital' ), (int) $index + 1 ); continue; } if ( isset( $seen[ $id ] ) ) { /* translators: %s: field id. */ $problems[] = sprintf( __( 'Two fields use the id "%s".', 'forensix-digital' ), $id ); continue; } if ( ! in_array( $type, $types, true ) ) { /* translators: %s: field id. */ $problems[] = sprintf( __( 'The field "%s" has an unknown type.', 'forensix-digital' ), $id ); continue; } if ( '' === $label && 'divider' !== $type ) { /* translators: %s: field id. */ $problems[] = sprintf( __( 'The field "%s" needs a label.', 'forensix-digital' ), $id ); continue; } $options = array(); foreach ( (array) ( $field['options'] ?? array() ) as $option ) { $option = self::text( (string) $option, 200 ); if ( '' !== $option && ! in_array( $option, $options, true ) ) { $options[] = $option; } } if ( in_array( $type, array( 'select', 'radio', 'checkbox' ), true ) && array() === $options ) { /* translators: %s: field id. */ $problems[] = sprintf( __( 'The field "%s" needs at least one option.', 'forensix-digital' ), $id ); continue; } /** * Filters the extra settings an add-on keeps in a field. Receives the raw field. * * @param array $ext Extra settings, empty by default. * @param array $field Raw field as submitted. * @param string $type Field type. */ $ext = apply_filters( 'forensix_form_field_ext', array(), $field, $type ); $seen[ $id ] = true; $fields[] = array( 'id' => $id, 'type' => $type, 'label' => $label, 'required' => ! empty( $field['required'] ) && ! self::is_layout( $type ) && 'hidden' !== $type, 'options' => in_array( $type, array( 'select', 'radio', 'checkbox' ), true ) ? $options : array(), 'help' => self::text( (string) ( $field['help'] ?? '' ), 300 ), 'placeholder' => self::text( (string) ( $field['placeholder'] ?? '' ), 100 ), 'default' => self::text( (string) ( $field['default'] ?? '' ), 200 ), 'min' => self::number( $field['min'] ?? '' ), 'max' => self::number( $field['max'] ?? '' ), 'step' => self::number( $field['step'] ?? '' ), 'css' => self::classes( $field['css'] ?? '' ), 'ext' => (array) $ext, ); } $settings = (array) ( $raw['settings'] ?? array() ); $confirmation = (array) ( $settings['confirmation'] ?? array() ); $redirect = 'redirect' === ( $confirmation['type'] ?? '' ) && 1 === preg_match( '#^https?://#i', (string) ( $confirmation['url'] ?? '' ) ); $page_id = max( 0, (int) ( $confirmation['page_id'] ?? 0 ) ); $on_page = 'page' === ( $confirmation['type'] ?? '' ) && $page_id > 0; $reply_to = (string) ( $settings['reply_to'] ?? '' ); /** * Filters the extra settings an add-on keeps for the whole form. Receives the raw settings. * * @param array $ext Extra settings, empty by default. * @param array $settings Raw settings as submitted. */ $settings_ext = apply_filters( 'forensix_form_settings_ext', array(), $settings ); $this->fields = $fields; $this->problems = $problems; $this->settings = array( 'to' => self::emails( (string) ( $settings['to'] ?? '' ) ), 'subject' => self::text( (string) ( $settings['subject'] ?? '' ), 200 ), 'button' => '' !== self::text( (string) ( $settings['button'] ?? '' ), 50 ) ? self::text( (string) $settings['button'], 50 ) : __( 'Send', 'forensix-digital' ), 'store' => ! array_key_exists( 'store', $settings ) || ! empty( $settings['store'] ), 'captcha' => ! array_key_exists( 'captcha', $settings ) || ! empty( $settings['captcha'] ), 'retention_days' => max( 0, min( 3650, (int) ( $settings['retention_days'] ?? 0 ) ) ), 'css' => self::classes( $settings['css'] ?? '' ), 'cc' => self::emails( (string) ( $settings['cc'] ?? '' ) ), 'from_name' => self::text( (string) ( $settings['from_name'] ?? '' ), 80 ), 'from_email' => false !== filter_var( trim( (string) ( $settings['from_email'] ?? '' ) ), FILTER_VALIDATE_EMAIL ) ? trim( (string) $settings['from_email'] ) : '', 'reply_to' => 1 === preg_match( '/^(none|[a-z][a-z0-9_]{0,39})$/', $reply_to ) ? $reply_to : '', 'blocklist' => self::blocklist( $settings['blocklist'] ?? array() ), 'confirmation' => array( 'type' => $redirect ? 'redirect' : ( $on_page ? 'page' : 'message' ), 'message' => '' !== self::text( (string) ( $confirmation['message'] ?? '' ), 500 ) ? self::text( (string) $confirmation['message'], 500 ) : __( 'Thank you. Your message has been sent.', 'forensix-digital' ), 'url' => $redirect ? trim( (string) $confirmation['url'] ) : '', 'page_id' => $on_page ? $page_id : 0, ), 'ext' => (array) $settings_ext, ); } /** * The definition as plain data, for storing. * * @return array{fields: array>, settings: array} */ public function to_array(): array { return array( 'fields' => $this->fields, 'settings' => $this->settings, ); } /** * Checks submitted values against the fields. Values of unknown fields are ignored. * * @param array $input Submitted values by field id. * @phpstan-param array $input * @return array{values: array, errors: array} Clean values (checkbox values joined with ", ") and one message per failing field. */ public function validate( array $input ): array { $values = array(); $errors = array(); /** * Filters the ids of fields that are skipped: not required, not checked, stored empty. * Used for fields an add-on hides by a rule or validates itself. * * @param string[] $hidden Field ids, empty by default. * @param FormDefinition $form This form. * @param array $input Submitted values by field id. */ $skipped = apply_filters( 'forensix_form_hidden_fields', array(), $this, $input ); $skipped = array_map( 'strval', (array) $skipped ); foreach ( $this->fields as $field ) { $id = $field['id']; $raw = $input[ $id ] ?? ''; if ( self::is_layout( $field['type'] ) ) { continue; } if ( in_array( $id, $skipped, true ) ) { $values[ $id ] = ''; continue; } if ( 'hidden' === $field['type'] ) { // What a visitor sends is ignored: the field always carries its set value. $values[ $id ] = $field['default']; continue; } if ( 'name' === $field['type'] ) { $parts = is_array( $raw ) ? $raw : array(); $first = self::value( is_scalar( $parts['first'] ?? '' ) ? (string) ( $parts['first'] ?? '' ) : '', false ); $last = self::value( is_scalar( $parts['last'] ?? '' ) ? (string) ( $parts['last'] ?? '' ) : '', false ); $value = trim( $first . ' ' . $last ); if ( $field['required'] && ( '' === $first || '' === $last ) ) { $errors[ $id ] = __( 'Please fill in your first and last name.', 'forensix-digital' ); } $values[ $id ] = mb_substr( $value, 0, 200 ); continue; } if ( 'checkbox' === $field['type'] ) { $chosen = array_values( array_intersect( $field['options'], array_map( 'strval', is_array( $raw ) ? $raw : array() ) ) ); $value = implode( ', ', $chosen ); } elseif ( 'consent' === $field['type'] ) { $value = '' !== (string) ( is_array( $raw ) ? '' : $raw ) ? __( 'Yes', 'forensix-digital' ) : ''; } else { $value = is_array( $raw ) ? '' : self::value( (string) $raw, 'textarea' === $field['type'] ); } if ( '' === $value ) { if ( $field['required'] ) { $errors[ $id ] = 'consent' === $field['type'] ? __( 'Please tick this box to continue.', 'forensix-digital' ) : __( 'Please fill in this field.', 'forensix-digital' ); } $values[ $id ] = ''; continue; } if ( 'url' === $field['type'] && 1 !== preg_match( '#^[a-z][a-z0-9+.-]*://#i', $value ) ) { $value = 'https://' . $value; } $message = $this->check( $field, $value ); if ( null !== $message ) { $errors[ $id ] = $message; } $values[ $id ] = $value; } return array( 'values' => $values, 'errors' => $errors, ); } /** * Checks one non-empty value against its field type. * * @param array $field Field. * @param string $value Value. * @phpstan-param array{id: string, type: string, label: string, required: bool, options: string[], help: string, placeholder: string, default: string, min: string, max: string, step: string, css: string, ext: array} $field * @return string|null Error message, null when the value is fine. */ private function check( array $field, string $value ): ?string { switch ( $field['type'] ) { case 'email': return false === filter_var( $value, FILTER_VALIDATE_EMAIL ) ? __( 'Please enter a valid email address.', 'forensix-digital' ) : null; case 'number': case 'range': if ( ! is_numeric( $value ) ) { return __( 'Please enter a number.', 'forensix-digital' ); } if ( '' !== $field['min'] && (float) $value < (float) $field['min'] ) { /* translators: %s: smallest allowed number. */ return sprintf( __( 'Please enter %s or more.', 'forensix-digital' ), $field['min'] ); } if ( '' !== $field['max'] && (float) $value > (float) $field['max'] ) { /* translators: %s: largest allowed number. */ return sprintf( __( 'Please enter %s or less.', 'forensix-digital' ), $field['max'] ); } return null; case 'url': return 1 === preg_match( '#^https?://#i', $value ) && false !== filter_var( $value, FILTER_VALIDATE_URL ) ? null : __( 'Please enter a web address, for example https://example.com', 'forensix-digital' ); case 'date': $date = \DateTimeImmutable::createFromFormat( '!Y-m-d', $value ); return false !== $date && $date->format( 'Y-m-d' ) === $value ? null : __( 'Please enter a date.', 'forensix-digital' ); case 'tel': return 1 === preg_match( '/^[0-9+()\-.\/ ]{5,30}$/', $value ) ? null : __( 'Please enter a valid phone number.', 'forensix-digital' ); case 'select': case 'radio': return in_array( $value, $field['options'], true ) ? null : __( 'Please choose one of the options.', 'forensix-digital' ); default: return null; } } /** * Plain single-line text: no tags, collapsed spaces, shortened. * * @param string $text Text. * @param int $length Maximum characters. */ private static function text( string $text, int $length ): string { return mb_substr( trim( (string) preg_replace( '/\s+/u', ' ', wp_strip_all_tags( $text ) ) ), 0, $length ); } /** * A submitted value: no tags and no control characters; line breaks kept in long text. * * @param string $text Text. * @param bool $multiline Whether line breaks stay. */ private static function value( string $text, bool $multiline ): string { $text = (string) preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', wp_strip_all_tags( $text ) ); $text = $multiline ? str_replace( "\r\n", "\n", $text ) : (string) preg_replace( '/\s+/u', ' ', $text ); return mb_substr( trim( $text ), 0, self::MAX_LENGTH ); } /** * A number setting as text, or empty when it is not a number. * * @param mixed $value Value. */ private static function number( $value ): string { if ( ! is_scalar( $value ) || ! is_numeric( trim( (string) $value ) ) ) { return ''; } return (string) (float) trim( (string) $value ); } /** * CSS class names the editor added, separated by spaces. Anything that is not a valid class * name is dropped. * * @param mixed $raw Raw value. */ private static function classes( mixed $raw ): string { $names = preg_split( '/\s+/', is_scalar( $raw ) ? trim( (string) $raw ) : '' ); $valid = array(); foreach ( false !== $names ? $names : array() as $name ) { if ( 1 === preg_match( '/^-?[A-Za-z_][A-Za-z0-9_-]{0,59}$/', $name ) ) { $valid[] = $name; } } return implode( ' ', array_slice( array_unique( $valid ), 0, 10 ) ); } /** * Block list entries: full email addresses or domains (a leading @ is dropped), one per line. * * @param mixed $raw Lines as a list or as text. * @return string[] */ private static function blocklist( mixed $raw ): array { $lines = is_array( $raw ) ? $raw : explode( "\n", (string) $raw ); $list = array(); foreach ( $lines as $line ) { $entry = strtolower( trim( (string) $line ) ); $entry = ltrim( $entry, '@' ); $valid = str_contains( $entry, '@' ) ? false !== filter_var( $entry, FILTER_VALIDATE_EMAIL ) : 1 === preg_match( '/^(\*\.)?[a-z0-9-]+(\.[a-z0-9-]+)+$/', $entry ); if ( $valid && strlen( $entry ) <= 100 ) { $list[] = $entry; } } return array_slice( array_values( array_unique( $list ) ), 0, 500 ); } /** * Whether an email address is on the form's block list, as a full address or by its domain. * A domain entry also covers its subdomains. * * @param string $email Email address. */ public function is_blocked( string $email ): bool { $email = strtolower( trim( $email ) ); $at = strrpos( $email, '@' ); if ( false === $at ) { return false; } $domain = substr( $email, $at + 1 ); foreach ( $this->settings['blocklist'] as $entry ) { $entry = (string) preg_replace( '/^\*\./', '', (string) $entry ); if ( $entry === $email || $entry === $domain || str_ends_with( $domain, '.' . $entry ) ) { return true; } } return false; } /** * Valid addresses from a comma-separated list, joined again. * * @param string $addresses Addresses. */ private static function emails( string $addresses ): string { $valid = array(); foreach ( explode( ',', $addresses ) as $address ) { $address = trim( $address ); if ( false !== filter_var( $address, FILTER_VALIDATE_EMAIL ) ) { $valid[] = $address; } } return implode( ', ', array_unique( $valid ) ); } }