Skip to content

Commit bd1935b

Browse files
authored
Merge pull request #1165 from cloudinary/bugfix/woocommerce-sideloading-duplication
WooCommerce: prevent image duplication when sideloading via the REST API
2 parents ad71ccb + a7c6269 commit bd1935b

2 files changed

Lines changed: 123 additions & 4 deletions

File tree

php/class-media.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3147,7 +3147,7 @@ public function setup() {
31473147
// Internal components.
31483148
$this->global_transformations = new Global_Transformations( $this );
31493149
$this->gallery = $this->plugin->get_component( 'gallery' );
3150-
$this->woocommerce_gallery = new WooCommerceGallery( $this->gallery );
3150+
$this->woocommerce_gallery = new WooCommerceGallery( $this->gallery, $this );
31513151
$this->filter = new Filter( $this );
31523152
$this->upgrade = new Upgrade( $this );
31533153
$this->video = new Video( $this );

php/media/class-woocommercegallery.php

Lines changed: 122 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,11 @@
77

88
namespace Cloudinary\Media;
99

10+
use Cloudinary\Media;
11+
use WP_REST_Request;
12+
use WP_REST_Response;
13+
use WP_REST_Server;
14+
1015
/**
1116
* Class WooCommerceGallery.
1217
*
@@ -20,16 +25,29 @@ class WooCommerceGallery {
2025
*/
2126
private $gallery;
2227

28+
/**
29+
* The media instance.
30+
*
31+
* @var Media
32+
*/
33+
private $media;
34+
2335
/**
2436
* Init woo gallery.
2537
*
2638
* @param Gallery $gallery Gallery instance.
39+
* @param Media $media Media instance.
2740
*/
28-
public function __construct( Gallery $gallery ) {
41+
public function __construct( Gallery $gallery, Media $media ) {
2942
$this->gallery = $gallery;
43+
$this->media = $media;
44+
45+
if ( self::woocommerce_active() ) {
46+
$this->setup_rest_hooks();
3047

31-
if ( self::woocommerce_active() && $this->enabled() ) {
32-
$this->setup_hooks();
48+
if ( $this->enabled() ) {
49+
$this->setup_hooks();
50+
}
3351
}
3452
}
3553

@@ -86,6 +104,13 @@ public function maybe_enqueue_scripts( $can ) {
86104
return $can;
87105
}
88106

107+
/**
108+
* Setup hooks for the REST API integration.
109+
*/
110+
public function setup_rest_hooks() {
111+
add_filter( 'rest_request_before_callbacks', array( $this, 'pre_process_product_images' ), 10, 3 );
112+
}
113+
89114
/**
90115
* Setup hooks for the gallery.
91116
*/
@@ -105,4 +130,98 @@ static function () {
105130

106131
add_filter( 'cloudinary_enqueue_gallery_script', array( $this, 'maybe_enqueue_scripts' ) );
107132
}
133+
134+
/**
135+
* Pre-process product images in REST API requests to resolve Cloudinary URLs to existing
136+
* media library attachment IDs, preventing unnecessary sideloads and duplicate assets.
137+
*
138+
* @param WP_REST_Response|null $response The response object or null.
139+
* @param WP_REST_Server $handler The request handler.
140+
* @param WP_REST_Request $request The request object.
141+
*
142+
* @return WP_REST_Response|null
143+
*/
144+
public function pre_process_product_images( $response, $handler, $request ) {
145+
$route = $request->get_route();
146+
$method = $request->get_method();
147+
148+
// Ignore requests to other API endpoints.
149+
if (
150+
false === strpos( $route, '/wc/' )
151+
|| false === strpos( $route, '/products' )
152+
|| ! in_array( $method, array( 'POST', 'PUT', 'PATCH' ), true )
153+
) {
154+
return $response;
155+
}
156+
157+
// We only care about requests that include images.
158+
$images = $request->get_param( 'images' );
159+
if ( empty( $images ) || ! is_array( $images ) ) {
160+
return $response;
161+
}
162+
163+
$modified = false;
164+
165+
foreach ( $images as $index => $image ) {
166+
// If the image ID is already passed, WooCommerce will be able to find the corresponding attachment from the Media Library.
167+
if ( ! empty( $image['id'] ) ) {
168+
continue;
169+
}
170+
171+
$src = isset( $image['src'] ) ? esc_url_raw( $image['src'] ) : '';
172+
173+
// We only care about images with a cloudinary URL.
174+
if ( ! $src || ! $this->media->is_cloudinary_url( $src ) ) {
175+
continue;
176+
}
177+
178+
$attachment_id = $this->find_attachment_by_cloudinary_url( $src );
179+
180+
// Apply the ID so that WooCommerce assigns the existing attachment.
181+
if ( ! is_null( $attachment_id ) ) {
182+
$images[ $index ]['id'] = $attachment_id;
183+
$modified = true;
184+
}
185+
}
186+
187+
if ( $modified ) {
188+
$request->set_param( 'images', $images );
189+
}
190+
191+
return $response;
192+
}
193+
194+
/**
195+
* Find an existing media library attachment that corresponds to a Cloudinary URL.
196+
*
197+
* The URL may include transformation segments, so the lookup proceeds in three steps:
198+
* exact sync key match, bare public ID match, then base key match.
199+
*
200+
* @param string $url A Cloudinary asset URL.
201+
*
202+
* @return int|null Attachment ID, or null if not found.
203+
*/
204+
private function find_attachment_by_cloudinary_url( $url ) {
205+
// Step 1: exact sync key — handles URLs that already exist verbatim in the library.
206+
$attachment_id = $this->media->get_id_from_url( $url );
207+
if ( $attachment_id ) {
208+
return $attachment_id;
209+
}
210+
211+
$public_id = $this->media->get_public_id_from_url( $url );
212+
if ( ! $public_id ) {
213+
return null;
214+
}
215+
216+
// Step 2: bare public ID — matches assets uploaded from WordPress without transformations.
217+
$linked = $this->media->get_linked_attachments( $public_id );
218+
if ( ! empty( $linked ) ) {
219+
return array_shift( $linked );
220+
}
221+
222+
// Step 3: base key — matches assets imported from Cloudinary.
223+
$base_id = $this->media->get_id_from_sync_key( 'base_' . $public_id );
224+
225+
return $base_id ? $base_id : null;
226+
}
108227
}

0 commit comments

Comments
 (0)