Commit a7beea4c586 for woocommerce
commit a7beea4c5860419a43e7f8b2f521e98a068b9111
Author: Lucio Giannotta <lucio.giannotta@a8c.com>
Date: Tue Nov 3 18:08:54 2026 +0000
Fix unused files left by product image uploads (#70565)
diff --git a/plugins/woocommerce/changelog/fix-product-image-sideload-leaves-orphaned-files b/plugins/woocommerce/changelog/fix-product-image-sideload-leaves-orphaned-files
new file mode 100644
index 00000000000..60f33d0914b
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-product-image-sideload-leaves-orphaned-files
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Remove rejected image uploads and unused files from failed API updates while keeping saved product images.
diff --git a/plugins/woocommerce/includes/import/abstract-wc-product-importer.php b/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
index 9b0b81f47dd..78a5d6dbee4 100644
--- a/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
+++ b/plugins/woocommerce/includes/import/abstract-wc-product-importer.php
@@ -773,6 +773,10 @@ abstract class WC_Product_Importer implements WC_Importer_Interface {
$id = wc_rest_set_uploaded_image_as_attachment( $upload, $product_id );
if ( ! wp_attachment_is_image( $id ) ) {
+ if ( $id > 0 ) {
+ wp_delete_attachment( $id, true );
+ }
+
/* translators: %s: image URL */
throw new Exception( sprintf( __( 'Not able to attach "%s".', 'woocommerce' ), $url ), 400 );
}
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-product-categories-v1-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-product-categories-v1-controller.php
index 3c88b5e41e4..5bb6612a338 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-product-categories-v1-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-product-categories-v1-controller.php
@@ -123,6 +123,8 @@ class WC_REST_Product_Categories_V1_Controller extends WC_REST_Terms_Controller
}
if ( isset( $request['image'] ) ) {
+ $is_new_upload = false;
+
if ( empty( $request['image']['id'] ) && ! empty( $request['image']['src'] ) ) {
$upload = wc_rest_upload_image_from_url( esc_url_raw( $request['image']['src'] ) );
@@ -130,7 +132,8 @@ class WC_REST_Product_Categories_V1_Controller extends WC_REST_Terms_Controller
return $upload;
}
- $image_id = wc_rest_set_uploaded_image_as_attachment( $upload );
+ $image_id = wc_rest_set_uploaded_image_as_attachment( $upload );
+ $is_new_upload = true;
} else {
$image_id = isset( $request['image']['id'] ) ? absint( $request['image']['id'] ) : 0;
}
@@ -152,6 +155,10 @@ class WC_REST_Product_Categories_V1_Controller extends WC_REST_Terms_Controller
) );
}
} else {
+ if ( $is_new_upload && $image_id > 0 ) {
+ wp_delete_attachment( $image_id, true );
+ }
+
delete_term_meta( $id, 'thumbnail_id' );
}
}
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-products-v1-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-products-v1-controller.php
index c2737181a5b..36083d30c21 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-products-v1-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version1/class-wc-rest-products-v1-controller.php
@@ -863,6 +863,7 @@ class WC_REST_Products_V1_Controller extends WC_REST_Posts_Controller {
foreach ( $images as $image ) {
$attachment_id = isset( $image['id'] ) ? absint( $image['id'] ) : 0;
+ $is_new_upload = false;
if ( 0 === $attachment_id && isset( $image['src'] ) ) {
$upload = wc_rest_upload_image_from_url( esc_url_raw( $image['src'] ) );
@@ -876,9 +877,14 @@ class WC_REST_Products_V1_Controller extends WC_REST_Posts_Controller {
}
$attachment_id = wc_rest_set_uploaded_image_as_attachment( $upload, $product->get_id() );
+ $is_new_upload = true;
}
if ( ! wp_attachment_is_image( $attachment_id ) ) {
+ if ( $is_new_upload && $attachment_id > 0 ) {
+ wp_delete_attachment( $attachment_id, true );
+ }
+
throw new WC_REST_Exception( 'woocommerce_product_invalid_image_id', sprintf( __( '#%s is an invalid image ID.', 'woocommerce' ), $attachment_id ), 400 );
}
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
index eba2d1add83..111bbe1a9d3 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version2/class-wc-rest-products-v2-controller.php
@@ -1473,6 +1473,7 @@ class WC_REST_Products_V2_Controller extends WC_REST_CRUD_Controller {
foreach ( $images as $index => $image ) {
$attachment_id = isset( $image['id'] ) ? absint( $image['id'] ) : 0;
+ $is_new_upload = false;
if ( 0 === $attachment_id && isset( $image['src'] ) ) {
$upload = wc_rest_upload_image_from_url( esc_url_raw( $image['src'] ) );
@@ -1486,9 +1487,14 @@ class WC_REST_Products_V2_Controller extends WC_REST_CRUD_Controller {
}
$attachment_id = wc_rest_set_uploaded_image_as_attachment( $upload, $product->get_id() );
+ $is_new_upload = true;
}
if ( ! wp_attachment_is_image( $attachment_id ) ) {
+ if ( $is_new_upload && $attachment_id > 0 ) {
+ wp_delete_attachment( $attachment_id, true );
+ }
+
/* translators: %s: attachment id */
throw new WC_REST_Exception( 'woocommerce_product_invalid_image_id', sprintf( __( '#%s is an invalid image ID.', 'woocommerce' ), $attachment_id ), 400 );
}
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-categories-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-categories-controller.php
index b81c13b85fa..84247a88c4e 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-categories-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-categories-controller.php
@@ -231,6 +231,8 @@ class WC_REST_Product_Categories_Controller extends WC_REST_Product_Categories_V
}
if ( isset( $request['image'] ) ) {
+ $is_new_upload = false;
+
if ( empty( $request['image']['id'] ) && ! empty( $request['image']['src'] ) ) {
$upload = wc_rest_upload_image_from_url( esc_url_raw( $request['image']['src'] ) );
@@ -238,7 +240,8 @@ class WC_REST_Product_Categories_Controller extends WC_REST_Product_Categories_V
return $upload;
}
- $image_id = wc_rest_set_uploaded_image_as_attachment( $upload );
+ $image_id = wc_rest_set_uploaded_image_as_attachment( $upload );
+ $is_new_upload = true;
} else {
$image_id = isset( $request['image']['id'] ) ? absint( $request['image']['id'] ) : 0;
}
@@ -262,6 +265,10 @@ class WC_REST_Product_Categories_Controller extends WC_REST_Product_Categories_V
);
}
} else {
+ if ( $is_new_upload && $image_id > 0 ) {
+ wp_delete_attachment( $image_id, true );
+ }
+
delete_term_meta( $id, 'thumbnail_id' );
}
}
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
index 231cbeaba3e..5d7e9bfcd4f 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-product-variations-controller.php
@@ -516,6 +516,7 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
*/
protected function set_variation_image( $variation, $image ) {
$attachment_id = isset( $image['id'] ) ? absint( $image['id'] ) : 0;
+ $is_new_upload = false;
if ( 0 === $attachment_id ) {
if ( isset( $image['src'] ) ) {
@@ -537,6 +538,7 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
}
$attachment_id = wc_rest_set_uploaded_image_as_attachment( $upload, $variation->get_id() );
+ $is_new_upload = true;
} else {
$variation->set_image_id( '' );
return $variation;
@@ -544,6 +546,10 @@ class WC_REST_Product_Variations_Controller extends WC_REST_Product_Variations_V
}
if ( ! wp_attachment_is_image( $attachment_id ) ) {
+ if ( $is_new_upload && $attachment_id > 0 ) {
+ wp_delete_attachment( $attachment_id, true );
+ }
+
/* translators: %s: attachment ID */
throw new WC_REST_Exception( 'woocommerce_variation_invalid_image_id', sprintf( __( '#%s is an invalid image ID.', 'woocommerce' ), $attachment_id ), 400 );
}
diff --git a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
index 9327a035c8e..86b1d783a62 100644
--- a/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
+++ b/plugins/woocommerce/includes/rest-api/Controllers/Version3/class-wc-rest-products-controller.php
@@ -15,6 +15,7 @@ use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\CatalogVisibility;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareRestControllerTrait;
use Automattic\WooCommerce\Internal\ProductCustoms\CustomsDataValidator;
+use Automattic\WooCommerce\Internal\RestApi\ProductImageUploadCleanupTrait;
use Automattic\WooCommerce\Internal\RestApi\ProductRequestPreparationTrait;
use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
use Automattic\WooCommerce\Utilities\I18nUtil;
@@ -31,6 +32,7 @@ defined( 'ABSPATH' ) || exit;
class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
use CogsAwareRestControllerTrait;
+ use ProductImageUploadCleanupTrait;
use ProductRequestPreparationTrait;
/**
@@ -89,13 +91,6 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
*/
private $exclude_status = array();
- /**
- * Stores attachment IDs processed during the current request for potential cleanup.
- *
- * @var array
- */
- private $processed_attachment_ids_for_request = array();
-
/**
* Register the routes for products.
*/
@@ -755,6 +750,10 @@ class WC_REST_Products_Controller extends WC_REST_Products_V2_Controller {
}
if ( ! wp_attachment_is_image( $attachment_id ) ) {
+ if ( $is_new_upload && $attachment_id > 0 ) {
+ wp_delete_attachment( $attachment_id, true );
+ }
+
/* translators: %s: image ID */
throw new WC_REST_Exception( 'woocommerce_product_invalid_image_id', sprintf( __( '#%s is an invalid image ID.', 'woocommerce' ), $attachment_id ), 400 );
}
diff --git a/plugins/woocommerce/includes/rest-api/Utilities/ImageAttachment.php b/plugins/woocommerce/includes/rest-api/Utilities/ImageAttachment.php
index debecff9f74..f2f0a046488 100644
--- a/plugins/woocommerce/includes/rest-api/Utilities/ImageAttachment.php
+++ b/plugins/woocommerce/includes/rest-api/Utilities/ImageAttachment.php
@@ -57,6 +57,10 @@ class ImageAttachment {
$this->id = wc_rest_set_uploaded_image_as_attachment( $upload, $this->object_id );
if ( ! wp_attachment_is_image( $this->id ) ) {
+ if ( $this->id > 0 ) {
+ wp_delete_attachment( $this->id, true );
+ }
+
/* translators: %s: image ID */
throw new \WC_REST_Exception( 'woocommerce_product_invalid_image_id', sprintf( __( '#%s is an invalid image ID.', 'woocommerce' ), $this->id ), 400 );
}
diff --git a/plugins/woocommerce/includes/wc-rest-functions.php b/plugins/woocommerce/includes/wc-rest-functions.php
index deeb5ed1e65..19ec1510372 100644
--- a/plugins/woocommerce/includes/wc-rest-functions.php
+++ b/plugins/woocommerce/includes/wc-rest-functions.php
@@ -87,6 +87,7 @@ function wc_rest_upload_image_from_url( $image_url ) {
$file_array = array();
$file_array['name'] = basename( current( explode( '?', $image_url ) ) );
+ $file_array['type'] = ''; // WordPress uses this fallback for unfiltered uploads without a recognized type.
// Download file to temp location.
$file_array['tmp_name'] = download_url( $image_url );
@@ -161,8 +162,10 @@ function wc_rest_set_uploaded_image_as_attachment( $upload, $id = 0 ) {
);
$attachment_id = wp_insert_attachment( $attachment, $upload['file'], $id );
- if ( ! is_wp_error( $attachment_id ) ) {
+ if ( $attachment_id && ! is_wp_error( $attachment_id ) ) {
@wp_update_attachment_metadata( $attachment_id, wp_generate_attachment_metadata( $attachment_id, $upload['file'] ) );
+ } else {
+ wp_delete_file( $upload['file'] );
}
return $attachment_id;
diff --git a/plugins/woocommerce/src/Internal/RestApi/ProductImageUploadCleanupTrait.php b/plugins/woocommerce/src/Internal/RestApi/ProductImageUploadCleanupTrait.php
new file mode 100644
index 00000000000..3ab40b13f13
--- /dev/null
+++ b/plugins/woocommerce/src/Internal/RestApi/ProductImageUploadCleanupTrait.php
@@ -0,0 +1,71 @@
+<?php
+/**
+ * Product REST image upload cleanup.
+ *
+ * @package WooCommerce\Internal\RestApi
+ */
+
+declare( strict_types = 1 );
+
+namespace Automattic\WooCommerce\Internal\RestApi;
+
+use WC_Product;
+use WP_Error;
+use WP_REST_Request;
+use WP_REST_Response;
+
+/**
+ * Track new product images and remove unused uploads after a failed update.
+ */
+trait ProductImageUploadCleanupTrait {
+
+ /**
+ * Stores attachment IDs processed during the current request for potential cleanup.
+ *
+ * @var int[]
+ */
+ private $processed_attachment_ids_for_request = array();
+
+ /**
+ * Update a product and remove new images that the saved product does not use on failure.
+ *
+ * @since 11.4.0
+ *
+ * @param WP_REST_Request<array> $request Request object.
+ * @return WP_REST_Response|WP_Error
+ */
+ public function update_item( $request ) {
+ $this->processed_attachment_ids_for_request = array();
+
+ try {
+ $response = parent::update_item( $request );
+
+ if ( is_wp_error( $response ) ) {
+ $this->delete_unused_uploaded_images( (int) $request['id'] );
+ }
+
+ return $response;
+ } finally {
+ $this->processed_attachment_ids_for_request = array();
+ }
+ }
+
+ /**
+ * Remove newly uploaded images that the persisted product does not use.
+ *
+ * @param int $product_id Product ID.
+ */
+ private function delete_unused_uploaded_images( int $product_id ): void {
+ if ( ! $this->processed_attachment_ids_for_request ) {
+ return;
+ }
+
+ // An error can occur after saving, so read the persisted image references.
+ $product = $this->get_object( $product_id );
+ $used_image_ids = $product instanceof WC_Product ? array_merge( array( $product->get_image_id( 'edit' ) ), $product->get_gallery_image_ids( 'edit' ) ) : array();
+
+ foreach ( array_diff( $this->processed_attachment_ids_for_request, $used_image_ids ) as $attachment_id ) {
+ wp_delete_attachment( (int) $attachment_id, true );
+ }
+ }
+}
diff --git a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
index 2c7b566b489..bab21f2c402 100644
--- a/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
+++ b/plugins/woocommerce/src/Internal/RestApi/Routes/V4/Products/Controller.php
@@ -20,6 +20,7 @@ use Automattic\WooCommerce\Enums\ProductTaxStatus;
use Automattic\WooCommerce\Enums\ProductType;
use Automattic\WooCommerce\Enums\WeightUnit;
use Automattic\WooCommerce\Internal\CostOfGoodsSold\CogsAwareRestControllerTrait;
+use Automattic\WooCommerce\Internal\RestApi\ProductImageUploadCleanupTrait;
use Automattic\WooCommerce\Internal\RestApi\ProductRequestPreparationTrait;
use Automattic\WooCommerce\Internal\Utilities\ProductUtil;
use Automattic\WooCommerce\Utilities\I18nUtil;
@@ -46,6 +47,7 @@ defined( 'ABSPATH' ) || exit;
class Controller extends WC_REST_Products_V2_Controller {
use CogsAwareRestControllerTrait;
+ use ProductImageUploadCleanupTrait;
use ProductRequestPreparationTrait;
/**
@@ -116,13 +118,6 @@ class Controller extends WC_REST_Products_V2_Controller {
*/
private $stock_quantity_filter = array();
- /**
- * Stores attachment IDs processed during the current request for potential cleanup.
- *
- * @var array
- */
- private $processed_attachment_ids_for_request = array();
-
/**
* Register the routes for products.
*/
@@ -971,6 +966,10 @@ class Controller extends WC_REST_Products_V2_Controller {
}
if ( ! wp_attachment_is_image( $attachment_id ) ) {
+ if ( $is_new_upload && $attachment_id > 0 ) {
+ wp_delete_attachment( $attachment_id, true );
+ }
+
/* translators: %s: image ID */
throw new WC_REST_Exception( 'woocommerce_product_invalid_image_id', sprintf( __( '#%s is an invalid image ID.', 'woocommerce' ), $attachment_id ), 400 ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped
}
diff --git a/plugins/woocommerce/tests/php/includes/rest-api/class-wc-rest-image-upload-cleanup-test.php b/plugins/woocommerce/tests/php/includes/rest-api/class-wc-rest-image-upload-cleanup-test.php
new file mode 100644
index 00000000000..b4b0bf1ec35
--- /dev/null
+++ b/plugins/woocommerce/tests/php/includes/rest-api/class-wc-rest-image-upload-cleanup-test.php
@@ -0,0 +1,385 @@
+<?php
+declare( strict_types = 1 );
+
+/**
+ * Tests for cleanup of image downloads used by REST writes.
+ *
+ * @package WooCommerce\Tests\RestApi
+ */
+class WC_REST_Image_Upload_Cleanup_Test extends WC_REST_Unit_Test_Case {
+ /** @var array Downloaded files, including files with no attachment. */
+ private $uploads = array();
+
+ /** @var int[] Attachments whose physical files need teardown. */
+ private $attachment_ids = array();
+
+ /** @var string Contents served by the fake download transport. */
+ private $download_content;
+
+ /**
+ * Set up a real upload pipeline with a local download fixture.
+ */
+ public function setUp(): void {
+ parent::setUp();
+ wp_set_current_user( self::factory()->user->create( array( 'role' => 'administrator' ) ) );
+ $this->download_content = file_get_contents( WC_ABSPATH . 'tests/legacy/data/Dr1Bczxq4q.png' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Local test fixture.
+
+ add_filter(
+ 'pre_http_request',
+ function ( $response, $args, $url ) {
+ if ( 'example.org' !== wp_parse_url( $url, PHP_URL_HOST ) ) {
+ return $response;
+ }
+ file_put_contents( $args['filename'], $this->download_content ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents -- Emulate download_url writing its temporary file.
+ return array(
+ 'headers' => array(),
+ 'body' => '',
+ 'response' => array(
+ 'code' => 200,
+ 'message' => 'OK',
+ ),
+ );
+ },
+ 10,
+ 3
+ );
+ add_action(
+ 'woocommerce_rest_api_uploaded_image_from_url',
+ function ( $upload ) {
+ $this->uploads[] = $upload;
+ }
+ );
+ add_action(
+ 'add_attachment',
+ function ( $attachment_id ) {
+ $this->attachment_ids[] = $attachment_id;
+ }
+ );
+ }
+
+ /**
+ * Remove physical files, which database rollback cannot remove.
+ */
+ public function tearDown(): void {
+ try {
+ foreach ( $this->attachment_ids as $attachment_id ) {
+ wp_delete_attachment( $attachment_id, true );
+ }
+ foreach ( $this->uploads as $upload ) {
+ if ( file_exists( $upload['file'] ) ) {
+ wp_delete_file( $upload['file'] );
+ }
+ }
+ } finally {
+ parent::tearDown();
+ }
+ }
+
+ /**
+ * One request per image-setting implementation; inherited versions share these paths.
+ *
+ * @return array
+ */
+ public static function provide_write_requests(): array {
+ return array(
+ 'products v1' => array( 'products', 1, 'POST' ),
+ 'products v2' => array( 'products', 2, 'POST' ),
+ 'products v3' => array( 'products', 3, 'POST' ),
+ 'products v4' => array( 'products', 4, 'POST' ),
+ 'variations v3' => array( 'variations', 3, 'PUT' ),
+ 'categories v1' => array( 'categories', 1, 'POST' ),
+ 'categories v3' => array( 'categories', 3, 'PUT' ),
+ );
+ }
+
+ /**
+ * Build a request with the minimum parent or existing object it needs.
+ *
+ * @param string $kind Object kind.
+ * @param int $version API version.
+ * @param string $method HTTP method.
+ * @param array $image Image request data.
+ * @return WP_REST_Request
+ */
+ private function image_request( string $kind, int $version, string $method, array $image ): WP_REST_Request {
+ $route = "/wc/v$version/products";
+ if ( 4 === $version ) {
+ $sut = new Automattic\WooCommerce\Internal\RestApi\Routes\V4\Products\Controller();
+ $sut->register_routes();
+ }
+
+ if ( 'categories' === $kind ) {
+ $route .= '/categories';
+ if ( 'PUT' === $method ) {
+ $term = wp_insert_term( 'Existing category', 'product_cat' );
+ $route .= '/' . $term['term_id'];
+ }
+ } elseif ( 'variations' === $kind ) {
+ $product = new WC_Product_Variable();
+ $product->save();
+ $route .= '/' . $product->get_id() . '/variations';
+ if ( 'PUT' === $method ) {
+ $variation = new WC_Product_Variation();
+ $variation->set_parent_id( $product->get_id() );
+ $route .= '/' . $variation->save();
+ }
+ } elseif ( 'PUT' === $method ) {
+ $product = new WC_Product_Simple();
+ $route .= '/' . $product->save();
+ }
+
+ $request = new WP_REST_Request( $method, $route );
+ $request->set_param( 'name', 'Image cleanup test' );
+ $request->set_param( 'products' === $kind ? 'images' : 'image', 'products' === $kind ? array( $image ) : $image );
+ return $request;
+ }
+
+ /**
+ * Enable only this test's unfiltered upload capability without a global constant.
+ */
+ private function allow_unfiltered_uploads(): void {
+ add_filter(
+ 'map_meta_cap',
+ static function ( $caps, $cap ) {
+ return 'unfiltered_upload' === $cap ? array( 'exist' ) : $caps;
+ },
+ 10,
+ 2
+ );
+ }
+
+ /**
+ * @testdox Rejected new non-images are deleted even when the category write succeeds.
+ * @dataProvider provide_write_requests
+ *
+ * @param string $kind Object kind.
+ * @param int $version API version.
+ * @param string $method HTTP method.
+ */
+ public function test_rejected_download_is_deleted( string $kind, int $version, string $method ): void {
+ $this->allow_unfiltered_uploads();
+ $this->download_content = 'Inert non-image fixture, with no executable code.';
+ $request = $this->image_request( $kind, $version, $method, array( 'src' => 'https://example.org/rejected.php' ) );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 'categories' === $kind ? ( 'POST' === $method ? 201 : 200 ) : 400, $response->get_status() );
+ $this->assertCount( 1, $this->uploads, 'The upload must reach the real sideload handler.' );
+ $this->assertCount( 1, $this->attachment_ids );
+ $this->assertFileDoesNotExist( $this->uploads[0]['file'] );
+ $this->assertNull( get_post( $this->attachment_ids[0] ) );
+ }
+
+ /**
+ * @testdox The default upload policy rejects PHP before it reaches the uploads directory.
+ */
+ public function test_default_policy_rejects_non_image(): void {
+ $this->download_content = 'Inert non-image fixture.';
+ $request = $this->image_request( 'products', 3, 'POST', array( 'src' => 'https://example.org/rejected.php' ) );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'woocommerce_product_image_upload_error', $response->get_data()['code'] );
+ $this->assertSame( array(), $this->uploads );
+ $this->assertSame( array(), $this->attachment_ids );
+ }
+
+ /**
+ * @testdox Other image-helper callers also delete their rejected new downloads.
+ * @testWith ["utility"]
+ * ["importer"]
+ *
+ * @param string $caller Image upload caller.
+ */
+ public function test_other_callers_delete_rejected_download( string $caller ): void {
+ $this->allow_unfiltered_uploads();
+ $this->download_content = 'Inert non-image fixture.';
+ $error = null;
+
+ try {
+ if ( 'utility' === $caller ) {
+ $sut = new Automattic\WooCommerce\RestApi\Utilities\ImageAttachment();
+ $sut->upload_image_from_src( 'https://example.org/rejected.php' );
+ } else {
+ require_once WC_ABSPATH . 'includes/import/abstract-wc-product-importer.php';
+ require_once WC_ABSPATH . 'includes/import/class-wc-product-csv-importer.php';
+ $sut = new WC_Product_CSV_Importer( WC_ABSPATH . 'tests/legacy/unit-tests/importer/sample.csv' );
+ $sut->get_attachment_id_from_url( 'https://example.org/rejected.php', 0 );
+ }
+ } catch ( Exception $exception ) {
+ $error = $exception;
+ }
+
+ $this->assertInstanceOf( Exception::class, $error );
+ $this->assertCount( 1, $this->uploads );
+ $this->assertCount( 1, $this->attachment_ids );
+ $this->assertFileDoesNotExist( $this->uploads[0]['file'] );
+ $this->assertNull( get_post( $this->attachment_ids[0] ) );
+ }
+
+ /**
+ * @testdox An unsuccessful attachment insert removes its otherwise untracked file.
+ */
+ public function test_failed_attachment_insert_removes_file(): void {
+ add_filter(
+ 'wp_insert_post_empty_content',
+ static function ( $is_empty, $post ) {
+ return 'attachment' === $post['post_type'] ? true : $is_empty;
+ },
+ 10,
+ 2
+ );
+ $upload = wc_rest_upload_image_from_url( 'https://example.org/insert-failure.png' );
+ $this->assertIsArray( $upload );
+ $this->assertFileExists( $upload['file'] );
+
+ $attachment_id = wc_rest_set_uploaded_image_as_attachment( $upload );
+
+ $this->assertSame( 0, $attachment_id );
+ $this->assertSame( array(), $this->attachment_ids );
+ $this->assertFileDoesNotExist( $upload['file'] );
+ }
+
+ /**
+ * @testdox Rejecting an existing non-image never deletes it from the media library.
+ * @dataProvider provide_write_requests
+ *
+ * @param string $kind Object kind.
+ * @param int $version API version.
+ * @param string $method HTTP method.
+ */
+ public function test_existing_non_image_is_preserved( string $kind, int $version, string $method ): void {
+ $upload = wp_upload_bits( 'existing-manual.pdf', null, 'Existing PDF fixture' );
+ $this->uploads[] = $upload;
+ $attachment_id = wp_insert_attachment( array( 'post_mime_type' => 'application/pdf' ), $upload['file'] );
+ $request = $this->image_request( $kind, $version, $method, array( 'id' => $attachment_id ) );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 'categories' === $kind ? ( 'POST' === $method ? 201 : 200 ) : 400, $response->get_status() );
+ $this->assertInstanceOf( WP_Post::class, get_post( $attachment_id ) );
+ $this->assertFileExists( $upload['file'] );
+ }
+
+ /**
+ * @testdox A valid PNG upload remains usable after a successful write.
+ * @dataProvider provide_write_requests
+ *
+ * @param string $kind Object kind.
+ * @param int $version API version.
+ * @param string $method HTTP method.
+ */
+ public function test_valid_image_is_preserved( string $kind, int $version, string $method ): void {
+ $request = $this->image_request(
+ $kind,
+ $version,
+ $method,
+ array(
+ 'src' => 'https://example.org/accepted.png',
+ 'position' => 0,
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 'POST' === $method ? 201 : 200, $response->get_status() );
+ $this->assertCount( 1, $this->attachment_ids );
+ $this->assertFileExists( $this->uploads[0]['file'] );
+ $data = $response->get_data();
+ $image = 'products' === $kind ? $data['images'][0] : $data['image'];
+ $this->assertSame( $this->attachment_ids[0], $image['id'] );
+ }
+
+ /**
+ * @testdox An SVG allowed by the existing upload filters remains accepted.
+ * @testWith ["products", 3, "POST"]
+ * ["variations", 3, "POST"]
+ * ["categories", 3, "POST"]
+ *
+ * @param string $kind Object kind.
+ * @param int $version API version.
+ * @param string $method HTTP method.
+ */
+ public function test_allowed_svg_is_preserved( string $kind, int $version, string $method ): void {
+ $allow_svg = static function ( $mimes ) {
+ $mimes['svg'] = 'image/svg+xml';
+ return $mimes;
+ };
+ add_filter( 'upload_mimes', $allow_svg );
+ add_filter( 'woocommerce_rest_allowed_image_mime_types', $allow_svg );
+ // Older Fileinfo versions need the XML declaration to recognize the SVG MIME type.
+ $this->download_content = '<?xml version="1.0" encoding="UTF-8"?><svg xmlns="http://www.w3.org/2000/svg" width="10" height="10"><rect width="10" height="10"/></svg>';
+ $request = $this->image_request( $kind, $version, $method, array( 'src' => 'https://example.org/accepted.svg' ) );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 201, $response->get_status() );
+ $this->assertCount( 1, $this->attachment_ids );
+ $this->assertSame( 'image/svg+xml', get_post_mime_type( $this->attachment_ids[0] ) );
+ $this->assertFileExists( $this->uploads[0]['file'] );
+ }
+
+ /**
+ * @testdox A failed update removes unused new images and preserves an existing library file.
+ * @testWith [3]
+ * [4]
+ *
+ * @param int $version API version.
+ */
+ public function test_failed_update_cleans_only_unused_new_images( int $version ): void {
+ $existing_id = self::factory()->attachment->create( array( 'post_mime_type' => 'application/pdf' ) );
+ $request = $this->image_request( 'products', $version, 'PUT', array() );
+ $request->set_param(
+ 'images',
+ array(
+ array( 'src' => 'https://example.org/featured.png' ),
+ array( 'src' => 'https://example.org/gallery.png' ),
+ array( 'id' => $existing_id ),
+ )
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertCount( 2, $this->uploads );
+ foreach ( $this->uploads as $upload ) {
+ $this->assertFileDoesNotExist( $upload['file'] );
+ }
+ foreach ( array_diff( $this->attachment_ids, array( $existing_id ) ) as $attachment_id ) {
+ $this->assertNull( get_post( $attachment_id ) );
+ }
+ $this->assertInstanceOf( WP_Post::class, get_post( $existing_id ) );
+ }
+
+ /**
+ * @testdox An error after saving preserves the persisted featured and gallery images.
+ * @testWith [3]
+ * [4]
+ *
+ * @param int $version API version.
+ */
+ public function test_error_after_save_preserves_adopted_images( int $version ): void {
+ $request = $this->image_request( 'products', $version, 'PUT', array() );
+ $request->set_param(
+ 'images',
+ array(
+ array( 'src' => 'https://example.org/featured.png' ),
+ array( 'src' => 'https://example.org/gallery.png' ),
+ )
+ );
+ add_action(
+ 'woocommerce_rest_insert_product_object',
+ static function () {
+ throw new WC_REST_Exception( 'test_after_save', 'Failure after saving.', 400 );
+ }
+ );
+ $response = $this->server->dispatch( $request );
+
+ $this->assertSame( 400, $response->get_status() );
+ $this->assertSame( 'test_after_save', $response->get_data()['code'] );
+ $this->assertCount( 2, $this->uploads );
+ $this->assertSame( $this->attachment_ids[0], (int) get_post_meta( $request['id'], '_thumbnail_id', true ) );
+ $this->assertSame( (string) $this->attachment_ids[1], get_post_meta( $request['id'], '_product_image_gallery', true ) );
+ foreach ( $this->uploads as $upload ) {
+ $this->assertFileExists( $upload['file'] );
+ }
+ foreach ( $this->attachment_ids as $attachment_id ) {
+ $this->assertInstanceOf( WP_Post::class, get_post( $attachment_id ) );
+ }
+ }
+}