description before modifying the switch cases. */ switch ( $type ) { case 'email': case 'name': case 'url': case 'subject': case 'textarea': $field_ids[ $type ] = $id; break; case 'consent': // Set email marketing consent for the first Consent type field if ( null === $field_ids['email_marketing_consent'] ) { $field_ids['email_marketing_consent_field'] = $id; if ( $field->value ) { $field_ids['email_marketing_consent'] = true; } else { $field_ids['email_marketing_consent'] = false; } } $field_ids['extra'][] = $id; break; default: // Put everything else in extra $field_ids['extra'][] = $id; } } return $field_ids; } /** * Process the contact form's POST submission * Stores feedback. Sends email. */ public function process_submission() { $response = Feedback::from_submission( $_POST, $this ); // phpcs:Ignore WordPress.Security.NonceVerification.Missing $response->set_source( $this->get_source() ); // If the submission came from an authenticated form preview, flag the // feedback as a test submission. The rest of the pipeline reads the // flag from the feedback (which also travels into the serialized // post_content via Feedback_Source). if ( $this->is_preview_submission ) { $response->mark_as_test(); } $is_test_submission = $response->is_test(); $plugin = Contact_Form_Plugin::init(); $id = $this->get_attribute( 'id' ); $to = $this->get_attribute( 'to' ); $widget = $this->get_attribute( 'widget' ); $block_template = $this->get_attribute( 'block_template' ); $block_template_part = $this->get_attribute( 'block_template_part' ); $contact_form_subject = $this->get_attribute( 'subject' ); $to = str_replace( ' ', '', $to ); $emails = explode( ',', $to ); $valid_emails = array(); foreach ( $emails as $email ) { if ( ! is_email( $email ) ) { continue; } if ( function_exists( 'is_email_address_unsafe' ) && is_email_address_unsafe( $email ) ) { continue; } $valid_emails[] = $email; } // No one to send it to, which means none of the "to" attributes are valid emails. // Use default email instead. if ( ! $valid_emails ) { $valid_emails = $this->defaults['to']; } $to = $valid_emails; // Last ditch effort to set a recipient if somehow none have been set. if ( empty( $to ) ) { $to = get_option( 'admin_email' ); } if ( ! $this->has_verified_jwt ) { // Make sure we're processing the form we think we're processing... probably a redundant check. if ( $widget ) { if ( isset( $_POST['contact-form-id'] ) && 'widget-' . $widget !== $_POST['contact-form-id'] ) { // phpcs:Ignore WordPress.Security.NonceVerification.Missing -- check done by caller process_form_submission() return Form_Submission_Error::system_error( 'form_id_mismatch_widget', __( 'Form ID mismatch.', 'jetpack-forms' ) ); } } elseif ( $block_template ) { if ( isset( $_POST['contact-form-id'] ) && 'block-template-' . $block_template !== $_POST['contact-form-id'] ) { // phpcs:Ignore WordPress.Security.NonceVerification.Missing -- check done by caller process_form_submission() return Form_Submission_Error::system_error( 'form_id_mismatch_block_template', __( 'Form ID mismatch.', 'jetpack-forms' ) ); } } elseif ( $block_template_part ) { if ( isset( $_POST['contact-form-id'] ) && 'block-template-part-' . $block_template_part !== $_POST['contact-form-id'] ) { // phpcs:Ignore WordPress.Security.NonceVerification.Missing -- check done by caller process_form_submission() return Form_Submission_Error::system_error( 'form_id_mismatch_block_template_part', __( 'Form ID mismatch.', 'jetpack-forms' ) ); } } elseif ( isset( $_POST['contact-form-id'] ) && ( empty( $this->current_post ) || self::get_post_property( $this->current_post, 'ID' ) !== (int) sanitize_text_field( wp_unslash( $_POST['contact-form-id'] ) ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- check done by caller process_form_submission() return Form_Submission_Error::system_error( 'form_id_mismatch_post', __( 'Form ID mismatch.', 'jetpack-forms' ) ); } } // Initialize all these "standard" fields to null $comment_author_email = $response->get_author_email(); $comment_author = $response->get_author(); $contact_form_subject = $response->get_subject(); // Set marketing consent $email_marketing_consent = $response->has_consent(); if ( null === $email_marketing_consent ) { $email_marketing_consent = false; } $all_values = $response->get_all_values( 'submit' ); $extra_values = $response->get_legacy_extra_values( 'submit' ); if ( ! empty( $_REQUEST['is_block'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- not changing the site. $extra_values['is_block'] = true; } $contact_form_subject = trim( $contact_form_subject ); $comment_author_ip = Contact_Form_Plugin::get_ip_address(); // Ensure that Akismet gets all of the relevant information from the contact form, // not just the textarea field and predetermined subject. $akismet_vars = $response->get_akismet_vars(); $spam = ''; $akismet_values = $plugin->prepare_for_akismet( $akismet_vars ); // Is it spam? Test submissions (from form preview) skip Akismet entirely — // the form owner is explicitly running a test and we don't want Akismet // to learn from synthetic data or bounce the submission. if ( $is_test_submission ) { $is_spam = false; } else { /** This filter is already documented in \Automattic\Jetpack\Forms\ContactForm\Admin */ $is_spam = apply_filters( 'jetpack_contact_form_is_spam', false, $akismet_values ); } if ( is_wp_error( $is_spam ) ) { // WP_Error to abort return $is_spam; // abort } elseif ( $is_spam === true ) { // TRUE to flag a spam $spam = '***SPAM*** '; } /** * Filter whether a submitted contact form is in the comment disallowed list. * * @module contact-form * * @since 8.9.0 * * @param bool $result Is the submitted feedback in the disallowed list. * @param array $akismet_values Feedack values returned by the Akismet plugin. */ $in_comment_disallowed_list = apply_filters( 'jetpack_contact_form_in_comment_disallowed_list', false, $akismet_values ); if ( ! $comment_author ) { $comment_author = $comment_author_email; } /** * Filter the email where a submitted feedback is sent. * * @module contact-form * * @since 1.3.1 * * @param string|array $to Array of valid email addresses, or single email address. * @param array $all_values Contact form fields */ $to = (array) apply_filters( 'contact_form_to', $to, $all_values ); $reply_to_addr = $to[0]; // get just the address part before the name part is added foreach ( $to as $to_key => $to_value ) { $to[ $to_key ] = Contact_Form_Plugin::strip_tags( $to_value ); $to[ $to_key ] = self::add_name_to_address( $to_value ); } // Get the site domain and get rid of www. $sitename = wp_parse_url( site_url(), PHP_URL_HOST ); $from_email_addr = 'wordpress@'; if ( null !== $sitename ) { if ( str_starts_with( $sitename, 'www.' ) ) { $sitename = substr( $sitename, 4 ); } $from_email_addr .= $sitename; } if ( ! empty( $comment_author_email ) ) { $reply_to_addr = $comment_author_email; } /* * The email headers here are formatted in a format * that is the most likely to be accepted by wp_mail(), * without escaping. * More info: https://github.com/Automattic/jetpack/pull/19727 */ $headers = 'From: ' . $comment_author . ' <' . $from_email_addr . ">\r\n" . 'Reply-To: ' . $comment_author . ' <' . $reply_to_addr . ">\r\n"; /** * Allow customizing the email headers. * * Warning: DO NOT add headers or header data from the form submission without proper * escaping and validation, or you're liable to allow abusers to use your site to send spam. * * Especially DO NOT take email addresses from the form data to add as CC or BCC headers * without strictly validating each address against a list of allowed addresses. * * @module contact-form * * @since 10.2.0 * * @param string|array $headers Email headers. * @param string $comment_author Name of the author of the submitted feedback, if provided in form. * @param string $reply_to_addr Email of the author of the submitted feedback, if provided in form. * @param string|array $to Array of valid email addresses, or single email address, where the form is sent. */ $headers = apply_filters( 'jetpack_contact_form_email_headers', $headers, $comment_author, $reply_to_addr, $to ); $all_values['email_marketing_consent'] = $email_marketing_consent; $entry_values = $response->get_entry_values(); // Prefix the subject with [TEST] for test submissions so the form owner // can immediately tell this email came from a preview-mode submission. if ( $is_test_submission ) { /** * Filter the subject prefix applied to test (preview) feedback emails. * * @module contact-form * * @since 7.19.0 * * @param string $prefix Default subject prefix for test submissions. */ $test_prefix = apply_filters( 'jetpack_forms_test_subject_prefix', '[TEST] ' ); $contact_form_subject = $test_prefix . $contact_form_subject; } /** This filter is already documented in \Automattic\Jetpack\Forms\ContactForm\Admin */ $subject = apply_filters( 'contact_form_subject', $contact_form_subject, $all_values ); /* * Links to the feedback and the post. */ if ( $block_template || $block_template_part || $widget ) { $url = home_url( '/' ); } else { $url = self::get_permalink( $this->current_post ? self::get_post_property( $this->current_post, 'ID' ) : 0 ); } // translators: the time of the form submission. $date_time_format = _x( '%1$s \a\t %2$s', '{$date_format} \a\t {$time_format}', 'jetpack-forms' ); $date_time_format = sprintf( $date_time_format, get_option( 'date_format' ), get_option( 'time_format' ) ); $time = wp_date( $date_time_format ); // Keep a copy of the feedback as a custom post type. if ( $in_comment_disallowed_list ) { $feedback_status = 'trash'; } elseif ( $is_spam ) { $feedback_status = 'spam'; } elseif ( 'no' === $this->get_attribute( 'saveResponses' ) ) { $feedback_status = 'jp-temp-feedback'; } else { $feedback_status = 'publish'; } $response->set_status( $feedback_status ); foreach ( (array) $akismet_values as $av_key => $av_value ) { $akismet_values[ $av_key ] = Contact_Form_Plugin::strip_tags( $av_value ); } foreach ( $all_values as $all_key => $all_value ) { $all_values[ $all_key ] = Contact_Form_Plugin::strip_tags( $all_value ); } foreach ( $extra_values as $ev_key => $ev_value ) { $extra_values[ $ev_key ] = Contact_Form_Plugin::strip_tags( $ev_value ); } /* * We need to make sure that the post author is always zero for contact * form submissions. This prevents export/import from trying to create * new users based on form submissions from people who were logged in * at the time. * * Unfortunately wp_insert_post() tries very hard to make sure the post * author gets the currently logged in user id. That is how we ended up * with this work around. */ add_filter( 'wp_insert_post_data', array( $plugin, 'insert_feedback_filter' ), 10, 2 ); /** * Allows site owners to not include IP addresses in the saved form response. * * The IP address is still used as part of spam filtering, if enabled, but it is removed when this filter * is set to true before saving to the database and e-mailing the form recipients. * @module contact-form * * @param bool $remove_ip_address Should the IP address be removed. Default false. * @param string $ip_address IP address of the form submission. * * @since 0.33.0 */ if ( apply_filters( 'jetpack_contact_form_forget_ip_address', false, $comment_author_ip ) ) { $comment_author_ip = null; } $post_id = 0; $feedback_post = $response->save(); if ( $feedback_post instanceof WP_Post ) { $post_id = $feedback_post->ID; } // once insert has finished we don't need this filter any more remove_filter( 'wp_insert_post_data', array( $plugin, 'insert_feedback_filter' ), 10 ); update_post_meta( $post_id, '_feedback_extra_fields', $this->addslashes_deep( $extra_values ) ); if ( 'publish' === $feedback_status ) { Contact_Form_Plugin::recalculate_unread_count(); } if ( defined( 'AKISMET_VERSION' ) ) { update_post_meta( $post_id, '_feedback_akismet_values', $this->addslashes_deep( $akismet_values ) ); } // Integrations must not see a field the visitor was never shown. MailPoet in // particular reads this payload directly for explicit consent and the subscriber's // email, so a forged POST naming a hidden consent field could otherwise subscribe // someone off a question that was never on screen. $visible_fields = $this->fields; foreach ( $this->get_resolved_field_visibility() as $field_id => $is_visible ) { if ( false === $is_visible ) { unset( $visible_fields[ $field_id ] ); } } /** * Fires after the feedback post for the contact form submission has been inserted. * * @module contact-form * * @since 8.6.0 * * @param integer $post_id The post id that contains the contact form data. * @param array $visible_fields The form's Contact_Form_Field objects, less any that * conditional logic hid from the visitor. * @param boolean $is_spam Whether the form submission has been identified as spam. * @param array $entry_values The feedback entry values. */ do_action( 'grunion_after_feedback_post_inserted', $post_id, $visible_fields, $is_spam, $entry_values ); // Build the complete email content via the renderer. $context_data = array( 'time' => $time, 'url' => $url, 'comment_author' => $comment_author, 'comment_author_email' => $comment_author_email, 'comment_author_ip' => $comment_author_ip, 'is_spam' => $is_spam, 'is_test' => $is_test_submission, 'feedback_status' => $feedback_status, ); $email = Feedback_Email_Renderer::build_email_content( $post_id, $this, $response, $context_data ); $message = $email['message']; // Always store the rendered email for the resend endpoint. update_post_meta( $post_id, '_feedback_email', $this->addslashes_deep( compact( 'to', 'message' ) ) ); /** * Filter to choose whether an email should be sent after each successful contact form submission. * This filter takes precedence over the emailNotifications attribute. * * @module contact-form * * @since 2.6.0 * * @param bool|null $should_send Should an email be sent after a form submission. * - true: Send email regardless of emailNotifications setting * - false: Don't send email regardless of emailNotifications setting * - null: Use emailNotifications attribute to determine (default behavior) * @param int $post_id Post ID. */ $should_send_email = apply_filters( 'grunion_should_send_email', null, $post_id ); // Determine if email should be sent based on filter precedence. if ( $should_send_email === true ) { // Filter explicitly says to send email $send_email = true; } elseif ( $should_send_email === false ) { // Filter explicitly says not to send email $send_email = false; } else { // Filter is null (default), use emailNotifications attribute $send_email = ( $this->get_attribute( 'emailNotifications' ) !== 'no' ); } // Test submissions always send the notification email (so the form // owner can verify their email flow end-to-end) regardless of the // emailNotifications attribute. Site admins who want to opt out can // return false from the filter below. if ( $is_test_submission ) { /** * Filter whether test (preview) submissions should trigger the notification email. * * @module contact-form * * @since 7.19.0 * * @param bool $send Whether to send the test submission email. Default true. * @param int $post_id The feedback post ID. * @param Feedback $response The feedback response object. */ $send_email = apply_filters( 'jetpack_forms_send_test_feedback_email', true, $post_id, $response ); } /** * Filter to determine if spam should still be emailed. * * @module contact-form */ $send_even_if_spam = apply_filters( 'grunion_still_email_spam', false ); // Only fire send-related side effects when we are actually going to send. $will_send = ( $is_spam !== true && $send_email ) || ( true === $is_spam && $send_even_if_spam ); if ( $will_send ) { /** * Fires right before the contact form message is sent via email to * the recipient specified in the contact form. * * @module contact-form * * @since 1.3.1 * * @param integer $post_id Post contact form lives on * @param array $all_values Contact form fields * @param array $extra_values Contact form fields not included in $all_values */ do_action( 'grunion_pre_message_sent', $post_id, $all_values, $extra_values ); // A mail header is plain text, so the subject can read as typed. self::wp_mail( $to, Feedback::decode_special_chars( "{$spam}{$subject}" ), $message, $headers ); } // Schedule deletes of old spam feedbacks. if ( ! wp_next_scheduled( 'grunion_scheduled_delete' ) ) { wp_schedule_event( time() + 250, 'daily', 'grunion_scheduled_delete' ); } // Schedule deletes of old temp feedbacks. if ( ! wp_next_scheduled( 'grunion_scheduled_delete_temp' ) ) { wp_schedule_event( time() + 250, 'daily', 'grunion_scheduled_delete_temp' ); } /** * Fires an action hook right after the email(s) have been sent. * * @module contact-form * * @since 7.3.0 * * @param int $post_id Post contact form lives on. * @param string|array $to Array of valid email addresses, or single email address. * @param string $subject Feedback email subject. * @param string $message Feedback email message. * @param string|array $headers Optional. Additional headers. * @param array $all_values Contact form fields. * @param array $extra_values Contact form fields not included in $all_values */ do_action( 'grunion_after_message_sent', $post_id, $to, $subject, $message, $headers, $all_values, $extra_values ); $refresh_args = array( 'contact-form-id' => $id, 'contact-form-sent' => $post_id, 'contact-form-hash' => $this->hash, '_wpnonce' => wp_create_nonce( "contact-form-sent-{$post_id}" ), // wp_nonce_url HTMLencodes :( . ); // If the request accepts JSON, return a JSON response instead of redirecting $accepts_json = isset( $_SERVER['HTTP_ACCEPT'] ) && false !== strpos( strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT'] ) ) ), 'application/json' ); if ( $this->is_response_without_reload_enabled && $accepts_json ) { $data = array(); if ( $response instanceof Feedback ) { $data = $response->get_compiled_fields( 'ajax', 'collection' ); } wp_send_json( array( 'success' => true, 'data' => $data, 'refreshArgs' => $refresh_args, ), null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int. JSON_UNESCAPED_SLASHES ); } if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) { return self::success_message( $post_id, $this ); } $redirect = $this->get_redirect_url( $refresh_args, $id, $post_id ); // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- We intentially allow external redirects here. wp_redirect( $redirect ); exit( 0 ); } /** * Check if the contact form has a custom redirect. * * @return bool True if the contact form has a custom redirect, false otherwise. */ public function has_custom_redirect() { $confirmation_type = $this->get_confirmation_type(); if ( ! empty( $this->get_attribute( 'customThankyouRedirect' ) ) && 'redirect' === $confirmation_type ) { return true; } /** * Filter to check if the contact form has a redirect filter. * * @module contact-form * * @since 1.9.0 * * @param bool $has_redirect True if the contact form has a redirect filter, false otherwise. */ return (bool) has_filter( 'grunion_contact_form_redirect_url' ); } /** * Get the URL where the reader is redirected after submitting a form. * * @param array $refresh_args The arguments to be added to the redirect URL. * @param int $id Contact Form ID. * @param int $post_id Post ID. * * @return string The redirect URL. */ public function get_redirect_url( $refresh_args, $id, $post_id ) { $confirmation_type = $this->get_confirmation_type(); $redirect = ''; $custom_redirect = false; if ( 'redirect' === $confirmation_type ) { $custom_redirect = true; $redirect = esc_url_raw( $this->get_attribute( 'customThankyouRedirect' ) ); } if ( ! $redirect ) { $custom_redirect = false; $redirect = wp_get_referer(); } if ( ! $redirect ) { // wp_get_referer() returns false if the referer is the same as the current page. $custom_redirect = false; $redirect = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : ''; } if ( ! $custom_redirect ) { $redirect = add_query_arg( urlencode_deep( $refresh_args ), $redirect ); } /** * Filter the URL where the reader is redirected after submitting a form. * * @module contact-form * * @since 1.9.0 * * @param string $redirect Post submission URL. * @param int $id Contact Form ID. * @param int $post_id Post ID. */ return apply_filters( 'grunion_contact_form_redirect_url', $redirect, $id, $post_id ); } /** * Get the permalink for the post ID that include the page query parameter if it was set. * * @param int $post_id The post ID. * * return string The permalink for the post ID. */ public static function get_permalink( $post_id ) { $url = get_permalink( $post_id ); $page = isset( $_POST['page'] ) ? absint( wp_unslash( $_POST['page'] ) ) : null; // phpcs:Ignore WordPress.Security.NonceVerification.Missing if ( $page ) { return add_query_arg( 'page', $page, $url ); } return $url; } /** * Wrapper for wp_mail() that enables HTML messages with text alternatives * * @param string|array $to Array or comma-separated list of email addresses to send message. * @param string $subject Email subject. * @param string $message Message contents. * @param string|array $headers Optional. Additional headers. * @param string|array $attachments Optional. Files to attach. * * @return bool Whether the email contents were sent successfully. */ public static function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() ) { return Feedback_Email_Renderer::wp_mail( $to, $subject, $message, $headers, $attachments ); } /** * Add a display name part to an email address * * SpamAssassin doesn't like addresses in HTML messages that are missing display names (e.g., `foo@bar.org` * instead of `Foo Bar `. * * @param string $address - the email address. * * @return string */ public function add_name_to_address( $address ) { // If it's just the address, without a display name if ( is_email( $address ) ) { $address_parts = explode( '@', $address ); /* * The email address format here is formatted in a format * that is the most likely to be accepted by wp_mail(), * without escaping. * More info: https://github.com/Automattic/jetpack/pull/19727 */ $address = sprintf( '%s <%s>', $address_parts[0], $address ); } return $address; } /** * Get the content type that should be assigned to outbound emails * * @return string */ public static function get_mail_content_type() { return Feedback_Email_Renderer::get_mail_content_type(); } /** * Wrap a message body with the appropriate in HTML tags * * This helps to ensure correct parsing by clients, and also helps avoid triggering spam filtering rules * * @param string $title - title of the email. * @param string $body - the message body. * @param string $footer - the footer containing meta information. * @param string $actions - HTML for actions displayed in the email. * @param array $respondent_info - Optional. Respondent information array with 'name', 'email', 'avatar'. * @param array $metadata - Optional. Metadata array with 'date', 'source', 'source_url', 'device', 'ip', 'ip_flag'. * * @return string */ public static function wrap_message_in_html_tags( $title, $body, $footer, $actions = '', $respondent_info = array(), $metadata = array() ) { return Feedback_Email_Renderer::wrap_message_in_html_tags( $title, $body, $footer, $actions, $respondent_info, $metadata ); } /** * Add a plain-text alternative part to an outbound email * * This makes the message more accessible to mail clients that aren't HTML-aware, and decreases the likelihood * that the message will be flagged as spam. * * @param PHPMailer $phpmailer - the phpmailer. */ public static function add_plain_text_alternative( $phpmailer ) { Feedback_Email_Renderer::add_plain_text_alternative( $phpmailer ); } /** * Add deepslashes. * * @param array $value - the value. * @return array The value, with slashes added. */ public function addslashes_deep( $value ) { if ( is_array( $value ) ) { return array_map( array( $this, 'addslashes_deep' ), $value ); } elseif ( is_object( $value ) ) { $vars = get_object_vars( $value ); foreach ( $vars as $key => $data ) { $value->{$key} = $this->addslashes_deep( $data ); } return (array) $value; } return addslashes( $value ); } /** * Get the block's classes. * This gathers both the alignment classes and the layout classes, * which go on the outermost div. * * @param array $attributes Block attributes. * @param array $extra_container_classes Extra container classes. * @return string The block's classes. */ public static function get_block_container_classes( $attributes = array(), $extra_container_classes = array() ) { // using wp-block-jetpack-contact-form-container here // confuses the layout support process, making it place the CSS classes on the container // instead of the actual block. $classes = array( 'jetpack-contact-form-container' ); $classes = array_merge( $classes, $extra_container_classes ); if ( isset( $attributes['variationName'] ) && $attributes['variationName'] === 'multistep' ) { $classes[] = 'is-multistep'; } $classes[] = self::get_block_alignment_class( $attributes ); return implode( ' ', $classes ); } /** * Rough implementation of Gutenberg's align-attribute-to-css-class map. * Only allowin "wide" and "full" as "center", "left" and "right" don't * make much sense for the form. * * @param array $attributes Block attributes. * @return string The CSS alignment class: alignfull | alignwide. */ public static function get_block_alignment_class( $attributes = array() ) { $align_to_class_map = array( 'wide' => 'alignwide', 'full' => 'alignfull', ); if ( empty( $attributes['align'] ) || ! array_key_exists( $attributes['align'], $align_to_class_map ) ) { return ''; } return $align_to_class_map[ $attributes['align'] ]; } /** * Process a file upload field. * * @param string $field_id The field ID. * @param object $field The field object. * * @return array A structured array with field_id and files array. */ public function process_file_upload_field( $field_id, $field ) { $field_id = sanitize_key( $field_id ); $raw_data = array(); // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( isset( $_POST[ $field_id ] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing $raw_post_data = wp_unslash( $_POST[ $field_id ] ); if ( is_array( $raw_post_data ) ) { $raw_data = array_map( 'sanitize_text_field', $raw_post_data ); } } $file_data_array = is_array( $raw_data ) ? array_map( function ( $json_str ) { $decoded = json_decode( $json_str, true ); return array( 'file_id' => isset( $decoded['file_id'] ) ? sanitize_text_field( $decoded['file_id'] ) : '', 'name' => isset( $decoded['name'] ) ? sanitize_text_field( $decoded['name'] ) : '', 'size' => isset( $decoded['size'] ) ? absint( $decoded['size'] ) : 0, 'type' => isset( $decoded['type'] ) ? sanitize_text_field( $decoded['type'] ) : '', ); }, $raw_data ) : array(); if ( empty( $file_data_array ) ) { $field->add_error( __( 'Failed to upload file.', 'jetpack-forms' ) ); return array( 'field_id' => $field_id, 'files' => array(), ); } return array( 'field_id' => $field_id, 'files' => $file_data_array, ); } /** * Ensures a value is formatted as a string, taking into account file upload fields. * * @param mixed $value The value to transform. * @return mixed The transformed value. */ private static function maybe_transform_value( $value ) { if ( is_array( $value ) && isset( $value['type'] ) && $value['type'] === 'image-select' ) { return implode( ', ', array_map( function ( $choice ) { $value = $choice['perceived']; if ( $choice['showLabels'] && ! empty( $choice['label'] ) ) { $value .= ' - ' . $choice['label']; } return $value; }, $value['choices'] ) ); } // For URL fields, extract the display text value (original user input without auto-added protocol). if ( is_array( $value ) && isset( $value['type'] ) && $value['type'] === 'url' ) { // Prefer displayValue (raw input) over url (which may have https:// prepended). return $value['displayValue'] ?? ( $value['url'] ?? '' ); } // For rating fields, return the displayValue (e.g., "3/5") for text fallback. if ( is_array( $value ) && isset( $value['type'] ) && $value['type'] === 'rating' ) { return $value['displayValue'] ?? ''; } // For file upload fields, we want to show the file name and size if ( is_array( $value ) && isset( $value['name'] ) && isset( $value['size'] ) ) { $file_name = $value['name']; $file_size = $value['size']; return empty( $file_size ) ? $file_name : $file_name . ' (' . $file_size . ')'; } return $value; } /** * Helper method to get the images from an image select field. * * Returns an array of image choice objects, each containing: * - src: The image URL * - letterCode: The letter code (e.g., 'A', 'B', 'C') * - label: The choice label text (empty string if showLabels is false) * * @param array $value The value to get the images from. * @return array|null The images with metadata, or null if not an image-select field. */ private static function get_images( $value ) { if ( is_array( $value ) && isset( $value['type'] ) && $value['type'] === 'image-select' ) { return array_map( function ( $choice ) { $letter_code = $choice['perceived'] ?? ''; $label = ''; if ( ! empty( $choice['showLabels'] ) && ! empty( $choice['label'] ) ) { $label = $choice['label']; } return array( 'src' => $choice['image']['src'] ?? '', 'letterCode' => $letter_code, 'label' => $label, ); }, $value['choices'] ); } return null; } /** * Get files from a file field value if present. * * @param mixed $value The field value. * * @return array|null Array of file data if this is a file field, null otherwise. */ private static function get_files( $value ) { if ( is_array( $value ) && isset( $value['type'] ) && $value['type'] === 'file' && ! empty( $value['files'] ) ) { return array_map( function ( $file ) { $preview_url = $file['previewUrl'] ?? null; $icon_url = $file['iconUrl'] ?? null; $has_preview = ! empty( $preview_url ) || ! empty( $icon_url ); return array( 'name' => $file['name'] ?? __( 'Attached file', 'jetpack-forms' ), 'size' => $file['size'] ?? '', 'url' => $file['url'] ?? '', // Preview URLs are captured from the DOM for AJAX submissions 'previewUrl' => $preview_url, 'iconUrl' => $icon_url, // Boolean flag for easier binding evaluation 'hasPreview' => $has_preview, ); }, $value['files'] ); } return null; } /** * Helper method to format a raw label string for display, including kses sanitization. * * @param string|null $raw_label The raw label input. * @return string The formatted and kses'd label string, or an empty string if raw_label is empty. */ public static function escape_and_sanitize_field_label( $raw_label ) { if ( empty( $raw_label ) ) { return ''; // kses the empty string } return wp_kses( (string) $raw_label, array() ); } /** * Enforce required block supports UIs for Classic themes. * * @param \WP_Theme_JSON_Data $theme_json_data Theme JSON data object. * * @return \WP_Theme_JSON_Data Updated theme JSON settings. */ public static function add_theme_json_data_for_classic_themes( $theme_json_data ) { if ( wp_is_block_theme() ) { return $theme_json_data; } $data = $theme_json_data->get_data(); if ( ! isset( $data['settings']['blocks'] ) ) { $data['settings']['blocks'] = array(); } $data['settings']['blocks']['jetpack/input'] = array( 'color' => array( 'text' => true, 'background' => false, ), 'border' => array( 'color' => true, 'radius' => true, 'style' => true, 'width' => true, ), 'typography' => array( 'fontFamily' => true, 'fontSize' => true, 'fontStyle' => true, 'fontWeight' => true, 'letterSpacing' => true, 'lineHeight' => true, 'textDecoration' => true, 'textTransform' => true, ), ); // maybe need to add support for jetpack/phone-input $data['settings']['blocks']['jetpack/options'] = array( 'color' => array( 'text' => true, 'background' => true, ), 'border' => array( 'color' => true, 'radius' => true, 'style' => true, 'width' => true, ), ); $shared_settings = array( 'color' => array( 'text' => true, 'background' => false, ), 'typography' => array( 'fontFamily' => true, 'fontSize' => true, 'fontStyle' => true, 'fontWeight' => true, 'letterSpacing' => true, 'lineHeight' => true, 'textDecoration' => true, 'textTransform' => true, ), ); $data['settings']['blocks']['jetpack/label'] = $shared_settings; $data['settings']['blocks']['jetpack/option'] = $shared_settings; $theme_json_class = get_class( $theme_json_data ); return new $theme_json_class( $data, 'default' ); } /** * Validate the contact form fields. * * This method checks each field for errors and ensures that at least one field has a value. * If no fields have values and there are no errors, it adds an error indicating that the form is empty. */ public function validate() { $has_value = false; // A field hidden by conditional logic was never shown to the visitor, so validating it // would block submission on an error they cannot see or clear — most visibly when the // hidden field is also required. $visibility = $this->get_resolved_field_visibility(); // Validate the form fields before processing the form. foreach ( $this->fields as $field_id => $field ) { if ( isset( $visibility[ $field_id ] ) && false === $visibility[ $field_id ] ) { continue; } $field->validate(); if ( ! $has_value && $field->has_value() ) { $has_value = true; } } if ( ! $has_value && ! $this->has_errors() ) { $this->add_error( 'empty', __( 'Please fill out at least one field.', 'jetpack-forms' ) ); } $ref_id = $this->get_attribute( 'ref' ); if ( ! empty( $ref_id ) ) { $this->validate_ref( $ref_id ); } } /** * Build the form-level conditional-logic context handed to the front end. * * Two maps rather than one: `types` covers every field, because any of them may be the * subject of a rule, while `logic` covers only the few that carry conditions. Emitting * types solely for fields that have logic would leave the evaluator unable to resolve the * subject of most rules, and it ignores rules whose subject it cannot type. * * Returns an empty array when no field uses conditional logic, so the common case adds * nothing to the page. * * @return array Either an empty array or `array( 'types' => ..., 'logic' => ... )`. */ public function get_conditional_logic_context() { $types = array(); $logic = array(); $formats = array(); foreach ( $this->fields as $field_id => $field ) { $types[ $field_id ] = $field->get_attribute( 'type' ); $date_format = $field->get_attribute( 'dateformat' ); if ( ! empty( $date_format ) ) { $formats[ $field_id ] = $date_format; } if ( $field->has_conditional_logic() ) { $logic[ $field_id ] = $field->get_attribute( 'conditionallogic' ); } } if ( empty( $logic ) || ! Jetpack_Forms::is_conditional_logic_enabled() ) { return array(); } return array( 'types' => $types, 'logic' => $logic, // Only date fields appear here; everything else compares without a format. 'formats' => $formats, ); } /** * Resolve which fields are visible for the current submission. * * Computed once and cached: validation and storage both consult it, and letting them * resolve separately would risk them disagreeing about whether a field was shown. * * @return array Map of field id to bool visibility. */ public function get_resolved_field_visibility() { if ( null !== $this->resolved_field_visibility ) { return $this->resolved_field_visibility; } // Without applicable conditions every field is visible, so validation and storage behave // exactly as they did before conditional logic existed; callers need no checks of their own. if ( ! $this->conditional_logic_applies() ) { $this->resolved_field_visibility = array(); return $this->resolved_field_visibility; } $this->resolved_field_visibility = $this->compute_field_visibility(); return $this->resolved_field_visibility; } /** * Whether any field carries conditions and the site's plan includes the feature. * * The field scan runs first because it is cheaper than the plan check. * * @return bool */ private function conditional_logic_applies() { foreach ( (array) $this->fields as $field ) { if ( $field->has_conditional_logic() ) { return Jetpack_Forms::is_conditional_logic_enabled(); } } return false; } /** * Resolve which fields are visible, without caching. * * @return array Map of field id to bool visibility. */ private function compute_field_visibility() { if ( ! is_array( $this->fields ) || empty( $this->fields ) ) { return array(); } $descriptors = array(); $values = array(); foreach ( $this->fields as $field_id => $field ) { $descriptors[ $field_id ] = array( 'logic' => $field->get_attribute( 'conditionallogic' ), 'type' => $field->get_attribute( 'type' ), // A date field's value is written in its own format, and the comparison has // to read it the same way the datepicker wrote it. 'format' => $field->get_attribute( 'dateformat' ), ); // Resolve the value exactly as the field itself does when rendering: submitted // value first, then a `?field_id=value` query parameter, then the configured // default, then the logged-in user's details. Reading $_POST alone would make a // prefilled form resolve against an empty one, so a field the visitor can already // see satisfying a condition would render hidden and then flash into view. $values[ $field_id ] = $field->get_conditional_logic_value(); } return Conditional_Logic::resolve_visibility( $descriptors, $values ); } /** * Validate the form reference. * * @param int $ref The form reference ID. */ public function validate_ref( $ref ) { $form_post = get_post( $ref ); if ( ! $form_post || self::POST_TYPE !== $form_post->post_type ) { $this->add_error( 'invalid_ref', __( 'Invalid form reference.', 'jetpack-forms' ) ); return; } if ( $form_post->post_status !== 'publish' ) { $this->add_error( 'unpublished_form', __( 'Invalid form reference.', 'jetpack-forms' ) ); return; } } /** * Reset the static errors for the contact form. * * @param string $id The ID of the contact form to reset errors for. If null, resets all static errors. * * This method is used to clear the static errors stored in the class. */ public static function reset_errors( $id = null ) { if ( $id && isset( self::$static_errors[ $id ] ) ) { unset( self::$static_errors[ $id ] ); return; } self::$static_errors = array(); } /** * Add an error to the contact form. * * @param string $error_code The error code. * @param string $error_message The error message. */ public function add_error( $error_code, $error_message ) { $id = $this->get_attribute( 'id' ); if ( ! isset( self::$static_errors[ $id ] ) ) { self::$static_errors[ $id ] = Form_Submission_Error::validation_error( $error_code, $error_message ); } else { // If we already have errors, add this error to the existing Form_Submission_Error self::$static_errors[ $id ]->add( $error_code, $error_message ); } $this->errors = self::$static_errors[ $id ]; } /** * Check if the contact form has errors. * * @return bool True if the contact form has errors, false otherwise. */ public function has_errors() { $id = $this->get_attribute( 'id' ); if ( ! isset( self::$static_errors[ $id ] ) ) { return false; } return is_wp_error( self::$static_errors[ $id ] ) && ! empty( self::$static_errors[ $id ]->get_error_codes() ); } /** * Get the error messages of the contact form. * * @return array The errors of the contact form. */ public function get_error_messages() { if ( ! $this->has_errors() ) { return array(); } $id = $this->get_attribute( 'id' ); return self::$static_errors[ $id ]->get_error_messages(); } /** * Get the confirmation type of the contact form from the deprecated customThankyou attribute. * * @return string The confirmation type of the contact form. */ public function get_confirmation_type() { // Backward compat: customThankyou 'redirect' takes precedence for old forms if ( 'redirect' === $this->get_attribute( 'customThankyou' ) ) { return 'redirect'; } return $this->get_attribute( 'confirmationType' ); } /** * Get the disable summary of the contact form from the deprecated customThankyou attribute. * * @return string The disable summary of the contact form. */ public function get_disable_summary() { $disable_summary = $this->get_attribute( 'disableSummary' ); $custom_thankyou = $this->get_attribute( 'customThankyou' ); if ( '' === $disable_summary ) { $disable_summary = 'noSummary' === $custom_thankyou || 'message' === $custom_thankyou; } return $disable_summary; } }