Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Feature] Full-Page Caching for 404s #46

Closed
wants to merge 54 commits into from
Closed
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
81b09b9
Serve cache for 404 pages - WIP
mslinnea Jun 15, 2023
4d57b69
add stale cache, switch to using a cron job
mslinnea Jun 22, 2023
4598a31
phpcs and work on tests
mslinnea Jun 22, 2023
bc52963
use output buffering to save the cache
mslinnea Jun 28, 2023
1c97d49
Merge remote-tracking branch 'origin/main' into feature/9/caching-404s
mslinnea Jun 28, 2023
b6e709f
schedule single event. remove stderror flag because test failures wer…
mslinnea Jun 28, 2023
730dd63
Merge branch 'main' into feature/9/caching-404s
mslinnea Nov 1, 2023
c8c9f8b
Update tests/alley/wp/alleyvate/features/test-full-page-cache-404.php
mslinnea Nov 1, 2023
466282f
Merge remote-tracking branch 'origin/main' into feature/9/caching-404s
mslinnea Dec 28, 2023
38ddf23
prevent outputting headers if already sent
mslinnea Dec 28, 2023
6e3946d
Merge branch 'feature/9/caching-404s-local' into feature/9/caching-404s
mslinnea Dec 28, 2023
951296c
Avoid setting cache to empty string
mslinnea Dec 28, 2023
71b88bb
phpcs
mslinnea Dec 28, 2023
20ad3e8
php cs fixer
mslinnea Dec 28, 2023
a51bcc9
Server 404 page early
mslinnea Dec 28, 2023
4a2ae4a
Logged in users should bypass cache
mslinnea Dec 28, 2023
b776b39
Fix issue where HTTP header was set incorrectly
mslinnea Dec 29, 2023
789eb19
Switch to static methods, add missing types, update phpdoc, remove se…
mslinnea Jan 9, 2024
15364ae
replace generator URI with request URI
mslinnea Jan 15, 2024
681cc8e
temp add --testdox to help with debugging unit tests
mslinnea Jan 15, 2024
70fff9a
Merge remote-tracking branch 'origin' into feature/9/caching-404s
mslinnea Jan 15, 2024
9b30221
phpcs
mslinnea Jan 15, 2024
b91b3da
use static methods
mslinnea Jan 15, 2024
900cb71
Merge branch 'main' into feature/9/caching-404s
renatonascalves Feb 5, 2024
b31104f
Adding tests
renatonascalves Feb 13, 2024
cde8591
Merge branch 'main' into feature/9/caching-404s
renatonascalves Feb 13, 2024
c9210e4
Merge branch 'feature/9/caching-404s' of https://github.com/alleyinte…
renatonascalves Feb 13, 2024
b53b3de
Merge branch 'feature/9/caching-404s' into feature/9/caching-404s-uni…
renatonascalves Feb 13, 2024
56b4921
Making `phpcs` happy
renatonascalves Feb 13, 2024
d36f87f
Making `php-cs-fixer` happy
renatonascalves Feb 13, 2024
7782eb8
php-cs-fixer lol
renatonascalves Feb 13, 2024
93c4b69
Making phpcs happy, conflicting tools ¯\_(ツ)_/¯
renatonascalves Feb 13, 2024
13c94f1
Only boot feature if external object cache is being used
renatonascalves Feb 13, 2024
0fcef02
Add object cache to Mantle
renatonascalves Feb 13, 2024
6b166c9
Set `MANTLE_REQUIRE_OBJECT_CACHE`
renatonascalves Feb 13, 2024
b326f71
Test using `niden/actions-memcached@v7`
renatonascalves Feb 13, 2024
ff06717
Organize tests
renatonascalves Feb 13, 2024
d905e53
Revert last change and debug Mantle
renatonascalves Feb 13, 2024
df26556
Reset
renatonascalves Feb 13, 2024
6336e62
Set `INSTALL_OBJECT_CACHE` via env
renatonascalves Feb 13, 2024
83b95d2
Set `INSTALL_OBJECT_CACHE` via env
renatonascalves Feb 13, 2024
5b2021b
Set `INSTALL_OBJECT_CACHE`
renatonascalves Feb 13, 2024
6bacb48
Remove `INSTALL_OBJECT_CACHE: true`
renatonascalves Feb 13, 2024
6f9c270
Skip tests if object cache is not available
renatonascalves Feb 13, 2024
7a8c1ee
Disable tests if object cache is not in use
renatonascalves Feb 13, 2024
f6f985f
Adding CR suggestions
renatonascalves Feb 14, 2024
cd33707
Minor tweak
renatonascalves Feb 14, 2024
f628f26
Merge pull request #76 from alleyinteractive/feature/9/caching-404s-u…
renatonascalves Feb 14, 2024
1590143
Sync with the latest
renatonascalves Feb 22, 2024
ac984cd
Dot not clean buffer too early
renatonascalves Feb 22, 2024
8017c6a
Clean up any previous buffer before starting our own
renatonascalves Feb 22, 2024
a2c04df
The "Full-Page Caching for 404s" feature requires ssl
renatonascalves Feb 28, 2024
370860d
php-cs-fixer fixes
renatonascalves Feb 28, 2024
006f900
Merge pull request #77 from alleyinteractive/feature/9/caching-404s-r…
renatonascalves Feb 28, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
243 changes: 243 additions & 0 deletions src/alley/wp/alleyvate/features/class-full-page-cache-404.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,243 @@
<?php
/**
* Class file for Full Page Cache for 404s
*
* (c) Alley <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @package wp-alleyvate
*/

