diff --git a/src/wp-includes/pluggable.php b/src/wp-includes/pluggable.php index f659186b9a70c..57c32801993ce 100644 --- a/src/wp-includes/pluggable.php +++ b/src/wp-includes/pluggable.php @@ -1535,6 +1535,8 @@ function wp_redirect( $location, $status = 302, $x_redirect_by = 'WordPress' ) { header( "X-Redirect-By: $x_redirect_by" ); } + wp_add_preload_header( $location ); + header( "Location: $location", true, $status ); return true; @@ -1592,6 +1594,81 @@ function _wp_sanitize_utf8_in_redirect( $matches ) { } endif; +if ( ! function_exists( 'wp_is_internal_url' ) ) : + /** + * Checks whether a URL is internal to the site. + * + * @since 6.9.0 + * + * @param string $url The URL to check. + * @return bool True if the URL is relative or matches the site URL, false otherwise. + */ + function wp_is_internal_url( $url ) { + if ( '/' === substr( $url, 0, 1 ) ) { + return true; + } + + if ( ! is_blog_installed() ) { + return false; + } + + $site_url = site_url(); + + return substr( $url, 0, strlen( $site_url ) ) === $site_url; + } +endif; + +if ( ! function_exists( 'wp_create_preload_header' ) ) : + /** + * Creates a preload Link header string for the given URL. + * + * @since 6.9.0 + * + * @param string $url The URL to preload. + * @param string $type Optional. The preload type. Default 'document'. + * @return string The Link header string, or an empty string if not applicable. + */ + function wp_create_preload_header( $url, $type = 'document' ) { + if ( ! wp_is_internal_url( $url ) ) { + return ''; + } + + $parts = parse_url( $url ); + $path = isset( $parts['path'] ) ? $parts['path'] : ''; + + if ( '/' !== substr( $path, -1 ) && + '.php' !== substr( $path, -4 ) && + '.html' !== substr( $path, -5 ) ) { + return ''; + } + + $link = $path; + if ( isset( $parts['query'] ) ) { + $link .= '?' . $parts['query']; + } + + return "Link: <{$link}>; rel=preload; as={$type}"; + } +endif; + +if ( ! function_exists( 'wp_add_preload_header' ) ) : + /** + * Sends a preload Link header for the given URL if it is an internal URL. + * + * @since 6.9.0 + * + * @param string $url The URL to preload. + * @param string $type Optional. The preload type. Default 'document'. + */ + function wp_add_preload_header( $url, $type = 'document' ) { + $header = wp_create_preload_header( $url, $type ); + + if ( $header ) { + header( $header, false ); + } + } +endif; + if ( ! function_exists( 'wp_safe_redirect' ) ) : /** * Performs a safe (local) redirect, using wp_redirect(). @@ -2372,7 +2449,7 @@ function wp_new_user_notification( $user_id, $deprecated = null, $notify = '' ) $switched_locale = switch_to_user_locale( $user_id ); - $message = __( 'To set your password, visit the following address:' ) . "\r\n\r\n"; + $message = __( 'To set your password, visit the following address:' ) . "\r\n\r\n"; /* * Since some user login names end in a period, this could produce ambiguous URLs that diff --git a/tests/phpunit/tests/formatting/redirect.php b/tests/phpunit/tests/formatting/redirect.php index c4b1d682094f4..9e4f2e7a9aeb8 100644 --- a/tests/phpunit/tests/formatting/redirect.php +++ b/tests/phpunit/tests/formatting/redirect.php @@ -260,4 +260,52 @@ public function data_wp_validate_redirect_relative_url() { ), ); } + + /** + * @ticket 39695 + * + * @dataProvider data_wp_create_preload_header + * + * @covers ::wp_create_preload_header + * + * @param string $url The URL to preload. + * @param string $expected Expected header string. + */ + public function test_wp_create_preload_header( $url, $expected ) { + $this->assertSame( $expected, wp_create_preload_header( $url ) ); + } + + /** + * Data provider for test_wp_create_preload_header(). + * + * @return array[] + */ + public function data_wp_create_preload_header() { + return array( + 'relative path ending in slash' => array( + '/wp-admin/', + 'Link: ; rel=preload; as=document', + ), + 'relative path ending in .php' => array( + '/wp-login.php', + 'Link: ; rel=preload; as=document', + ), + 'relative path with query string' => array( + '/wp-login.php?redirect_to=%2Fwp-admin%2F&reauth=1', + 'Link: ; rel=preload; as=document', + ), + 'same-site absolute URL' => array( + site_url( '/wp-login.php' ), + 'Link: ; rel=preload; as=document', + ), + 'external URL returns empty string' => array( + 'https://external.example.com/wp-login.php', + '', + ), + 'non-document extension returns empty string' => array( + '/wp-content/uploads/file.zip', + '', + ), + ); + } } diff --git a/tests/phpunit/tests/pluggable/signatures.php b/tests/phpunit/tests/pluggable/signatures.php index 68b511b9d0d2d..80ec0b71d317b 100644 --- a/tests/phpunit/tests/pluggable/signatures.php +++ b/tests/phpunit/tests/pluggable/signatures.php @@ -180,6 +180,15 @@ public function get_pluggable_function_signatures() { ), 'wp_sanitize_redirect' => array( 'location' ), '_wp_sanitize_utf8_in_redirect' => array( 'matches' ), + 'wp_is_internal_url' => array( 'url' ), + 'wp_create_preload_header' => array( + 'url', + 'type' => 'document', + ), + 'wp_add_preload_header' => array( + 'url', + 'type' => 'document', + ), 'wp_safe_redirect' => array( 'location', 'status' => 302,