dSanitizedInput.InputNotSanitized ) ); } $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; } /** * Download exported data as CSV */ public function download_feedback_as_csv() { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verification is done on get_feedback_entries_from_post function $post_data = wp_unslash( $_POST ); $data = $this->get_feedback_entries_from_post(); if ( empty( $data ) ) { return; } // Check if we want to download all the feedbacks or just a certain contact form if ( ! empty( $post_data['post'] ) && $post_data['post'] !== 'all' ) { $filename = sprintf( '%s - %s.csv', Admin::init()->get_export_filename( get_the_title( (int) $post_data['post'] ) ), gmdate( 'Y-m-d H:i' ) ); } else { $filename = sprintf( '%s - %s.csv', Admin::init()->get_export_filename(), gmdate( 'Y-m-d H:i' ) ); } /** * Extract field names from `$data` for later use. */ $fields = array_keys( $data ); /** * Count how many rows will be exported. */ $row_count = count( reset( $data ) ); // Forces the download of the CSV instead of echoing header( 'Content-Disposition: attachment; filename=' . $filename ); header( 'Pragma: no-cache' ); header( 'Expires: 0' ); header( 'Content-Type: text/csv; charset=utf-8' ); $output = fopen( 'php://output', 'w' ); /** * Print CSV headers */ fputcsv( $output, $fields ); /** * Print rows to the output. */ for ( $i = 0; $i < $row_count; $i++ ) { $current_row = array(); /** * Put all the fields in `$current_row` array. */ foreach ( $fields as $single_field_name ) { $current_row[] = $this->esc_csv( $data[ $single_field_name ][ $i ] ); } /** * Output the complete CSV row */ fputcsv( $output, $current_row ); } fclose( $output ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose $this->record_tracks_event( 'forms_export_responses', array( 'format' => 'csv' ) ); exit(); } /** * Create a new post with a Form block */ public function create_new_form() { $post_id = wp_insert_post( array( 'post_title' => esc_html__( 'Jetpack Forms', 'jetpack-forms' ), 'post_content' => '
', ) ); if ( ! is_wp_error( $post_id ) ) { $array_result = array( 'post_url' => admin_url( 'post.php?post=' . intval( $post_id ) . '&action=edit' ), ); wp_send_json( $array_result ); } wp_die(); } /** * Send an event to Tracks * * @param string $event_name - the name of the event. * @param array $event_props - event properties to send. * * @return null|void */ public function record_tracks_event( $event_name, $event_props ) { /* * Event details. */ $event_user = wp_get_current_user(); /* * Record event. * We use different libs on wpcom and Jetpack. */ if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) { $event_name = 'wpcom_' . $event_name; $event_props['blog_id'] = get_current_blog_id(); // logged out visitor, record event with blog owner. if ( empty( $event_user->ID ) ) { $event_user_id = wpcom_get_blog_owner( $event_props['blog_id'] ); $event_user = get_userdata( $event_user_id ); } require_lib( 'tracks/client' ); tracks_record_event( $event_user, $event_name, $event_props ); } else { $user_connected = ( new \Automattic\Jetpack\Connection\Manager( 'jetpack-forms' ) )->is_user_connected( get_current_user_id() ); if ( ! $user_connected ) { return; } // logged out visitor, record event with Jetpack master user. if ( empty( $event_user->ID ) ) { $master_user_id = Jetpack_Options::get_option( 'master_user' ); if ( ! empty( $master_user_id ) ) { $event_user = get_userdata( $master_user_id ); } } $tracking = new \Automattic\Jetpack\Tracking(); $tracking->record_user_event( $event_name, $event_props, $event_user ); } } /** * Escape a string to be used in a CSV context * * Malicious input can inject formulas into CSV files, opening up the possibility for phishing attacks and * disclosure of sensitive information. * * Additionally, Excel exposes the ability to launch arbitrary commands through the DDE protocol. * * @see https://www.contextis.com/en/blog/comma-separated-vulnerabilities * * @param string $field - the CSV field. * * @return string */ public function esc_csv( $field ) { $active_content_triggers = array( '=', '+', '-', '@' ); if ( in_array( mb_substr( $field, 0, 1 ), $active_content_triggers, true ) ) { $field = "'" . $field; } return $field; } /** * Returns an array of parent post IDs for the user. * The parent posts are those posts where forms have been published. * * @param array $query_args A WP_Query compatible array of query args. * * @return array The array of post IDs */ public static function get_all_parent_post_ids( $query_args = array() ) { $default_query_args = array( 'fields' => 'id=>parent', 'posts_per_page' => 100000, // phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page 'post_type' => 'feedback', 'post_status' => 'publish', 'suppress_filters' => false, ); $args = array_merge( $default_query_args, $query_args ); // Get the feedbacks' parents' post IDs $feedbacks = get_posts( $args ); return array_values( array_unique( array_values( $feedbacks ) ) ); } /** * Returns a string of HTML ', esc_attr( $parent_id ), $selected_id === $parent_id ? 'selected' : '', esc_html( basename( $parsed_url['path'] ) ) ); } return $options; } /** * Get the names of all the form's fields * * @param array|int $posts the post we want the fields of. * * @return array the array of fields * * @deprecated As this is no longer necessary as of the CSV export rewrite. - 2015-12-29 */ protected function get_field_names( $posts ) { $posts = (array) $posts; $all_fields = array(); foreach ( $posts as $post ) { $fields = self::parse_fields_from_content( $post ); if ( isset( $fields['_feedback_all_fields'] ) ) { $extra_fields = array_keys( $fields['_feedback_all_fields'] ); $all_fields = array_merge( $all_fields, $extra_fields ); } } $all_fields = array_unique( $all_fields ); return $all_fields; } /** * Returns if the feedback post has JSON data * * @param int $post_id The feedback post ID to check. * @return bool */ public function has_json_data( $post_id ) { $post_content = get_post_field( 'post_content', $post_id ); $content = explode( "\nJSON_DATA", $post_content ); if ( empty( $content[1] ) ) { return false; } $json_data = json_decode( $content[1], true ); return is_array( $json_data ) && ! empty( $json_data ); } /** * Parse the contact form fields. * * @param int $post_id - the post ID. * @return array Fields. */ public static function parse_fields_from_content( $post_id ) { static $post_fields; if ( ! is_array( $post_fields ) ) { $post_fields = array(); } if ( isset( $post_fields[ $post_id ] ) ) { return $post_fields[ $post_id ]; } $all_values = array(); $post_content = get_post_field( 'post_content', $post_id ); $content = explode( '', $post_content ); $lines = array(); if ( count( $content ) > 1 ) { $content = str_ireplace( array( '
', ')

