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']); + } +}