diff --git a/projects/packages/forms/.phan/baseline.php b/projects/packages/forms/.phan/baseline.php index 2c4f2d21a6532..1db81e8722ec1 100644 --- a/projects/packages/forms/.phan/baseline.php +++ b/projects/packages/forms/.phan/baseline.php @@ -15,10 +15,10 @@ // PhanTypeMismatchArgumentInternal : 6 occurrences // PhanTypeMismatchArgumentProbablyReal : 6 occurrences // PhanPluginDuplicateAdjacentStatement : 2 occurrences - // PhanPluginRedundantAssignment : 2 occurrences // PhanTypeConversionFromArray : 2 occurrences // PhanTypeMismatchReturn : 2 occurrences // PhanPluginMixedKeyNoKey : 1 occurrence + // PhanPluginRedundantAssignment : 1 occurrence // PhanPossiblyNullTypeMismatchProperty : 1 occurrence // PhanTypeArraySuspiciousNullable : 1 occurrence // PhanTypeMismatchReturnNullable : 1 occurrence @@ -28,7 +28,7 @@ 'file_suppressions' => [ 'src/contact-form/class-admin.php' => ['PhanPluginDuplicateConditionalNullCoalescing', 'PhanTypeArraySuspiciousNullable', 'PhanTypeMismatchArgument', 'PhanTypeMismatchArgumentInternal', 'PhanTypeMismatchArgumentProbablyReal', 'PhanTypeMismatchReturn'], 'src/contact-form/class-contact-form-field.php' => ['PhanPluginDuplicateConditionalNullCoalescing', 'PhanPossiblyNullTypeMismatchProperty', 'PhanTypeConversionFromArray', 'PhanTypeMismatchArgument', 'PhanTypeMismatchReturnProbablyReal'], - 'src/contact-form/class-contact-form-plugin.php' => ['PhanPluginDuplicateAdjacentStatement', 'PhanPluginDuplicateConditionalNullCoalescing', 'PhanPluginRedundantAssignment', 'PhanTypeMismatchArgument', 'PhanTypeMismatchArgumentInternal', 'PhanTypeMismatchArgumentProbablyReal', 'PhanTypeMismatchReturnProbablyReal'], + 'src/contact-form/class-contact-form-plugin.php' => ['PhanPluginDuplicateAdjacentStatement', 'PhanPluginDuplicateConditionalNullCoalescing', 'PhanTypeMismatchArgument', 'PhanTypeMismatchArgumentInternal', 'PhanTypeMismatchArgumentProbablyReal', 'PhanTypeMismatchReturnProbablyReal'], 'src/contact-form/class-contact-form-shortcode.php' => ['PhanPluginDuplicateConditionalNullCoalescing', 'PhanTypeMismatchReturnProbablyReal'], 'src/contact-form/class-contact-form.php' => ['PhanPluginDuplicateConditionalNullCoalescing', 'PhanPluginRedundantAssignment', 'PhanTypeMismatchArgument', 'PhanTypeMismatchReturnNullable', 'PhanTypeMismatchReturnProbablyReal'], 'src/dashboard/class-dashboard-view-switch.php' => ['PhanUnreferencedUseNormal'], diff --git a/projects/packages/forms/changelog/update-form-response-storage b/projects/packages/forms/changelog/update-form-response-storage new file mode 100644 index 0000000000000..20f6fcff76668 --- /dev/null +++ b/projects/packages/forms/changelog/update-form-response-storage @@ -0,0 +1,5 @@ +Significance: patch +Type: fixed + +Forms: Adds a new Feedback and Feedback_Field class that keeps consistency between DB form response and the one before we submit it. + diff --git a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php index bc7d998b0b5fc..2a72686894d78 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php +++ b/projects/packages/forms/src/contact-form/class-contact-form-endpoint.php @@ -530,100 +530,48 @@ public function prepare_item_for_response( $item, $request ) { $data = $response->get_data(); $fields = $this->get_fields_for_response( $request ); - $has_file = false; - $base_fields = array( - 'email_marketing_consent' => '', - 'entry_title' => '', - 'entry_permalink' => '', - 'feedback_id' => '', - ); - - $data_defaults = array( - '_feedback_author' => '', - '_feedback_author_email' => '', - '_feedback_author_url' => '', - '_feedback_all_fields' => array(), - '_feedback_ip' => '', - '_feedback_subject' => '', - ); - - $feedback_data = array_merge( - $data_defaults, - \Automattic\Jetpack\Forms\ContactForm\Contact_Form_Plugin::parse_fields_from_content( $item->ID ) - ); - - $all_fields = array_merge( $base_fields, $feedback_data['_feedback_all_fields'] ); + $response = Feedback::get( $item->ID ); $data['date'] = get_the_date( 'c', $data['id'] ); if ( rest_is_field_included( 'uid', $fields ) ) { - $data['uid'] = $all_fields['feedback_id']; + $data['uid'] = $response->get_feedback_id(); } if ( rest_is_field_included( 'author_name', $fields ) ) { - $data['author_name'] = $feedback_data['_feedback_author']; + $data['author_name'] = $response->get_author(); } if ( rest_is_field_included( 'author_email', $fields ) ) { - $data['author_email'] = $feedback_data['_feedback_author_email']; + $data['author_email'] = $response->get_author_email(); } if ( rest_is_field_included( 'author_url', $fields ) ) { - $data['author_url'] = $feedback_data['_feedback_author_url']; + $data['author_url'] = $response->get_author_url(); } if ( rest_is_field_included( 'author_avatar', $fields ) ) { - $data['author_avatar'] = empty( $feedback_data['_feedback_author_email'] ) ? '' : get_avatar_url( $feedback_data['_feedback_author_email'] ); + $data['author_avatar'] = $response->get_author_avatar(); } if ( rest_is_field_included( 'email_marketing_consent', $fields ) ) { - $data['email_marketing_consent'] = $all_fields['email_marketing_consent']; + $data['email_marketing_consent'] = $response->has_consent() ? __( 'Yes', 'jetpack-forms' ) : __( 'No', 'jetpack-forms' ); } if ( rest_is_field_included( 'ip', $fields ) ) { - $data['ip'] = $feedback_data['_feedback_ip']; + $data['ip'] = $response->get_ip_address(); } if ( rest_is_field_included( 'entry_title', $fields ) ) { - $data['entry_title'] = $all_fields['entry_title']; + $data['entry_title'] = $response->get_entry_title(); } if ( rest_is_field_included( 'entry_permalink', $fields ) ) { - $data['entry_permalink'] = $all_fields['entry_permalink']; + $data['entry_permalink'] = $response->get_entry_permalink(); } if ( rest_is_field_included( 'subject', $fields ) ) { - $data['subject'] = $feedback_data['_feedback_subject']; + $data['subject'] = $response->get_subject(); } if ( rest_is_field_included( 'fields', $fields ) ) { - $fields_data = array_diff_key( $all_fields, $base_fields ); - - foreach ( $fields_data as &$field ) { - if ( Contact_Form::is_file_upload_field( $field ) ) { - - foreach ( $field['files'] as &$file ) { - if ( ! isset( $file['size'] ) || ! isset( $file['file_id'] ) ) { - // this shouldn't happen, todo: log this - continue; - } - $file_id = absint( $file['file_id'] ); - $file['file_id'] = $file_id; - $file['size'] = size_format( $file['size'] ); - $file['url'] = apply_filters( 'jetpack_unauth_file_download_url', '', $file_id ); - $file['is_previewable'] = self::is_previewable_file( $file ); - $has_file = true; - } - } - } + $data['fields'] = $response->get_compiled_fields( 'api', 'key-value' ); + } - $data['fields'] = $fields_data; - $data['has_file'] = $has_file; + if ( rest_is_field_included( 'has_file', $fields ) ) { + $data['has_file'] = $response->has_file(); } return rest_ensure_response( $data ); } - /** - * Checks if the file is previewable based on its type or extension. - * - * @param array $file File data. - * @return bool True if the file is previewable, false otherwise. - */ - private static function is_previewable_file( $file ) { - $file_type = strtolower( pathinfo( $file['name'], PATHINFO_EXTENSION ) ); - // Check if the file is previewable based on its type or extension. - // Note: This is a simplified check and does not match if the file is allowed to be uploaded by the server. - $previewable_types = array( 'jpg', 'jpeg', 'png', 'gif', 'webp' ); - return in_array( $file_type, $previewable_types, true ); - } /** * Retrieves the query params for the feedback collection. diff --git a/projects/packages/forms/src/contact-form/class-contact-form-plugin.php b/projects/packages/forms/src/contact-form/class-contact-form-plugin.php index 11ad30751d100..0f4a483fb03c7 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form-plugin.php +++ b/projects/packages/forms/src/contact-form/class-contact-form-plugin.php @@ -2000,33 +2000,42 @@ public function internal_personal_data_exporter( $email, $page = 1, $per_page = $post_ids = $this->personal_data_post_ids_by_email( $email, $per_page, $page ); foreach ( $post_ids as $post_id ) { - $post_fields = $this->get_parsed_field_contents_of_post( $post_id ); - - if ( ! is_array( $post_fields ) || empty( $post_fields['_feedback_subject'] ) ) { - continue; // Corrupt data. - } - - $post_fields['_feedback_main_comment'] = $this->get_post_content_for_csv_export( $post_id ); - $post_fields = $this->map_parsed_field_contents_of_post_to_field_names( $post_fields ); + $post_export_data = array(); + $feedback = Feedback::get( $post_id ); - if ( ! is_array( $post_fields ) || empty( $post_fields ) ) { - continue; // No fields to export. - } + $fields = $feedback->get_compiled_fields( 'personal_export', 'all' ); + $post_export_data[] = array( + 'name' => __( 'Date', 'jetpack-forms' ), + 'value' => $feedback->get_time(), + ); - $post_meta = $this->get_post_meta_for_csv_export( $post_id ); - $post_meta = is_array( $post_meta ) ? $post_meta : array(); + $post_export_data[] = array( + 'name' => __( 'Source Title', 'jetpack-forms' ), + 'value' => $feedback->get_entry_title(), + ); - $post_export_data = array(); - $post_data = array_merge( $post_fields, $post_meta ); - ksort( $post_data ); + $post_export_data[] = array( + 'name' => __( 'Source URL:', 'jetpack-forms' ), + 'value' => $feedback->get_entry_permalink(), + ); - foreach ( $post_data as $post_data_key => $post_data_value ) { + foreach ( $fields as $field ) { $post_export_data[] = array( - 'name' => preg_replace( '/^[0-9]+_/', '', $post_data_key ), - 'value' => $post_data_value, + 'name' => $field['label'], + 'value' => $field['value'], ); } + $post_export_data[] = array( + 'name' => __( 'Consent', 'jetpack-forms' ), + 'value' => $feedback->has_consent() ? __( 'Yes', 'jetpack-forms' ) : __( 'No', 'jetpack-forms' ), + ); + + $post_export_data[] = array( + 'name' => __( 'IP Address', 'jetpack-forms' ), + 'value' => $feedback->get_ip_address(), + ); + $export_data[] = array( 'group_id' => 'feedback', 'group_label' => __( 'Feedback', 'jetpack-forms' ), @@ -2214,141 +2223,68 @@ public function personal_data_search_filter( $search ) { } /** - * Prepares feedback post data for CSV export. + * Returns an array of feedback data for export. * - * @param array $post_ids Post IDs to fetch the data for. These need to be Feedback posts. + * @param array $feedback_ids Array of feedback IDs to fetch the data for. * * @return array */ - public function get_export_data_for_posts( $post_ids ) { - - $posts_data = array(); - $field_names = array(); - $result = array(); - - /** - * Fetch posts and get the possible field names for later use - */ - foreach ( $post_ids as $post_id ) { - - /** - * Fetch post main data, because we need the subject and author data for the feedback form. - */ - $post_real_data = $this->get_parsed_field_contents_of_post( $post_id ); - - /** - * Whether the feedback post has JSON data or not. - * This is used as optional parameter on legacy functions. - */ - $post_has_json_data = $this->has_json_data( $post_id ); - - /** - * If `$post_real_data` is not an array or there is no `_feedback_subject` set, - * then something must be wrong with the feedback post. Skip it. - */ - if ( ! is_array( $post_real_data ) || ! isset( $post_real_data['_feedback_subject'] ) ) { - continue; - } - - /** - * Fetch main post comment. This is from the default textarea fields. - * If it is non-empty, then we add it to data, otherwise skip it. - */ - $post_comment_content = $this->get_post_content_for_csv_export( $post_id ); - if ( ! empty( $post_comment_content ) ) { - $post_real_data['_feedback_main_comment'] = $post_comment_content; - } - - /** - * Map parsed fields to proper field names - */ - $mapped_fields = $this->map_parsed_field_contents_of_post_to_field_names( $post_real_data, ! $post_has_json_data ); - - /** - * Fetch post meta data. - */ - $post_meta_data = $this->get_post_meta_for_csv_export( $post_id, $post_has_json_data ); - - /** - * If `$post_meta_data` is not an array or if it is empty, then there is no - * extra feedback to work with. Create an empty array. - */ - if ( ! is_array( $post_meta_data ) || empty( $post_meta_data ) ) { - $post_meta_data = array(); - } + public static function get_export_feedback_data( $feedback_ids ) { + $feedback_data = array(); + $field_names = array(); + $results = array(); - /** - * Prepend the feedback subject to the list of fields. - */ - $post_meta_data = array_merge( - $mapped_fields, - $post_meta_data - ); - - /** - * Save post metadata for later usage. - */ - $posts_data[ $post_id ] = $post_meta_data; - - /** - * Save field names, so we can use them as header fields later in the CSV. - */ - $field_names = array_merge( $field_names, array_keys( $post_meta_data ) ); + foreach ( $feedback_ids as $feedback_id ) { + $feedback_data[ $feedback_id ] = Feedback::get( $feedback_id ); + $field_names = array_merge( $field_names, $feedback_data[ $feedback_id ]->get_compiled_fields( 'csv', 'label' ) ); } /** * Make sure the field names are unique, because we don't want duplicate data. */ $field_names = array_unique( $field_names ); + foreach ( $feedback_data as $feedback_id => $feedback ) { - /** - * Sort the field names by the field id number - */ - sort( $field_names, SORT_NUMERIC ); - - $well_known_column_names = $this->get_well_known_column_names(); - $result = array(); - - /** - * Loop through every post, which is essentially CSV row. - */ - foreach ( $posts_data as $post_id => $single_post_data ) { + if ( ! $feedback instanceof Feedback ) { + continue; // Skip if the feedback is not an instance of Feedback. + } + $results[ __( 'ID', 'jetpack-forms' ) ][] = $feedback_id; + $results[ __( 'Date', 'jetpack-forms' ) ][] = $feedback->get_time(); + $results[ __( 'Title', 'jetpack-forms' ) ][] = $feedback->get_entry_title(); + $results[ __( 'Source', 'jetpack-forms' ) ][] = $feedback->get_entry_short_permalink(); /** * Go through all the possible fields and check if the field is available - * in the current post. + * in the current feedback. * * If it is - add the data as a value. * If it is not - add an empty string, which is just a placeholder in the CSV. */ foreach ( $field_names as $single_field_name ) { - $renamed_field = isset( $well_known_column_names[ $single_field_name ] ) - ? $well_known_column_names[ $single_field_name ] - : $single_field_name; - - /** - * Remove the numeral prefix -3_, 1_, 2_, etc, only for export results. - * Prefixes can be both positive and negative numeral values, functional to the SORT_NUMERIC above. - * TODO: to return fieldnames based on field label, we need to work both field names and post data: - * unique -> sort -> unique/rename - * $renamed_field = preg_replace( '/^(-?\d{1,2}_)/', '', $renamed_field ); - */ - - if ( ! isset( $result[ $renamed_field ] ) ) { - $result[ $renamed_field ] = array(); - } - - if ( - isset( $single_post_data[ $single_field_name ] ) - && ! empty( $single_post_data[ $single_field_name ] ) - ) { - $result[ $renamed_field ][] = trim( $single_post_data[ $single_field_name ] ); - } else { - $result[ $renamed_field ][] = ''; + if ( ! isset( $results[ $single_field_name ] ) ) { + $results[ $single_field_name ] = array(); } + $results[ $single_field_name ][] = $feedback->get_field_value_by_label( $single_field_name, 'csv' ); } + + $results[ __( 'Consent', 'jetpack-forms' ) ][] = $feedback->has_consent() ? __( 'Yes', 'jetpack-forms' ) : __( 'No', 'jetpack-forms' ); + $results[ __( 'IP Address', 'jetpack-forms' ) ][] = $feedback->get_ip_address(); + } + return $results; + } - return $result; + /** + * Prepares feedback post data for CSV export. + * + * @deprecated since $$next-version$$ + * + * @see get_export_feedback_data() + * @param array $post_ids Post IDs to fetch the data for. These need to be Feedback posts. + * + * @return array + */ + public function get_export_data_for_posts( $post_ids ) { + return self::get_export_feedback_data( $post_ids ); } /** @@ -2358,6 +2294,8 @@ public function get_export_data_for_posts( $post_ids ) { * - Positive values render AFTER any form field/value column: 1, 30, 93... * Mind using high numbering on these ones as the prefix is used on regular inputs: 1_Name, 2_Email, etc * + * @deprecated since $$next-version$$ + * * @return array */ public function get_well_known_column_names() { @@ -2440,23 +2378,7 @@ function ( $selected ) { $feedbacks = get_posts( $args ); - if ( empty( $feedbacks ) ) { - return; - } - - /** - * Prepare data for export. - */ - $data = $this->get_export_data_for_posts( $feedbacks ); - - /** - * If `$data` is empty, there's nothing we can do below. - */ - if ( ! is_array( $data ) || empty( $data ) ) { - return; - } - - return $data; + return self::get_export_feedback_data( $feedbacks ); } /** diff --git a/projects/packages/forms/src/contact-form/class-contact-form.php b/projects/packages/forms/src/contact-form/class-contact-form.php index 6a43e0e60fa08..78a9ceb9638b9 100644 --- a/projects/packages/forms/src/contact-form/class-contact-form.php +++ b/projects/packages/forms/src/contact-form/class-contact-form.php @@ -330,6 +330,14 @@ public static function get_default_subject( $attributes ) { return $default_subject; } + /** + * Get the forms processed count. + * + * @return int + */ + public static function get_forms_count() { + return count( self::$forms ); + } /** * Store shortcode content for recall later @@ -831,7 +839,7 @@ public static function success_message( $feedback_id, $form ) { ); $message = wp_kses( $raw_message, $allowed_html ); } else { - $compiled_form = self::get_compiled_form( $feedback_id, $form ); + $compiled_form = self::get_compiled_form( $feedback_id ); $message = '

