diff --git a/src/wp-includes/class-wp.php b/src/wp-includes/class-wp.php index 1907844e95ef4..d7c2add3742b2 100644 --- a/src/wp-includes/class-wp.php +++ b/src/wp-includes/class-wp.php @@ -720,9 +720,9 @@ public function query_posts() { * - {@see wp_random_content_post_limits()} on the {@see 'post_limits'} filter. * * If no post is selected, for example on an empty archive, the request is a 404 as - * there is no post to redirect to. If a post is selected but the request is not - * redirected, the request continues with the results of the main query. The main - * query is not run again. + * there is no post to redirect to. If a post is selected, the request ends with + * the redirect. Otherwise the request continues with the results of the main + * query, which is not run again. * * @since 7.2.0 * diff --git a/src/wp-includes/query.php b/src/wp-includes/query.php index 40a9a285c9fbe..7a21ba50dfabf 100644 --- a/src/wp-includes/query.php +++ b/src/wp-includes/query.php @@ -1465,13 +1465,16 @@ function wp_random_content_post_limits( $limits, WP_Query $query ): string { * Redirects random content requests to the randomly selected post. * * Called by {@see WP::handle_random()} immediately after the main query selects the - * random post, to avoid unnecessary processing before the redirect. As the redirect - * is sent before {@see WP::send_headers()}, the headers preventing the redirect being - * cached or indexed are sent with it. + * random post, to avoid unnecessary processing before the redirect. The headers + * preventing the response being cached or indexed are sent as soon as a post is + * selected, before {@see WP::send_headers()}, so they are sent with the redirect. + * + * The request ends once a post is selected. The redirect is to the post's permalink + * as returned by {@see get_permalink()}, including a permalink filtered to be on + * another site. * * If no post is selected, for example on an empty archive, {@see WP::handle_random()} - * handles the request as a 404. If the post's permalink is on another site, the - * request is handled as a regular archive request. + * handles the request as a 404. * * @since 7.2.0 * @@ -1484,22 +1487,16 @@ function wp_random_content_redirect(): void { return; } - $location = get_permalink( $wp_query->post ); - - // Only consider redirecting permitted locations so wp_safe_redirect doesn't redirect to the fallback. - if ( ! $location || ! wp_validate_redirect( $location, '' ) ) { - return; - } - nocache_headers(); if ( ! headers_sent() ) { header( 'X-Robots-Tag: noindex, follow' ); } - if ( wp_safe_redirect( $location, 302 ) ) { - exit; - } + $location = get_permalink( $wp_query->post ); + + wp_redirect( $location, 302 ); + exit; } /** diff --git a/tests/phpunit/tests/query/randomContentRedirect.php b/tests/phpunit/tests/query/randomContentRedirect.php index 0baa2a82791e3..825c1d15667e6 100644 --- a/tests/phpunit/tests/query/randomContentRedirect.php +++ b/tests/phpunit/tests/query/randomContentRedirect.php @@ -68,6 +68,13 @@ class Tests_Query_RandomContentRedirect extends WP_UnitTestCase { */ protected static $admin; + /** + * Message of the exception thrown in place of the `exit` following a redirect. + * + * @var string + */ + const REDIRECT_EXCEPTION_MESSAGE = 'Random content redirect.'; + /** * Redirect location captured from the `wp_redirect` filter. * @@ -152,7 +159,7 @@ public function set_up(): void { $this->queries = array(); $this->recording_queries = false; - // Capture redirects and prevent `exit` from ending the test run. + // Capture redirects and end the request before `exit` ends the test run. add_filter( 'wp_redirect', array( $this, 'filter_wp_redirect' ), 10, 2 ); } @@ -169,18 +176,35 @@ public function tear_down(): void { } /** - * Captures the redirect and prevents it from being sent. + * Captures the redirect and ends the request in place of the `exit` that follows it. + * + * @throws WP_Tests_Exception Always, caught by {@see self::go_to()}. * * @param string $location Redirect location. * @param int $status Redirect status. - * @return false + * @return never */ public function filter_wp_redirect( string $location, int $status ) { $this->redirect_location = $location; $this->redirect_status = $status; $this->recording_queries = false; - return false; + throw new WP_Tests_Exception( self::REDIRECT_EXCEPTION_MESSAGE ); + } + + /** + * Requests a URL, stopping where a random content redirect ends the request. + * + * @param string $url The URL for the request. + */ + public function go_to( $url ) { + try { + parent::go_to( $url ); + } catch ( WP_Tests_Exception $e ) { + if ( self::REDIRECT_EXCEPTION_MESSAGE !== $e->getMessage() ) { + throw $e; + } + } } /** @@ -1877,17 +1901,6 @@ static function ( array $posts ): array { $this->assertSame( get_permalink( self::$arts_post ), $this->redirect_location, 'A non-list array returned by the posts filter should still redirect.' ); } - /** - * Ensure the request does not redirect when the permalink is empty. - */ - public function test_no_redirect_when_the_permalink_is_empty(): void { - add_filter( 'post_link', '__return_empty_string' ); - - $this->go_to( home_url( '/random/' ) ); - - $this->assertNotRedirected( 'An empty permalink should not redirect.' ); - } - /** * Ensure the redirect happens before the `wp` action fires. */ @@ -2075,9 +2088,9 @@ static function ( WP_Query $query ): void { */ /** - * Ensure the request does not redirect to a permalink on another site. + * Ensure the request redirects to the permalink when it is on another site. */ - public function test_no_redirect_when_the_permalink_is_on_another_site(): void { + public function test_redirect_to_a_permalink_on_another_site(): void { add_filter( 'post_link', static function (): string { @@ -2087,30 +2100,7 @@ static function (): string { $this->go_to( home_url( '/random/' ) ); - $this->assertNotRedirected( 'Off-site permalinks must not fall back to a redirect to the dashboard.' ); - } - - /** - * Ensure the request redirects to a permalink on an allowed redirect host. - */ - public function test_redirect_to_a_permalink_on_an_allowed_host(): void { - add_filter( - 'allowed_redirect_hosts', - static function ( array $hosts ): array { - $hosts[] = 'cdn.example.org'; - return $hosts; - } - ); - add_filter( - 'post_link', - static function (): string { - return 'https://cdn.example.org/post/'; - } - ); - - $this->go_to( home_url( '/random/' ) ); - - $this->assertSame( 'https://cdn.example.org/post/', $this->redirect_location, 'Permalinks on an allowed host should be redirected to.' ); + $this->assertSame( 'https://elsewhere.example/post/', $this->redirect_location, 'Permalinks on another site should be redirected to.' ); } /** @@ -2502,10 +2492,14 @@ public function test_handle_random_runs_the_main_query_for_random_requests(): vo $wp = new WP(); $wp->query_vars = array( 'random' => '' ); - $this->assertTrue( $wp->handle_random(), 'Random content requests should be handled.' ); + try { + $wp->handle_random(); + } catch ( WP_Tests_Exception $e ) { + $this->assertSame( self::REDIRECT_EXCEPTION_MESSAGE, $e->getMessage(), 'An unexpected exception was thrown.' ); + } + $this->assertTrue( $this->get_main_query()->is_random(), 'The main query should be the random content query.' ); $this->assertRedirectedToOneOf( array( self::$arts_post, self::$sport_post ), 'The random content request should redirect after running the main query.' ); - $this->assertFalse( is_404(), 'A random content request with a post should not 404 when the redirect does not exit.' ); } /** diff --git a/tests/phpunit/tests/query/randomContentRedirectHeaders.php b/tests/phpunit/tests/query/randomContentRedirectHeaders.php new file mode 100644 index 0000000000000..7fd2bf12dfba5 --- /dev/null +++ b/tests/phpunit/tests/query/randomContentRedirectHeaders.php @@ -0,0 +1,96 @@ +set_permalink_structure( '/%postname%/' ); + self::factory()->post->create( array( 'post_status' => 'publish' ) ); + add_filter( + 'wp_redirect', + static function (): void { + throw new WP_Tests_Exception( self::REDIRECT_EXCEPTION_MESSAGE ); + } + ); + } + + /** + * Requests a URL, stopping where a random content redirect ends the request. + * + * @param string $url The URL for the request. + */ + public function go_to( $url ) { + try { + parent::go_to( $url ); + } catch ( WP_Tests_Exception $e ) { + if ( self::REDIRECT_EXCEPTION_MESSAGE !== $e->getMessage() ) { + throw $e; + } + } + } + + /** + * Ensure random content redirects are neither cached nor indexed, whatever the permalink. + * + * @ticket 66625 + * + * @requires function xdebug_get_headers + * + * @dataProvider data_permalinks + * + * @param string|null $permalink Permalink of the selected post, or null to keep the real one. + */ + public function test_random_content_response_is_not_cached_or_indexed( ?string $permalink ): void { + if ( null !== $permalink ) { + add_filter( + 'post_link', + static function () use ( $permalink ): string { + return $permalink; + } + ); + } + + $this->go_to( home_url( '/random/' ) ); + + $headers = xdebug_get_headers(); + + $this->assertContains( 'Cache-Control: ' . wp_get_nocache_headers()['Cache-Control'], $headers, 'The random content response should not be cached.' ); + $this->assertContains( 'X-Robots-Tag: noindex, follow', $headers, 'The random content response should not be indexed.' ); + } + + /** + * Data provider. + * + * @return array + */ + public static function data_permalinks(): array { + return array( + 'permalink on this site' => array( null ), + 'permalink on another site' => array( 'https://elsewhere.example/post/' ), + 'empty permalink' => array( '' ), + ); + } +} diff --git a/tests/phpunit/tests/rewrite/randomRewriteRules.php b/tests/phpunit/tests/rewrite/randomRewriteRules.php index 47e828489df0b..f2bc7ffb59fb6 100644 --- a/tests/phpunit/tests/rewrite/randomRewriteRules.php +++ b/tests/phpunit/tests/rewrite/randomRewriteRules.php @@ -11,11 +11,38 @@ */ class Tests_Rewrite_RandomRewriteRules extends WP_UnitTestCase { + /** + * Message of the exception thrown in place of the `exit` following a redirect. + * + * @var string + */ + const REDIRECT_EXCEPTION_MESSAGE = 'Random content redirect.'; + + /** + * Requests a URL, stopping where a random content redirect ends the request. + * + * @param string $url The URL for the request. + */ + public function go_to( $url ) { + try { + parent::go_to( $url ); + } catch ( WP_Tests_Exception $e ) { + if ( self::REDIRECT_EXCEPTION_MESSAGE !== $e->getMessage() ) { + throw $e; + } + } + } + public function set_up(): void { parent::set_up(); - // Prevent random content redirects from exiting, only the rewrite rules are under test. - add_filter( 'wp_redirect', '__return_false' ); + // End the request before random content redirects exit, only the rewrite rules are under test. + add_filter( + 'wp_redirect', + static function (): void { + throw new WP_Tests_Exception( self::REDIRECT_EXCEPTION_MESSAGE ); + } + ); $this->set_permalink_structure( '/%postname%/' ); // Register the taxonomy rewrite rules for the permalink structure. diff --git a/tests/phpunit/tests/wp/handleRandom.php b/tests/phpunit/tests/wp/handleRandom.php index 8fe206cb6b15e..714382d5cf53d 100644 --- a/tests/phpunit/tests/wp/handleRandom.php +++ b/tests/phpunit/tests/wp/handleRandom.php @@ -8,6 +8,13 @@ */ class Tests_WP_HandleRandom extends WP_UnitTestCase { + /** + * Message of the exception thrown in place of the `exit` following a redirect. + * + * @var string + */ + const REDIRECT_EXCEPTION_MESSAGE = 'Random content redirect.'; + /** * Redirect location captured from the `wp_redirect` filter. * @@ -32,20 +39,22 @@ public function set_up(): void { $this->redirect_location = null; - // Capture redirects and prevent `exit` from ending the test run. + // Capture redirects and end the request before `exit` ends the test run. add_filter( 'wp_redirect', array( $this, 'filter_wp_redirect' ) ); } /** - * Captures the redirect and prevents it from being sent. + * Captures the redirect and ends the request in place of the `exit` that follows it. + * + * @throws WP_Tests_Exception Always, caught by {@see self::handle_random()}. * * @param string $location Redirect location. - * @return false + * @return never */ public function filter_wp_redirect( string $location ) { $this->redirect_location = $location; - return false; + throw new WP_Tests_Exception( self::REDIRECT_EXCEPTION_MESSAGE ); } /** @@ -55,16 +64,25 @@ public function filter_wp_redirect( string $location ) { * cache or running the rest of WP::main(). * * @param array $query_vars Query variables for the request. - * @return bool Whether the main query was run for a random content request. + * @return bool|null Whether the main query was run for a random content request, + * or null if the request was redirected. */ - protected function handle_random( array $query_vars ): bool { + protected function handle_random( array $query_vars ): ?bool { $GLOBALS['wp_the_query'] = new WP_Query(); $GLOBALS['wp_query'] = $GLOBALS['wp_the_query']; $wp = new WP(); $wp->query_vars = $query_vars; - return $wp->handle_random(); + try { + return $wp->handle_random(); + } catch ( WP_Tests_Exception $e ) { + if ( self::REDIRECT_EXCEPTION_MESSAGE !== $e->getMessage() ) { + throw $e; + } + + return null; + } } /** @@ -178,15 +196,16 @@ public static function data_requests_that_are_not_random_content_requests(): arr } /** - * Ensure the request continues with the selected post when it cannot be redirected to. + * Ensure the request is redirected to the selected post's permalink, ending the request. * * @ticket 64498 + * @ticket 66625 * - * @dataProvider data_permalinks_that_cannot_be_redirected_to + * @dataProvider data_permalinks * * @param string $permalink Permalink of the selected post. */ - public function test_request_continues_with_the_selected_post_when_it_is_not_redirected( string $permalink ): void { + public function test_request_is_redirected_to_the_permalink_of_the_selected_post( string $permalink ): void { add_filter( 'post_link', static function () use ( $permalink ): string { @@ -196,21 +215,19 @@ static function () use ( $permalink ): string { $handled = $this->handle_random( array( 'random' => '' ) ); - $this->assertTrue( $handled, 'The main query should be reported as run.' ); - $this->assertNull( $this->redirect_location, 'The request should not redirect.' ); - $this->assertInstanceOf( WP_Post::class, $GLOBALS['wp_the_query']->post, 'The selected post should remain in the main query.' ); - $this->assertFalse( is_404(), 'The request should not be set to a 404.' ); + $this->assertNull( $handled, 'The request should end with the redirect.' ); + $this->assertSame( $permalink, $this->redirect_location, 'The request should redirect to the permalink of the selected post.' ); } /** - * Data provider for {@see self::test_request_continues_with_the_selected_post_when_it_is_not_redirected()}. + * Data provider for {@see self::test_request_is_redirected_to_the_permalink_of_the_selected_post()}. * - * @return array + * @return array */ - public static function data_permalinks_that_cannot_be_redirected_to(): array { + public static function data_permalinks(): array { return array( - 'empty permalink' => array( '' ), - 'off-site permalink' => array( 'https://elsewhere.example/post/' ), + 'permalink on this site' => array( 'http://' . WP_TESTS_DOMAIN . '/post/' ), + 'permalink on another site' => array( 'https://elsewhere.example/post/' ), ); } }