diff --git a/wp-admin/includes/class-wp-site-health.php b/wp-admin/includes/class-wp-site-health.php index 4bc27ce6c5..6926736cd7 100644 --- a/wp-admin/includes/class-wp-site-health.php +++ b/wp-admin/includes/class-wp-site-health.php @@ -1666,12 +1666,126 @@ class WP_Site_Health { return $result; } + /** + * Tests if a full page cache is available. + * + * @since 6.1.0 + * + * @return array The test result. + */ + public function get_test_page_cache() { + $description = '

' . __( 'Page cache enhances the speed and performance of your site by saving and serving static pages instead of calling for a page every time a user visits.' ) . '

'; + $description .= '

' . __( 'Page cache is detected by looking for an active page cache plugin as well as making three requests to the homepage and looking for one or more of the following HTTP client caching response headers:' ) . '

'; + $description .= '' . implode( ', ', array_keys( $this->get_page_cache_headers() ) ) . '.'; + + $result = array( + 'badge' => array( + 'label' => __( 'Performance' ), + 'color' => 'blue', + ), + 'description' => wp_kses_post( $description ), + 'test' => 'page_cache', + 'status' => 'good', + 'label' => '', + 'actions' => sprintf( + '

%2$s %3$s

', + __( 'https://wordpress.org/support/article/optimization/#Caching' ), + __( 'Learn more about page cache' ), + /* translators: Accessibility text. */ + __( '(opens in a new tab)' ) + ), + ); + + $page_cache_detail = $this->get_page_cache_detail(); + + if ( is_wp_error( $page_cache_detail ) ) { + $result['label'] = __( 'Unable to detect the presence of page cache' ); + $result['status'] = 'recommended'; + $error_info = sprintf( + /* translators: 1 is error message, 2 is error code */ + __( 'Unable to detect page cache due to possible loopback request problem. Please verify that the loopback request test is passing. Error: %1$s (Code: %2$s)' ), + $page_cache_detail->get_error_message(), + $page_cache_detail->get_error_code() + ); + $result['description'] = wp_kses_post( "

$error_info

" ) . $result['description']; + return $result; + } + + $result['status'] = $page_cache_detail['status']; + + switch ( $page_cache_detail['status'] ) { + case 'recommended': + $result['label'] = __( 'Page cache is not detected but the server response time is OK' ); + break; + case 'good': + $result['label'] = __( 'Page cache is detected and the server response time is good' ); + break; + default: + if ( empty( $page_cache_detail['headers'] ) && ! $page_cache_detail['advanced_cache_present'] ) { + $result['label'] = __( 'Page cache is not detected and the server response time is slow' ); + } else { + $result['label'] = __( 'Page cache is detected but the server response time is still slow' ); + } + } + + $page_cache_test_summary = array(); + + if ( empty( $page_cache_detail['response_time'] ) ) { + $page_cache_test_summary[] = ' ' . __( 'Server response time could not be determined. Verify that loopback requests are working.' ); + } else { + + $threshold = $this->get_good_response_time_threshold(); + if ( $page_cache_detail['response_time'] < $threshold ) { + $page_cache_test_summary[] = ' ' . sprintf( + /* translators: 1: The response time in milliseconds. 2: The recommended threshold milliseconds. */ + __( 'Median server response time was %1$s milliseconds. This is less than the recommended %2$s milliseconds threshold.' ), + number_format_i18n( $page_cache_detail['response_time'] ), + number_format_i18n( $threshold ) + ); + } else { + $page_cache_test_summary[] = ' ' . sprintf( + /* translators: 1: The response time in milliseconds. 2: The recommended threshold milliseconds. */ + __( 'Median server response time was %1$s milliseconds. It should be less than the recommended %2$s milliseconds threshold.' ), + number_format_i18n( $page_cache_detail['response_time'] ), + number_format_i18n( $threshold ) + ); + } + + if ( empty( $page_cache_detail['headers'] ) ) { + $page_cache_test_summary[] = ' ' . __( 'No client caching response headers were detected.' ); + } else { + $headers_summary = ''; + $headers_summary .= sprintf( + /* translators: Placeholder is number of caching headers */ + _n( + ' There was %d client caching response header detected: ', + ' There were %d client caching response headers detected: ', + count( $page_cache_detail['headers'] ) + ), + count( $page_cache_detail['headers'] ) + ); + $headers_summary .= '' . implode( ', ', $page_cache_detail['headers'] ) . '.'; + $page_cache_test_summary[] = $headers_summary; + } + } + + if ( $page_cache_detail['advanced_cache_present'] ) { + $page_cache_test_summary[] = ' ' . __( 'A page cache plugin was detected.' ); + } elseif ( ! ( is_array( $page_cache_detail ) && ! empty( $page_cache_detail['headers'] ) ) ) { + // Note: This message is not shown if client caching response headers were present since an external caching layer may be employed. + $page_cache_test_summary[] = ' ' . __( 'A page cache plugin was not detected.' ); + } + + $result['description'] .= ''; + return $result; + } + /** * Check if the HTTP API can handle SSL/TLS requests. * * @since 5.2.0 * - * @return array The test results. + * @return array The test result. */ public function get_test_ssl_support() { $result = array( @@ -2482,8 +2596,15 @@ class WP_Site_Health { ); } - // Only check for a persistent object cache in production environments to not unnecessarily promote complicated setups. + // Only check for caches in production environments. if ( 'production' === wp_get_environment_type() ) { + $tests['async']['page_cache'] = array( + 'label' => __( 'Page cache' ), + 'test' => rest_url( 'wp-site-health/v1/tests/page-cache' ), + 'has_rest' => true, + 'async_direct_test' => array( WP_Site_Health::get_instance(), 'get_test_page_cache' ), + ); + $tests['direct']['persistent_object_cache'] = array( 'label' => __( 'Persistent object cache' ), 'test' => 'persistent_object_cache', @@ -2965,6 +3086,200 @@ class WP_Site_Health { return in_array( wp_get_environment_type(), array( 'development', 'local' ), true ); } + /** + * Returns a list of headers and its verification callback to verify if page cache is enabled or not. + * + * Note: key is header name and value could be callable function to verify header value. + * Empty value mean existence of header detect page cache is enabled. + * + * @since 6.1.0 + * + * @return array List of client caching headers and their (optional) verification callbacks. + */ + public function get_page_cache_headers() { + + $cache_hit_callback = static function ( $header_value ) { + return false !== strpos( strtolower( $header_value ), 'hit' ); + }; + + $cache_headers = array( + 'cache-control' => static function ( $header_value ) { + return (bool) preg_match( '/max-age=[1-9]/', $header_value ); + }, + 'expires' => static function ( $header_value ) { + return strtotime( $header_value ) > time(); + }, + 'age' => static function ( $header_value ) { + return is_numeric( $header_value ) && $header_value > 0; + }, + 'last-modified' => '', + 'etag' => '', + 'x-cache-enabled' => static function ( $header_value ) { + return 'true' === strtolower( $header_value ); + }, + 'x-cache-disabled' => static function ( $header_value ) { + return ( 'on' !== strtolower( $header_value ) ); + }, + 'x-srcache-store-status' => $cache_hit_callback, + 'x-srcache-fetch-status' => $cache_hit_callback, + ); + + /** + * Filters the list of cache headers supported by core. + * + * @since 6.1.0 + * + * @param int $cache_headers Array of supported cache headers. + */ + return apply_filters( 'site_status_page_cache_supported_cache_headers', $cache_headers ); + } + + /** + * Checks if site has page cache enabled or not. + * + * @since 6.1.0 + * + * @return WP_Error|array { + * Page cache detection details or else error information. + * + * @type bool $advanced_cache_present Whether a page cache plugin is present. + * @type array[] $page_caching_response_headers Sets of client caching headers for the responses. + * @type float[] $response_timing Response timings. + * } + */ + private function check_for_page_caching() { + + /** This filter is documented in wp-includes/class-wp-http-streams.php */ + $sslverify = apply_filters( 'https_local_ssl_verify', false ); + + $headers = array(); + + // Include basic auth in loopback requests. Note that this will only pass along basic auth when user is + // initiating the test. If a site requires basic auth, the test will fail when it runs in WP Cron as part of + // wp_site_health_scheduled_check. This logic is copied from WP_Site_Health::can_perform_loopback(). + if ( isset( $_SERVER['PHP_AUTH_USER'] ) && isset( $_SERVER['PHP_AUTH_PW'] ) ) { + $headers['Authorization'] = 'Basic ' . base64_encode( wp_unslash( $_SERVER['PHP_AUTH_USER'] ) . ':' . wp_unslash( $_SERVER['PHP_AUTH_PW'] ) ); + } + + $caching_headers = $this->get_page_cache_headers(); + $page_caching_response_headers = array(); + $response_timing = array(); + + for ( $i = 1; $i <= 3; $i++ ) { + $start_time = microtime( true ); + $http_response = wp_remote_get( home_url( '/' ), compact( 'sslverify', 'headers' ) ); + $end_time = microtime( true ); + + if ( is_wp_error( $http_response ) ) { + return $http_response; + } + if ( wp_remote_retrieve_response_code( $http_response ) !== 200 ) { + return new WP_Error( + 'http_' . wp_remote_retrieve_response_code( $http_response ), + wp_remote_retrieve_response_message( $http_response ) + ); + } + + $response_headers = array(); + + foreach ( $caching_headers as $header => $callback ) { + $header_values = wp_remote_retrieve_header( $http_response, $header ); + if ( empty( $header_values ) ) { + continue; + } + $header_values = (array) $header_values; + if ( empty( $callback ) || ( is_callable( $callback ) && count( array_filter( $header_values, $callback ) ) > 0 ) ) { + $response_headers[ $header ] = $header_values; + } + } + + $page_caching_response_headers[] = $response_headers; + $response_timing[] = ( $end_time - $start_time ) * 1000; + } + + return array( + 'advanced_cache_present' => ( + file_exists( WP_CONTENT_DIR . '/advanced-cache.php' ) + && + ( defined( 'WP_CACHE' ) && WP_CACHE ) + && + /** This filter is documented in wp-settings.php */ + apply_filters( 'enable_loading_advanced_cache_dropin', true ) + ), + 'page_caching_response_headers' => $page_caching_response_headers, + 'response_timing' => $response_timing, + ); + } + + /** + * Get page cache details. + * + * @since 6.1.0 + * + * @return WP_Error|array { + * Page cache detail or else a WP_Error if unable to determine. + * + * @type string $status Page cache status. Good, Recommended or Critical. + * @type bool $advanced_cache_present Whether page cache plugin is available or not. + * @type string[] $headers Client caching response headers detected. + * @type float $response_time Response time of site. + * } + */ + private function get_page_cache_detail() { + $page_cache_detail = $this->check_for_page_caching(); + if ( is_wp_error( $page_cache_detail ) ) { + return $page_cache_detail; + } + + // Use the median server response time. + $response_timings = $page_cache_detail['response_timing']; + rsort( $response_timings ); + $page_speed = $response_timings[ floor( count( $response_timings ) / 2 ) ]; + + // Obtain unique set of all client caching response headers. + $headers = array(); + foreach ( $page_cache_detail['page_caching_response_headers'] as $page_caching_response_headers ) { + $headers = array_merge( $headers, array_keys( $page_caching_response_headers ) ); + } + $headers = array_unique( $headers ); + + // Page cache is detected if there are response headers or a page cache plugin is present. + $has_page_caching = ( count( $headers ) > 0 || $page_cache_detail['advanced_cache_present'] ); + + if ( $page_speed && $page_speed < $this->get_good_response_time_threshold() ) { + $result = $has_page_caching ? 'good' : 'recommended'; + } else { + $result = 'critical'; + } + + return array( + 'status' => $result, + 'advanced_cache_present' => $page_cache_detail['advanced_cache_present'], + 'headers' => $headers, + 'response_time' => $page_speed, + ); + } + + /** + * Get the threshold below which a response time is considered good. + * + * @since 6.1.0 + * + * @return int Threshold in milliseconds. + */ + private function get_good_response_time_threshold() { + /** + * Filters the threshold below which a response time is considered good. + * + * The default is based on https://web.dev/time-to-first-byte/. + * + * @param int $threshold Threshold in milliseconds. Default 600. + * + * @since 6.1.0 + */ + return (int) apply_filters( 'site_status_good_response_time_threshold', 600 ); + } + /** * Determines whether to suggest using a persistent object cache. * diff --git a/wp-includes/rest-api/endpoints/class-wp-rest-site-health-controller.php b/wp-includes/rest-api/endpoints/class-wp-rest-site-health-controller.php index d5d78255cf..954165b825 100644 --- a/wp-includes/rest-api/endpoints/class-wp-rest-site-health-controller.php +++ b/wp-includes/rest-api/endpoints/class-wp-rest-site-health-controller.php @@ -43,6 +43,7 @@ class WP_REST_Site_Health_Controller extends WP_REST_Controller { * Registers API routes. * * @since 5.6.0 + * @since 6.1.0 Adds page-cache async test. * * @see register_rest_route() */ @@ -156,6 +157,24 @@ class WP_REST_Site_Health_Controller extends WP_REST_Controller { }, ) ); + + register_rest_route( + $this->namespace, + sprintf( + '/%s/%s', + $this->rest_base, + 'page-cache' + ), + array( + array( + 'methods' => 'GET', + 'callback' => array( $this, 'test_page_cache' ), + 'permission_callback' => function () { + return $this->validate_request_permission( 'view_site_health_checks' ); + }, + ), + ) + ); } /** @@ -242,6 +261,18 @@ class WP_REST_Site_Health_Controller extends WP_REST_Controller { return $this->site_health->get_test_authorization_header(); } + /** + * Checks that full page cache is active. + * + * @since 6.1.0 + * + * @return array The test result. + */ + public function test_page_cache() { + $this->load_admin_textdomain(); + return $this->site_health->get_test_page_cache(); + } + /** * Gets the current directory sizes for this install. * diff --git a/wp-includes/version.php b/wp-includes/version.php index a92579a9df..7757698e18 100644 --- a/wp-includes/version.php +++ b/wp-includes/version.php @@ -16,7 +16,7 @@ * * @global string $wp_version */ -$wp_version = '6.1-alpha-54042'; +$wp_version = '6.1-alpha-54043'; /** * Holds the WordPress DB revision, increments when changes are made to the WordPress DB schema.