' ), '', $content[1] ); if ( str_contains( $content, 'JSON_DATA' ) ) { $chunks = explode( "\nJSON_DATA", $content ); $all_values = json_decode( $chunks[1], true ); if ( is_array( $all_values ) ) { $fields_array = array_keys( $all_values ); } $lines = array_filter( explode( "\n", $chunks[0] ) ); } else { $fields_array = preg_replace( '/.*Array\s\( (.*)\)/msx', '$1', $content ); // TODO: some explanation on this regex could help preg_match_all( '/^\s*\[([^\]]+)\] =\>\; (.*)(?=^\s*(\[[^\]]+\] =\>\;)|\z)/msU', $fields_array, $matches ); if ( count( $matches ) > 1 ) { $all_values = array_combine( array_map( 'trim', $matches[1] ), array_map( 'trim', $matches[2] ) ); } $lines = array_filter( explode( "\n", $content ) ); } } $var_map = array( 'AUTHOR' => '_feedback_author', 'AUTHOR EMAIL' => '_feedback_author_email', 'AUTHOR URL' => '_feedback_author_url', 'SUBJECT' => '_feedback_subject', 'IP' => '_feedback_ip', ); $fields = array(); foreach ( $lines as $line ) { $vars = explode( ': ', $line, 2 ); if ( ! empty( $vars ) ) { if ( isset( $var_map[ $vars[0] ] ) ) { $fields[ $var_map[ $vars[0] ] ] = self::strip_tags( trim( $vars[1] ) ); } } } $fields['_feedback_all_fields'] = $all_values; $post_fields[ $post_id ] = $fields; return $fields; } /** * Creates a valid csv row from a post id * * @param int $post_id The id of the post. * @param array $fields An array containing the names of all the fields of the csv. * * @return String The csv row * * @deprecated This is no longer needed, as of the CSV export rewrite. */ protected static function make_csv_row_from_feedback( $post_id, $fields ) { $content_fields = self::parse_fields_from_content( $post_id ); $all_fields = array(); if ( isset( $content_fields['_feedback_all_fields'] ) ) { $all_fields = $content_fields['_feedback_all_fields']; } // Overwrite the parsed content with the content we stored in post_meta in a better format. $extra_fields = get_post_meta( $post_id, '_feedback_extra_fields', true ); foreach ( $extra_fields as $extra_field => $extra_value ) { $all_fields[ $extra_field ] = $extra_value; } // The first element in all of the exports will be the subject $row_items = array(); $row_items[] = $content_fields['_feedback_subject']; // Loop the fields array in order to fill the $row_items array correctly foreach ( $fields as $field ) { if ( $field === __( 'Contact Form', 'jetpack-forms' ) ) { // the first field will ever be the contact form, so we can continue continue; } elseif ( array_key_exists( $field, $all_fields ) ) { $row_items[] = $all_fields[ $field ]; } else { $row_items[] = ''; } } return $row_items; } /** * Get the IP address. * * @return string|null IP address. */ public static function get_ip_address() { return isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : null; } /** * Disable Block Editor for feedbacks. * * @param bool $can_edit Whether the post type can be edited or not. * @param string $post_type The post type being checked. * @return bool */ public function use_block_editor_for_post_type( $can_edit, $post_type ) { return 'feedback' === $post_type ? false : $can_edit; } /** * Kludge method: reverses the output of a standard print_r( $array ). * Sort of what unserialize does to a serialized object. * This is here while we work on a better data storage inside the posts. See: * - p1675781140892129-slack-C01CSBEN0QZ * - https://www.php.net/manual/en/function.print-r.php#93529 * * @param string $print_r_output The array string to be reverted. Needs to being with 'Array'. * @param bool $parse_html Whether to run html_entity_decode on each line. * As strings are stored right now, they are all escaped, so '=>' are '>'. * @return array|string Array when succesfully reconstructed, string otherwise. Output will always be esc_html'd. */ public static function reverse_that_print( $print_r_output, $parse_html = false ) { $lines = explode( "\n", trim( $print_r_output ) ); if ( $parse_html ) { $lines = array_map( 'html_entity_decode', $lines ); } if ( trim( $lines[0] ) !== 'Array' ) { // bottomed out to something that isn't an array, escape it and be done return esc_html( $print_r_output ); } else { // this is an array, lets parse it if ( preg_match( '/(\s{5,})\(/', $lines[1], $match ) ) { // this is a tested array/recursive call to this function // take a set of spaces off the beginning $spaces = $match[1]; $spaces_length = strlen( $spaces ); $lines_total = count( $lines ); for ( $i = 0; $i < $lines_total; $i++ ) { if ( substr( $lines[ $i ], 0, $spaces_length ) === $spaces ) { $lines[ $i ] = substr( $lines[ $i ], $spaces_length ); } } } array_shift( $lines ); // Array array_shift( $lines ); // ( array_pop( $lines ); // ) $print_r_output = implode( "\n", $lines ); // make sure we only match stuff with 4 preceding spaces (stuff for this array and not a nested one preg_match_all( '/^\s{4}\[(.+?)\] \=\> /m', $print_r_output, $matches, PREG_OFFSET_CAPTURE | PREG_SET_ORDER ); $pos = array(); $previous_key = ''; $in_length = strlen( $print_r_output ); // store the following in $pos: // array with key = key of the parsed array's item // value = array(start position in $print_r_output, $end position in $print_r_output) foreach ( $matches as $match ) { $key = $match[1][0]; $start = $match[0][1] + strlen( $match[0][0] ); $pos[ $key ] = array( $start, $in_length ); if ( $previous_key !== '' ) { $pos[ $previous_key ][1] = $match[0][1] - 1; } $previous_key = $key; } $ret = array(); foreach ( $pos as $key => $where ) { // recursively see if the parsed out value is an array too $ret[ $key ] = self::reverse_that_print( substr( $print_r_output, $where[0], $where[1] - $where[0] ), $parse_html ); } return $ret; } } /** * Method untrash_feedback_status_handler * wp_untrash_post filter handler. * * @param string $current_status The status to be set. * @param int $post_id The post ID. * @param string $previous_status The previous status. */ public function untrash_feedback_status_handler( $current_status, $post_id, $previous_status ) { $post = get_post( $post_id ); if ( 'feedback' === $post->post_type ) { if ( in_array( $previous_status, array( 'spam', 'publish' ), true ) ) { return $previous_status; } return 'publish'; } return $current_status; } }