diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php index a283ef07..407dc448 100644 --- a/.php-cs-fixer.dist.php +++ b/.php-cs-fixer.dist.php @@ -24,11 +24,10 @@ // Enabled by '@PHP81Migration' but generates invalid spacing for WordPress. 'method_argument_space' => false, - 'final_class' => true, - 'native_constant_invocation' => true, - 'native_function_casing' => true, - 'native_function_invocation' => true, - 'native_function_type_declaration_casing' => true, + 'final_class' => true, + 'native_constant_invocation' => true, + 'native_function_casing' => true, + 'native_function_invocation' => true, ] ); $config->setFinder( $finder ); diff --git a/src/alley/wp/alleyvate/features/class-full-page-cache-404.php b/src/alley/wp/alleyvate/features/class-full-page-cache-404.php new file mode 100644 index 00000000..5b764fc7 --- /dev/null +++ b/src/alley/wp/alleyvate/features/class-full-page-cache-404.php @@ -0,0 +1,309 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + * @package wp-alleyvate + */ + +declare( strict_types=1 ); + +namespace Alley\WP\Alleyvate\Features; + +use Alley\WP\Types\Feature; + +/** + * Full Page Cache for 404s. + */ +final class Full_Page_Cache_404 implements Feature { + + /** + * Cache group. + * + * @var string + */ + public const CACHE_GROUP = 'alleyvate'; + + /** + * Cache key. + * + * @var string + */ + public const CACHE_KEY = '404_cache'; + + /** + * Cache key for stale cache. + * + * @var string + */ + public const STALE_CACHE_KEY = '404_cache_stale'; + + /** + * Cache time. + * + * @var int + */ + public const CACHE_TIME = HOUR_IN_SECONDS; + + /** + * Stale cache time. + * + * @var int + */ + public const STALE_CACHE_TIME = DAY_IN_SECONDS; + + /** + * Guaranteed 404 URI. + * Used for populating the cache. + * + * @var string + */ + public const TEMPLATE_GENERATOR_URI = '/wp-alleyvate/404-template-generator/?generate=1&uri=1'; + + /** + * Boot the feature. + */ + public function boot(): void { + + /** + * Only boot feature if external object cache is being used. + * + * We don't want to store the cached 404 page in the database. + * + * And only boot feature if the site is using SSL. + */ + if ( ! (bool) wp_using_ext_object_cache() || ! is_ssl() ) { + return; + } + + // Return 404 page cache on template_redirect. + add_action( 'template_redirect', [ self::class, 'action__template_redirect' ], 1 ); + + // For the Guaranteed 404 page, hook in on WP to start output buffering, to capture the HTML. + add_action( 'wp', [ self::class, 'action__wp' ] ); + + // Replenish the cache every hour. + if ( ! wp_next_scheduled( 'alleyvate_404_cache' ) ) { + wp_schedule_event( time(), 'hourly', 'alleyvate_404_cache' ); + } + + // Callback for Cron Event. + add_action( 'alleyvate_404_cache', [ self::class, 'trigger_404_page_cache' ] ); + add_action( 'alleyvate_404_cache_single', [ self::class, 'trigger_404_page_cache' ] ); + } + + /** + * Get 404 Page Cache and return early if found. + */ + public static function action__template_redirect(): void { + + // Don't cache if user is logged in. + if ( is_user_logged_in() ) { + return; + } + + // Don't cache if not a 404. + if ( ! is_404() ) { + return; + } + + // Allow this URL through, as this request will populate the cache. + if ( isset( $_SERVER['REQUEST_URI'] ) && self::TEMPLATE_GENERATOR_URI === $_SERVER['REQUEST_URI'] ) { + return; + } + + echo self::get_cached_response_with_headers(); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + + // If we're testing, don't exit, die instead. + if ( \defined( 'MANTLE_IS_TESTING' ) && MANTLE_IS_TESTING ) { + wp_die( '', '', [ 'response' => 404 ] ); + } + + exit; + } + + /** + * Get cached response with headers. + * + * @return string + */ + public static function get_cached_response_with_headers(): string { + $stale_cache_in_use = false; + $cache = self::get_cache(); + + if ( false === $cache ) { + $cache = self::get_stale_cache(); + $stale_cache_in_use = true; + } + + if ( ! empty( $cache ) ) { + $html = self::prepare_response( $cache ); + + self::send_header( 'HIT', $stale_cache_in_use ); + + // Cached content is already escaped. + return $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } + + // Schedule a single event to generate the cache immediately. + if ( ! wp_next_scheduled( 'alleyvate_404_cache_single' ) ) { + wp_schedule_single_event( time(), 'alleyvate_404_cache_single' ); + } + + self::send_header( 'MISS' ); + + // If no cache, return an empty string. + return ''; + } + + /** + * Send X-Alleyvate HTTP Header. + * + * @param string $type HIT or MISS. + * @param bool $stale Whether the stale cache is in use. Default false. + */ + public static function send_header( string $type, bool $stale = false ): void { + + if ( headers_sent() ) { + return; + } + + if ( ! $stale && 'HIT' === $type ) { + header( 'X-Alleyvate-404-Cache: HIT' ); + } elseif ( $stale && 'HIT' === $type ) { + header( 'X-Alleyvate-404-Cache: HIT (stale)' ); + } elseif ( 'MISS' === $type ) { + header( 'X-Alleyvate-404-Cache: MISS' ); + } + } + + /** + * Start output buffering, so we can cache the 404 page. + * + * @global WP_Query $wp_query WordPress database access object. + */ + public static function action__wp(): void { + if ( isset( $_SERVER['REQUEST_URI'] ) && self::TEMPLATE_GENERATOR_URI === $_SERVER['REQUEST_URI'] ) { + global $wp_query; + + if ( ! $wp_query->is_404() ) { + return; + } + + // Clean up any buffer first. + ob_end_clean(); + + ob_start( [ self::class, 'finish_output_buffering' ] ); + } + } + + /** + * Finish output buffering. + * + * @global WP_Query $wp_query WordPress database access object. + * + * @param string $buffer Buffer. + * @return string + */ + public static function finish_output_buffering( string $buffer ): string { + global $wp_query; + + if ( ! $wp_query->is_404() ) { + return $buffer; + } + + if ( is_user_logged_in() ) { + return $buffer; + } + + if ( ! self::get_cache() && ! empty( $buffer ) ) { + self::set_cache( $buffer ); + } + + return $buffer; + } + + /** + * Get cache. + * + * @return mixed + */ + public static function get_cache(): mixed { + return wp_cache_get( self::CACHE_KEY, self::CACHE_GROUP ); + } + + /** + * Get stale cache. + * + * @return mixed + */ + public static function get_stale_cache(): mixed { + return wp_cache_get( self::STALE_CACHE_KEY, self::CACHE_GROUP ); + } + + /** + * Set cache. + * + * @param string $buffer The Output Buffer. + */ + public static function set_cache( string $buffer ): void { + wp_cache_set( self::CACHE_KEY, $buffer, self::CACHE_GROUP, self::CACHE_TIME ); // phpcs:ignore WordPressVIPMinimum.Performance.LowExpiryCacheTime.CacheTimeUndetermined + wp_cache_set( self::STALE_CACHE_KEY, $buffer, self::CACHE_GROUP, self::STALE_CACHE_TIME ); // phpcs:ignore WordPressVIPMinimum.Performance.LowExpiryCacheTime.CacheTimeUndetermined + } + + /** + * Delete cache. + */ + public static function delete_cache(): void { + wp_cache_delete( self::CACHE_KEY, self::CACHE_GROUP ); + wp_cache_delete( self::STALE_CACHE_KEY, self::CACHE_GROUP ); + } + + /** + * Prepare response. + * + * @param string $content The content. + * @return string + */ + public static function prepare_response( string $content ): string { + // To avoid analytics issues, replace the Generator URI with the requested URI. + $uri = sanitize_text_field( $_SERVER['REQUEST_URI'] ?? '' ); + + return str_replace( + [ + self::TEMPLATE_GENERATOR_URI, + wp_json_encode( self::TEMPLATE_GENERATOR_URI ), + esc_html( self::TEMPLATE_GENERATOR_URI ), + esc_url( self::TEMPLATE_GENERATOR_URI ), + ], + [ + $uri, + wp_json_encode( $uri ), + esc_html( $uri ), + esc_url( $uri ), + ], + $content + ); + } + + /** + * Spin up a request to the guaranteed 404 page to populate the cache. + */ + public static function trigger_404_page_cache(): void { + $url = home_url( self::TEMPLATE_GENERATOR_URI, 'https' ); + + // Replace http with https to ensure the styles don't get blocked due to insecure content. + $url = str_replace( 'http://', 'https://', $url ); + + // This request will populate the cache using output buffering. + if ( \function_exists( 'wpcom_vip_file_get_contents' ) ) { + wpcom_vip_file_get_contents( $url ); + } else { + wp_remote_get( $url ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_remote_get_wp_remote_get + } + } +} diff --git a/src/alley/wp/alleyvate/load.php b/src/alley/wp/alleyvate/load.php index daebe3df..95791a0f 100644 --- a/src/alley/wp/alleyvate/load.php +++ b/src/alley/wp/alleyvate/load.php @@ -77,6 +77,10 @@ function load(): void { 'user_enumeration_restrictions', new Features\User_Enumeration_Restrictions(), ), + new Feature( + 'full_page_cache_404', + new Features\Full_Page_Cache_404(), + ), ); $plugin->boot(); diff --git a/tests/alley/wp/alleyvate/features/test-full-page-cache-404.php b/tests/alley/wp/alleyvate/features/test-full-page-cache-404.php new file mode 100644 index 00000000..2e425641 --- /dev/null +++ b/tests/alley/wp/alleyvate/features/test-full-page-cache-404.php @@ -0,0 +1,317 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + * + * @package wp-alleyvate + */ + +declare( strict_types=1 ); + +namespace Alley\WP\Alleyvate\Features; + +use Mantle\Testing\Concerns\Admin_Screen; +use Mantle\Testing\Concerns\Refresh_Database; +use Mantle\Testkit\Test_Case; + +/** + * Tests for Full Page Cache 404 functionality. + */ +final class Test_Full_Page_Cache_404 extends Test_Case { + use Refresh_Database; + use Admin_Screen; + + /** + * Feature instance. + * + * @var Full_Page_Cache_404 + */ + private Full_Page_Cache_404 $feature; + + /** + * Set up. + */ + protected function setUp(): void { + parent::setUp(); + + $this->prevent_stray_requests(); + + // Turn SSL on. + $_SERVER['HTTPS'] = 'on'; + + $this->feature = new Full_Page_Cache_404(); + } + + /** + * Tear down. + */ + public function tearDown(): void { + parent::tearDown(); + + $this->feature::delete_cache(); + } + + /** + * Test that the feature is disabled if SSL is off. + */ + public function test_feature_is_disabled_if_ssl_is_off(): void { + $this->assertTrue( is_ssl() ); + + $_SERVER['HTTPS'] = 'off'; + + $this->assertFalse( is_ssl() ); + + $this->feature->boot(); + + $response = $this->get( '/this-is-a-404-page' ); + $response->assertStatus( 404 ); + + $this->assertFalse( + wp_next_scheduled( 'alleyvate_404_cache_single' ), + 'Cron job to generate cached 404 page is scheduled and should not be.' + ); + } + + /** + * Test the feature is disabled if the object cache is not in use. + */ + public function test_feature_is_disabled_if_object_cache_is_not_in_use(): void { + + if ( ! (bool) wp_using_ext_object_cache() ) { + $this->markTestSkipped( 'This test requires that an external object cache is in use.' ); + } + + $this->assertTrue( (bool) wp_using_ext_object_cache() ); + + // Disable the object cache. + wp_using_ext_object_cache( false ); + + $this->feature->boot(); + + $this->assertFalse( (bool) wp_using_ext_object_cache() ); + + $response = $this->get( '/this-is-a-404-page' ); + $response->assertStatus( 404 ); + + $this->assertFalse( + wp_next_scheduled( 'alleyvate_404_cache_single' ), + 'Cron job to generate cached 404 page is scheduled and should not be.' + ); + + // Re-enable the object cache. + wp_using_ext_object_cache( true ); + + $this->assertTrue( (bool) wp_using_ext_object_cache() ); + } + + /** + * Test full page cache 404. + */ + public function test_full_page_cache_404_returns_cache(): void { + + if ( ! (bool) wp_using_ext_object_cache() ) { + $this->markTestSkipped( 'This test requires that an external object cache is in use.' ); + } + + $this->feature->boot(); + + $response = $this->get( '/this-is-a-404-page' ); + + // Expect empty string if cache isn't set. + $response->assertNoContent( 404 ); + + // Expect cron job to be scheduled. + $this->assertTrue( wp_next_scheduled( 'alleyvate_404_cache_single' ) > 0 ); + + add_action( 'template_redirect', [ $this, 'set_404_cache' ], 0 ); + + // Expect the cache to be returned. + $response = $this->get( '/this-is-a-404-page' ); + $response->assertSee( $this->feature::prepare_response( $this->get_404_html() ) ); + $response->assertStatus( 404 ); + + remove_action( 'template_redirect', [ $this, 'set_404_cache' ], 0 ); + } + + /** + * Test full page cache 404 does not return cache for logged in user. + */ + public function test_full_page_cache_404_does_not_return_cache_for_logged_in_user(): void { + + if ( ! (bool) wp_using_ext_object_cache() ) { + $this->markTestSkipped( 'This test requires that an external object cache is in use.' ); + } + + $this->feature->boot(); + + $response = $this->get( '/this-is-a-404-page' ); + + // Expect empty string if cache isn't set. + $response->assertNoContent( 404 ); + + // Expect cron job to be scheduled. + $this->assertTrue( wp_next_scheduled( 'alleyvate_404_cache_single' ) > 0 ); + + add_action( 'template_redirect', [ $this, 'set_404_cache' ], 0 ); + + // Expect the cache NOT be returned for logged in user. + $this->acting_as( self::factory()->user->create() ); + $this->assertAuthenticated(); + + $response = $this->get( '/this-is-a-404-page' ); + $response->assertDontSee( $this->feature::prepare_response( $this->get_404_html() ) ); + $response->assertStatus( 404 ); + + remove_action( 'template_redirect', [ $this, 'set_404_cache' ], 0 ); + } + + /** + * Test full page cache 404 does not return cache for generator URI. + */ + public function test_full_page_cache_404_does_not_return_cache_for_generator_uri(): void { + + if ( ! (bool) wp_using_ext_object_cache() ) { + $this->markTestSkipped( 'This test requires that an external object cache is in use.' ); + } + + $this->feature->boot(); + + $response = $this->get( '/this-is-a-404-page' ); + $response->assertNoContent( 404 ); + + // Hit the generator URI to populate the cache. + $response = $this->get( '/wp-alleyvate/404-template-generator/?generate=1&uri=1' ); + $response->assertDontSee( $this->feature::prepare_response( $this->get_404_html() ) ); + $response->assertStatus( 404 ); + + // Pretend to update the cache. + add_action( 'template_redirect', [ $this, 'set_404_cache' ], 0 ); + + $response = $this->get( '/this-is-a-404-page' ); + $response->assertSee( $this->feature::prepare_response( $this->get_404_html() ) ); + $response->assertStatus( 404 ); + + remove_action( 'template_redirect', [ $this, 'set_404_cache' ], 0 ); + } + + /** + * Test that the 404 page cache is not returned for non-404 pages. + */ + public function test_full_page_cache_not_returned_for_non_404(): void { + + if ( ! (bool) wp_using_ext_object_cache() ) { + $this->markTestSkipped( 'This test requires that an external object cache is in use.' ); + } + + $this->feature->boot(); + + $post_id = self::factory()->post->create( [ 'post_title' => 'Hello World' ] ); + $response = $this->get( get_the_permalink( $post_id ) ); + $response->assertStatus( 200 ); + $response->assertHeaderMissing( 'X-Alleyvate-404-Cache' ); + $response->assertSee( 'Hello World' ); + + $this->assertFalse( + wp_next_scheduled( 'alleyvate_404_cache_single' ), + 'Cron job to generate cached 404 page is scheduled and should not be.' + ); + } + + /** + * Test that the content manipulation works. + */ + public function test_full_page_cache_prepare_content(): void { + $raw_html = $this->get_404_html(); + $_SERVER['REQUEST_URI'] = '/news/breaking_story/?_ga=2.123456789.123456789.123456789.123456789&_gl=1*123456789*123456789*123456789*1'; + +// phpcs:ignore Generic.WhiteSpace.ScopeIndent.Incorrect +$expected_html = << +
+The requested URL was not found on this server.
+This test includes different ways the URI may be output in the content. Above shows the use of esc_url and + wp_json_encode.
+So that we can do content aware replacement of the URI for security and analytics reporting.
+esc_html would output: /news/breaking_story/?_ga=2.123456789.123456789.123456789.123456789&_gl=1*123456789*123456789*123456789*1
+ + + HTML; + $this->assertEquals( $expected_html, $this->feature::prepare_response( $raw_html ) ); + } + + /** + * Test full page cache 404 cron. + */ + public function test_full_page_cache_404_cron(): void { + + if ( ! (bool) wp_using_ext_object_cache() ) { + $this->markTestSkipped( 'This test requires that an external object cache is in use.' ); + } + + $this->fake_request( 'https://example.org/*' ) + ->with_response_code( 400 ); + + $this->feature->boot(); + + $response = $this->get( '/this-is-a-404-page' ); + + // Expect empty string if cache isn't set. + $response->assertNoContent( 404 ); + + // Expect cron job to be scheduled. + $this->assertTrue( wp_next_scheduled( 'alleyvate_404_cache_single' ) > 0 ); + + // Run the cron job. + do_action( 'alleyvate_404_cache' ); + + // This is a hourly cron job, so we expect it to be scheduled again. + $this->assertTrue( wp_next_scheduled( 'alleyvate_404_cache_single' ) > 0 ); + } + + /** + * Set the cache. + */ + public function set_404_cache(): void { + $this->feature::set_cache( $this->get_404_html() ); + } + + /** + * Get the 404 HTML. + * + * @return string + */ + private function get_404_html(): string { + // phpcs:ignore Generic.WhiteSpace.ScopeIndent.Incorrect +return << + +The requested URL was not found on this server.
+This test includes different ways the URI may be output in the content. Above shows the use of esc_url and + wp_json_encode.
+So that we can do content aware replacement of the URI for security and analytics reporting.
+esc_html would output: /wp-alleyvate/404-template-generator/?generate=1&uri=1
+ + + HTML; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 11baec86..72a9d3ab 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -11,7 +11,7 @@ */ \Mantle\Testing\manager() - // Fires on 'muplugins_loaded'. + ->with_object_cache() ->loaded( function () { require_once __DIR__ . '/../wp-alleyvate.php';