From 8fc6512575ac5707a6ee93e60fe528936d51c092 Mon Sep 17 00:00:00 2001 From: Sukhendu Sekhar Guria Date: Thu, 25 Jun 2026 11:50:43 +0530 Subject: [PATCH] Sitemaps: Render sitemap requests before main query --- .../sitemaps/class-wp-sitemaps.php | 116 +++++++++++- tests/phpunit/tests/canonical/sitemaps.php | 44 ++++- tests/phpunit/tests/sitemaps/sitemaps.php | 168 +++++++++++++++++- 3 files changed, 318 insertions(+), 10 deletions(-) diff --git a/src/wp-includes/sitemaps/class-wp-sitemaps.php b/src/wp-includes/sitemaps/class-wp-sitemaps.php index d4bbe38c86b11..8023d24b57a4f 100644 --- a/src/wp-includes/sitemaps/class-wp-sitemaps.php +++ b/src/wp-includes/sitemaps/class-wp-sitemaps.php @@ -66,7 +66,7 @@ public function init() { // These will all fire on the init hook. $this->register_rewrites(); - add_action( 'template_redirect', array( $this, 'render_sitemaps' ) ); + add_action( 'parse_request', array( $this, 'render_sitemaps' ) ); if ( ! $this->sitemaps_enabled() ) { return; @@ -159,14 +159,33 @@ public function register_rewrites() { * @since 5.5.0 * * @global WP_Query $wp_query WordPress Query object. + * + * @param WP|null $wp Optional. Current WordPress environment instance. */ - public function render_sitemaps() { + public function render_sitemaps( $wp = null ) { global $wp_query; - $sitemap = sanitize_text_field( get_query_var( 'sitemap' ) ); - $object_subtype = sanitize_text_field( get_query_var( 'sitemap-subtype' ) ); - $stylesheet_type = sanitize_text_field( get_query_var( 'sitemap-stylesheet' ) ); - $paged = absint( get_query_var( 'paged' ) ); + $query_vars = $wp instanceof WP ? $wp->query_vars : $wp_query->query_vars; + + $sitemap = ''; + if ( ! empty( $query_vars['sitemap'] ) && is_scalar( $query_vars['sitemap'] ) ) { + $sitemap = sanitize_text_field( $query_vars['sitemap'] ); + } + + $object_subtype = ''; + if ( ! empty( $query_vars['sitemap-subtype'] ) && is_scalar( $query_vars['sitemap-subtype'] ) ) { + $object_subtype = sanitize_text_field( $query_vars['sitemap-subtype'] ); + } + + $stylesheet_type = ''; + if ( ! empty( $query_vars['sitemap-stylesheet'] ) && is_scalar( $query_vars['sitemap-stylesheet'] ) ) { + $stylesheet_type = sanitize_text_field( $query_vars['sitemap-stylesheet'] ); + } + + $paged = 0; + if ( ! empty( $query_vars['paged'] ) && is_scalar( $query_vars['paged'] ) ) { + $paged = absint( $query_vars['paged'] ); + } // Bail early if this isn't a sitemap or stylesheet route. if ( ! ( $sitemap || $stylesheet_type ) ) { @@ -176,9 +195,18 @@ public function render_sitemaps() { if ( ! $this->sitemaps_enabled() ) { $wp_query->set_404(); status_header( 404 ); + + if ( $wp instanceof WP ) { + exit; + } + return; } + if ( $wp instanceof WP ) { + $this->redirect_sitemap_to_canonical_url( $sitemap, $object_subtype, $stylesheet_type, $paged ); + } + // Render stylesheet if this is stylesheet route. if ( $stylesheet_type ) { $stylesheet = new WP_Sitemaps_Stylesheet(); @@ -198,6 +226,13 @@ public function render_sitemaps() { $provider = $this->registry->get_provider( $sitemap ); if ( ! $provider ) { + $wp_query->set_404(); + status_header( 404 ); + + if ( $wp instanceof WP ) { + exit; + } + return; } @@ -211,6 +246,11 @@ public function render_sitemaps() { if ( empty( $url_list ) ) { $wp_query->set_404(); status_header( 404 ); + + if ( $wp instanceof WP ) { + exit; + } + return; } @@ -218,6 +258,70 @@ public function render_sitemaps() { exit; } + /** + * Redirects sitemap requests to their canonical URL. + * + * @since x.x.x + * + * @param string $sitemap Sitemap name. + * @param string $object_subtype Sitemap object subtype. + * @param string $stylesheet_type Sitemap stylesheet type. + * @param int $paged Sitemap page number. + */ + private function redirect_sitemap_to_canonical_url( $sitemap, $object_subtype, $stylesheet_type, $paged ) { + global $wp_rewrite; + + if ( + ! $wp_rewrite->using_permalinks() + || empty( $_SERVER['HTTP_HOST'] ) + || ! is_scalar( $_SERVER['HTTP_HOST'] ) + || empty( $_SERVER['REQUEST_URI'] ) + || ! is_scalar( $_SERVER['REQUEST_URI'] ) + ) { + return; + } + + $redirect_url = ''; + + if ( $sitemap ) { + $redirect_url = get_sitemap_url( $sitemap, $object_subtype, $paged ); + } elseif ( 'sitemap' === $stylesheet_type ) { + $redirect_url = home_url( '/wp-sitemap.xsl' ); + } elseif ( 'index' === $stylesheet_type ) { + $redirect_url = home_url( '/wp-sitemap-index.xsl' ); + } + + if ( ! $redirect_url ) { + return; + } + + $request_uri = (string) wp_unslash( $_SERVER['REQUEST_URI'] ); + $query_string = wp_parse_url( $request_uri, PHP_URL_QUERY ); + + if ( is_string( $query_string ) && '' !== $query_string ) { + wp_parse_str( $query_string, $query_args ); + $redirect_url = add_query_arg( + array_diff_key( + $query_args, + array_flip( array( 'sitemap', 'sitemap-subtype', 'sitemap-stylesheet', 'paged' ) ) + ), + $redirect_url + ); + } + + $requested_url = is_ssl() ? 'https://' : 'http://'; + $requested_url .= (string) wp_unslash( $_SERVER['HTTP_HOST'] ); + $requested_url .= $request_uri; + + if ( $requested_url === $redirect_url ) { + return; + } + + if ( wp_redirect( $redirect_url, 301 ) ) { + exit; + } + } + /** * Redirects a URL to the wp-sitemap.xml * diff --git a/tests/phpunit/tests/canonical/sitemaps.php b/tests/phpunit/tests/canonical/sitemaps.php index 97fa6c4ee8937..d2883d7558f5a 100644 --- a/tests/phpunit/tests/canonical/sitemaps.php +++ b/tests/phpunit/tests/canonical/sitemaps.php @@ -10,8 +10,48 @@ class Tests_Canonical_Sitemaps extends WP_Canonical_UnitTestCase { public function set_up() { parent::set_up(); - $wp_sitemaps = new WP_Sitemaps(); - $wp_sitemaps->init(); + wp_sitemaps_get_server(); + $this->remove_sitemap_parse_request_callbacks(); + } + + public function tear_down() { + add_action( 'parse_request', array( wp_sitemaps_get_server(), 'render_sitemaps' ) ); + + parent::tear_down(); + } + + /** + * Assert that a given URL is the same as the canonical URL generated by WP. + * + * @param string $test_url Raw URL that will be run through redirect_canonical(). + * @param string $expected Expected string. + * @param int $ticket Optional. Trac ticket number. + * @param array $expected_doing_it_wrong Array of class/function names expected to throw _doing_it_wrong() notices. + */ + public function assertCanonical( $test_url, $expected, $ticket = 0, $expected_doing_it_wrong = array() ) { + $this->remove_sitemap_parse_request_callbacks(); + parent::assertCanonical( $test_url, $expected, $ticket, $expected_doing_it_wrong ); + } + + /** + * Removes sitemap rendering callbacks so canonical tests can inspect redirects. + */ + private function remove_sitemap_parse_request_callbacks() { + global $wp_filter; + + if ( empty( $wp_filter['parse_request']->callbacks ) ) { + return; + } + + foreach ( $wp_filter['parse_request']->callbacks as $priority => $callbacks ) { + foreach ( $callbacks as $callback ) { + $function = $callback['function']; + + if ( is_array( $function ) && $function[0] instanceof WP_Sitemaps && 'render_sitemaps' === $function[1] ) { + remove_action( 'parse_request', $function, $priority ); + } + } + } } public function test_remove_trailing_slashes_for_sitemap_index_requests() { diff --git a/tests/phpunit/tests/sitemaps/sitemaps.php b/tests/phpunit/tests/sitemaps/sitemaps.php index 85f9965245842..9a51363f48950 100644 --- a/tests/phpunit/tests/sitemaps/sitemaps.php +++ b/tests/phpunit/tests/sitemaps/sitemaps.php @@ -449,6 +449,151 @@ public function test_robots_text_prefixed_with_line_feed() { $this->assertStringContainsString( $sitemap_string, $robots_text, 'Sitemap URL not prefixed with "\n".' ); } + /** + * @ticket 51117 + */ + public function test_sitemaps_render_on_parse_request() { + $sitemaps = wp_sitemaps_get_server(); + + $this->assertSame( 10, has_action( 'parse_request', array( $sitemaps, 'render_sitemaps' ) ) ); + $this->assertFalse( has_action( 'template_redirect', array( $sitemaps, 'render_sitemaps' ) ) ); + } + + /** + * @ticket 51117 + */ + public function test_sitemap_requests_short_circuit_before_main_query() { + wp_sitemaps_get_server(); + + $pre_get_posts = static function ( $query ) { + throw new Exception( 'The main query was run.' ); + }; + $set_404 = static function ( $query ) { + throw new Exception( 'The sitemap request was handled.' ); + }; + + add_action( 'pre_get_posts', $pre_get_posts ); + add_action( 'set_404', $set_404 ); + + try { + $this->go_to( home_url( '/?sitemap=unknown' ) ); + $this->fail( 'The sitemap request was not handled.' ); + } catch ( Exception $e ) { + $this->assertSame( 'The sitemap request was handled.', $e->getMessage() ); + } finally { + remove_action( 'pre_get_posts', $pre_get_posts ); + remove_action( 'set_404', $set_404 ); + } + } + + /** + * @ticket 51117 + * + * @dataProvider data_sitemap_canonical_redirects + * + * @param array $query_vars Query vars for the sitemap request. + * @param string $request_uri Requested URI. + * @param string $expected_path Expected redirect path. + */ + public function test_sitemap_requests_redirect_to_canonical_url_early( $query_vars, $request_uri, $expected_path ) { + $this->set_permalink_structure( '/%postname%/' ); + + $old_http_host = $_SERVER['HTTP_HOST'] ?? null; + $old_request_uri = $_SERVER['REQUEST_URI'] ?? null; + + $_SERVER['HTTP_HOST'] = wp_parse_url( home_url(), PHP_URL_HOST ); + $_SERVER['REQUEST_URI'] = $request_uri; + + $redirect_url = null; + $redirect = static function ( $location ) use ( &$redirect_url ) { + $redirect_url = $location; + throw new Exception( 'The sitemap request was redirected.' ); + }; + + add_filter( 'wp_redirect', $redirect ); + + try { + $wp = new WP(); + $wp->query_vars = $query_vars; + + wp_sitemaps_get_server()->render_sitemaps( $wp ); + $this->fail( 'The sitemap request was not redirected.' ); + } catch ( Exception $e ) { + $this->assertSame( 'The sitemap request was redirected.', $e->getMessage() ); + } finally { + remove_filter( 'wp_redirect', $redirect ); + + if ( null === $old_http_host ) { + unset( $_SERVER['HTTP_HOST'] ); + } else { + $_SERVER['HTTP_HOST'] = $old_http_host; + } + + if ( null === $old_request_uri ) { + unset( $_SERVER['REQUEST_URI'] ); + } else { + $_SERVER['REQUEST_URI'] = $old_request_uri; + } + } + + $this->assertSame( home_url( $expected_path ), $redirect_url ); + } + + /** + * Data provider for test_sitemap_requests_redirect_to_canonical_url_early(). + * + * @return array[] { + * Data to test with. + * + * @type array $0 Query vars for the sitemap request. + * @type string $1 Requested URI. + * @type string $2 Expected redirect path. + * } + */ + public static function data_sitemap_canonical_redirects() { + return array( + 'index' => array( + array( 'sitemap' => 'index' ), + '/?sitemap=index', + '/wp-sitemap.xml', + ), + 'index with query' => array( + array( 'sitemap' => 'index' ), + '/?sitemap=index&foo=bar', + '/wp-sitemap.xml?foo=bar', + ), + 'legacy index' => array( + array( 'sitemap' => 'index' ), + '/sitemap.xml?foo=bar', + '/wp-sitemap.xml?foo=bar', + ), + 'posts' => array( + array( + 'sitemap' => 'posts', + 'sitemap-subtype' => 'post', + 'paged' => 2, + ), + '/?sitemap=posts&sitemap-subtype=post&paged=2', + '/wp-sitemap-posts-post-2.xml', + ), + 'stylesheet' => array( + array( 'sitemap-stylesheet' => 'sitemap' ), + '/?sitemap-stylesheet=sitemap', + '/wp-sitemap.xsl', + ), + 'stylesheet query' => array( + array( 'sitemap-stylesheet' => 'sitemap' ), + '/?sitemap-stylesheet=sitemap&foo=bar', + '/wp-sitemap.xsl?foo=bar', + ), + 'index stylesheet' => array( + array( 'sitemap-stylesheet' => 'index' ), + '/?sitemap-stylesheet=index', + '/wp-sitemap-index.xsl', + ), + ); + } + /** * @ticket 50643 */ @@ -468,9 +613,11 @@ public function test_sitemaps_enabled() { * @preserveGlobalState disabled */ public function test_disable_sitemap_should_return_404() { + global $wp_query; + add_filter( 'wp_sitemaps_enabled', '__return_false' ); - $this->go_to( home_url( '/?sitemap=index' ) ); + $wp_query->query_vars = array( 'sitemap' => 'index' ); wp_sitemaps_get_server()->render_sitemaps(); @@ -485,9 +632,26 @@ public function test_disable_sitemap_should_return_404() { * @preserveGlobalState disabled */ public function test_empty_url_list_should_return_404() { + global $wp_query; + wp_register_sitemap_provider( 'foo', new WP_Sitemaps_Empty_Test_Provider( 'foo' ) ); - $this->go_to( home_url( '/?sitemap=foo' ) ); + $wp_query->query_vars = array( 'sitemap' => 'foo' ); + + wp_sitemaps_get_server()->render_sitemaps(); + + $this->assertTrue( is_404() ); + } + + /** + * @ticket 51117 + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + public function test_unknown_sitemap_provider_should_return_404() { + global $wp_query; + + $wp_query->query_vars = array( 'sitemap' => 'unknown' ); wp_sitemaps_get_server()->render_sitemaps();