From 233d0e0d5a8b921188583a9b34ccf5684a569c7c Mon Sep 17 00:00:00 2001 From: Gabriel de Tassigny Date: Tue, 15 Sep 2026 11:24:52 +0200 Subject: [PATCH 1/3] Fix media uploaded in a non-default WPML language never finishing sync WPML hooks WordPress's `home_url` filter to inject the browsing language into every generated URL. Since rest_url() applies that filter to the REST base before the endpoint path is appended, a non-default language corrupts the background sync loopback URL into e.g. `/wp-json/?lang=fr/cloudinary/v1/queue` instead of a valid route. The loopback request "succeeds" (200) by hitting the REST index rather than the sync endpoint, so queued assets are silently never processed and stay stuck in a "syncing" state. --- php/integrations/class-wpml.php | 34 +++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/php/integrations/class-wpml.php b/php/integrations/class-wpml.php index cbbf050cb..b546a99e7 100644 --- a/php/integrations/class-wpml.php +++ b/php/integrations/class-wpml.php @@ -64,6 +64,7 @@ public function register_hooks() { add_filter( 'cloudinary_media_context_query', array( $this, 'filter_media_context_query' ) ); add_filter( 'cloudinary_media_context_things', array( $this, 'filter_media_context_things' ) ); add_filter( 'cloudinary_home_url', array( $this, 'home_url' ) ); + add_filter( 'cloudinary_rest_url', array( $this, 'rest_url' ), 10, 3 ); add_action( 'cloudinary_edit_asset_permalink', array( $this, 'add_locale' ) ); add_filter( 'cloudinary_contextualized_post_id', array( $this, 'contextualized_post_id' ) ); add_filter( 'wpml_admin_language_switcher_items', array( $this, 'language_switcher_items' ) ); @@ -197,6 +198,39 @@ public function home_url() { return get_option( 'home' ); } + /** + * Rebuild the REST URL with WPML's URL-language filtering removed. + * + * WPML hooks WordPress's `home_url` filter to inject the browsing language into every + * generated URL. Since `rest_url()` applies that filter to the REST base before the + * endpoint path is appended, a non-default language corrupts the URL, e.g. + * `/wp-json/?lang=fr/cloudinary/v1/queue` instead of `/wp-json/cloudinary/v1/queue`. + * This silently breaks the background sync loopback request: it "succeeds" by hitting + * the REST index instead of the intended route, so queued assets never finish syncing. + * + * `WPML_URL_Filters::remove_global_hooks()`/`add_global_hooks()` is WPML's own supported + * way of getting a clean, language-unfiltered URL (used by WPML's own Google Site Kit and + * canonical-URL compatibility code for the same reason). + * + * @param string $rest_url The REST url, already corrupted by WPML's `home_url` filter. + * @param string $path The REST path that was requested. + * @param string|null $scheme The scheme used for the REST url. + * + * @return string + */ + public function rest_url( $rest_url, $path, $scheme ) { + if ( ! class_exists( 'WPML_URL_Filters' ) || ! function_exists( 'WPML\Container\make' ) ) { + return $rest_url; + } + + $url_filters = make( 'WPML_URL_Filters' ); + $url_filters->remove_global_hooks(); + $rest_url = rest_url( $path, $scheme ); + $url_filters->add_global_hooks(); + + return $rest_url; + } + /** * Add the locale to the edit asset link. * This will ensure that the asset is edited in the correct language. From dd29364cbb1678d52b177320969a0019e00a48c3 Mon Sep 17 00:00:00 2001 From: Gabriel de Tassigny Date: Tue, 15 Sep 2026 12:46:05 +0200 Subject: [PATCH 2/3] Re-apply the language via wpml_permalink after cleaning the REST URL remove_global_hooks() alone only fixes directory/domain negotiation mode, where WPML separately hooks core's rest_url filter to re-insert the language after the full URL is built. In parameter mode (WPML_Lang_Parameter_Filters), nothing does that, so the cleaned URL was missing ?lang= entirely - not just for the sync loopback, but for every Utils::rest_url() consumer (asset fetch/save, cache purge, analytics, UI state), silently running them under the default language's context. wpml_permalink is WPML's documented API for exactly this and is idempotent across all three negotiation modes. Also wraps the hook removal in try/finally so a thrown exception can't leave WPML's URL filters permanently disabled for the rest of the request, and guards the undocumented remove_global_hooks/ add_global_hooks methods with method_exists(). Addresses review feedback on PR #1281. --- php/integrations/class-wpml.php | 35 ++++++++++++++++++++++++++------- 1 file changed, 28 insertions(+), 7 deletions(-) diff --git a/php/integrations/class-wpml.php b/php/integrations/class-wpml.php index b546a99e7..97c8cd86b 100644 --- a/php/integrations/class-wpml.php +++ b/php/integrations/class-wpml.php @@ -199,7 +199,8 @@ public function home_url() { } /** - * Rebuild the REST URL with WPML's URL-language filtering removed. + * Rebuild the REST URL so it carries the current language correctly in every WPML + * negotiation mode. * * WPML hooks WordPress's `home_url` filter to inject the browsing language into every * generated URL. Since `rest_url()` applies that filter to the REST base before the @@ -208,9 +209,22 @@ public function home_url() { * This silently breaks the background sync loopback request: it "succeeds" by hitting * the REST index instead of the intended route, so queued assets never finish syncing. * - * `WPML_URL_Filters::remove_global_hooks()`/`add_global_hooks()` is WPML's own supported - * way of getting a clean, language-unfiltered URL (used by WPML's own Google Site Kit and - * canonical-URL compatibility code for the same reason). + * First, `WPML_URL_Filters::remove_global_hooks()`/`add_global_hooks()` (WPML's own + * supported way of getting a clean, language-unfiltered URL, also used by WPML's Google + * Site Kit and canonical-URL compatibility code) strips that `home_url` mangling. + * + * That alone is only enough in directory/domain negotiation mode, where WPML separately + * hooks core's `rest_url` filter directly (`WPML_URL_Converter_Subdir_Strategy`/ + * `_Domain_Strategy`) to re-insert the language after the full URL is built - a hook + * `remove_global_hooks()` doesn't touch. In "language as a parameter" mode, nothing does + * that (`WPML_Lang_Parameter_Filters` only hooks `request`/`get_pagenum_link`/ + * `wp_link_pages_link`), so the clean URL would be missing `?lang=` entirely - not just for + * this loopback request, but for every browser-facing endpoint built via `Utils::rest_url()` + * (asset fetch/save, cache purge, analytics, UI state), silently running them under the + * default language's context instead. Re-applying the language via `wpml_permalink` - + * WPML's documented public filter, routing to `WPML_URL_Converter::convert_url()` - fixes + * that: it's a no-op when the URL already carries the right language (directory/domain + * mode), and appends `?lang=` correctly when it doesn't (parameter mode). * * @param string $rest_url The REST url, already corrupted by WPML's `home_url` filter. * @param string $path The REST path that was requested. @@ -224,11 +238,18 @@ public function rest_url( $rest_url, $path, $scheme ) { } $url_filters = make( 'WPML_URL_Filters' ); + if ( ! method_exists( $url_filters, 'remove_global_hooks' ) || ! method_exists( $url_filters, 'add_global_hooks' ) ) { + return $rest_url; + } + $url_filters->remove_global_hooks(); - $rest_url = rest_url( $path, $scheme ); - $url_filters->add_global_hooks(); + try { + $clean_rest_url = rest_url( $path, $scheme ); + } finally { + $url_filters->add_global_hooks(); + } - return $rest_url; + return apply_filters( 'wpml_permalink', $clean_rest_url, apply_filters( 'wpml_current_language', null ) ); } /** From b472240641fc5cd610fd61cc66b870b50252d780 Mon Sep 17 00:00:00 2001 From: Gabriel de Tassigny Date: Tue, 15 Sep 2026 12:56:23 +0200 Subject: [PATCH 3/3] Trim rest_url() docblock --- php/integrations/class-wpml.php | 27 ++++----------------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/php/integrations/class-wpml.php b/php/integrations/class-wpml.php index 97c8cd86b..7a19358ce 100644 --- a/php/integrations/class-wpml.php +++ b/php/integrations/class-wpml.php @@ -202,29 +202,10 @@ public function home_url() { * Rebuild the REST URL so it carries the current language correctly in every WPML * negotiation mode. * - * WPML hooks WordPress's `home_url` filter to inject the browsing language into every - * generated URL. Since `rest_url()` applies that filter to the REST base before the - * endpoint path is appended, a non-default language corrupts the URL, e.g. - * `/wp-json/?lang=fr/cloudinary/v1/queue` instead of `/wp-json/cloudinary/v1/queue`. - * This silently breaks the background sync loopback request: it "succeeds" by hitting - * the REST index instead of the intended route, so queued assets never finish syncing. - * - * First, `WPML_URL_Filters::remove_global_hooks()`/`add_global_hooks()` (WPML's own - * supported way of getting a clean, language-unfiltered URL, also used by WPML's Google - * Site Kit and canonical-URL compatibility code) strips that `home_url` mangling. - * - * That alone is only enough in directory/domain negotiation mode, where WPML separately - * hooks core's `rest_url` filter directly (`WPML_URL_Converter_Subdir_Strategy`/ - * `_Domain_Strategy`) to re-insert the language after the full URL is built - a hook - * `remove_global_hooks()` doesn't touch. In "language as a parameter" mode, nothing does - * that (`WPML_Lang_Parameter_Filters` only hooks `request`/`get_pagenum_link`/ - * `wp_link_pages_link`), so the clean URL would be missing `?lang=` entirely - not just for - * this loopback request, but for every browser-facing endpoint built via `Utils::rest_url()` - * (asset fetch/save, cache purge, analytics, UI state), silently running them under the - * default language's context instead. Re-applying the language via `wpml_permalink` - - * WPML's documented public filter, routing to `WPML_URL_Converter::convert_url()` - fixes - * that: it's a no-op when the URL already carries the right language (directory/domain - * mode), and appends `?lang=` correctly when it doesn't (parameter mode). + * WPML's `home_url` filter corrupts REST URLs for a non-default language (e.g. + * `/wp-json/?lang=fr/cloudinary/v1/queue`), so `remove_global_hooks()` strips it before + * rebuilding the URL, then `wpml_permalink` re-applies the language correctly - a no-op in + * directory/domain mode, and required in parameter mode, which has no other way to do it. * * @param string $rest_url The REST url, already corrupted by WPML's `home_url` filter. * @param string $path The REST path that was requested.