diff --git a/inc/class-domain-mapping.php b/inc/class-domain-mapping.php index 4cf9df21..abbbcaa8 100644 --- a/inc/class-domain-mapping.php +++ b/inc/class-domain-mapping.php @@ -372,7 +372,14 @@ public function verify_dns_mapping($current_site, $domain, $path) { // phpcs:ign $mapping = Domain::get_by_domain($domains); if ($mapping) { - wp_send_json($mapping->to_array()); + // Not using wp_send_json(): it calls get_option('blog_charset') before $wpdb->set_prefix() has run during early multisite bootstrap, producing a MariaDB syntax error against an empty options table name. + if ( ! headers_sent()) { + header('Content-Type: application/json; charset=UTF-8'); + } + + echo wp_json_encode($mapping->to_array()); + + exit; } } } @@ -646,8 +653,8 @@ public function replace_url($url, $current_mapping = null) { $domain = rtrim($domain . '/' . preg_quote(ltrim($path, '/'), '#'), '/'); } - $regex = '#^(\w+://)' . $domain . '#i'; - $mangled = preg_replace($regex, '${1}' . $current_mapping->get_domain(), $url); + $regex = '#^(\w+://)' . $domain . '#i'; + $mangled = preg_replace($regex, '${1}' . $current_mapping->get_domain(), $url); /* * Another try if we don't need to deal with subdirectory. diff --git a/tests/WP_Ultimo/Domain_Mapping_Test.php b/tests/WP_Ultimo/Domain_Mapping_Test.php index fea60ef9..2427ba83 100644 --- a/tests/WP_Ultimo/Domain_Mapping_Test.php +++ b/tests/WP_Ultimo/Domain_Mapping_Test.php @@ -639,8 +639,14 @@ public function test_fix_srcset_no_mapping_returns_unchanged(): void { $this->domain_mapping->current_mapping = null; $sources = [ - 100 => ['url' => 'http://example.com/image-100.jpg', 'value' => 100], - 200 => ['url' => 'http://example.com/image-200.jpg', 'value' => 200], + 100 => [ + 'url' => 'http://example.com/image-100.jpg', + 'value' => 100, + ], + 200 => [ + 'url' => 'http://example.com/image-200.jpg', + 'value' => 200, + ], ]; $result = $this->domain_mapping->fix_srcset($sources); @@ -654,7 +660,10 @@ public function test_fix_srcset_no_mapping_returns_unchanged(): void { public function test_fix_srcset_returns_array(): void { $sources = [ - 100 => ['url' => 'http://example.com/image.jpg', 'value' => 100], + 100 => [ + 'url' => 'http://example.com/image.jpg', + 'value' => 100, + ], ]; $result = $this->domain_mapping->fix_srcset($sources); @@ -960,7 +969,7 @@ public function test_original_url_can_be_set(): void { * @param string $domain Domain string. * @return Domain */ - private function create_db_domain( string $domain ): Domain { + private function create_db_domain(string $domain): Domain { $result = wu_create_domain( [ @@ -974,7 +983,7 @@ private function create_db_domain( string $domain ): Domain { ); if ( is_wp_error( $result ) || ! $result instanceof Domain ) { - $this->markTestSkipped( 'Could not create domain record: ' . ( is_wp_error( $result ) ? $result->get_error_message() : 'unknown' ) ); + $this->markTestSkipped( 'Could not create domain record: ' . (is_wp_error( $result ) ? $result->get_error_message() : 'unknown') ); } return $result; @@ -985,7 +994,7 @@ private function create_db_domain( string $domain ): Domain { * * @param string $domain Domain string. */ - private function flush_domain_cache( string $domain ): void { + private function flush_domain_cache(string $domain): void { wp_cache_delete( 'domain:' . $domain, 'domain_mappings' ); wp_cache_delete( 'domain:www.' . $domain, 'domain_mappings' ); @@ -1066,6 +1075,63 @@ public function test_verify_dns_mapping_is_callable(): void { $this->assertTrue(is_callable([$this->domain_mapping, 'verify_dns_mapping'])); } + /** + * Regression: when verify_dns_mapping fires very early in the multisite + * bootstrap (`pre_get_site_by_path` / `ms_site_not_found`), `$wpdb->options` + * is empty because `$wpdb->set_prefix()` runs later in `ms-settings.php`. + * + * Calling `wp_send_json()` at that stage internally calls + * `get_option( 'blog_charset' )`, which produced + * + * "WordPress database error You have an error in your SQL syntax; + * check the manual that corresponds to your MariaDB server version + * for the right syntax to use near 'WHERE option_name = 'blog_charset' + * LIMIT 1' at line 1 for query SELECT option_value FROM WHERE + * option_name = 'blog_charset' LIMIT 1" + * + * The fix emits the JSON response inline with a hard-coded UTF-8 charset, + * bypassing `get_option()` entirely. + * + * Because the matching branch calls `exit`, this regression test exercises + * the early-bootstrap path with a matching nonce but no persisted mapping — + * that path still runs `wp_hash()` and the `require_once` includes against + * the empty `$wpdb->options`, and must not produce a `wpdb->last_error`. + */ + public function test_verify_dns_mapping_does_not_query_options_table_in_early_bootstrap(): void { + + global $wpdb; + + // Capture any wpdb error produced during the call. + $wpdb->last_error = ''; + + // Simulate the empty $wpdb->options state seen in the real bug: + // at `pre_get_site_by_path` time, `set_prefix` has not run yet. + $saved_options = $wpdb->options; + $wpdb->options = ''; + $_REQUEST['async_check_dns_nonce'] = wp_hash('regression-blog-charset.test'); + + try { + // No mapping is persisted for this domain, so Domain::get_by_domain() + // returns false and the method returns without reaching the `exit` + // branch. That still exercises the wp_hash() and require_once paths + // against the empty options table. + $this->domain_mapping->verify_dns_mapping(null, 'regression-blog-charset.test', '/'); + } finally { + // Restore options table reference and clean up no matter what. + $wpdb->options = $saved_options; + unset($_REQUEST['async_check_dns_nonce']); + } + + // Critically, no SQL syntax error was produced — the old code path + // would have populated wpdb->last_error with the malformed + // "SELECT option_value FROM WHERE option_name = 'blog_charset'" query. + $this->assertSame( + '', + $wpdb->last_error, + 'verify_dns_mapping must not query the options table during early multisite bootstrap.' + ); + } + // ---------------------------------------------------------------- // register_mapped_filters (with current_blog set) // ---------------------------------------------------------------- @@ -1117,7 +1183,10 @@ public function test_fix_srcset_mapping_no_site_returns_unchanged(): void { $this->domain_mapping->current_mapping = $mapping; $sources = [ - 100 => ['url' => 'http://example.com/image.jpg', 'value' => 100], + 100 => [ + 'url' => 'http://example.com/image.jpg', + 'value' => 100, + ], ]; $result = $this->domain_mapping->fix_srcset($sources); @@ -1325,7 +1394,7 @@ public function test_clear_mappings_on_delete_with_mappings(): void { // Verify the domain is gone from the DB. $this->flush_domain_cache( $domain_str ); - $fetched = Domain::get_by_domain( [ $domain_str ] ); + $fetched = Domain::get_by_domain( [$domain_str] ); $this->assertNull( $fetched ); } @@ -1349,8 +1418,14 @@ public function test_fix_srcset_with_valid_mapping_replaces_urls(): void { $this->domain_mapping->current_mapping = $mapping; $sources = [ - 100 => [ 'url' => 'http://example.org/image-100.jpg', 'value' => 100 ], - 200 => [ 'url' => 'http://example.org/image-200.jpg', 'value' => 200 ], + 100 => [ + 'url' => 'http://example.org/image-100.jpg', + 'value' => 100, + ], + 200 => [ + 'url' => 'http://example.org/image-200.jpg', + 'value' => 200, + ], ]; $result = $this->domain_mapping->fix_srcset( $sources ); @@ -1358,7 +1433,7 @@ public function test_fix_srcset_with_valid_mapping_replaces_urls(): void { $this->assertIsArray( $result ); // At least one URL should be mangled to the mapped domain. $all_urls = array_column( $result, 'url' ); - $mangled = array_filter( $all_urls, fn( $u ) => str_contains( $u, 'srcset-mapped.example.com' ) ); + $mangled = array_filter( $all_urls, fn($u) => str_contains( $u, 'srcset-mapped.example.com' ) ); $this->assertNotEmpty( $mangled ); $this->domain_mapping->current_mapping = null; @@ -1396,11 +1471,11 @@ public function test_register_mapped_filters_with_db_mapping_registers_filters() $this->domain_mapping->register_mapped_filters(); // site_url filter should now be registered. - $this->assertTrue( has_filter( 'site_url', [ $this->domain_mapping, 'mangle_url' ] ) !== false ); + $this->assertTrue( has_filter( 'site_url', [$this->domain_mapping, 'mangle_url'] ) !== false ); // Clean up. - remove_filter( 'site_url', [ $this->domain_mapping, 'mangle_url' ], -10 ); - remove_filter( 'home_url', [ $this->domain_mapping, 'mangle_url' ], -10 ); + remove_filter( 'site_url', [$this->domain_mapping, 'mangle_url'], -10 ); + remove_filter( 'home_url', [$this->domain_mapping, 'mangle_url'], -10 ); unset( $_SERVER['HTTP_HOST'] ); $domain->delete(); $this->flush_domain_cache( $domain_str ); @@ -1512,5 +1587,4 @@ public function test_replace_url_second_attempt_branch(): void { $this->domain_mapping->current_mapping = null; } - }