From 9b41815229b09ec0fa452bf632afc0b89bcfcfaa Mon Sep 17 00:00:00 2001 From: Barry Hughes <3594411+barryhughes@users.noreply.github.com> Date: Fri, 13 Sep 2024 11:39:37 -0700 Subject: [PATCH] Produce CSV Import enhancements (cherry-pick PR#51344) (#51348) * Move AJAX callback to produt importer controller * Add better validation for file paths in CSV product import * Add utility function to create a no-index directory * Add CSV upload helper * Simplify product CSV import logic using helper * Update tests * Add changelog * Changelog. * Remove changelog file; entry added directly to root changelog.txt. --------- Co-authored-by: Jorge Torres --- changelog.txt | 1 + .../admin/class-wc-admin-importers.php | 106 +------ ...ass-wc-product-csv-importer-controller.php | 269 +++++++++++++----- plugins/woocommerce/src/Container.php | 2 + .../Admin/ImportExport/CSVUploadHelper.php | 175 ++++++++++++ .../ImportExportServiceProvider.php | 35 +++ .../src/Internal/Utilities/FilesystemUtil.php | 30 ++ .../tests/merchant/product-import-csv.spec.js | 3 +- 8 files changed, 448 insertions(+), 173 deletions(-) create mode 100644 plugins/woocommerce/src/Internal/Admin/ImportExport/CSVUploadHelper.php create mode 100644 plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/ImportExportServiceProvider.php diff --git a/changelog.txt b/changelog.txt index 19f15145abc..2ba165e2bdc 100644 --- a/changelog.txt +++ b/changelog.txt @@ -145,6 +145,7 @@ * Update - Update core profiler continue button container on extension screen [#50582](https://github.com/woocommerce/woocommerce/pull/50582) * Update - Update Store Alert actions to have unique keys. [#50424](https://github.com/woocommerce/woocommerce/pull/50424) * Update - Update WooCommercePayments task is_supported to use default suggestions [#50585](https://github.com/woocommerce/woocommerce/pull/50585) +* Update - Enhance CSV path and upload handling in product import [#51344](https://github.com/woocommerce/woocommerce/pull/51344) * Dev - Execute test env setup on host instead of wp-env container [#51021](https://github.com/woocommerce/woocommerce/pull/51021) * Dev - Added code docs with examples to the Analytics classes [#49425](https://github.com/woocommerce/woocommerce/pull/49425) * Dev - Add lost password e2e tests [#50611](https://github.com/woocommerce/woocommerce/pull/50611) diff --git a/plugins/woocommerce/includes/admin/class-wc-admin-importers.php b/plugins/woocommerce/includes/admin/class-wc-admin-importers.php index 6dfec075b89..3c16a0b07d9 100644 --- a/plugins/woocommerce/includes/admin/class-wc-admin-importers.php +++ b/plugins/woocommerce/includes/admin/class-wc-admin-importers.php @@ -215,114 +215,12 @@ class WC_Admin_Importers { * Ajax callback for importing one batch of products from a CSV. */ public function do_ajax_product_import() { - global $wpdb; - - check_ajax_referer( 'wc-product-import', 'security' ); - - if ( ! $this->import_allowed() || ! isset( $_POST['file'] ) ) { // PHPCS: input var ok. + if ( ! $this->import_allowed() ) { wp_send_json_error( array( 'message' => __( 'Insufficient privileges to import products.', 'woocommerce' ) ) ); } include_once WC_ABSPATH . 'includes/admin/importers/class-wc-product-csv-importer-controller.php'; - include_once WC_ABSPATH . 'includes/import/class-wc-product-csv-importer.php'; - - $file = wc_clean( wp_unslash( $_POST['file'] ) ); // PHPCS: input var ok. - $params = array( - 'delimiter' => ! empty( $_POST['delimiter'] ) ? wc_clean( wp_unslash( $_POST['delimiter'] ) ) : ',', // PHPCS: input var ok. - 'start_pos' => isset( $_POST['position'] ) ? absint( $_POST['position'] ) : 0, // PHPCS: input var ok. - 'mapping' => isset( $_POST['mapping'] ) ? (array) wc_clean( wp_unslash( $_POST['mapping'] ) ) : array(), // PHPCS: input var ok. - 'update_existing' => isset( $_POST['update_existing'] ) ? (bool) $_POST['update_existing'] : false, // PHPCS: input var ok. - 'character_encoding' => isset( $_POST['character_encoding'] ) ? wc_clean( wp_unslash( $_POST['character_encoding'] ) ) : '', - - /** - * Batch size for the product import process. - * - * @param int $size Batch size. - * - * @since 3.1.0 - */ - 'lines' => apply_filters( 'woocommerce_product_import_batch_size', 30 ), - 'parse' => true, - ); - - // Log failures. - if ( 0 !== $params['start_pos'] ) { - $error_log = array_filter( (array) get_user_option( 'product_import_error_log' ) ); - } else { - $error_log = array(); - } - - $importer = WC_Product_CSV_Importer_Controller::get_importer( $file, $params ); - $results = $importer->import(); - $percent_complete = $importer->get_percent_complete(); - $error_log = array_merge( $error_log, $results['failed'], $results['skipped'] ); - - update_user_option( get_current_user_id(), 'product_import_error_log', $error_log ); - - if ( 100 === $percent_complete ) { - // @codingStandardsIgnoreStart. - $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_original_id' ) ); - $wpdb->delete( $wpdb->posts, array( - 'post_type' => 'product', - 'post_status' => 'importing', - ) ); - $wpdb->delete( $wpdb->posts, array( - 'post_type' => 'product_variation', - 'post_status' => 'importing', - ) ); - // @codingStandardsIgnoreEnd. - - // Clean up orphaned data. - $wpdb->query( - " - DELETE {$wpdb->posts}.* FROM {$wpdb->posts} - LEFT JOIN {$wpdb->posts} wp ON wp.ID = {$wpdb->posts}.post_parent - WHERE wp.ID IS NULL AND {$wpdb->posts}.post_type = 'product_variation' - " - ); - $wpdb->query( - " - DELETE {$wpdb->postmeta}.* FROM {$wpdb->postmeta} - LEFT JOIN {$wpdb->posts} wp ON wp.ID = {$wpdb->postmeta}.post_id - WHERE wp.ID IS NULL - " - ); - // @codingStandardsIgnoreStart. - $wpdb->query( " - DELETE tr.* FROM {$wpdb->term_relationships} tr - LEFT JOIN {$wpdb->posts} wp ON wp.ID = tr.object_id - LEFT JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id - WHERE wp.ID IS NULL - AND tt.taxonomy IN ( '" . implode( "','", array_map( 'esc_sql', get_object_taxonomies( 'product' ) ) ) . "' ) - " ); - // @codingStandardsIgnoreEnd. - - // Send success. - wp_send_json_success( - array( - 'position' => 'done', - 'percentage' => 100, - 'url' => add_query_arg( array( '_wpnonce' => wp_create_nonce( 'woocommerce-csv-importer' ) ), admin_url( 'edit.php?post_type=product&page=product_importer&step=done' ) ), - 'imported' => is_countable( $results['imported'] ) ? count( $results['imported'] ) : 0, - 'imported_variations' => is_countable( $results['imported_variations'] ) ? count( $results['imported_variations'] ) : 0, - 'failed' => is_countable( $results['failed'] ) ? count( $results['failed'] ) : 0, - 'updated' => is_countable( $results['updated'] ) ? count( $results['updated'] ) : 0, - 'skipped' => is_countable( $results['skipped'] ) ? count( $results['skipped'] ) : 0, - ) - ); - } else { - wp_send_json_success( - array( - 'position' => $importer->get_file_position(), - 'percentage' => $percent_complete, - 'imported' => is_countable( $results['imported'] ) ? count( $results['imported'] ) : 0, - 'imported_variations' => is_countable( $results['imported_variations'] ) ? count( $results['imported_variations'] ) : 0, - 'failed' => is_countable( $results['failed'] ) ? count( $results['failed'] ) : 0, - 'updated' => is_countable( $results['updated'] ) ? count( $results['updated'] ) : 0, - 'skipped' => is_countable( $results['skipped'] ) ? count( $results['skipped'] ) : 0, - ) - ); - } + WC_Product_CSV_Importer_Controller::dispatch_ajax(); } /** diff --git a/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php b/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php index e5fac1cecb9..cadf53a7bc5 100644 --- a/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php +++ b/plugins/woocommerce/includes/admin/importers/class-wc-product-csv-importer-controller.php @@ -103,6 +103,56 @@ class WC_Product_CSV_Importer_Controller { return wc_is_file_valid_csv( $file, $check_path ); } + /** + * Runs before controller actions to check that the file used during the import is valid. + * + * @since 9.3.0 + * + * @param string $path Path to test. + * + * @throws \Exception When file validation fails. + */ + protected static function check_file_path( string $path ): void { + $is_valid_file = false; + + if ( ! empty( $path ) ) { + $path = realpath( $path ); + $is_valid_file = false !== $path; + } + + // File must be readable. + $is_valid_file = $is_valid_file && is_readable( $path ); + + // Check that file is within an allowed location. + if ( $is_valid_file ) { + $in_valid_location = false; + $valid_locations = array(); + $valid_locations[] = ABSPATH; + + $upload_dir = wp_get_upload_dir(); + if ( false === $upload_dir['error'] ) { + $valid_locations[] = $upload_dir['basedir']; + } + + foreach ( $valid_locations as $valid_location ) { + if ( 0 === stripos( $path, trailingslashit( realpath( $valid_location ) ) ) ) { + $in_valid_location = true; + break; + } + } + + $is_valid_file = $in_valid_location; + } + + if ( ! $is_valid_file ) { + throw new \Exception( esc_html__( 'File path provided for import is invalid.', 'woocommerce' ) ); + } + + if ( ! self::is_file_valid_csv( $path ) ) { + throw new \Exception( esc_html__( 'Invalid file type. The importer supports CSV and TXT file formats.', 'woocommerce' ) ); + } + } + /** * Get all the valid filetypes for a CSV file. * @@ -263,17 +313,151 @@ class WC_Product_CSV_Importer_Controller { * Dispatch current step and show correct view. */ public function dispatch() { - // phpcs:ignore WordPress.Security.NonceVerification.Missing - if ( ! empty( $_POST['save_step'] ) && ! empty( $this->steps[ $this->step ]['handler'] ) ) { - call_user_func( $this->steps[ $this->step ]['handler'], $this ); + $output = ''; + + try { + // phpcs:ignore WordPress.Security.NonceVerification.Missing + if ( ! empty( $_POST['save_step'] ) && ! empty( $this->steps[ $this->step ]['handler'] ) ) { + if ( is_callable( $this->steps[ $this->step ]['handler'] ) ) { + call_user_func( $this->steps[ $this->step ]['handler'], $this ); + } + } + + ob_start(); + + if ( is_callable( $this->steps[ $this->step ]['view'] ) ) { + call_user_func( $this->steps[ $this->step ]['view'], $this ); + } + + $output = ob_get_clean(); + } catch ( \Exception $e ) { + $this->add_error( $e->getMessage() ); } + $this->output_header(); $this->output_steps(); $this->output_errors(); - call_user_func( $this->steps[ $this->step ]['view'], $this ); + echo $output; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- output is HTML we've generated ourselves. $this->output_footer(); } + /** + * Processes AJAX requests related to a product CSV import. + * + * @since 9.3.0 + */ + public static function dispatch_ajax() { + global $wpdb; + + check_ajax_referer( 'wc-product-import', 'security' ); + + try { + $file = wc_clean( wp_unslash( $_POST['file'] ?? '' ) ); // PHPCS: input var ok. + self::check_file_path( $file ); + + $params = array( + 'delimiter' => ! empty( $_POST['delimiter'] ) ? wc_clean( wp_unslash( $_POST['delimiter'] ) ) : ',', // PHPCS: input var ok. + 'start_pos' => isset( $_POST['position'] ) ? absint( $_POST['position'] ) : 0, // PHPCS: input var ok. + 'mapping' => isset( $_POST['mapping'] ) ? (array) wc_clean( wp_unslash( $_POST['mapping'] ) ) : array(), // PHPCS: input var ok. + 'update_existing' => isset( $_POST['update_existing'] ) ? (bool) $_POST['update_existing'] : false, // PHPCS: input var ok. + 'character_encoding' => isset( $_POST['character_encoding'] ) ? wc_clean( wp_unslash( $_POST['character_encoding'] ) ) : '', + + /** + * Batch size for the product import process. + * + * @param int $size Batch size. + * + * @since 3.1.0 + */ + 'lines' => apply_filters( 'woocommerce_product_import_batch_size', 1 ), + 'parse' => true, + ); + + // Log failures. + if ( 0 !== $params['start_pos'] ) { + $error_log = array_filter( (array) get_user_option( 'product_import_error_log' ) ); + } else { + $error_log = array(); + } + + include_once WC_ABSPATH . 'includes/import/class-wc-product-csv-importer.php'; + + $importer = self::get_importer( $file, $params ); + $results = $importer->import(); + $percent_complete = $importer->get_percent_complete(); + $error_log = array_merge( $error_log, $results['failed'], $results['skipped'] ); + + update_user_option( get_current_user_id(), 'product_import_error_log', $error_log ); + + if ( 100 === $percent_complete ) { + // @codingStandardsIgnoreStart. + $wpdb->delete( $wpdb->postmeta, array( 'meta_key' => '_original_id' ) ); + $wpdb->delete( $wpdb->posts, array( + 'post_type' => 'product', + 'post_status' => 'importing', + ) ); + $wpdb->delete( $wpdb->posts, array( + 'post_type' => 'product_variation', + 'post_status' => 'importing', + ) ); + // @codingStandardsIgnoreEnd. + + // Clean up orphaned data. + $wpdb->query( + " + DELETE {$wpdb->posts}.* FROM {$wpdb->posts} + LEFT JOIN {$wpdb->posts} wp ON wp.ID = {$wpdb->posts}.post_parent + WHERE wp.ID IS NULL AND {$wpdb->posts}.post_type = 'product_variation' + " + ); + $wpdb->query( + " + DELETE {$wpdb->postmeta}.* FROM {$wpdb->postmeta} + LEFT JOIN {$wpdb->posts} wp ON wp.ID = {$wpdb->postmeta}.post_id + WHERE wp.ID IS NULL + " + ); + // @codingStandardsIgnoreStart. + $wpdb->query( " + DELETE tr.* FROM {$wpdb->term_relationships} tr + LEFT JOIN {$wpdb->posts} wp ON wp.ID = tr.object_id + LEFT JOIN {$wpdb->term_taxonomy} tt ON tr.term_taxonomy_id = tt.term_taxonomy_id + WHERE wp.ID IS NULL + AND tt.taxonomy IN ( '" . implode( "','", array_map( 'esc_sql', get_object_taxonomies( 'product' ) ) ) . "' ) + " ); + // @codingStandardsIgnoreEnd. + + // Send success. + wp_send_json_success( + array( + 'position' => 'done', + 'percentage' => 100, + 'url' => add_query_arg( array( '_wpnonce' => wp_create_nonce( 'woocommerce-csv-importer' ) ), admin_url( 'edit.php?post_type=product&page=product_importer&step=done' ) ), + 'imported' => is_countable( $results['imported'] ) ? count( $results['imported'] ) : 0, + 'imported_variations' => is_countable( $results['imported_variations'] ) ? count( $results['imported_variations'] ) : 0, + 'failed' => is_countable( $results['failed'] ) ? count( $results['failed'] ) : 0, + 'updated' => is_countable( $results['updated'] ) ? count( $results['updated'] ) : 0, + 'skipped' => is_countable( $results['skipped'] ) ? count( $results['skipped'] ) : 0, + ) + ); + } else { + wp_send_json_success( + array( + 'position' => $importer->get_file_position(), + 'percentage' => $percent_complete, + 'imported' => is_countable( $results['imported'] ) ? count( $results['imported'] ) : 0, + 'imported_variations' => is_countable( $results['imported_variations'] ) ? count( $results['imported_variations'] ) : 0, + 'failed' => is_countable( $results['failed'] ) ? count( $results['failed'] ) : 0, + 'updated' => is_countable( $results['updated'] ) ? count( $results['updated'] ) : 0, + 'skipped' => is_countable( $results['skipped'] ) ? count( $results['skipped'] ) : 0, + ) + ); + } + } catch ( \Exception $e ) { + wp_send_json_error( array( 'message' => $e->getMessage() ) ); + } + } + /** * Output information about the uploading process. */ @@ -314,60 +498,20 @@ class WC_Product_CSV_Importer_Controller { // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce already verified in WC_Product_CSV_Importer_Controller::upload_form_handler() $file_url = isset( $_POST['file_url'] ) ? wc_clean( wp_unslash( $_POST['file_url'] ) ) : ''; - if ( empty( $file_url ) ) { - if ( ! isset( $_FILES['import'] ) ) { - return new WP_Error( 'woocommerce_product_csv_importer_upload_file_empty', __( 'File is empty. Please upload something more substantial. This error could also be caused by uploads being disabled in your php.ini or by post_max_size being defined as smaller than upload_max_filesize in php.ini.', 'woocommerce' ) ); + try { + if ( ! empty( $file_url ) ) { + $path = ABSPATH . $file_url; + self::check_file_path( $path ); + } else { + $csv_import_util = wc_get_container()->get( Automattic\WooCommerce\Internal\Admin\ImportExport\CSVUploadHelper::class ); + $upload = $csv_import_util->handle_csv_upload( 'product', 'import', self::get_valid_csv_filetypes() ); + $path = $upload['file']; } - // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotValidated - if ( ! self::is_file_valid_csv( wc_clean( wp_unslash( $_FILES['import']['name'] ) ), false ) ) { - return new WP_Error( 'woocommerce_product_csv_importer_upload_file_invalid', __( 'Invalid file type. The importer supports CSV and TXT file formats.', 'woocommerce' ) ); - } - - $overrides = array( - 'test_form' => false, - 'mimes' => self::get_valid_csv_filetypes(), - ); - $import = $_FILES['import']; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash - $upload = wp_handle_upload( $import, $overrides ); - - if ( isset( $upload['error'] ) ) { - return new WP_Error( 'woocommerce_product_csv_importer_upload_error', $upload['error'] ); - } - - // Construct the object array. - $object = array( - 'post_title' => basename( $upload['file'] ), - 'post_content' => $upload['url'], - 'post_mime_type' => $upload['type'], - 'guid' => $upload['url'], - 'context' => 'import', - 'post_status' => 'private', - ); - - // Save the data. - $id = wp_insert_attachment( $object, $upload['file'] ); - - /* - * Schedule a cleanup for one day from now in case of failed - * import or missing wp_import_cleanup() call. - */ - wp_schedule_single_event( time() + DAY_IN_SECONDS, 'importer_scheduled_cleanup', array( $id ) ); - - return $upload['file']; - } elseif ( - ( 0 === stripos( realpath( ABSPATH . $file_url ), ABSPATH ) ) && - file_exists( ABSPATH . $file_url ) - ) { - if ( ! self::is_file_valid_csv( ABSPATH . $file_url ) ) { - return new WP_Error( 'woocommerce_product_csv_importer_upload_file_invalid', __( 'Invalid file type. The importer supports CSV and TXT file formats.', 'woocommerce' ) ); - } - - return ABSPATH . $file_url; + return $path; + } catch ( \Exception $e ) { + return new \WP_Error( 'woocommerce_product_csv_importer_upload_invalid_file', $e->getMessage() ); } - // phpcs:enable - - return new WP_Error( 'woocommerce_product_csv_importer_upload_invalid_file', __( 'Please upload or provide the link to a valid CSV file.', 'woocommerce' ) ); } /** @@ -375,6 +519,8 @@ class WC_Product_CSV_Importer_Controller { */ protected function mapping_form() { check_admin_referer( 'woocommerce-csv-importer' ); + self::check_file_path( $this->file ); + $args = array( 'lines' => 1, 'delimiter' => $this->delimiter, @@ -412,18 +558,7 @@ class WC_Product_CSV_Importer_Controller { // Displaying this page triggers Ajax action to run the import with a valid nonce, // therefore this page needs to be nonce protected as well. check_admin_referer( 'woocommerce-csv-importer' ); - - if ( ! self::is_file_valid_csv( $this->file ) ) { - $this->add_error( __( 'Invalid file type. The importer supports CSV and TXT file formats.', 'woocommerce' ) ); - $this->output_errors(); - return; - } - - if ( ! is_file( $this->file ) ) { - $this->add_error( __( 'The file does not exist, please try again.', 'woocommerce' ) ); - $this->output_errors(); - return; - } + self::check_file_path( $this->file ); if ( ! empty( $_POST['map_from'] ) && ! empty( $_POST['map_to'] ) ) { $mapping_from = wc_clean( wp_unslash( $_POST['map_from'] ) ); diff --git a/plugins/woocommerce/src/Container.php b/plugins/woocommerce/src/Container.php index 56d3b5b1e98..17ceac62783 100644 --- a/plugins/woocommerce/src/Container.php +++ b/plugins/woocommerce/src/Container.php @@ -32,6 +32,7 @@ use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\BatchP use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\LayoutTemplatesServiceProvider; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\ComingSoonServiceProvider; use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\StatsServiceProvider; +use Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders\ImportExportServiceProvider; /** * PSR11 compliant dependency injection container for WooCommerce. @@ -83,6 +84,7 @@ final class Container { EnginesServiceProvider::class, ComingSoonServiceProvider::class, StatsServiceProvider::class, + ImportExportServiceProvider::class, ); /** diff --git a/plugins/woocommerce/src/Internal/Admin/ImportExport/CSVUploadHelper.php b/plugins/woocommerce/src/Internal/Admin/ImportExport/CSVUploadHelper.php new file mode 100644 index 00000000000..c1759bc5e93 --- /dev/null +++ b/plugins/woocommerce/src/Internal/Admin/ImportExport/CSVUploadHelper.php @@ -0,0 +1,175 @@ +get_import_subdir_name(); + if ( $create ) { + FilesystemUtil::mkdir_p_not_indexable( $upload_dir ); + } + return $upload_dir; + } + + /** + * Handles a CSV file upload. + * + * @param string $import_type Type of upload or context. + * @param string $files_index $_FILES index that contains the file to upload. + * @param array|null $allowed_mime_types List of allowed MIME types. + * @return array { + * Details for the uploaded file. + * + * @type int $id Attachment ID. + * @type string $file Full path to uploaded file. + * } + * + * @throws \Exception In case of error. + */ + public function handle_csv_upload( string $import_type, string $files_index = 'import', ?array $allowed_mime_types = null ): array { + $import_type = sanitize_key( $import_type ); + if ( ! $import_type ) { + throw new \Exception( 'Import type is invalid.' ); + } + + if ( ! $allowed_mime_types ) { + $allowed_mime_types = array( + 'csv' => 'text/csv', + 'txt' => 'text/plain', + ); + } + + $file = $_FILES[ $files_index ] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Missing + if ( ! isset( $file['tmp_name'] ) || ! is_uploaded_file( $file['tmp_name'] ) ) { + throw new \Exception( esc_html__( 'File is empty. Please upload something more substantial. This error could also be caused by uploads being disabled in your php.ini or by post_max_size being defined as smaller than upload_max_filesize in php.ini.', 'woocommerce' ) ); + } + + if ( ! function_exists( 'wp_import_handle_upload' ) ) { + require_once ABSPATH . 'wp-admin/includes/import.php'; + } + + // Make sure upload dir exists. + $this->get_import_dir(); + + // Add prefix. + $file['name'] = $import_type . '-' . $file['name']; + + $overrides_callback = function ( $overrides_ ) use ( $allowed_mime_types ) { + $overrides_['test_form'] = false; + $overrides_['test_type'] = true; + $overrides_['mimes'] = $allowed_mime_types; + return $overrides_; + }; + + self::add_filter( 'upload_dir', array( $this, 'override_upload_dir' ) ); + self::add_filter( 'wp_unique_filename', array( $this, 'override_unique_filename' ), 0, 2 ); + self::add_filter( 'wp_handle_upload_overrides', $overrides_callback, 999 ); + self::add_filter( 'wp_handle_upload_prefilter', array( $this, 'remove_txt_from_uploaded_file' ), 0 ); + + $orig_files_import = $_FILES['import'] ?? null; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.NonceVerification.Missing + $_FILES['import'] = $file; // wp_import_handle_upload() expects the file to be in 'import'. + + $upload = wp_import_handle_upload(); + + remove_filter( 'upload_dir', array( $this, 'override_upload_dir' ) ); + remove_filter( 'wp_unique_filename', array( $this, 'override_unique_filename' ), 0 ); + remove_filter( 'wp_handle_upload_overrides', $overrides_callback, 999 ); + remove_filter( 'wp_handle_upload_prefilter', array( $this, 'remove_txt_from_uploaded_file' ), 0 ); + + if ( $orig_files_import ) { + $_FILES['import'] = $orig_files_import; + } else { + unset( $_FILES['import'] ); + } + + if ( ! empty( $upload['error'] ) ) { + throw new \Exception( esc_html( $upload['error'] ) ); + } + + if ( ! wc_is_file_valid_csv( $upload['file'], false ) ) { + wp_delete_attachment( $file['id'], true ); + throw new \Exception( esc_html__( 'Invalid file type for a CSV import.', 'woocommerce' ) ); + } + + return $upload; + } + + /** + * Hooked onto 'upload_dir' to override the default upload directory for a CSV upload. + * + * @param array $uploads WP upload dir details. + * @return array + */ + private function override_upload_dir( $uploads ): array { + $new_subdir = '/' . $this->get_import_subdir_name(); + + $uploads['path'] = $uploads['basedir'] . $new_subdir; + $uploads['url'] = $uploads['baseurl'] . $new_subdir; + $uploads['subdir'] = $new_subdir; + + return $uploads; + } + + /** + * Adds a random string to the name of an uploaded CSV file to make it less discoverable. Hooked onto 'wp_unique_filename'. + * + * @param string $filename File name. + * @param string $ext File extension. + * @return string + */ + private function override_unique_filename( string $filename, string $ext ): string { + $length = min( 10, 255 - strlen( $filename ) - 1 ); + if ( 1 < $length ) { + $suffix = strtolower( wp_generate_password( $length, false, false ) ); + $filename = substr( $filename, 0, strlen( $filename ) - strlen( $ext ) ) . '-' . $suffix . $ext; + } + + return $filename; + } + + /** + * `wp_import_handle_upload()` appends .txt to any file name. This function is hooked onto 'wp_handle_upload_prefilter' + * to remove those extra characters. + * + * @param array $file File details in the form of a $_FILES entry. + * @return array Modified file details. + */ + private function remove_txt_from_uploaded_file( array $file ): array { + $file['name'] = substr( $file['name'], 0, -4 ); + return $file; + } +} diff --git a/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/ImportExportServiceProvider.php b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/ImportExportServiceProvider.php new file mode 100644 index 00000000000..58984a41fbf --- /dev/null +++ b/plugins/woocommerce/src/Internal/DependencyManagement/ServiceProviders/ImportExportServiceProvider.php @@ -0,0 +1,35 @@ +share( CSVUploadHelper::class ); + } +} diff --git a/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php b/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php index 4bac73265b3..3b91fcbc721 100644 --- a/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php +++ b/plugins/woocommerce/src/Internal/Utilities/FilesystemUtil.php @@ -31,6 +31,36 @@ class FilesystemUtil { return $wp_filesystem; } + /** + * Recursively creates a directory (if it doesn't exist) and adds an empty index.html and a .htaccess to prevent + * directory listing. + * + * @since 9.3.0 + * + * @param string $path Directory to create. + * @throws \Exception In case of error. + */ + public static function mkdir_p_not_indexable( string $path ): void { + $wp_fs = self::get_wp_filesystem(); + + if ( $wp_fs->is_dir( $path ) ) { + return; + } + + if ( ! wp_mkdir_p( $path ) ) { + throw new \Exception( esc_html( sprintf( 'Could not create directory: %s.', wp_basename( $path ) ) ) ); + } + + $files = array( + '.htaccess' => 'deny from all', + 'index.html' => '', + ); + + foreach ( $files as $name => $content ) { + $wp_fs->put_contents( trailingslashit( $path ) . $name, $content ); + } + } + /** * Wrapper to initialize the WP filesystem with defined credentials if they are available. * diff --git a/plugins/woocommerce/tests/e2e-pw/tests/merchant/product-import-csv.spec.js b/plugins/woocommerce/tests/e2e-pw/tests/merchant/product-import-csv.spec.js index 103d50fd8f6..507568812a0 100644 --- a/plugins/woocommerce/tests/e2e-pw/tests/merchant/product-import-csv.spec.js +++ b/plugins/woocommerce/tests/e2e-pw/tests/merchant/product-import-csv.spec.js @@ -89,8 +89,7 @@ const productCategories = [ ]; const productAttributes = [ 'Color', 'Size' ]; -const errorMessage = - 'Invalid file type. The importer supports CSV and TXT file formats.'; +const errorMessage = 'File is empty. Please upload something more substantial.'; test.describe.serial( 'Import Products from a CSV file',