namespace Alley\WP\Alleyvate\Features;

use Alley\WP\Alleyvate\Feature;

/**
* Full Page Cache for 404s.
*/
final class Full_Page_Cache_404 implements Feature {

/**
* Cache group.
*
* @var string
*/
const CACHE_GROUP = 'alleyvate';

/**
* Cache key.
*
* @var string
*/
const CACHE_KEY = 'alleyvate_404_cache';

/**
* Cache key for stale cache.
*
* @var string
*/
const STALE_CACHE_KEY = 'alleyvate_404_cache_stale';

/**
* Cache time.
*
* @var int
*/
const CACHE_TIME = HOUR_IN_SECONDS;

/**
* Stale cache time.
*
* @var int
*/
const STALE_CACHE_TIME = DAY_IN_SECONDS;

/**
* Guaranteed 404 URI.
* Used for populating the cache.
*/
const GUARANTEED_404_URI = '/wp-alleyvate-this-is-a-404-page';

/**
* Boot the feature.
*/
public function boot(): void {

// Return 404 page cache on template_redirect.
add_action( 'template_redirect', [ $this, 'action__template_redirect' ], 9999 );

// Add HTTP header for debugging.
add_action( 'send_headers', [ $this, 'action__send_headers' ] );

// Force the Guaranteed 404 page to be a 404, because this is the page we will cache.
add_action( 'pre_get_posts', [ $this, 'action__pre_get_posts' ] );

// For the Guaranteed 404 page, hook in on WP to start output buffering, to capture the HTML.
add_action( 'wp', [ $this, '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', [ $this, 'trigger_404_page_cache' ] );
add_action( 'alleyvate_404_cache_single', [ $this, 'trigger_404_page_cache' ] );
}

/**
* Get 404 Page Cache and return early if found.
*/
public function action__template_redirect() {

// Allow 404s for the Admin.
if ( is_admin() ) {
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::GUARANTEED_404_URI === $_SERVER['REQUEST_URI'] ) {
return;
}

$cache = self::get_cache();

if ( false === $cache ) {
$cache = self::get_stale_cache();
}
if ( $cache ) {
// Cached content is already escaped.
echo $cache; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
renatonascalves marked this conversation as resolved.
Show resolved Hide resolved
exit;
} else {
// 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' );
}
// If no cache, return an empty string.
echo '';
renatonascalves marked this conversation as resolved.
Show resolved Hide resolved
exit;
}
}

/**
* Send Headers.
*/
public function action__send_headers() {
if ( ! is_404() ) {
return;
}
if ( isset( $_SERVER['REQUEST_URI'] ) && self::GUARANTEED_404_URI === $_SERVER['REQUEST_URI'] ) {
return;
}
if ( self::get_cache() ) {
header( 'X-Alleyvate-404-Cache: HIT' );
} elseif ( self::get_stale_cache() ) {
header( 'X-Alleyvate-404-Cache: HIT (stale)' );
} else {
header( 'X-Alleyvate-404-Cache: MISS' );
}
}

/**
* Ensure that the 404 page is always a 404.
* We cache this page, so need to make sure it's always a 404.
*
* @param \WP_Query $query WP Query.
*/
public function action__pre_get_posts( $query ) {
if ( isset( $_SERVER['REQUEST_URI'] ) && self::GUARANTEED_404_URI === $_SERVER['REQUEST_URI'] ) {
global $wp_query;
$wp_query->set_404();
renatonascalves marked this conversation as resolved.
Show resolved Hide resolved
}
}


/**
* Start output buffering, so we can cache the 404 page.
*/
public function action__wp() {
renatonascalves marked this conversation as resolved.
Show resolved Hide resolved
if ( isset( $_SERVER['REQUEST_URI'] ) && self::GUARANTEED_404_URI === $_SERVER['REQUEST_URI'] ) {
ob_start( [ self::class, 'finish_output_buffering' ] );
renatonascalves marked this conversation as resolved.
Show resolved Hide resolved
}
}

/**
* Finish output buffering.
*
* @param string $buffer Buffer.
*/
public function finish_output_buffering( $buffer ) {
mslinnea marked this conversation as resolved.
Show resolved Hide resolved
mslinnea marked this conversation as resolved.
Show resolved Hide resolved
global $wp_query;
if ( ! $wp_query->is_404() ) {
return $buffer;
}
if ( ! $this->get_cache() ) {
self::set_cache( $buffer );
}
return $buffer;
}

/**
* Get cache.
*
* @return mixed
*/
public function get_cache(): mixed {
return wp_cache_get( self::CACHE_KEY, self::CACHE_GROUP );
}

/**
* Get stale cache.
*
* @return mixed
*/
public function get_stale_cache() {
return wp_cache_get( self::STALE_CACHE_KEY, self::CACHE_GROUP );
}

/**
* Set cache.
*
* @param string $buffer The Output Buffer.
*
* @return void
mslinnea marked this conversation as resolved.
Show resolved Hide resolved
*/
public 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 function delete_cache() {
wp_cache_delete( self::CACHE_KEY, self::CACHE_GROUP );
wp_cache_delete( self::STALE_CACHE_KEY, self::CACHE_GROUP );
}

/**
* Populate cache.
*/
public function populate_cache() {
ob_start( [ self::class, 'finish_output_buffering' ] );
}

/**
* Spin up a request to the guaranteed 404 page to populate the cache.
*/
public function trigger_404_page_cache() {
mslinnea marked this conversation as resolved.
Show resolved Hide resolved
$url = home_url( self::GUARANTEED_404_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.
wpcom_vip_file_get_contents( $url );
renatonascalves marked this conversation as resolved.
Show resolved Hide resolved
}
}
1 change: 1 addition & 0 deletions src/alley/wp/alleyvate/load.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ function load(): void {
'disallow_file_edit' => new Features\Disallow_File_Edit(),
'redirect_guess_shortcircuit' => new Features\Redirect_Guess_Shortcircuit(),
'user_enumeration_restrictions' => new Features\User_Enumeration_Restrictions(),
'full_page_cache_404' => new Features\Full_Page_Cache_404(),
];

foreach ( $features as $handle => $feature ) {
Expand Down
96 changes: 96 additions & 0 deletions tests/alley/wp/alleyvate/features/test-full-page-cache-404.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php
/**
* Class file for Test_Disable_Comments
*
* (c) Alley <[email protected]>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*
* @package wp-alleyvate
*/

namespace Alley\WP\Alleyvate\Features;

use Alley\WP\Alleyvate\Feature;
use Mantle\Testkit\Test_Case;

/**
* Tests for fully disabling comment functionality.
*/
mslinnea marked this conversation as resolved.
Show resolved Hide resolved
mslinnea marked this conversation as resolved.
Show resolved Hide resolved
final class Test_Full_Page_Cache_404 extends Test_Case {
use \Mantle\Testing\Concerns\Admin_Screen;

/**
* Feature instance.
*
* @var Feature
*/
private Feature $feature;

/**
* Set up.
*/
protected function setUp(): void {
parent::setUp();
$this->feature = new Full_Page_Cache_404();
}

/**
* Test full page cache 404.
*/
public function test_full_page_cache_404_returns_cache() {
renatonascalves marked this conversation as resolved.
Show resolved Hide resolved
$this->feature->boot();
renatonascalves marked this conversation as resolved.
Show resolved Hide resolved
$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 );

$this->set_404_cache();

// Expect the cache to be returned.
$response = $this->get( '/this-is-a-404-page' );
$response->assertSee( $this->get_404_html() );
$response->assertStatus( 404 );
}

/**
* Test that a post request returns the correct content.
*/
public function test_full_page_cache_not_returned_for_non_404() {
renatonascalves marked this conversation as resolved.
Show resolved Hide resolved
$this->feature->boot();
$post_id = self::factory()->post->create( [ 'post_title' => 'Hello World' ] );
$response = $this->get( get_the_permalink( $post_id ) );
$response->assertHeaderMissing( 'X-Alleyvate-404-Cache' );
$response->assertSee( 'Hello World' );
}

/**
* Set the cache.
*/
private function set_404_cache() {
renatonascalves marked this conversation as resolved.
Show resolved Hide resolved
$html = $this->get_404_html();
$this->feature->set_cache( $html );
}

/**
* Get the 404 HTML.
*
* @return string
*/
private function get_404_html() {
renatonascalves marked this conversation as resolved.
Show resolved Hide resolved
return '<html><head></head><body><h1>404 Not Found</h1><p>The requested URL was not found on this server.</p></body></html>';
}

/**
* Tear down.
*/
public function tearDown(): void {
renatonascalves marked this conversation as resolved.
Show resolved Hide resolved
$this->feature->delete_cache();
parent::tearDown();
}

}
Loading