From 37edc1d7f0fcd75c9c59d4909b846e0a4d00ffc8 Mon Sep 17 00:00:00 2001 From: David Stone Date: Thu, 29 Jan 2026 11:49:15 -0700 Subject: [PATCH] Fix URL replacement failing for Elementor elements on subdirectory multisite Three bugs in MUCD_Data caused URLs in page builder elements (buttons, links) to not be replaced during site duplication on subdirectory installs: 1. replace() skipped replacement when the to-string already existed anywhere in the value. On subdirectory multisite, the upload URL replacement runs first and introduces the new site path, causing the subsequent blog URL replacement to skip entirely - leaving old URLs in buttons and links. 2. No JSON-escaped URL replacement. Elementor stores data as JSON where forward slashes are escaped (/ becomes \/). The plain search strings never matched these escaped URLs. Added a second pass with JSON-escaped versions of all URL replacement pairs. 3. try_replace() did not recurse into non-serialized arrays/objects. Nested data structures inside unserialized values had their URLs silently skipped. Adds 15 unit tests covering all replacement scenarios. Co-Authored-By: Claude Opus 4.5 --- inc/duplication/data.php | 46 ++- .../WP_Ultimo/Duplication/MUCD_Data_Test.php | 287 ++++++++++++++++++ 2 files changed, 322 insertions(+), 11 deletions(-) create mode 100644 tests/WP_Ultimo/Duplication/MUCD_Data_Test.php diff --git a/inc/duplication/data.php b/inc/duplication/data.php index 2c994d00b..17e2309e6 100644 --- a/inc/duplication/data.php +++ b/inc/duplication/data.php @@ -229,13 +229,30 @@ public static function db_update_data($from_site_id, $to_site_id, $saved_options $from_site_prefix = $wpdb->get_blog_prefix($from_site_id); $to_site_prefix = $wpdb->get_blog_prefix($to_site_id); + $from_upload_url_clean = wu_replace_scheme($from_upload_url); + $to_upload_url_clean = wu_replace_scheme($to_upload_url); + $from_upload_url_w_network_clean = wu_replace_scheme($from_upload_url_w_network); + $to_upload_url_w_network_clean = wu_replace_scheme($to_upload_url_w_network); + $from_blog_url_clean = wu_replace_scheme($from_blog_url); + $to_blog_url_clean = wu_replace_scheme($to_blog_url); + $string_to_replace = [ - wu_replace_scheme($from_upload_url) => wu_replace_scheme($to_upload_url), - wu_replace_scheme($from_upload_url_w_network) => wu_replace_scheme($to_upload_url_w_network), - wu_replace_scheme($from_blog_url) => wu_replace_scheme($to_blog_url), - $from_site_prefix => $to_site_prefix, + $from_upload_url_clean => $to_upload_url_clean, + $from_upload_url_w_network_clean => $to_upload_url_w_network_clean, + $from_blog_url_clean => $to_blog_url_clean, + $from_site_prefix => $to_site_prefix, + ]; + + // Add JSON-escaped versions of URLs (forward slashes escaped as \/). + // Page builders like Elementor store URLs in JSON where / becomes \/. + $json_replacements = [ + str_replace('/', '\\/', $from_upload_url_clean) => str_replace('/', '\\/', $to_upload_url_clean), + str_replace('/', '\\/', $from_upload_url_w_network_clean) => str_replace('/', '\\/', $to_upload_url_w_network_clean), + str_replace('/', '\\/', $from_blog_url_clean) => str_replace('/', '\\/', $to_blog_url_clean), ]; + $string_to_replace = array_merge($string_to_replace, $json_replacements); + $string_to_replace = apply_filters('mucd_string_to_replace', $string_to_replace, $from_site_id, $to_site_id); foreach ( $tables as $table => $field) { @@ -313,7 +330,6 @@ public static function update($table, $fields, $from_string, $to_string): void { /** * Replace $from_string with $to_string in $val - * Warning : if $to_string already in $val, no replacement is made * * @since 0.2.0 * @param string $val Original value to modify. @@ -322,15 +338,11 @@ public static function update($table, $fields, $from_string, $to_string): void { * @return string The new string. */ public static function replace($val, $from_string, $to_string) { - $new = $val; if (is_string($val)) { - $pos = strpos($val, $to_string); - if (false === $pos) { - $new = str_replace($from_string, $to_string, $val); - } + return str_replace($from_string, $to_string, $val); } - return $new; + return $val; } /** @@ -402,6 +414,18 @@ public static function try_replace($row, $field, $from_string, $to_string) { if ($double_serialize) { $row[ $field ] = serialize($row[ $field ]); } + } elseif (is_array($row[ $field ])) { + $row[ $field ] = self::replace_recursive($row[ $field ], $from_string, $to_string); + } elseif (is_object($row[ $field ])) { + $array_object = (array) $row[ $field ]; + $array_object = self::replace_recursive($array_object, $from_string, $to_string); + foreach ($array_object as $key => $value) { + try { + $row[ $field ]->$key = $value; + } catch (\Throwable $exception) { + // ...nothing + } + } } else { $row[ $field ] = self::replace($row[ $field ], $from_string, $to_string); } diff --git a/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php b/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php new file mode 100644 index 000000000..c259f95f6 --- /dev/null +++ b/tests/WP_Ultimo/Duplication/MUCD_Data_Test.php @@ -0,0 +1,287 @@ +assertEquals('Visit example.com/new-site for details', $result); + } + + /** + * Test replacement works when to_string already exists in value. + * + * This was the bug: the old code skipped replacement if $to_string + * was found anywhere in $val, which broke subdirectory multisite + * where upload URL replacement happened first. + */ + public function test_replace_when_to_string_already_present() { + // Simulate: upload URL was already replaced, now blog URL replacement runs. + // Value already contains "example.com/new-site" from previous replacement, + // but still has "example.com/old-site" in button URLs. + $val = 'https://example.com/new-site/wp-content/uploads/image.jpg and https://example.com/old-site/contact'; + + $result = \MUCD_Data::replace( + $val, + 'example.com/old-site', + 'example.com/new-site' + ); + + $this->assertEquals( + 'https://example.com/new-site/wp-content/uploads/image.jpg and https://example.com/new-site/contact', + $result + ); + } + + /** + * Test replacement with multiple occurrences of from_string. + */ + public function test_replace_multiple_occurrences() { + $val = 'Link: example.com/old and another: example.com/old/page'; + + $result = \MUCD_Data::replace($val, 'example.com/old', 'example.com/new'); + + $this->assertEquals('Link: example.com/new and another: example.com/new/page', $result); + } + + /** + * Test replacement with non-string value. + */ + public function test_replace_non_string_returns_unchanged() { + $this->assertEquals(42, \MUCD_Data::replace(42, 'foo', 'bar')); + $this->assertNull(\MUCD_Data::replace(null, 'foo', 'bar')); + $this->assertTrue(\MUCD_Data::replace(true, 'foo', 'bar')); + } + + /** + * Test replacement when from_string is not found. + */ + public function test_replace_no_match() { + $val = 'No matching content here'; + $result = \MUCD_Data::replace($val, 'example.com/old', 'example.com/new'); + + $this->assertEquals($val, $result); + } + + /** + * Test try_replace with serialized array data. + */ + public function test_try_replace_serialized_array() { + $data = serialize(['url' => 'https://example.com/old-site/page']); + $row = ['meta_value' => $data]; + + $result = \MUCD_Data::try_replace($row, 'meta_value', 'example.com/old-site', 'example.com/new-site'); + + $unserialized = unserialize($result); + $this->assertEquals('https://example.com/new-site/page', $unserialized['url']); + } + + /** + * Test try_replace with nested serialized data. + */ + public function test_try_replace_nested_serialized_array() { + $data = serialize([ + 'settings' => [ + 'link' => 'https://example.com/old-site/about', + 'icon' => 'fa-home', + ], + 'content' => 'Visit https://example.com/old-site for more', + ]); + $row = ['meta_value' => $data]; + + $result = \MUCD_Data::try_replace($row, 'meta_value', 'example.com/old-site', 'example.com/new-site'); + $unserialized = unserialize($result); + + $this->assertEquals('https://example.com/new-site/about', $unserialized['settings']['link']); + $this->assertEquals('Visit https://example.com/new-site for more', $unserialized['content']); + $this->assertEquals('fa-home', $unserialized['settings']['icon']); + } + + /** + * Test try_replace with plain (non-serialized) string. + */ + public function test_try_replace_plain_string() { + $row = ['post_content' => 'Link']; + + $result = \MUCD_Data::try_replace($row, 'post_content', 'example.com/old-site', 'example.com/new-site'); + + $this->assertEquals('Link', $result); + } + + /** + * Test try_replace with double-serialized data. + */ + public function test_try_replace_double_serialized() { + $data = serialize(serialize(['url' => 'https://example.com/old-site/page'])); + $row = ['meta_value' => $data]; + + $result = \MUCD_Data::try_replace($row, 'meta_value', 'example.com/old-site', 'example.com/new-site'); + + $unserialized = unserialize(unserialize($result)); + $this->assertEquals('https://example.com/new-site/page', $unserialized['url']); + } + + /** + * Test try_replace with serialized data containing the to_string already. + * + * Ensures the fix works: even when to_string is present, remaining + * from_string occurrences should still be replaced. + */ + public function test_try_replace_serialized_with_partial_replacement() { + $data = serialize([ + 'upload_url' => 'https://example.com/new-site/wp-content/uploads/img.jpg', + 'button_url' => 'https://example.com/old-site/contact', + 'page_url' => 'https://example.com/old-site/about', + ]); + $row = ['meta_value' => $data]; + + $result = \MUCD_Data::try_replace($row, 'meta_value', 'example.com/old-site', 'example.com/new-site'); + $unserialized = unserialize($result); + + $this->assertEquals('https://example.com/new-site/wp-content/uploads/img.jpg', $unserialized['upload_url']); + $this->assertEquals('https://example.com/new-site/contact', $unserialized['button_url']); + $this->assertEquals('https://example.com/new-site/about', $unserialized['page_url']); + } + + /** + * Test try_replace with Elementor-like JSON data stored in postmeta. + * + * JSON encodes forward slashes as \/, so the plain URL replacement won't + * match. The system handles this by running a separate pass with + * JSON-escaped search/replace strings at the DB level. + */ + public function test_try_replace_elementor_json_data() { + $elementor_data = json_encode([ + [ + 'elType' => 'section', + 'settings' => [], + 'elements' => [ + [ + 'elType' => 'widget', + 'settings' => [ + 'link' => [ + 'url' => 'https://example.com/old-site/services', + ], + 'button_link' => [ + 'url' => 'https://example.com/old-site/contact-us', + ], + ], + ], + ], + ], + ]); + + // Elementor stores _elementor_data as a plain JSON string (not serialized). + $row = ['meta_value' => $elementor_data]; + + // First pass: plain URL replacement (won't match JSON-escaped slashes). + $result = \MUCD_Data::try_replace($row, 'meta_value', 'example.com/old-site', 'example.com/new-site'); + + // Second pass: JSON-escaped URL replacement (matches \/ in JSON). + $row = ['meta_value' => $result]; + $result = \MUCD_Data::try_replace( + $row, + 'meta_value', + str_replace('/', '\\/', 'example.com/old-site'), + str_replace('/', '\\/', 'example.com/new-site') + ); + + $this->assertStringNotContainsString('old-site', $result); + $this->assertStringContainsString('new-site\\/services', $result); + $this->assertStringContainsString('new-site\\/contact-us', $result); + } + + /** + * Test replace with JSON-escaped forward slashes. + */ + public function test_replace_json_escaped_slashes() { + // JSON-encoded URL: forward slashes become \/ + $val = 'https:\\/\\/example.com\\/old-site\\/page'; + + $result = \MUCD_Data::replace( + $val, + str_replace('/', '\\/', 'example.com/old-site'), + str_replace('/', '\\/', 'example.com/new-site') + ); + + $this->assertEquals('https:\\/\\/example.com\\/new-site\\/page', $result); + } + + /** + * Test replace_recursive with nested array. + */ + public function test_replace_recursive_nested_array() { + $data = [ + 'level1' => [ + 'level2' => [ + 'url' => 'https://example.com/old/deep-page', + ], + ], + 'flat' => 'https://example.com/old/flat-page', + ]; + + $result = \MUCD_Data::replace_recursive($data, 'example.com/old', 'example.com/new'); + + $this->assertEquals('https://example.com/new/deep-page', $result['level1']['level2']['url']); + $this->assertEquals('https://example.com/new/flat-page', $result['flat']); + } + + /** + * Test replace_recursive with mixed types. + */ + public function test_replace_recursive_mixed_types() { + $data = [ + 'string' => 'https://example.com/old/page', + 'int' => 42, + 'bool' => true, + 'null' => null, + 'array' => ['https://example.com/old/nested'], + ]; + + $result = \MUCD_Data::replace_recursive($data, 'example.com/old', 'example.com/new'); + + $this->assertEquals('https://example.com/new/page', $result['string']); + $this->assertEquals(42, $result['int']); + $this->assertTrue($result['bool']); + $this->assertNull($result['null']); + $this->assertEquals('https://example.com/new/nested', $result['array'][0]); + } + + /** + * Test serialized data preserves string lengths correctly after replacement. + */ + public function test_try_replace_serialized_preserves_length() { + $data = serialize(['url' => 'https://example.com/short/page']); + $row = ['meta_value' => $data]; + + $result = \MUCD_Data::try_replace($row, 'meta_value', 'example.com/short', 'example.com/much-longer-path'); + + // Should be valid serialized data. + $unserialized = @unserialize($result); + $this->assertIsArray($unserialized); + $this->assertEquals('https://example.com/much-longer-path/page', $unserialized['url']); + } +}