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 <jorge.torres@automattic.com>
This commit is contained in:
parent
93053f39ed
commit
9b41815229
|
@ -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)
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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() {
|
||||
$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' ) );
|
||||
return $path;
|
||||
} catch ( \Exception $e ) {
|
||||
return new \WP_Error( 'woocommerce_product_csv_importer_upload_invalid_file', $e->getMessage() );
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
// 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'] ) );
|
||||
|
|
|
@ -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,
|
||||
);
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,175 @@
|
|||
<?php
|
||||
declare( strict_types = 1 );
|
||||
|
||||
namespace Automattic\WooCommerce\Internal\Admin\ImportExport;
|
||||
|
||||
use Automattic\WooCommerce\Internal\Traits\AccessiblePrivateMethods;
|
||||
use Automattic\WooCommerce\Internal\Utilities\FilesystemUtil;
|
||||
|
||||
/**
|
||||
* Helper for CSV import functionality.
|
||||
*
|
||||
* @since 9.3.0
|
||||
*/
|
||||
class CSVUploadHelper {
|
||||
|
||||
use AccessiblePrivateMethods;
|
||||
|
||||
/**
|
||||
* Name (inside the uploads folder) to use for the CSV import directory.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function get_import_subdir_name(): string {
|
||||
return 'wc-imports';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full path to the CSV import directory within the uploads folder.
|
||||
* It will attempt to create the directory if it doesn't exist.
|
||||
*
|
||||
* @param bool $create TRUE to attempt to create the directory. FALSE otherwise.
|
||||
* @return string
|
||||
* @throws \Exception In case the upload directory doesn't exits or can't be created.
|
||||
*/
|
||||
public function get_import_dir( bool $create = true ): string {
|
||||
$wp_upload_dir = wp_upload_dir( null, $create );
|
||||
if ( $wp_upload_dir['error'] ) {
|
||||
throw new \Exception( esc_html( $wp_upload_dir['error'] ) );
|
||||
}
|
||||
|
||||
$upload_dir = trailingslashit( $wp_upload_dir['basedir'] ) . $this->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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
<?php
|
||||
/**
|
||||
* ImportExportServiceProvider class file.
|
||||
*/
|
||||
|
||||
declare( strict_types = 1 );
|
||||
|
||||
namespace Automattic\WooCommerce\Internal\DependencyManagement\ServiceProviders;
|
||||
|
||||
use Automattic\WooCommerce\Internal\Admin\ImportExport\CSVUploadHelper;
|
||||
use Automattic\WooCommerce\Internal\DependencyManagement\AbstractServiceProvider;
|
||||
|
||||
/**
|
||||
* Service provider for the import/export classes.
|
||||
*
|
||||
* @since 9.3.0
|
||||
*/
|
||||
class ImportExportServiceProvider extends AbstractServiceProvider {
|
||||
|
||||
/**
|
||||
* The classes/interfaces that are serviced by this service provider.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $provides = array(
|
||||
CSVUploadHelper::class,
|
||||
);
|
||||
|
||||
/**
|
||||
* Register the classes.
|
||||
*/
|
||||
public function register() {
|
||||
$this->share( CSVUploadHelper::class );
|
||||
}
|
||||
}
|
|
@ -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.
|
||||
*
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Reference in New Issue