*/ public static function field_types() { $types = array( 'text' => array( 'label' => __( 'Text', 'accessible-forms-by-accessible-org' ), 'input' => 'text', 'choice' => false, 'autocomplete' => array( 'name', 'given-name', 'family-name', 'nickname', 'username', 'organization', 'organization-title', 'street-address', 'address-line1', 'address-line2', 'address-level2', 'address-level1', 'postal-code', 'country-name', ), ), 'email' => array( 'label' => __( 'Email', 'accessible-forms-by-accessible-org' ), 'input' => 'email', 'choice' => false, 'autocomplete' => array( 'email' ), ), 'tel' => array( 'label' => __( 'Telephone', 'accessible-forms-by-accessible-org' ), 'input' => 'tel', 'choice' => false, 'autocomplete' => array( 'tel' ), ), 'url' => array( 'label' => __( 'URL', 'accessible-forms-by-accessible-org' ), 'input' => 'url', 'choice' => false, 'autocomplete' => array( 'url' ), ), 'number' => array( 'label' => __( 'Number', 'accessible-forms-by-accessible-org' ), 'input' => 'number', 'choice' => false, 'autocomplete' => array(), ), 'date' => array( 'label' => __( 'Date', 'accessible-forms-by-accessible-org' ), 'input' => 'date', 'choice' => false, 'autocomplete' => array(), ), 'time' => array( 'label' => __( 'Time', 'accessible-forms-by-accessible-org' ), 'input' => 'time', 'choice' => false, 'autocomplete' => array(), ), 'section' => array( 'label' => __( 'Section heading', 'accessible-forms-by-accessible-org' ), 'input' => 'section', 'choice' => false, 'autocomplete' => array(), ), 'content' => array( 'label' => __( 'Content block', 'accessible-forms-by-accessible-org' ), 'input' => 'content', 'choice' => false, 'autocomplete' => array(), ), 'textarea' => array( 'label' => __( 'Message (multi-line)', 'accessible-forms-by-accessible-org' ), 'input' => 'textarea', 'choice' => false, 'autocomplete' => array(), ), 'select' => array( 'label' => __( 'Dropdown', 'accessible-forms-by-accessible-org' ), 'input' => 'select', 'choice' => false, 'options' => true, 'autocomplete' => array(), ), 'radio' => array( 'label' => __( 'Radio group (choose one)', 'accessible-forms-by-accessible-org' ), 'input' => 'radio', 'choice' => true, 'options' => true, 'autocomplete' => array(), ), 'checkbox' => array( 'label' => __( 'Checkbox group (choose any)', 'accessible-forms-by-accessible-org' ), 'input' => 'checkbox', 'choice' => true, 'options' => true, 'autocomplete' => array(), ), 'consent' => array( 'label' => __( 'Single checkbox (consent)', 'accessible-forms-by-accessible-org' ), 'input' => 'consent', 'choice' => false, 'autocomplete' => array(), ), ); // Add-ons add field types. Each names its add-on in `addon`; built-in types cannot be replaced. $added = (array) Extensions::filter( 'accessible_forms_field_types', array() ); foreach ( $added as $key => $type ) { $key = sanitize_key( (string) $key ); if ( '' !== $key && ! isset( $types[ $key ] ) && is_array( $type ) && ! empty( $type['addon'] ) ) { $types[ $key ] = array_merge( array( 'label' => $key, 'input' => $key, 'choice' => false, 'autocomplete' => array(), ), $type, array( 'addon' => sanitize_key( (string) $type['addon'] ) ) ); } } return $types; } public static function is_answer_field( array $field ) { return ! in_array( $field['type'] ?? '', array( 'section', 'content' ), true ); } /** A separate allowlist keeps author content from inheriting form markup permissions. */ public static function content_html( $value ) { if ( ! is_string( $value ) ) { return ''; } $tags = array_fill_keys( array( 'p', 'br', 'strong', 'b', 'em', 'i', 'ul', 'ol', 'li' ), array() ); $tags['a'] = array( 'href' => true ); $html = wp_kses( substr( $value, 0, 12000 ), $tags, array( 'http', 'https', 'mailto', 'tel' ) ); return trim( force_balance_tags( $html ) ); } /** Content is the only rich text accepted in an editor schema. */ public static function clean_editor_input( array $raw ) { $clean = self::clean_input( $raw ); foreach ( (array) ( $raw['fields'] ?? array() ) as $index => $field ) { if ( is_array( $field ) && array_key_exists( 'content', $field ) ) { $clean['fields'][ $index ]['content'] = self::content_html( $field['content'] ); } } return $clean; } /** * Fields a visitor can answer now. A field whose type belongs to an * inactive add-on is kept in the form but paused: it is neither shown nor * validated. * * @param array $schema Form schema. * @return array Schema with only usable fields. */ public static function usable( array $schema ) { $types = self::field_types(); $schema['fields'] = array_values( array_filter( (array) ( $schema['fields'] ?? array() ), static function ( $field ) use ( $types ) { return is_array( $field ) && isset( $types[ $field['type'] ?? '' ] ); } ) ); return $schema; } /** Shared, conservative autofill hints for recognizable personal information. */ public static function autocomplete_hints() { return array( 'name' => array( 'name', 'full name', 'your name', 'your full name', 'contact name', 'fullname', __( 'Name', 'accessible-forms-by-accessible-org' ), __( 'Full name', 'accessible-forms-by-accessible-org' ), ), 'given-name' => array( 'first name', 'given name', 'firstname', 'fname', __( 'First name', 'accessible-forms-by-accessible-org' ), ), 'family-name' => array( 'last name', 'family name', 'surname', 'lastname', 'lname', __( 'Last name', 'accessible-forms-by-accessible-org' ), ), 'nickname' => array( 'nickname', 'preferred name', __( 'Nickname', 'accessible-forms-by-accessible-org' ) ), 'username' => array( 'username', 'user name', __( 'Username', 'accessible-forms-by-accessible-org' ) ), 'organization' => array( 'company', 'company name', 'organization', 'organisation', 'business name', __( 'Company', 'accessible-forms-by-accessible-org' ), ), 'organization-title' => array( 'job title', 'position', __( 'Job title', 'accessible-forms-by-accessible-org' ) ), 'street-address' => array( 'address', 'street address', 'mailing address', __( 'Address', 'accessible-forms-by-accessible-org' ), ), 'address-line1' => array( 'address line 1', 'address1', 'street', __( 'Address line 1', 'accessible-forms-by-accessible-org' ), ), 'address-line2' => array( 'address line 2', 'address2', 'apartment', __( 'Address line 2', 'accessible-forms-by-accessible-org' ), ), 'address-level2' => array( 'city', 'town', __( 'City', 'accessible-forms-by-accessible-org' ) ), 'address-level1' => array( 'state', 'province', 'region', __( 'State', 'accessible-forms-by-accessible-org' ) ), 'postal-code' => array( 'zip', 'zip code', 'zipcode', 'postal code', 'postcode', 'pin code', 'pincode', __( 'Postal code', 'accessible-forms-by-accessible-org' ), ), 'country-name' => array( 'country', 'country name', __( 'Country', 'accessible-forms-by-accessible-org' ) ), ); } /** Infer only supported autofill purposes; unrelated text fields stay unmarked. */ public static function infer_autocomplete( $type, $label, $name = '' ) { $fixed = array( 'email' => 'email', 'tel' => 'tel', 'url' => 'url', ); if ( isset( $fixed[ $type ] ) ) { return $fixed[ $type ]; } if ( 'text' !== $type ) { return ''; } foreach ( array( $label, $name ) as $candidate ) { $candidate = function_exists( 'mb_strtolower' ) ? mb_strtolower( $candidate ) : strtolower( $candidate ); $candidate = trim( preg_replace( '/[\W_]+/u', ' ', $candidate ) ); $candidate = preg_replace( '/^(your|enter your|enter|please enter your) /', '', $candidate ); foreach ( self::autocomplete_hints() as $token => $labels ) { foreach ( $labels as $hint ) { $hint = function_exists( 'mb_strtolower' ) ? mb_strtolower( $hint ) : strtolower( $hint ); if ( $candidate === $hint ) { return $token; } } } } return ''; } /** * Default schema for a newly created form. * * @return array */ public static function defaults() { return array_merge( FormOptions::defaults(), array( 'heading' => __( 'Contact Us', 'accessible-forms-by-accessible-org' ), 'appearance' => Appearance::defaults(), 'heading_level' => 'h2', 'fields' => array( array( 'type' => 'text', 'name' => 'name', 'label' => __( 'Name', 'accessible-forms-by-accessible-org' ), 'required' => true, 'autocomplete' => 'name', 'autocomplete_mode' => 'auto', 'options' => array(), ), array( 'type' => 'email', 'name' => 'email', 'label' => __( 'Email', 'accessible-forms-by-accessible-org' ), 'required' => true, 'autocomplete' => 'email', 'autocomplete_mode' => 'auto', 'options' => array(), ), array( 'type' => 'textarea', 'name' => 'message', 'label' => __( 'Message', 'accessible-forms-by-accessible-org' ), 'required' => true, 'autocomplete' => '', 'options' => array(), ), ), 'recipients' => array( get_option( 'admin_email' ) ), 'subject_template' => '[{site}] ' . __( 'Contact from', 'accessible-forms-by-accessible-org' ) . ' {name}', 'success_message' => __( 'Thank you. Your message has been sent.', 'accessible-forms-by-accessible-org' ), 'submit_label' => __( 'Send message', 'accessible-forms-by-accessible-org' ), // Add-on data keyed by add-on slug. See Extensions. 'extensions' => array(), ) ); } /** * Load a form's schema. * * @param int $form_id Form post ID. * @return array|null Schema, or null when the form does not exist or is not published. */ public static function get( $form_id, $allow_unpublished = false ) { $form_id = (int) $form_id; $post = $form_id ? get_post( $form_id ) : null; if ( ! $post || 'accessible_form' !== $post->post_type || ( ! $allow_unpublished && 'publish' !== $post->post_status ) ) { return null; } $stored = $allow_unpublished ? get_post_meta( $form_id, Revisions::DRAFT, true ) : ''; if ( ! $stored ) { $stored = get_post_meta( $form_id, self::META_KEY, true ); } $schema = is_string( $stored ) && '' !== $stored ? json_decode( $stored, true ) : null; if ( ! is_array( $schema ) ) { $schema = self::defaults(); } return wp_parse_args( $schema, self::defaults() ); } /** Read the published configuration, including for archived forms. */ public static function get_live( $form_id ) { $raw = get_post_meta( $form_id, self::META_KEY, true ); $schema = is_string( $raw ) ? json_decode( $raw, true ) : null; return is_array( $schema ) ? wp_parse_args( $schema, self::defaults() ) : null; } /** * Persist a schema. * * @param int $form_id Form post ID. * @param array $schema Sanitized schema. */ public static function save( $form_id, array $schema ) { if ( self::validate( $schema )[1] ) { return false; } return Persistence::meta( (int) $form_id, self::META_KEY, wp_json_encode( $schema ) ); } /** * Sanitize untrusted nested request input at the point it is read. Strings * become plain text (newlines kept); other scalar types are left for the * strict validators that follow. * * @param mixed $value Unslashed request value. * @return mixed */ public static function clean_input( $value ) { return map_deep( $value, static function ( $item ) { return is_string( $item ) ? sanitize_textarea_field( $item ) : $item; } ); } /** * Sanitize a schema submitted from the editor, refusing anything that would * produce inaccessible output. * * This is the enforcement point for the plugin's central promise. Where other * plugins warn, this returns errors and the save is rejected. * * Kept for callers that expect plain messages. The editor uses validate(), * which returns the same messages with the field or setting they refer to. * * @param array $raw Raw $_POST fragment. * @return array{0: array, 1: string[]} Sanitized schema and a list of errors. */ public static function sanitize( array $raw ) { [$schema, $issues] = self::validate( $raw ); return array( $schema, self::messages( $issues ) ); } /** * Build one structured validation issue. * * @param string $message Translated, human-readable message. * @param string|null $setting Top-level setting key, such as heading or recipients. * @param int|null $field Zero-based position of the field in the submitted list. * @param string|null $control Field property the message is about, such as label or min_length. * @return array{message: string, setting: string|null, field: int|null, control: string|null} */ public static function issue( $message, $setting = null, $field = null, $control = null ) { return array( 'message' => (string) $message, 'setting' => null === $setting ? null : (string) $setting, 'field' => null === $field ? null : (int) $field, 'control' => null === $control ? null : (string) $control, ); } /** * Reduce structured issues (or plain strings) to a unique list of messages. * * @param array $issues Issues from validate(), or plain strings. * @return string[] */ public static function messages( array $issues ) { $messages = array(); foreach ( $issues as $issue ) { $messages[] = is_array( $issue ) ? (string) ( $issue['message'] ?? '' ) : (string) $issue; } return array_values( array_unique( array_filter( $messages, 'strlen' ) ) ); } /** * Whether text says something: at least one letter or digit in any script. * Punctuation, symbols or emoji alone make a meaningless accessible name. * * @param string $text Text to check. * @return bool */ public static function meaningful( $text ) { return (bool) preg_match( '/[\p{L}\p{N}]/u', (string) $text ); } /** * A label as a person hears it, for comparing labels: case, spacing and * trailing punctuation do not tell two questions apart. * * @param string $label Field label. * @return string */ public static function label_key( $label ) { $key = function_exists( 'mb_strtolower' ) ? mb_strtolower( (string) $label, 'UTF-8' ) : strtolower( (string) $label ); $key = preg_replace( '/\s+/u', ' ', $key ); return trim( preg_replace( '/[\s:*?.!]+$/u', '', $key ) ); } /** * Name a field in a message by its visible label. * * @param string $label Field label. * @param string $message Message that follows the label. * @return string */ public static function about( $label, $message ) { return sprintf( /* translators: 1: field label, 2: what needs to change. */ __( '“%1$s”: %2$s', 'accessible-forms-by-accessible-org' ), $label, $message ); } /** * Sanitize a schema and return structured issues that point at the field or * setting to correct. * * @param array $raw Raw $_POST fragment. * @return array{0: array, 1: array[]} Sanitized schema and a list of issues (see issue()). */ public static function validate( array $raw ) { $types = self::field_types(); $errors = array(); $schema = self::defaults(); $raw = array_intersect_key( $raw, self::defaults() + array_fill_keys( array( 'cc', 'body_template', 'merge_subject' ), null ) ); [$schema['appearance'], $appearance_errors] = Appearance::sanitize( $raw['appearance'] ?? array() ); foreach ( $appearance_errors as $appearance_error ) { $errors[] = self::issue( $appearance_error, 'appearance' ); } $schema['heading'] = sanitize_text_field( $raw['heading'] ?? '' ); if ( '' !== $schema['heading'] && ! self::meaningful( $schema['heading'] ) ) { $errors[] = self::issue( __( 'Write the form heading in words. It gives the form its accessible name.', 'accessible-forms-by-accessible-org' ), 'heading' ); } if ( '' === $schema['heading'] ) { $errors[] = self::issue( __( 'The form needs a heading. It gives the form its accessible name.', 'accessible-forms-by-accessible-org' ), 'heading' ); } // The level is configurable so the form can slot into an existing heading // outline without skipping a level. "none" keeps the text as the form's // accessible name without adding a heading to the page outline. $level = sanitize_key( $raw['heading_level'] ?? 'h2' ); $schema['heading_level'] = in_array( $level, array( 'h2', 'h3', 'h4', 'h5', 'h6', 'none' ), true ) ? $level : 'h2'; $schema['submit_label'] = sanitize_text_field( $raw['submit_label'] ?? '' ); if ( '' === $schema['submit_label'] ) { $schema['submit_label'] = __( 'Send message', 'accessible-forms-by-accessible-org' ); } if ( ! self::meaningful( $schema['submit_label'] ) ) { $errors[] = self::issue( __( 'Write the button text in words, such as “Send message”. It is the button’s accessible name.', 'accessible-forms-by-accessible-org' ), 'submit_label' ); } $schema['success_message'] = sanitize_text_field( $raw['success_message'] ?? '' ); if ( '' !== $schema['success_message'] && ! self::meaningful( $schema['success_message'] ) ) { $errors[] = self::issue( __( 'Write the success message in words so people know the form was sent.', 'accessible-forms-by-accessible-org' ), 'success_message' ); } if ( '' === $schema['success_message'] ) { $errors[] = self::issue( __( 'A success message is required so people know the form was sent.', 'accessible-forms-by-accessible-org' ), 'success_message' ); } $schema['subject_template'] = sanitize_text_field( $raw['subject_template'] ?? '' ); if ( ! empty( $raw['merge_subject'] ) ) { $schema['merge_subject'] = true; } $body = is_string( $raw['body_template'] ?? null ) ? sanitize_textarea_field( $raw['body_template'] ) : ''; $body = mb_substr( $body, 0, 10000, 'UTF-8' ); if ( '' !== $body ) { $schema['body_template'] = $body; } $cc = $raw['cc'] ?? array(); $cc = is_array( $cc ) ? $cc : preg_split( '/[\s,]+/', FormOptions::text( $cc, 5500 ), -1, PREG_SPLIT_NO_EMPTY ); $cc = array_values( array_unique( array_filter( array_map( static function ( $address ) { return is_string( $address ) ? trim( $address ) : ''; }, array_slice( $cc, 0, 21 ) ) ) ) ); $valid_cc = array_values( array_filter( $cc, array( Mailer::class, 'valid_header_address' ) ) ); if ( count( $cc ) !== count( $valid_cc ) || count( $cc ) > 20 ) { $errors[] = self::issue( __( 'Enter up to 20 valid CC addresses, one per line.', 'accessible-forms-by-accessible-org' ), 'cc' ); } if ( $valid_cc ) { $schema['cc'] = $valid_cc; } // Recipients. $recipients = array(); $recipient_input = $raw['recipients'] ?? ''; $recipient_input = is_array( $recipient_input ) ? $recipient_input : preg_split( '/[\s,]+/', (string) $recipient_input ); foreach ( $recipient_input as $candidate ) { $candidate = is_scalar( $candidate ) ? (string) $candidate : ''; $candidate = trim( $candidate ); if ( '' !== $candidate && ! is_email( $candidate ) ) { $errors[] = self::issue( __( 'Check every recipient address. Enter one valid email address in each row.', 'accessible-forms-by-accessible-org' ), 'recipients' ); } $candidate = sanitize_email( $candidate ); if ( $candidate && is_email( $candidate ) ) { $recipients[] = $candidate; } } if ( ! $recipients ) { $errors[] = self::issue( __( 'Add at least one valid recipient email address.', 'accessible-forms-by-accessible-org' ), 'recipients' ); } $schema['recipients'] = array_values( array_unique( $recipients ) ); // Fields. $fields = array(); $positions = array(); $seen = array(); $seen_ids = array(); $all_names = array(); $label_keys = array(); $section_number = 0; $raw_field = isset( $raw['fields'] ) && is_array( $raw['fields'] ) ? $raw['fields'] : array(); if ( count( $raw_field ) > self::MAX_FIELDS ) { $errors[] = self::issue( __( 'Use no more than 50 fields in one form.', 'accessible-forms-by-accessible-org' ), 'fields' ); } foreach ( array_values( array_slice( $raw_field, 0, self::MAX_FIELDS ) ) as $index => $field ) { if ( ! is_array( $field ) ) { continue; } $type = isset( $field['type'] ) ? sanitize_key( $field['type'] ) : ''; $addon = isset( $field['addon'] ) && is_string( $field['addon'] ) ? sanitize_key( $field['addon'] ) : ''; if ( 'file' === $type && '' === $addon ) { // Upload fields saved before 2.0 belong to Accessible Forms Pro, with their settings. $addon = 'pro'; $legacy = array_intersect_key( $field, array_flip( array( 'file_types', 'file_size' ) ) ); $bag = is_array( $field['extensions'] ?? null ) ? $field['extensions'] : array(); if ( $legacy && ! is_string( $bag['pro'] ?? null ) ) { $bag['pro'] = ( is_array( $bag['pro'] ?? null ) ? $bag['pro'] : array() ) + $legacy; $field['extensions'] = $bag; } } if ( ! isset( $types[ $type ] ) && '' !== $addon && ! Extensions::registered( $addon ) ) { // A field from an inactive add-on is kept, paused, until the add-on returns. $types[ $type ] = array( 'label' => $type, 'input' => $type, 'choice' => false, 'autocomplete' => array(), 'addon' => $addon, ); } if ( ! isset( $types[ $type ] ) ) { $errors[] = self::issue( sprintf( /* translators: %d: field position in the form. */ __( 'Field %d: choose a supported field type.', 'accessible-forms-by-accessible-org' ), $index + 1 ), null, $index, 'type' ); continue; } $content = 'content' === $type; $label = $content ? '' : sanitize_text_field( $field['label'] ?? '' ); $name = sanitize_key( $field['name'] ?? '' ); $name = preg_replace( '/[^a-z0-9_]/', '', $name ); $name = substr( $name, 0, 64 ); // Rule: a label is mandatory. There is no placeholder-as-label option. if ( ! $content && '' === $label ) { $errors[] = self::issue( sprintf( /* translators: %d: field position in the form. */ __( 'Field %d needs a label. Every control must have a visible label.', 'accessible-forms-by-accessible-org' ), $index + 1 ), null, $index, 'label' ); continue; } // Rule: the label must say something. "?", "*" or an emoji alone is announced as a meaningless name. if ( ! $content && ! self::meaningful( $label ) ) { $errors[] = self::issue( sprintf( /* translators: %d: field position in the form. */ __( 'Field %d needs a label written in words, not only symbols. Screen readers announce it as the field’s name.', 'accessible-forms-by-accessible-org' ), $index + 1 ), null, $index, 'label' ); } // Rule: two questions with the same label cannot be told apart by name, // unless they are under different section headings, which then become // part of their accessible names ("Billing, Street" and "Shipping, Street"). if ( 'section' === $type ) { ++$section_number; } elseif ( ! $content ) { $label_key = $section_number . '|' . self::label_key( $label ); if ( isset( $label_keys[ $label_key ] ) ) { $errors[] = self::issue( self::about( $label, __( 'another question in this part of the form already uses this label. Make each label unique, or put the questions under different section headings, so people can tell them apart.', 'accessible-forms-by-accessible-org' ) ), null, $index, 'label' ); } $label_keys[ $label_key ] = true; } if ( '' === $name ) { $name = $content ? 'content_' . bin2hex( random_bytes( 8 ) ) : sanitize_key( substr( sanitize_title( $label ), 0, 32 ) ); $name = preg_replace( '/[^a-z0-9_]/', '_', $name ); } if ( '' === $name ) { $errors[] = self::issue( self::about( $label, __( 'enter a field key using lowercase letters, numbers or underscores.', 'accessible-forms-by-accessible-org' ) ), null, $index, 'name' ); continue; } // Rule: names must be unique, otherwise labels and values desynchronise. if ( isset( $all_names[ $name ] ) ) { $errors[] = self::issue( self::about( $label, sprintf( /* translators: %s: duplicated field key. */ __( 'another field already uses the key “%s”. Field keys must be unique.', 'accessible-forms-by-accessible-org' ), $name ) ), null, $index, 'name' ); continue; } $all_names[ $name ] = true; if ( self::is_answer_field( array( 'type' => $type ) ) ) { $seen[ $name ] = true; } $uid = sanitize_key( FormOptions::text( $field['uid'] ?? '' ) ); if ( ! $uid ) { $uid = 'f_' . bin2hex( random_bytes( 8 ) ); } if ( isset( $seen_ids[ $uid ] ) ) { $errors[] = self::issue( self::about( $label, __( 'duplicate field identity. Use Duplicate field to create an independent field.', 'accessible-forms-by-accessible-org' ) ), null, $index, 'label' ); } $seen_ids[ $uid ] = true; $options = array(); $option_labels = array(); if ( ! empty( $types[ $type ]['options'] ) ) { $option_text = is_array( $field['options'] ?? '' ) ? implode( "\n", array_filter( $field['options'], 'is_scalar' ) ) : ( is_scalar( $field['options'] ?? '' ) ? (string) ( $field['options'] ?? '' ) : '' ); $lines = preg_split( '/\r\n|\r|\n/', $option_text ); foreach ( $lines as $line ) { $line = sanitize_text_field( trim( $line ) ); if ( '' !== $line ) { $options[] = $line; } } $options = array_values( array_unique( $options ) ); if ( count( $options ) > 100 ) { $errors[] = self::issue( self::about( $label, __( 'use no more than 100 choices.', 'accessible-forms-by-accessible-org' ) ), null, $index, 'choices' ); } [$options, $option_labels, $choice_errors] = Choices::sanitize( $field, $options ); foreach ( $choice_errors as $choice_error ) { $errors[] = self::issue( self::about( $label, $choice_error ), null, $index, 'choices' ); } // Rule: a choice group with no options renders an empty fieldset. if ( count( $options ) < 2 ) { $errors[] = self::issue( self::about( $label, __( 'add at least two choices.', 'accessible-forms-by-accessible-org' ) ), null, $index, 'choices' ); continue; } } $autocomplete = sanitize_text_field( $field['autocomplete'] ?? 'auto' ); $inferred = self::infer_autocomplete( $type, $label, $name ); $automatic = 'auto' === $autocomplete || ( '' !== $autocomplete && 'auto' === ( $field['autocomplete_mode'] ?? '' ) ) || // A saved automatic field whose inference found no purpose keeps its mode, so // sanitizing an exported form again gives the same form. An empty purpose where // inference would find one is a deliberate override. ( '' === $autocomplete && 'auto' === ( $field['autocomplete_mode'] ?? '' ) && '' === $inferred ) || // A purpose this type cannot have falls back to the automatic one, never to none. ( '' !== $autocomplete && ! in_array( $autocomplete, $types[ $type ]['autocomplete'], true ) ); if ( $automatic ) { $autocomplete = $inferred; } if ( ! in_array( $autocomplete, $types[ $type ]['autocomplete'], true ) ) { $autocomplete = ''; } // Rule: a question about the visitor's own details must say so to their // browser (WCAG 1.3.5). Autofill can only be turned off for a question // that asks about someone else, and the owner has to say that it does. $about_other = ! empty( $field['about_other'] ); if ( '' === $autocomplete && '' !== $inferred && ! $automatic && ! $about_other ) { $errors[] = self::issue( self::about( $label, __( 'this looks like the visitor’s own details, so browsers need to know its purpose. Keep autofill on, or tick “This asks about someone else” if it does.', 'accessible-forms-by-accessible-org' ) ), null, $index, 'autocomplete' ); } $minimum = max( 0, min( 10000, (int) ( $field['min_length'] ?? 0 ) ) ); $maximum = max( 0, min( 'textarea' === $type ? 10000 : 500, (int) ( $field['max_length'] ?? 0 ) ) ); $effective_maximum = $maximum ?: ( 'textarea' === $type ? 10000 : 500 ); if ( $minimum > $effective_maximum ) { $errors[] = self::issue( self::about( $label, __( 'minimum length must not exceed maximum length.', 'accessible-forms-by-accessible-org' ) ), null, $index, 'min_length' ); } $min = FormOptions::text( $field['min'] ?? '', 40 ); $max = FormOptions::text( $field['max'] ?? '', 40 ); $step = FormOptions::text( $field['increment'] ?? '', 40 ); if ( 'number' === $type && ( ( '' !== $min && ! is_numeric( $min ) ) || ( '' !== $max && ! is_numeric( $max ) ) || ( '' !== $step && ( ! is_numeric( $step ) || (float) $step <= 0 ) ) || ( '' !== $min && '' !== $max && (float) $min > (float) $max ) ) ) { $errors[] = self::issue( self::about( $label, __( 'check the minimum, maximum and increment. The minimum must not exceed the maximum and the increment must be above 0.', 'accessible-forms-by-accessible-org' ) ), null, $index, 'min' ); } if ( 'checkbox' === $type && ( ( ! empty( $field['max_choices'] ) && (int) ( $field['min_choices'] ?? 0 ) > (int) $field['max_choices'] ) || (int) ( $field['min_choices'] ?? 0 ) > count( $options ) ) ) { $errors[] = self::issue( self::about( $label, __( 'the minimum number of choices must not exceed the maximum or the number of choices offered.', 'accessible-forms-by-accessible-org' ) ), null, $index, 'min_choices' ); } $positions[] = $index; $fields[] = array( 'uid' => $uid, 'min_length' => $minimum, 'max_length' => $maximum, 'min_choices' => max( 0, min( 100, (int) ( $field['min_choices'] ?? 0 ) ) ), 'max_choices' => max( 0, min( 100, (int) ( $field['max_choices'] ?? 0 ) ) ), 'min' => $min, 'max' => $max, 'increment' => $step, 'autofill_section' => in_array( $field['autofill_section'] ?? '', array( 'shipping', 'billing' ), true ) ? $field['autofill_section'] : '', 'prefill' => 'url' === $type && 'referrer' === ( $field['prefill'] ?? '' ) ? 'referrer' : '', // Share of the row on wide forms; sections always span the row. 'width' => self::is_answer_field( array( 'type' => $type ) ) && in_array( $field['width'] ?? '', array( 'half', 'third', 'two-thirds' ), true ) ? $field['width'] : 'full', 'type' => $type, 'name' => $name, 'label' => $label, 'help' => sanitize_text_field( $field['help'] ?? '' ), 'required' => self::is_answer_field( array( 'type' => $type ) ) && ! empty( $field['required'] ), 'autocomplete' => $autocomplete, 'autocomplete_mode' => $automatic ? 'auto' : 'manual', 'options' => $options, 'option_labels' => $option_labels, ); $last = count( $fields ) - 1; if ( 'email' === $type ) { [$policy, $policy_issues] = EmailDomains::sanitize( $field, $index ); $fields[ $last ] += $policy; $errors = array_merge( $errors, $policy_issues ); } if ( $content ) { $html = self::content_html( $field['content'] ?? '' ); $fields[ $last ]['content'] = $html; $text = html_entity_decode( wp_strip_all_tags( $html ), ENT_QUOTES, 'UTF-8' ); if ( ! preg_match( '/\S/u', $text ) ) { $errors[] = self::issue( __( 'Write some text for this content block.', 'accessible-forms-by-accessible-org' ), null, $index, 'content' ); } $tags = new \WP_HTML_Tag_Processor( $html ); while ( $tags->next_tag( 'a' ) ) { $href = $tags->get_attribute( 'href' ); if ( ! is_string( $href ) || '' === trim( $href ) ) { $errors[] = self::issue( __( 'Give each link in this content block a valid address.', 'accessible-forms-by-accessible-org' ), null, $index, 'content' ); break; } } preg_match_all( '#]*>(.*?)#is', $html, $links ); foreach ( $links[1] as $link_text ) { if ( ! self::meaningful( html_entity_decode( wp_strip_all_tags( $link_text ), ENT_QUOTES, 'UTF-8' ) ) ) { $errors[] = self::issue( __( 'Give each link in this content block text that describes where it goes.', 'accessible-forms-by-accessible-org' ), null, $index, 'content' ); break; } } } if ( '' === $autocomplete && $about_other ) { $fields[ count( $fields ) - 1 ]['about_other'] = true; } // Fields of an add-on's type name it, so they are kept while it is inactive. if ( ! empty( $types[ $type ]['addon'] ) ) { $fields[ count( $fields ) - 1 ]['addon'] = $types[ $type ]['addon']; } [$field_extensions, $field_issues] = Extensions::sanitize_field( $field['extensions'] ?? array(), $fields[ $last ], $index, $field ); $fields[ $last ]['extensions'] = $field_extensions; foreach ( $field_issues as $field_issue ) { $errors[] = is_array( $field_issue ) ? $field_issue : self::issue( self::about( $label, $field_issue ), null, $index ); } } if ( ! $seen ) { $errors[] = self::issue( __( 'A form needs at least one answer field.', 'accessible-forms-by-accessible-org' ), 'fields' ); } $schema['fields'] = $fields; [$schema, $option_errors] = FormOptions::sanitize( $raw, $schema ); foreach ( $option_errors as [$option_setting, $option_error] ) { $errors[] = self::issue( $option_error, $option_setting ); } [$schema['extensions'], $extension_issues] = Extensions::sanitize_form( $raw['extensions'] ?? array(), $schema, $raw ); foreach ( $extension_issues as $extension_issue ) { $errors[] = is_array( $extension_issue ) ? $extension_issue : self::issue( $extension_issue, 'extensions' ); } if ( count( $recipients ) > 20 ) { $errors[] = self::issue( __( 'Use no more than 20 primary recipients.', 'accessible-forms-by-accessible-org' ), 'recipients' ); } // Add-ons name a field by its uid; the editor needs its submitted position. $uid_positions = array_combine( array_column( $fields, 'uid' ), $positions ); foreach ( Extensions::check( $schema ) as $extension_issue ) { if ( ! is_array( $extension_issue ) ) { $extension_issue = self::issue( $extension_issue, 'extensions' ); } elseif ( isset( $extension_issue['field_uid'] ) ) { $extension_issue['field'] = $uid_positions[ (string) $extension_issue['field_uid'] ] ?? null; unset( $extension_issue['field_uid'] ); } $errors[] = $extension_issue; } return array( $schema, self::unique_issues( $errors ) ); } /** * Whether an answer field could ever hold the value a condition waits for. * Choice fields are checked against their options elsewhere; free text can * hold anything. * * @param array $source Field the condition depends on. * @param string $value Value the condition compares with. * @return bool */ public static function condition_value_possible( array $source, $value ) { switch ( $source['type'] ) { case 'email': return (bool) is_email( $value ); case 'url': return (bool) filter_var( $value, FILTER_VALIDATE_URL ); case 'number': return is_numeric( $value ); case 'date': return (bool) preg_match( '/^\d{4}-\d{2}-\d{2}$/', $value ); case 'time': return (bool) preg_match( '/^([01]\d|2[0-3]):[0-5]\d$/', $value ); case 'consent': return '1' === $value; default: return true; } } /** * Drop repeated issues (the same message for the same target). * * @param array[] $issues Structured issues. * @return array[] */ private static function unique_issues( array $issues ) { $unique = array(); foreach ( $issues as $issue ) { $unique[ md5( serialize( $issue ) ) ] = $issue; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- In-memory de-duplication key only. } return array_values( $unique ); } }