' . implode( '

', $compiled_form ) . '

'; } @@ -842,13 +850,17 @@ public static function success_message( $feedback_id, $form ) { * Returns a compiled form with labels and values in a form of an array * of lines. * - * @param int $feedback_id - the feedback ID. - * @param Contact_Form $form - the form. + * @param int $feedback_id - the feedback ID. + * @param Contact_Form|null $form - the contact form object. This parameter is deprecated and no longer used. * * @return array $lines */ - public static function get_compiled_form( $feedback_id, $form ) { - $compiled_form = self::get_raw_compiled_form_data( $feedback_id, $form ); + public static function get_compiled_form( $feedback_id, $form = null ) { + if ( $form ) { + // you are doing it wrong, the $form parameter is deprecated and no longer used + _deprecated_argument( __METHOD__, '$$next-version$$', 'The $form parameter is deprecated and no longer used.' ); + } + $compiled_form = self::get_raw_compiled_form_data( $feedback_id ); foreach ( $compiled_form as $field_index => $data ) { $safe_display_value = self::escape_and_sanitize_field_value( $data['value'] ); @@ -882,138 +894,35 @@ public static function get_compiled_form( $feedback_id, $form ) { /** * Returns the JSON data for the form submission. * - * @param int $feedback_id - the feedback ID. - * @param Contact_Form $form - the form. + * @param int $feedback_id - the feedback ID. + * @param Contact_Form|null $form - the contact form object. This parameter is deprecated and no longer used. * * @return array $json_data */ - public static function get_json_data( $feedback_id, $form ) { - $raw_data = self::get_raw_compiled_form_data( $feedback_id, $form ); - $json_data = array(); - - // Handle file upload field (new structure with field_id and files array) - foreach ( $raw_data as $field_data ) { - $value = $field_data['value']; - $label = $field_data['label']; - - if ( self::is_file_upload_field( $value ) ) { - $files = $value['files']; - - if ( empty( $files ) ) { - continue; - } - - foreach ( $files as $file ) { - if ( ! empty( $file['file_id'] ) ) { - $file_name = isset( $file['name'] ) ? $file['name'] : __( 'Attached file', 'jetpack-forms' ); - $file_size = isset( $file['size'] ) ? size_format( $file['size'] ) : ''; - - $json_data[] = array( - 'label' => $label, - 'value' => array( - 'name' => $file_name, - 'size' => $file_size, - ), - ); - } - } - } else { - $json_data[] = array( - 'label' => $label, - 'value' => $value, - ); - } + public static function get_json_data( $feedback_id, $form = null ) { + if ( $form ) { + // you are doing it wrong, the $form parameter is deprecated and no longer used + _deprecated_argument( __METHOD__, '$$next-version$$', 'The $form parameter is deprecated and no longer used.' ); } - - return $json_data; + $response = Feedback::get( $feedback_id ); + return $response->get_compiled_fields( 'ajax', 'label|value' ); } /** * Retrieves raw compiled form data. * - * @param int $feedback_id - the feedback ID. - * @param Contact_Form $form - the form. + * @param int $feedback_id - the feedback ID. + * @param Contact_Form|null $form - the contact form object. This parameter is deprecated and no longer used. * * @return array $raw_data Associative array where keys are field_index and values are arrays with 'label' and 'value'. */ - private static function get_raw_compiled_form_data( $feedback_id, $form ) { - $feedback = get_post( $feedback_id ); - $field_ids = $form->get_field_ids(); - $content_fields = Contact_Form_Plugin::parse_fields_from_content( $feedback_id ); - - // Maps field_ids to post_meta keys - $field_value_map = array( - 'name' => 'author', - 'email' => 'author_email', - 'url' => 'author_url', - 'subject' => 'subject', - 'textarea' => false, // not a post_meta key. This is stored in post_content - ); - - $raw_data = array(); - - // "Standard" field allowed list. - foreach ( $field_value_map as $type => $meta_key ) { - if ( isset( $field_ids[ $type ] ) ) { - $field = $form->fields[ $field_ids[ $type ] ]; - $value = null; - - if ( $meta_key ) { - if ( isset( $content_fields[ "_feedback_{$meta_key}" ] ) ) { - if ( 'name' === $type ) { - // If a form contains both email and name fields but the user doesn't provide a name, we don't need to show the name field - // in the success message after submision. We have this specific check because in the above case the `author` field gets - // a fallback value of the provided email and is used in the backend in various places. - if ( isset( $content_fields['_feedback_author_email'] ) && $content_fields['_feedback_author'] === $content_fields['_feedback_author_email'] ) { - continue; - } - } - $value = $content_fields[ "_feedback_{$meta_key}" ]; - } - } else { - // The feedback content is stored as the first "half" of post_content - $current_value = ( is_object( $feedback ) && is_a( $feedback, '\WP_Post' ) ) ? - $feedback->post_content : ''; - list( $current_value ) = explode( '', $current_value ); - $value = trim( $current_value ); - } - - $field_index = array_search( $field_ids[ $type ], $field_ids['all'], true ); - $field_label = $field->get_attribute( 'label' ); - - $raw_data[ $field_index ] = array( - 'label' => $field_label, - 'value' => $value, - ); - } - } - - // "Non-standard" fields - if ( $field_ids['extra'] ) { - // array indexed by field label (not field id) - $extra_fields = get_post_meta( $feedback_id, '_feedback_extra_fields', true ); - /** - * Only get data for the compiled form if `$extra_fields` is a valid and non-empty array. - */ - if ( is_array( $extra_fields ) && ! empty( $extra_fields ) ) { - - $extra_field_keys = array_keys( $extra_fields ); - - $i = 0; - foreach ( $field_ids['extra'] as $field_id ) { - $field = $form->fields[ $field_id ]; - $field_index = array_search( $field_id, $field_ids['all'], true ); - $field_label = $field->get_attribute( 'label' ); - $value = isset( $extra_field_keys[ $i ] ) && isset( $extra_fields[ $extra_field_keys[ $i ] ] ) ? $extra_fields[ $extra_field_keys[ $i ] ] : ''; - $raw_data[ $field_index ] = array( - 'label' => $field_label, - 'value' => $value, - ); - ++$i; - } - } + private static function get_raw_compiled_form_data( $feedback_id, $form = null ) { + if ( $form ) { + // you are doing it wring, the $form parameter is deprecated and no longer used. + _deprecated_argument( __METHOD__, '$$next-version$$', 'The $form parameter is deprecated and no longer used.' ); } - return $raw_data; + $response = Feedback::get( $feedback_id ); + return $response->get_compiled_fields(); } /** @@ -1026,7 +935,7 @@ private static function get_raw_compiled_form_data( $feedback_id, $form ) { * @return array $lines */ public static function get_compiled_form_for_email( $feedback_id, $form ) { - $compiled_form = self::get_raw_compiled_form_data( $feedback_id, $form ); + $compiled_form = self::get_raw_compiled_form_data( $feedback_id ); /** * This filter allows a site owner to customize the response to be emailed, by adding their own HTML around it for example. @@ -1415,7 +1324,8 @@ public function get_field_ids() { ); // Initialize marketing consent - $field_ids['email_marketing_consent'] = null; + $field_ids['email_marketing_consent'] = null; + $field_ids['email_marketing_consent_field'] = null; foreach ( $this->fields as $id => $field ) { $type = $field->get_attribute( 'type' ); @@ -1448,6 +1358,7 @@ public function get_field_ids() { 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 { @@ -2149,7 +2060,7 @@ public function process_submission() { echo wp_json_encode( array( 'success' => true, - 'data' => self::get_json_data( $post_id, $this ), + 'data' => self::get_json_data( $post_id ), 'refreshArgs' => $refresh_args, ) ); diff --git a/projects/packages/forms/src/contact-form/class-feedback-author.php b/projects/packages/forms/src/contact-form/class-feedback-author.php new file mode 100644 index 0000000000000..25d0614fd5721 --- /dev/null +++ b/projects/packages/forms/src/contact-form/class-feedback-author.php @@ -0,0 +1,148 @@ +name = $name; + $this->email = $email; + $this->url = $url; + } + + /** + * Create a Feedback_Author instance from the submission data. + * + * @param array $post_data The post data from the form submission. + * @param Contact_Form $form The form object. + * @return Feedback_Author The Feedback_Author instance. + */ + public static function from_submission( $post_data, $form ) { + return new self( + self::get_computed_author_info( $post_data, 'name', 'pre_comment_author_name', $form ), + self::get_computed_author_info( $post_data, 'email', 'pre_comment_author_email', $form ), + self::get_computed_author_info( $post_data, 'url', 'pre_comment_author_url', $form ) + ); + } + + /** + * Gets the computed author. + * + * @param array $post_data The post data from the form submission. + * @param string $type The type of author information to retrieve (e.g., 'name', 'email', 'url'). + * @param string $filter Optional filter to apply to the value. + * @param Contact_Form $form The form object. + * + * @return string Filter value for the author information. + */ + private static function get_computed_author_info( $post_data, $type, $filter, $form ) { + $field_ids = $form->get_field_ids(); + if ( isset( $field_ids[ $type ] ) ) { + $key = $field_ids[ $type ]; + $value = isset( $post_data[ $key ] ) ? sanitize_text_field( wp_unslash( $post_data[ $key ] ) ) : ''; + if ( is_string( $value ) ) { + return Contact_Form_Plugin::strip_tags( + stripslashes( + /** + * + * Listed to help search find the filters. + * apply_filters( ''pre_comment_author_name', $value ) + * apply_filters( ''pre_comment_author_email', $value ) + * apply_filters( ''pre_comment_author_url', $value ) + */ + apply_filters( $filter, addslashes( $value ) ) + ) + ); + + } + } + return ''; + } + + /** + * Get the display name of the author. + * + * If the name is not set, it will return the email. + * + * @return string The display name of the author. + */ + public function get_display_name(): string { + return empty( $this->name ) ? $this->email : $this->name; + } + + /** + * Get the avatar URL of the author. + * + * If the email is not set, it will return an empty string. + * + * @return string The avatar URL of the author. + */ + public function get_avatar_url(): string { + return ! empty( $this->email ) ? get_avatar_url( $this->email ) : ''; + } + + /** + * Get the name of the author. + * + * @return string The name of the author. + */ + public function get_name() { + return $this->name; + } + + /** + * Get the email of the author. + * + * @return string The email of the author. + */ + public function get_email() { + return $this->email; + } + + /** + * Get the URL of the author. + * + * @return string The URL of the author. + */ + public function get_url() { + return $this->url; + } +} diff --git a/projects/packages/forms/src/contact-form/class-feedback-entry.php b/projects/packages/forms/src/contact-form/class-feedback-entry.php new file mode 100644 index 0000000000000..e7d2b7b0a1d47 --- /dev/null +++ b/projects/packages/forms/src/contact-form/class-feedback-entry.php @@ -0,0 +1,151 @@ +id = $id > 0 ? (int) $id : 0; + $this->title = $title; + $this->page_number = $page_number; + $this->permalink = ''; + + if ( $id <= 0 ) { + return; + } + + $entry_post = get_post( $id ); + + if ( $entry_post && $entry_post->post_status === 'publish' ) { + $this->permalink = get_permalink( $entry_post ); + $this->title = get_the_title( $entry_post ); + } + } + + /** + * Creates a Feedback_Entry instance from a submission. + * + * @param \WP_Post|null $current_post The current post object. + * @param int $current_page_number The current page number, default is 1. + * @return Feedback_Entry Returns an instance of Feedback_Entry. + */ + public static function from_submission( $current_post, int $current_page_number = 1 ) { + $id = isset( $current_post->ID ) ? (int) $current_post->ID : 0; + + if ( ! $current_post instanceof \WP_Post || $id === 0 ) { + return new self( 0, '', $current_page_number ); + } + + $title = $current_post->post_title ?? ''; + + return new self( $id, $title, $current_page_number ); + } + + /** + * Get the permalink of the feedback entry. + * + * @return string The permalink of the feedback entry. + */ + public function get_permalink() { + if ( $this->page_number > 1 && ! empty( $this->permalink ) ) { + return add_query_arg( 'page', $this->page_number, $this->permalink ); + } + return $this->permalink; + } + + /** + * Get the relative permalink of the feedback entry. + * + * @return string The relative permalink of the feedback entry. + */ + public function get_relative_permalink() { + if ( ! empty( $this->permalink ) ) { + return wp_make_link_relative( $this->get_permalink() ); + } + return ''; + } + + /** + * Get the page number of the feedback entry. + * + * @return int The page number of the feedback entry. + */ + public function get_page_number() { + return $this->page_number; + } + /** + * Get the title of the feedback entry. + * + * @return string The title of the feedback entry. + */ + public function get_title() { + return $this->title; + } + /** + * Get the post id of the feedback entry. + * + * @return int The ID of the feedback entry. + */ + public function get_id() { + return $this->id; + } + + /** + * Get the page number of the enrty title. + * + * @return array + */ + public function serialize() { + return array( + 'entry_title' => $this->title, + 'entry_page' => $this->page_number, + ); + } +} diff --git a/projects/packages/forms/src/contact-form/class-feedback-field.php b/projects/packages/forms/src/contact-form/class-feedback-field.php new file mode 100644 index 0000000000000..f78f700f77aab --- /dev/null +++ b/projects/packages/forms/src/contact-form/class-feedback-field.php @@ -0,0 +1,285 @@ +key = $key; + $this->label = mb_convert_encoding( $label, 'UTF-8', 'HTML-ENTITIES' ); + $this->value = $value; + $this->type = $type; + $this->meta = $meta; + } + + /** + * Get the value of the field. + * + * @return string + */ + public function get_key() { + return $this->key; + } + + /** + * Get the label of the field. + * + * @return string + */ + public function get_label() { + return $this->label; + } + + /** + * Get the value of the field. + * + * @return mixed + */ + public function get_value() { + return $this->value; + } + + /** + * Get the value of the field for rendering. + * + * @param string $context The context in which the value is being rendered (default is 'default'). + * + * @return string + */ + public function get_render_value( $context = 'default' ) { + switch ( $context ) { + case 'api': + return $this->get_render_api_value(); + case 'default': + default: + return $this->get_render_default_value(); + } + } + + /** + * Get the default value of the field for rendering. + * + * @return string + */ + private function get_render_default_value() { + if ( $this->is_of_type( 'file' ) ) { + $files = array(); + foreach ( $this->value['files'] as &$file ) { + if ( ! isset( $file['size'] ) || ! isset( $file['file_id'] ) ) { + // this shouldn't happen, todo: log this + continue; + } + $file_name = $file['name'] ?? __( 'Attached file', 'jetpack-forms' ); + $file_size = isset( $file['size'] ) ? size_format( $file['size'] ) : ''; + $files[] = $file_name . ' (' . $file_size . ')'; + } + return implode( ', ', $files ); + } + + if ( is_array( $this->value ) ) { + return implode( ', ', $this->value ); + } + + return $this->value; + } + + /** + * Get the value of the field for the API. + * + * @return string + */ + private function get_render_api_value() { + + if ( $this->is_of_type( 'file' ) ) { + $files = array(); + foreach ( $this->value['files'] as &$file ) { + if ( ! isset( $file['size'] ) || ! isset( $file['file_id'] ) ) { + // this shouldn't happen, todo: log this + continue; + } + $file_id = absint( $file['file_id'] ); + $file['file_id'] = $file_id; + $file['size'] = size_format( $file['size'] ); + $file['url'] = apply_filters( 'jetpack_unauth_file_download_url', '', $file_id ); + $file['is_previewable'] = $this->is_previewable_file( $file ); + $files[] = $file; + } + $this->value['files'] = $files; + return $this->value; + } + + if ( is_array( $this->value ) ) { + // If the value is an array, we can return it as a JSON string. + return implode( ', ', $this->value ); + } + // This method is deprecated, use render_value instead. + return $this->value; + } + /** + * Check if the field is of a specific type. + * + * @param string $type The type to check against. + * + * @return bool True if the field is of the specified type, false otherwise. + */ + public function is_of_type( $type ) { + return $this->type === $type; + } + + /** + * Check if the field should be compiled. + * + * @return bool + */ + public function compile_field() { + return $this->get_meta_key_value( 'render' ) === false; + } + + /** + * Get the type of the field. + * + * @return string + */ + public function get_type() { + return $this->type; + } + + /** + * Get the meta array of the field. + * + * @return array + */ + public function get_meta() { + return $this->meta; + } + + /** + * Get a specific meta value by key. + * + * @param string $meta_key The key of the meta to retrieve. + * + * @return mixed|null Returns the value of the meta key if it exists, null otherwise. + */ + public function get_meta_key_value( $meta_key ) { + if ( isset( $this->meta[ $meta_key ] ) ) { + return $this->meta[ $meta_key ]; + } + return null; + } + + /** + * Get the serialized representation of the field. + * + * @return array + */ + public function serialize() { + return array( + 'key' => $this->get_key(), + 'label' => $this->get_label(), + 'value' => $this->get_value(), + 'type' => $this->get_type(), + 'meta' => $this->get_meta(), + ); + } + /** + * Create a Feedback_Field object from serialized data. + * + * @param array $data The serialized data. + * + * @return Feedback_Field|null Returns a Feedback_Field object or null if the data is invalid. + */ + public static function from_serialized( $data ) { + if ( ! is_array( $data ) || ! isset( $data['key'] ) || ! isset( $data['value'] ) || ! isset( $data['label'] ) ) { + return null; + } + + return new self( + $data['key'], + $data['label'], + $data['value'], + $data['type'] ?? 'basic', + $data['meta'] ?? array() + ); + } + + /** + * Check if the field has a file + * + * @return bool + */ + public function has_file() { + if ( $this->is_of_type( 'file' ) ) { + return count( $this->value['files'] ) > 0; + } + + return false; + } + + /** + * Checks if the file is previewable based on its type or extension. + * + * @param array $file File data. + * @return bool True if the file is previewable, false otherwise. + */ + private function is_previewable_file( $file ) { + $file_type = strtolower( pathinfo( $file['name'], PATHINFO_EXTENSION ) ); + // Check if the file is previewable based on its type or extension. + // Note: This is a simplified check and does not match if the file is allowed to be uploaded by the server. + $previewable_types = array( 'jpg', 'jpeg', 'png', 'gif', 'webp' ); + return in_array( $file_type, $previewable_types, true ); + } +} diff --git a/projects/packages/forms/src/contact-form/class-feedback.php b/projects/packages/forms/src/contact-form/class-feedback.php new file mode 100644 index 0000000000000..999d47eaf475e --- /dev/null +++ b/projects/packages/forms/src/contact-form/class-feedback.php @@ -0,0 +1,1030 @@ +post_type ) { + return null; + } + + $instance = new self(); + $instance->load_from_post( $feedback_post ); + return $instance; + } + + /** + * Create a Feedback object from a feedback post. + * + * @param WP_Post $feedback_post The feedback post object. + */ + private function load_from_post( WP_Post $feedback_post ) { + + $parsed_content = $this->parse_content( $feedback_post->post_content, $feedback_post->post_mime_type ); + + $this->status = $feedback_post->post_status; + $this->legacy_feedback_id = $feedback_post->post_name; + $this->feedback_time = $feedback_post->post_date; + + $this->fields = $parsed_content['fields'] ?? array(); + + $this->entry = new Feedback_Entry( + $feedback_post->post_parent, + $parsed_content['entry_title'] ?? '', + $parsed_content['entry_page'] ?? 1 + ); + + $this->ip_address = $parsed_content['ip'] ?? $this->get_first_field_of_type( 'ip' ); + $this->subject = $parsed_content['subject'] ?? $this->get_first_field_of_type( 'subject' ); + + $this->author_data = new Feedback_Author( + $this->get_first_field_of_type( 'name', 'pre_comment_author_name' ), + $this->get_first_field_of_type( 'email', 'pre_comment_author_email' ), + $this->get_first_field_of_type( 'url', 'pre_comment_author_url' ) + ); + + $this->comment_content = $this->get_first_field_of_type( 'textarea' ); + $this->has_consent = ( $this->get_first_field_of_type( 'consent' ) === 'Yes' ); + + $this->legacy_feedback_title = $feedback_post->post_title ? $feedback_post->post_title : $this->get_author() . ' - ' . $feedback_post->post_date; + } + + /** + * Create a response object from a form submission. + * + * @param array $post_data Typically $_POST. + * @param Contact_Form $form The form object. + * @param WP_Post|null $current_post The current post object, if available. + * @param int $current_page_number The current page number associated with the current post object entry. + * + * @return static + */ + public static function from_submission( $post_data, $form, $current_post = null, $current_page_number = 1 ) { + $instance = new self(); + $instance->load_from_submission( $post_data, $form, $current_post, $current_page_number ); + return $instance; + } + + /** + * Load from Form Submission. + * + * @param array $post_data The $_POST received during the form submission. + * @param Contact_Form $form The form object. + * @param WP_Post|null $current_post The current post object, if available. + * @param int $current_page_number The current page number associated with the current post object entry. + */ + private function load_from_submission( $post_data, $form, $current_post = null, $current_page_number = 1 ) { + + $this->entry = Feedback_Entry::from_submission( $current_post, $current_page_number ); + // If post_data is provided, use it to populate fields. + $this->fields = $this->get_computed_fields( $post_data, $form ); + $this->ip_address = Contact_Form_Plugin::get_ip_address(); + $this->subject = $this->get_computed_subject( $post_data, $form ); + $this->author_data = Feedback_Author::from_submission( $post_data, $form ); + $this->comment_content = $this->get_computed_comment_content( $post_data, $form ); + $this->has_consent = $this->get_computed_consent( $post_data, $form ); + + $this->feedback_time = current_time( 'mysql' ); + $this->legacy_feedback_title = "{$this->get_author()} - {$this->feedback_time}"; + $this->legacy_feedback_id = md5( $this->legacy_feedback_title ); + } + + /** + * Get a sanitized value from the post data. + * + * @param string $key The key to look for in the post data. + * @param array $post_data The post data array, typically $_POST. + * + * @return string|array The sanitized value, or an empty string if the key is not found. + */ + private function get_field_value( $key, $post_data ) { + if ( isset( $post_data[ $key ] ) ) { + if ( is_array( $post_data[ $key ] ) ) { + return array_map( 'sanitize_text_field', wp_unslash( $post_data[ $key ] ) ); + } else { + return sanitize_text_field( wp_unslash( $post_data[ $key ] ) ); + } + } + return ''; + } + + /** + * Get the computed fields from the post data. + * + * @param string $label The label of the field to look for. + * @param string $context The context in which the value is being rendered (default is 'default'). + * + * @return string The Value of the field. + */ + public function get_field_value_by_label( $label, $context = 'default' ) { + // This method is used to get the value of a field by its label. + foreach ( $this->fields as $field ) { + if ( $field->get_label() === $label ) { + return $field->get_render_value( $context ); + } + } + return ''; + } + /** + * Get the value of the field based on the first type found. + * + * @param string $type The type of the field to look for. + * @param string|null $filter Optional filter to apply to the value. + * @param string $context The context in which the value is being rendered (default is 'default'). + * + * @return string The value of the first field of the specified type, or an empty string if not found. + */ + private function get_first_field_of_type( $type, $filter = null, $context = 'default' ) { + // This method is used to get the first field of a specific type. + foreach ( $this->fields as $field ) { + if ( $field->get_type() === $type ) { + if ( $filter ) { + return Contact_Form_Plugin::strip_tags( + stripslashes( + /** This filter is already documented in core/wp-includes/comment-functions.php */ + \apply_filters( $filter, addslashes( $field->get_render_value( $context ) ) ) + ) + ); + } + return $field->get_render_value( $context ); + } + } + return ''; + } + + /** + * Get all the fields of the response. + */ + public function get_fields() { + return $this->fields; + } + + /** + * Get the values related to where the form was submitted from. + * + * @return array An array of entry values. + */ + private function get_entry_values() { + // This is a convenience method to get the entry values in a simple array format. + $entry_values = array( + 'email_marketing_consent' => (string) $this->has_consent, + 'entry_title' => $this->entry->get_title(), + 'entry_permalink' => $this->entry->get_permalink(), + 'feedback_id' => $this->legacy_feedback_id, + ); + + if ( $this->entry->get_page_number() > 1 ) { + $entry_values['entry_page'] = $this->entry->get_page_number(); + } + return $entry_values; + } + + /** + * Get all values of the response. + * + * @return array An array of all values, including fields and entry values. + */ + public function get_all_values() { + // This is a legacy method to maintain compatibility with older code. + return array_merge( $this->get_compiled_fields( 'default', 'key-value' ), $this->get_entry_values() ); + } + + /** + * Return the compiled fields for the given context. + * + * @param string $context The context in which the fields are compiled. + * @param string $array_shape The shape of the array to return. Can be 'all', 'value', 'label', or 'key-value'. + * + * @return array An array of compiled fields with labels and values. + */ + public function get_compiled_fields( $context = 'default', $array_shape = 'all' ) { + $compiled_fields = array(); + foreach ( $this->fields as $field ) { + if ( $field->compile_field( $context ) ) { + continue; // Skip fields that are not meant to be rendered. + } + + // Compile the field based on the requested shape. + switch ( $array_shape ) { + case 'all': + $compiled_fields[ $field->get_key() ] = array( + 'label' => $field->get_label( $context ), + 'value' => $field->get_render_value( $context ), + ); + break; + case 'label|value': + $compiled_fields[] = array( + 'label' => $field->get_label( $context ), + 'value' => $field->get_render_value( $context ), + ); + break; + case 'value': + $compiled_fields[] = $field->get_render_value( $context ); + break; + case 'label': + $compiled_fields[] = $field->get_label( $context ); + break; + case 'key-value': + $compiled_fields[ $field->get_key() ] = $field->get_render_value( $context ); + break; + } + } + + return $compiled_fields; + } + + /** + * Get the feedback ID of the response. + * Which is the same as the post name for feedback entries. + * Please note that this is not the same as the feedback post ID. + * + * @return string + */ + public function get_feedback_id() { + return $this->legacy_feedback_id; + } + + /** + * Get the feedback title of the response. + * + * This is mostly used for legacy reasons. + * + * @return string + */ + public function get_title() { + return $this->legacy_feedback_title; + } + + /** + * Get the time of the feedback entry. + * + * @return string + */ + public function get_time() { + return $this->feedback_time; + } + + /** + * Get the askimet vars that are used to check for spam. + * + * These are the variables that are sent to Akismet to check if the feedback is spam or not. + * + * @return array + */ + public function get_akismet_vars() { + $akismet_vars = array( + 'comment_author' => $this->author_data->get_name(), + 'comment_author_email' => $this->author_data->get_email(), + 'comment_author_url' => $this->author_data->get_url(), + 'contact_form_subject' => $this->get_subject(), + 'comment_author_ip' => $this->get_ip_address(), + 'comment_content' => empty( $this->get_comment_content() ) ? null : $this->get_comment_content(), + ); + + foreach ( $this->fields as $field ) { + + // Skip any fields that are just a choice from a pre-defined list. They wouldn't have any value + // from a spam-filtering point of view. + if ( in_array( $field->get_type(), array( 'select', 'checkbox', 'checkbox-multiple', 'radio', 'file' ), true ) ) { + continue; + } + + // Normalize the label into a slug. + $field_slug = trim( // Strip all leading/trailing dashes. + preg_replace( // Normalize everything to a-z0-9_- + '/[^a-z0-9_]+/', + '-', + strtolower( $field->get_label() ) // Lowercase + ), + '-' + ); + + $field_value = $field->get_render_value( 'akismet' ); + + // Skip any values that are already in the array we're sending. + if ( $field_value && in_array( $field_value, $akismet_vars, true ) ) { + continue; + } + + $akismet_vars[ 'contact_form_field_' . $field_slug ] = $field_value; + } + + return $akismet_vars; + } + + /** + * Get the author name of the feedback entry. + * If the author is not provided we will use the email instead. + * + * @return string + */ + public function get_author() { + return $this->author_data->get_display_name(); + } + + /** + * Get the author email of a feedback entry. + * + * @return string + */ + public function get_author_email() { + return $this->author_data->get_email(); + } + + /** + * Get the author's gravatar URL. + * + * This is a convenience method to get the author's gravatar URL. + * + * @return string + */ + public function get_author_avatar() { + return $this->author_data->get_avatar_url(); + } + + /** + * Get the author url of a feedback entry. + * + * @return string + */ + public function get_author_url() { + return $this->author_data->get_url(); + } + + /** + * Get the comment content of a feedback entry. + * + * @return string + */ + public function get_comment_content() { + return $this->comment_content; + } + + /** + * Get the IP address of the submitted feedback request. + * + * @return string|null + */ + public function get_ip_address() { + return $this->ip_address; + } + + /** + * Get the email subject. + * + * @return string + */ + public function get_subject() { + return $this->subject; + } + + /** + * Gets the value of the consent field. + * + * @return bool + */ + public function has_consent() { + return $this->has_consent; + } + + /** + * Gets the value of the consent field. + * + * @return bool + */ + public function has_file() { + return $this->has_file; + } + + /** + * Get the feedback status. For example 'publish', 'spam' or 'trash'. + * + * @return string + */ + public function get_status() { + return $this->status; + } + + /** + * Sets the status of the feedback. + * + * @param string $status The status to set for the feedback entry. + * @return void + */ + public function set_status( $status ) { + $this->status = $status; + } + + /** + * Get the entry ID of the post that the feedback was submitted from. + * + * This is the post ID of the post or page that the feedback was submitted from. + * + * @return int|null + */ + public function get_entry_id() { + return $this->entry->get_id(); + } + + /** + * Get the entry title of the post that the feedback was submitted from. + * + * This is the title of the post or page that the feedback was submitted from. + * + * @return string + */ + public function get_entry_title() { + return $this->entry->get_title(); + } + + /** + * Get the permalink of the post or page that the feedback was submitted from. + * This includes the page number if the feedback was submitted from a paginated form. + * + * @return string + */ + public function get_entry_permalink() { + return $this->entry->get_permalink(); + } + /** + * Get the short permalink of a post. + * + * @return string + */ + public function get_entry_short_permalink() { + return $this->entry->get_relative_permalink(); + } + /** + * Save the feedback entry to the database. + * + * @return int + */ + public function save() { + $post_id = wp_insert_post( + array( + 'post_type' => self::POST_TYPE, + 'post_status' => $this->status, + 'post_title' => $this->legacy_feedback_title, + 'post_date' => $this->feedback_time, + 'post_name' => $this->legacy_feedback_id, + 'post_content' => $this->serialize(), + 'post_mime_type' => 'v2', // a way to help us identify what version of the data this is. + 'post_parent' => $this->entry->get_id(), + ) + ); + + $feedback_post = get_post( $post_id ); + return $feedback_post ?? 0; + } + + /** + * Serialize the fields to JSON format. + * + * @return string + */ + public function serialize() { + + $fields_to_serialize = array_merge( + array( + 'subject' => $this->subject, + 'ip' => $this->ip_address, + ), + $this->entry->serialize() + ); + + $fields_to_serialize['fields'] = array(); + foreach ( $this->fields as $field ) { + $fields_to_serialize['fields'][] = $field->serialize(); + } + + // Check if the IP should be included. + if ( apply_filters( 'jetpack_contact_form_forget_ip_address', false, $this->ip_address ) ) { + $fields_to_serialize['ip'] = null; + } + + return wp_json_encode( $fields_to_serialize ); + } + + /** + * Helper function to parse the post content. + * + * @param string $post_content The post content to parse. + * @param string|null $version The version of the content format. + * @return array Parsed fields. + */ + private function parse_content( $post_content = '', $version = null ) { + if ( $version === 'v2' ) { + return $this->parse_content_v2( $post_content ); + } + return $this->parse_legacy_content( $post_content ); + } + + /** + * Parse the content in the v2 format. + * + * @param string $post_content The post content to parse. + * + * @return array Parsed fields. + */ + private function parse_content_v2( $post_content = '' ) { + $decoded_content = json_decode( $post_content, true ); + if ( $decoded_content === null ) { + // If JSON decoding fails, try to decode the second try with stripslashes and trim. + // This is a workaround for some cases where the JSON data is not properly formatted. + $decoded_content = json_decode( stripslashes( trim( $post_content ) ), true ); + } + + if ( $decoded_content === null ) { + return array(); + } + $fields = array(); + foreach ( $decoded_content['fields'] as $field ) { + $fields[ $field['key'] ] = Feedback_Field::from_serialized( $field ); + if ( ! $this->has_file && $fields[ $field['key'] ]->has_file() ) { + $this->has_file = true; + } + } + $decoded_content['fields'] = $fields; + return $decoded_content; + } + + /** + * Parse the legacy content format. + * + * @param string $post_content The post content to parse. + * + * @return array Parsed fields. + */ + private function parse_legacy_content( $post_content = '' ) { + $content_parts = $this->split_legacy_content( $post_content ); + $comment_content = $content_parts['comment_content']; + $field_content = $content_parts['field_content']; + + $all_values = $this->extract_legacy_values( $field_content ); + $lines = $this->extract_legacy_lines( $field_content ); + + $decoded_fields = array(); + $decoded_fields['fields'] = array(); + + // Process lines for specific field types + $this->process_legacy_lines( $lines, $decoded_fields ); + + // Process all other values + $this->process_legacy_values( $all_values, $decoded_fields ); + + // Add comment content field + $this->add_comment_content_field( $comment_content, $decoded_fields ); + + return $decoded_fields; + } + + /** + * Split legacy content into comment and field sections. + * + * @param string $post_content The post content to parse. + * @return array Array with 'comment_content' and 'field_content' keys. + */ + private function split_legacy_content( $post_content ) { + $content = explode( '', $post_content ); + $comment_content = ''; + $field_content = ''; + + if ( count( $content ) > 1 ) { + $comment_content = $content[0]; + $field_content = str_ireplace( array( '
', ')

' ), '', $content[1] ); + } + + return array( + 'comment_content' => $comment_content, + 'field_content' => $field_content, + ); + } + + /** + * Extract values from legacy field content. + * + * @param string $field_content The field content to parse. + * @return array Extracted values. + */ + private function extract_legacy_values( $field_content ) { + $all_values = array(); + + if ( str_contains( $field_content, 'JSON_DATA' ) ) { + $all_values = $this->parse_json_data( $field_content ); + } else { + $all_values = $this->parse_array_format( $field_content ); + } + + // Ensure all_values is always an array + if ( ! is_array( $all_values ) ) { + $all_values = array(); + } + + return $all_values; + } + + /** + * Extract lines from legacy field content. + * + * @param string $field_content The field content to parse. + * @return array Filtered lines. + */ + private function extract_legacy_lines( $field_content ) { + if ( str_contains( $field_content, 'JSON_DATA' ) ) { + $chunks = explode( "\nJSON_DATA", $field_content ); + return array_filter( explode( "\n", $chunks[0] ) ); + } else { + return array_filter( explode( "\n", $field_content ) ); + } + } + + /** + * Parse JSON data from field content. + * + * @param string $field_content The field content containing JSON data. + * @return array Parsed JSON data. + */ + private function parse_json_data( $field_content ) { + $chunks = explode( "\nJSON_DATA", $field_content ); + $json_data = $chunks[1]; + + $all_values = json_decode( $json_data, true ); + + if ( $all_values === null ) { + // Fallback for improperly formatted JSON + $all_values = json_decode( stripslashes( trim( $json_data ) ), true ); + } + + return $all_values === null ? array() : $all_values; + } + + /** + * Parse array format from field content. + * + * @param string $field_content The field content in array format. + * @return array Parsed array data. + */ + private function parse_array_format( $field_content ) { + $fields_array = preg_replace( '/.*Array\s\( (.*)\)/msx', '$1', $field_content ); + + // Parse key-value pairs formatted as [Key] => Value + preg_match_all( '/^\s*\[([^\]]+)\] =\>\; (.*)(?=^\s*(\[[^\]]+\] =\>\;)|\z)/msU', $fields_array, $matches ); + + if ( count( $matches ) > 1 ) { + return array_combine( array_map( 'trim', $matches[1] ), array_map( 'trim', $matches[2] ) ); + } + + return array(); + } + + /** + * Process legacy lines into field objects. + * + * We do this so that we can extract specific fields but we don't display the values in the UI. + * + * @param array $lines The lines to process. + * @param array &$decoded_fields Reference to the decoded fields array. + */ + private function process_legacy_lines( $lines, &$decoded_fields ) { + $var_map = array( + 'AUTHOR' => array( + 'type' => 'name', + 'label' => 'Author', + ), + 'AUTHOR EMAIL' => array( + 'type' => 'email', + 'label' => 'Email', + ), + 'AUTHOR URL' => array( + 'type' => 'url', + 'label' => 'Url', + ), + 'SUBJECT' => array( + 'type' => 'subject', + 'label' => 'Subject', + ), + 'IP' => array( + 'type' => 'ip', + 'label' => 'IP', + ), + ); + + foreach ( $lines as $line ) { + $line_parts = explode( ': ', $line, 2 ); + + if ( count( $line_parts ) !== 2 ) { + continue; + } + + list( $key, $value ) = $line_parts; + + if ( ! empty( $key ) && isset( $var_map[ $key ] ) ) { + $map_to_field = $var_map[ $key ]; + $value = Contact_Form_Plugin::strip_tags( trim( $value ) ); + + $decoded_fields['fields'][ $key ] = new Feedback_Field( + $key, + $map_to_field['label'], + $value, + $map_to_field['type'], + array( 'render' => false ) + ); + } + } + } + + /** + * Check if the field is a legacy file upload. + * + * @param array $field The field to check. + * + * @return bool True if it's a legacy file upload, false otherwise. + */ + private function is_legacy_file_upload( $field ) { + return ( + is_array( $field ) && + ! empty( $field['field_id'] ) && + isset( $field['files'] ) && + is_array( $field['files'] ) + ); + } + + /** + * Process legacy values into field objects. + * + * @param array $all_values The values to process. + * @param array &$decoded_fields Reference to the decoded fields array. + */ + private function process_legacy_values( $all_values, &$decoded_fields ) { + $non_user_fields = array( + 'email_marketing_consent', + 'entry_title', + 'entry_permalink', + 'entry_page', + 'feedback_id', + ); + + foreach ( $all_values as $key => $value ) { + $key = wp_strip_all_tags( $key ); + $label = self::extract_label_from_key( $key ); + + if ( in_array( $key, $non_user_fields, true ) ) { + $decoded_fields[ $key ] = $value; + continue; + } + + // check for file upload data and then set it as a file type field. + if ( $this->is_legacy_file_upload( $value ) ) { + // If the value is a file upload, we need to handle it differently. + $decoded_fields['fields'][ $key ] = new Feedback_Field( + $key, + $label, + $value, + 'file' + ); + $this->has_file = ! empty( $value['files'] ); // Set has_file to true if any file upload is found. + } else { + $decoded_fields['fields'][ $key ] = new Feedback_Field( $key, $label, $value ); + } + } + } + + /** + * Add comment content as a field. + * + * @param string $comment_content The comment content. + * @param array &$decoded_fields Reference to the decoded fields array. + */ + private function add_comment_content_field( $comment_content, &$decoded_fields ) { + $decoded_fields['fields']['comment_content'] = new Feedback_Field( + 'comment_content', + 'Comment Content', + trim( Contact_Form_Plugin::strip_tags( $comment_content ) ), + 'textarea', + array( 'render' => false ) + ); + } + + /** + * Extract the label from a key that might be in the format "1_label". + * + * @param string $key The key to extract the label from. + * @return string The extracted label. + */ + private static function extract_label_from_key( $key ) { + // Check if the key starts with a number followed by underscore + if ( preg_match( '/^\d+_(.+)$/', $key, $matches ) ) { + return $matches[1]; + } + // If no number prefix, return the key as is + return $key; + } + + /** + * Get all the fields of the response, computed from the post data. + * + * @param array $post_data The post data from the form submission. + * @param Contact_Form $form The form object. + * @return array An array of Feedback_Field objects. + */ + private function get_computed_fields( $post_data, $form ) { + + $fields = array(); + + $field_ids = $form->get_field_ids(); + // For all fields, grab label and value + $i = 1; + foreach ( $field_ids['all'] as $field_id ) { + $field = $form->fields[ $field_id ]; + $type = $field->get_attribute( 'type' ); + if ( ! $field->is_field_renderable( $type ) ) { + continue; + } + + $value = $this->get_field_value( $field_id, $post_data ); + $label = wp_strip_all_tags( $field->get_attribute( 'label' ) ); + $key = $i . '_' . $label; + + $fields[ $key ] = new Feedback_Field( $key, $label, $value, $type ); + if ( ! $this->has_file && $fields[ $key ]->has_file() ) { + $this->has_file = true; + } + ++$i; // Increment prefix counter for the next field. + } + + return $fields; + } + + /** + * Gets the computed subject. + * + * @param array $post_data The post data from the form submission. + * @param Contact_Form $form The form object. + * @return string + */ + private function get_computed_subject( $post_data, $form ) { + + $contact_form_subject = $form->get_attribute( 'subject' ); + $field_ids = $form->get_field_ids(); + + if ( isset( $field_ids['subject'] ) ) { + $value = $this->get_field_value( $field_ids['subject'], $post_data ); + if ( ! empty( $value ) ) { + $contact_form_subject = $value; + } + } + + return apply_filters( 'contact_form_subject', $contact_form_subject, $this->get_all_values() ); + } + + /** + * Gets the computed comment content. + * + * @param array $post_data The post data from the form submission. + * @param Contact_Form $form The form object. + * @return string + */ + private function get_computed_comment_content( $post_data, $form ) { + $field_ids = $form->get_field_ids(); + if ( isset( $field_ids['textarea'] ) ) { + $value = $this->get_field_value( $field_ids['textarea'], $post_data ); + if ( is_string( $value ) ) { + return trim( Contact_Form_Plugin::strip_tags( stripslashes( $value ) ) ); + } + } + return ''; + } + + /** + * Gets the computed consent. + * + * @param array $post_data The post data from the form submission. + * @param Contact_Form $form The form object. + * @return bool + */ + private function get_computed_consent( $post_data, $form ) { + $field_ids = $form->get_field_ids(); + + if ( isset( $field_ids['email_marketing_consent_field'] ) && $field_ids['email_marketing_consent_field'] !== null ) { + return (bool) $this->get_field_value( $field_ids['email_marketing_consent_field'], $post_data ); + } + + return false; + } +} diff --git a/projects/packages/forms/tests/php/contact-form/Contact_Form_Plugin_Test.php b/projects/packages/forms/tests/php/contact-form/Contact_Form_Plugin_Test.php index 20fc6e9a4f610..ecd53b09afee2 100644 --- a/projects/packages/forms/tests/php/contact-form/Contact_Form_Plugin_Test.php +++ b/projects/packages/forms/tests/php/contact-form/Contact_Form_Plugin_Test.php @@ -594,4 +594,199 @@ private function teardown_post_for_test( $previous_post ) { public function return_error_for_test() { return new WP_Error( 'check_spam', 'check_spam form submission.' ); } + + public function test_export_csv_legacy_data() { + + $current_post = Utility::create_post_context(); + $post_ids = array(); + $post_ids[] = Utility::create_legacy_feedback( + array( + '1_field_A' => 'value1', + '2_field_B' => 'value2', + ) + ); + $post_ids[] = Utility::create_legacy_feedback( + array( + '1_field_A' => 'value1', + '2_field_C' => 'value2', + ) + ); + $current_time = current_time( 'mysql' ); + $default_consent = 'No'; + $ip = 'https://127.0.0.1'; + + $this->assertEquals( + array( + 'ID' => array( $post_ids[0], $post_ids[1] ), + 'Date' => array( $current_time, $current_time ), + 'Title' => array( $current_post->post_title, $current_post->post_title ), + 'field_A' => array( 'value1', 'value1' ), + 'field_B' => array( 'value2', '' ), + 'field_C' => array( '', 'value2' ), + 'Source' => array( '/?p=' . $current_post->ID, '/?p=' . $current_post->ID ), + 'Consent' => array( $default_consent, $default_consent ), + 'IP Address' => array( $ip, $ip ), + + ), + Contact_Form_Plugin::get_export_feedback_data( $post_ids ) + ); + Utility::destroy_post_context( $current_post ); + } + + /** + * ====================================================== + * Tests for the strip_tags method in Feedback class. + * ====================================================== + * + * Test that strip_tags handles simple string without HTML tags. + */ + public function test_strip_tags_with_plain_string() { + $input = 'Hello, this is a plain text string.'; + $expected = 'Hello, this is a plain text string.'; + $result = Contact_Form_Plugin::strip_tags( $input ); + $this->assertEquals( $expected, $result ); + } + + /** + * Test that strip_tags removes script tags but keeps the content. + */ + public function test_strip_tags_removes_script_tags() { + $input = 'Hello world!'; + $expected = 'alert("XSS")Hello world!'; + $result = Contact_Form_Plugin::strip_tags( $input ); + $this->assertEquals( $expected, $result ); + } + + /** + * Test that strip_tags handles HTML entities correctly. + */ + public function test_strip_tags_handles_html_entities() { + $input = 'Hello & goodbye'; + $expected = 'Hello & goodbye'; + $result = Contact_Form_Plugin::strip_tags( $input ); + $this->assertEquals( $expected, $result ); + } + + /** + * Test that strip_tags handles arrays recursively. + */ + public function test_strip_tags_handles_arrays() { + $input = array( + 'field1' => 'Hello world', + 'field2' => array( + 'nested' => 'Test bold text', + 'deep' => array( + 'deeper' => 'More & testing', + ), + ), + ); + + $expected = array( + 'field1' => 'Hello alert("XSS")world', + 'field2' => array( + 'nested' => 'Test bold text', + 'deep' => array( + 'deeper' => 'More & testing', + ), + ), + ); + + $result = Contact_Form_Plugin::strip_tags( $input ); + $this->assertEquals( $expected, $result ); + } + + /** + * Test that strip_tags sanitizes array keys. + */ + public function test_strip_tags_sanitizes_array_keys() { + $input = array( + '' => 'value1', + 'normal_key' => 'value2', + ); + + $result = Contact_Form_Plugin::strip_tags( $input ); + + // Check that the results exist and are correct + // We need to check what key actually gets created after sanitization + $keys = array_keys( $result ); + $this->assertCount( 2, $keys ); + $this->assertEquals( 'value2', $result['normal_key'] ); + + // Find the sanitized key (should be the one that's not 'normal_key') + $sanitized_key = null; + foreach ( $keys as $key ) { + if ( $key !== 'normal_key' ) { + $sanitized_key = $key; + break; + } + } + $this->assertEquals( 'value1', $result[ $sanitized_key ] ); + } + + /** + * Test that strip_tags handles empty values. + */ + public function test_strip_tags_handles_empty_values() { + $this->assertSame( '', Contact_Form_Plugin::strip_tags( '' ) ); + $this->assertEquals( array(), Contact_Form_Plugin::strip_tags( array() ) ); + $this->assertSame( '0', Contact_Form_Plugin::strip_tags( 0 ) ); + $this->assertSame( '', Contact_Form_Plugin::strip_tags( null ) ); + } + + /** + * Test that strip_tags handles numeric values. + */ + public function test_strip_tags_handles_numeric_values() { + $this->assertSame( '123', Contact_Form_Plugin::strip_tags( 123 ) ); + $this->assertSame( '123.45', Contact_Form_Plugin::strip_tags( 123.45 ) ); + } + + /** + * Test that strip_tags preserves allowed HTML tags as per wp_kses_post. + */ + public function test_strip_tags_preserves_allowed_html() { + $input = '

This is a test with emphasis and links.

'; + $result = Contact_Form_Plugin::strip_tags( $input ); + + // wp_kses_post should preserve these tags - let's just verify we get a non-empty string with HTML + $this->assertNotEquals( '', $result ); + $this->assertStringContainsString( 'This is a', $result ); + $this->assertStringContainsString( 'test', $result ); + } + + /** + * Test that strip_tags removes dangerous HTML tags. + */ + public function test_strip_tags_removes_dangerous_html() { + $input = 'Hello world'; + $result = Contact_Form_Plugin::strip_tags( $input ); + + // These dangerous tags should be removed, but content might remain + $this->assertStringNotContainsString( '