This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,235 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache;
use Automattic\Jetpack_Boost\Contracts\Has_Activate;
use Automattic\Jetpack_Boost\Contracts\Has_Deactivate;
use Automattic\Jetpack_Boost\Contracts\Is_Always_On;
use Automattic\Jetpack_Boost\Contracts\Sub_Feature;
use Automattic\Jetpack_Boost\Lib\Cornerstone\Cornerstone_Utils;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Logger;
/**
* Class Cache_Preload
*
* Handles the rebuilding/preloading of cache for pages, currently only for Cornerstone Pages.
* This module automagically preloads the cache after cache invalidation events such as rebuilds, or when
* Cornerstone Pages are updated, to ensure that important pages always have a cache.
*
* @since 3.11.0
* @package Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache
*/
class Cache_Preload implements Sub_Feature, Has_Activate, Has_Deactivate, Is_Always_On {
/**
* @since 3.11.0
*/
public function setup() {
add_action( 'update_option_jetpack_boost_ds_cornerstone_pages_list', array( $this, 'schedule_cornerstone' ) );
add_action( 'jetpack_boost_preload_cornerstone', array( $this, 'preload_cornerstone' ) );
add_action( 'jetpack_boost_preload', array( $this, 'preload' ) );
add_action( 'post_updated', array( $this, 'handle_post_update' ) );
add_action( 'jetpack_boost_invalidate_cache_success', array( $this, 'handle_cache_invalidation' ), 10, 3 );
}
/**
* @since 3.11.0
*/
public static function get_slug() {
return 'cache_preload';
}
/**
* @since 3.11.0
*/
public static function is_available() {
return true;
}
/**
* As this is a submodule, this activate is triggered when the parent module is activated,
* despite the module having Is_Always_On.
*
* @since 3.12.0
*/
public static function activate() {
$instance = new self();
$instance->schedule_cornerstone_cronjob();
}
/**
*
* @since 3.12.0
*/
public static function deactivate() {
wp_unschedule_hook( 'jetpack_boost_preload_cornerstone' );
}
/**
* Schedule the cronjob to preload the cache for Cornerstone Pages.
*
* @since 3.12.0
* @return void
*/
public function schedule_cornerstone_cronjob() {
if ( ! wp_next_scheduled( 'jetpack_boost_preload_cornerstone' ) ) {
wp_schedule_event( time(), 'twicehourly', 'jetpack_boost_preload_cornerstone' );
}
}
/**
* Schedule Preload for all Cornerstone Pages.
*
* This method is triggered when the Cornerstone Pages list is updated,
* ensuring all Cornerstone Pages have their cache rebuilt.
*
* @since 3.12.0
* @return void
*/
public function schedule_cornerstone() {
wp_schedule_single_event( time(), 'jetpack_boost_preload_cornerstone' );
}
/**
* Schedule a rebuild for the given URLs.
*
* @since 3.12.0
* @param array $urls The URLs of the Cornerstone Pages to rebuild.
* @return void
*/
public function schedule( array $urls ) {
Logger::debug( sprintf( 'Scheduling preload for %d pages', count( $urls ) ) );
wp_schedule_single_event( time(), 'jetpack_boost_preload', array( $urls ) );
}
/**
* Rebuild the cache for all Cornerstone Pages.
*
* @since 3.12.0
* @return void
*/
public function preload_cornerstone() {
$urls = Cornerstone_Utils::get_list();
$this->preload( $urls );
}
/**
* Rebuild the cache for the given URLs.
*
* @since 3.12.0
* @param array $urls The URLs of the pages to preload.
* @return void
*/
public function preload( array $urls ) {
Logger::debug( sprintf( 'Preload started for %d pages', count( $urls ) ) );
// Pause the hook here to avoid an infinite loop of: invalidation → preload → invalidation.
remove_action( 'jetpack_boost_invalidate_cache_success', array( $this, 'handle_cache_invalidation' ) );
$boost_cache = new Boost_Cache();
foreach ( $urls as $url ) {
$boost_cache->rebuild_page( $url );
$this->request_page( $url );
}
add_action( 'jetpack_boost_invalidate_cache_success', array( $this, 'handle_cache_invalidation' ), 10, 3 );
Logger::debug( sprintf( 'Preload completed for %d pages', count( $urls ) ) );
}
/**
* Requests the pages scheduled for preload.
*
* @since 3.11.0
* @param array $posts The posts to preload.
* @return void
*/
public function request_pages( $posts ) {
if ( empty( $posts ) ) {
return;
}
foreach ( $posts as $url ) {
$this->request_page( $url );
}
}
/**
* Make an HTTP request to the specified URL to generate a fresh cache entry.
*
* @since 3.11.0
* @param string $page The URL of the page to preload.
* @return void
*/
private function request_page( string $page ) {
// Add a cache-busting header to ensure our response is fresh.
$args = array(
'headers' => array(
'Cache-Control' => 'no-cache, no-store, must-revalidate, max-age=0',
'Pragma' => 'no-cache',
'Expires' => '0',
),
);
$response = wp_remote_get( $page, $args );
if ( is_wp_error( $response ) ) {
Logger::debug( 'Error preloading page: ' . $response->get_error_message() );
return;
}
$status_code = wp_remote_retrieve_response_code( $response );
if ( $status_code !== 200 ) {
Logger::debug( sprintf( 'Error preloading page %s: HTTP status code %d', $page, $status_code ) );
}
}
/**
* Handle post updates to check if the post is a cornerstone page and schedule preload if needed.
*
* @since 3.11.0
* @param int $post_id The ID of the post being updated.
* @return void
*/
public function handle_post_update( int $post_id ) {
if ( Cornerstone_Utils::is_cornerstone_page( $post_id ) ) {
$this->schedule( array( get_permalink( $post_id ) ) );
}
}
/**
* Handle cache invalidation events to schedule preloading for affected pages.
*
* If cache for Cornerstone Pages is invalidated, this method schedules those pages
* for preloading to ensure they have fresh cache.
*
* @since 3.11.0
* @param string $path The path that was invalidated.
* @param string $type The type of invalidation that occurred (rebuild or delete).
* @param string $scope The scope of the invalidation (page or recursive).
* @return void
*/
public function handle_cache_invalidation( string $path, string $type, string $scope ) {
if ( $path === home_url() && $scope === 'recursive' ) {
Logger::debug( 'Invalidating all files, scheduling preload for all Cornerstone Pages.' );
// If the cache is invalidated for all files, schedule preload for all Cornerstone Pages.
$this->schedule_cornerstone();
return;
}
// Otherwise identify if a Cornerstone Page cache file is being deleted and schedule preload that page if it is.
Logger::debug( 'Invalidating a specific page, scheduling preload for that page if it is a Cornerstone Page.' );
$cornerstone_pages = Cornerstone_Utils::get_list();
$cornerstone_pages = array_map( 'untrailingslashit', $cornerstone_pages );
// If the $path is in the Cornerstone Page list, add it to the preload list.
if ( in_array( untrailingslashit( $path ), $cornerstone_pages, true ) ) {
$this->schedule( array( $path ) );
}
}
public static function get_parent_features(): array {
return array(
Page_Cache::class,
);
}
}
@@ -0,0 +1,64 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Logger;
class Garbage_Collection {
const ACTION = 'jetpack_boost_cache_garbage_collection';
/**
* Register hooks.
*/
public static function setup() {
add_action( self::ACTION, array( self::class, 'garbage_collect' ) );
// Clear old log files when garbage collection is run. Do not pass the $older_than parameter to the method as it's not supported.
add_action( self::ACTION, array( Logger::class, 'delete_old_logs' ), 10, 0 );
}
public static function schedule_single_garbage_collection() {
$older_than = time();
wp_schedule_single_event( $older_than, self::ACTION, array( 'older_than' => $older_than ) );
}
/**
* Garbage collect expired files.
*
* @param int|null $older_than The timestamp before which files should be deleted. If not provided, the files older than default cache duration will be deleted.
*/
public static function garbage_collect( $older_than = null ) {
$cache_ttl = JETPACK_BOOST_CACHE_DURATION;
/*
* If an $older_than value is specified, use it to calculate the cache TTL.
* By specifying $older_than, you can instruct garbage collection to apply on files created before a certain point in time.
* This ensures garbage collection is not clearing files that were created after the request was made. Useful to avoid race conditions.
*/
if ( $older_than !== null ) {
$cache_ttl = time() - $older_than;
}
$cache = new Boost_Cache();
$cache->get_storage()->garbage_collect( $cache_ttl );
}
/**
* Setup the garbage collection cron job.
*/
public static function activate() {
self::setup();
if ( ! wp_next_scheduled( self::ACTION ) ) {
wp_schedule_event( time(), 'hourly', self::ACTION );
}
}
/**
* Remove the garbage collection cron job.
*/
public static function deactivate() {
wp_clear_scheduled_hook( self::ACTION );
}
}
@@ -0,0 +1,449 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache;
use Automattic\Jetpack_Boost\Lib\Analytics;
use Automattic\Jetpack_Boost\Lib\Super_Cache_Config_Compatibility;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache_Error;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache_Settings;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Filesystem_Utils;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Logger;
class Page_Cache_Setup {
private static $notices = array();
/**
* Runs setup steps and returns whether setup was successful or not.
*
* @return bool|\WP_Error
*/
public static function run_setup() {
// Steps that are only for cache system verification. They don't change anything.
$verification_steps = array(
'verify_wp_content_writable',
'verify_permalink_setting',
);
foreach ( $verification_steps as $step ) {
$result = self::run_step( $step );
if ( is_wp_error( $result ) ) {
return $result;
}
}
/*
* Steps that may change something to setup the cache system.
* Each of them should return the result in following format:
* - true if the step was successful and changes were made
* - false if the step was successful but no changes were made
* - WP_Error if the step failed
*/
$setup_steps = array(
'create_settings_file',
'create_advanced_cache',
'add_wp_cache_define',
'enable_caching',
);
$changes_made = false;
foreach ( $setup_steps as $step ) {
$result = self::run_step( $step );
if ( is_wp_error( $result ) ) {
return $result;
}
if ( $result === true ) {
$changes_made = true;
}
}
if ( $changes_made ) {
Analytics::record_user_event( 'page_cache_setup_succeeded' );
}
return true;
}
public static function get_notices() {
return self::$notices;
}
private static function add_notice( $title, $message ) {
self::$notices[] = array(
'title' => $title,
'message' => $message,
);
}
private static function run_step( $step ) {
$result = self::$step();
if ( $result instanceof Boost_Cache_Error ) {
Analytics::record_user_event( 'page_cache_setup_failed', array( 'error_code' => $result->get_error_code() ) );
return $result->to_wp_error();
}
if ( is_wp_error( $result ) ) {
Analytics::record_user_event( 'page_cache_setup_failed', array( 'error_code' => $result->get_error_code() ) );
return $result;
}
}
/**
* Enable caching step of setup.
*
* @return Boost_Cache_Error|bool - True on success, false if it was already enabled, error otherwise.
*/
private static function enable_caching() {
$settings = Boost_Cache_Settings::get_instance();
$previous_value = $settings->get_enabled();
if ( $previous_value === true ) {
return false;
}
$enabled_result = $settings->set( array( 'enabled' => true ) );
if ( $enabled_result === true ) {
Logger::debug( 'Caching enabled in cache config' );
}
return $enabled_result;
}
/**
* Returns true if the wp-content directory is writeable.
*/
private static function verify_wp_content_writable() {
$filename = WP_CONTENT_DIR . '/' . uniqid() . '.txt';
$result = @file_put_contents( $filename, 'test' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.NoSilencedErrors.Discouraged
wp_delete_file( $filename );
if ( $result === false ) {
return new \WP_Error( 'wp-content-not-writable' );
}
return true;
}
/**
* Returns true if WordPress is using a proper permalink setup. WP_Error if not.
*/
private static function verify_permalink_setting() {
global $wp_rewrite;
if ( ! $wp_rewrite || ! $wp_rewrite->using_permalinks() ) {
return new \WP_Error( 'not-using-permalinks' );
}
}
/**
* Create a settings file, if one does not already exist.
*
* @return bool|\WP_Error - True if the file was created, WP_Error if there was a problem, or false if the file already exists.
*/
private static function create_settings_file() {
$result = Boost_Cache_Settings::get_instance()->create_settings_file();
return $result;
}
/**
* Get the path to the advanced-cache.php file.
*
* @return string The full path to the advanced-cache.php file.
*/
public static function get_advanced_cache_path() {
return WP_CONTENT_DIR . '/advanced-cache.php';
}
/**
* Creates the advanced-cache.php file.
*
* Returns true if the files were setup correctly, or WP_Error if there was a problem.
*
* @return bool|\WP_Error
*/
private static function create_advanced_cache() {
$advanced_cache_filename = self::get_advanced_cache_path();
if ( file_exists( $advanced_cache_filename ) ) {
$content = file_get_contents( $advanced_cache_filename ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
if ( strpos( $content, 'WP SUPER CACHE' ) !== false ) {
// advanced-cache.php is already in use by WP Super Cache.
if ( Super_Cache_Config_Compatibility::is_compatible() ) {
$deactivation = new Data_Sync_Actions\Deactivate_WPSC();
$deactivation->handle();
self::add_notice(
__( 'WP Super Cache Has Been Deactivated', 'jetpack-boost' ),
__( 'To ensure optimal performance, WP Super Cache has been automatically deactivated because Jetpack Boost\'s Cache is now active. Only one caching system can be used at a time.', 'jetpack-boost' )
);
Analytics::record_user_event(
'switch_to_boost_cache',
array(
'type' => 'silent',
'reason' => 'super_cache_compatible',
)
);
} else {
return new \WP_Error( 'advanced-cache-for-super-cache' );
}
} elseif ( strpos( $content, Page_Cache::ADVANCED_CACHE_SIGNATURE ) === false ) {
// advanced-cache.php is in use by another plugin.
return new \WP_Error( 'advanced-cache-incompatible' );
}
if ( strpos( $content, Page_Cache::ADVANCED_CACHE_VERSION ) !== false ) {
// The advanced-cache.php file belongs to current version of Boost Cache.
return false;
}
}
$plugin_dir_name = untrailingslashit( str_replace( JETPACK_BOOST_PLUGIN_FILENAME, '', JETPACK_BOOST_PLUGIN_BASE ) );
$boost_cache_filename = WP_CONTENT_DIR . '/plugins/' . $plugin_dir_name . '/app/modules/optimizations/page-cache/pre-wordpress/class-boost-cache.php';
if ( ! file_exists( $boost_cache_filename ) ) {
return new \WP_Error( 'boost-cache-file-not-found' );
}
$contents = '<?php
// ' . Page_Cache::ADVANCED_CACHE_SIGNATURE . ' - ' . Page_Cache::ADVANCED_CACHE_VERSION . '
if ( ! file_exists( \'' . $boost_cache_filename . '\' ) ) {
return;
}
require_once( \'' . $boost_cache_filename . '\');
$boost_cache = new Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache();
$boost_cache->init_actions();
$boost_cache->serve();
';
$write_advanced_cache = Filesystem_Utils::write_to_file( $advanced_cache_filename, $contents );
if ( $write_advanced_cache instanceof Boost_Cache_Error ) {
return new \WP_Error( 'unable-to-write-to-advanced-cache', $write_advanced_cache->get_error_code() );
}
self::clear_opcache( $advanced_cache_filename );
Logger::debug( 'Advanced cache file created' );
return true;
}
/**
* Adds the WP_CACHE define to wp-config.php
*/
private static function add_wp_cache_define() {
$config_file = self::find_wp_config();
if ( $config_file === false ) {
return new \WP_Error( 'wp-config-not-found' );
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
$content = file_get_contents( $config_file );
if ( preg_match( '#^\s*(define\s*\(\s*[\'"]WP_CACHE[\'"]|const\s+WP_CACHE\s*=)#m', $content ) === 1 ) {
/*
* wp-settings.php checks "if ( WP_CACHE )" so it may be truthy and
* not === true to pass that check.
* Later, it is defined as false in default-constants.php, but
* it may have been defined manually as true using "true", 1, or "1"
* in wp-config.php.
*/
if ( defined( 'WP_CACHE' ) && ! WP_CACHE ) {
return new \WP_Error( 'wp-cache-defined-not-true' );
}
return false; // WP_CACHE already added.
}
$content = preg_replace(
'#^<\?php#',
'<?php
define( \'WP_CACHE\', true ); // ' . Page_Cache::ADVANCED_CACHE_SIGNATURE,
$content
);
$result = self::write_to_file_direct( $config_file, $content );
if ( $result === false ) {
return new \WP_Error( 'wp-config-not-writable' );
}
self::clear_opcache( $config_file );
Logger::debug( 'WP_CACHE constant added to wp-config.php' );
return true;
}
/**
* Checks if page cache can be run or not.
*
* @return bool True if the advanced-cache.php file doesn't exist or belongs to Boost, false otherwise.
*/
public static function can_run_cache() {
$advanced_cache_path = self::get_advanced_cache_path();
if ( ! file_exists( $advanced_cache_path ) ) {
return true;
}
$content = file_get_contents( $advanced_cache_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
return strpos( $content, Page_Cache::ADVANCED_CACHE_SIGNATURE ) !== false;
}
/**
* Removes the advanced-cache.php file and the WP_CACHE define from wp-config.php
* Fired when the plugin is deactivated.
*/
public static function deactivate() {
$advanced_cache_deleted = self::delete_advanced_cache();
// Only remove constant if Boost was the last to run caching.
// This is to avoid breaking caching for other plugins.
if ( $advanced_cache_deleted ) {
self::delete_wp_cache_constant();
} else {
self::cleanup_wp_cache_constant();
}
return true;
}
/**
* Removes the boost-cache directory, removing all cached files and the config file.
* Fired when the plugin is uninstalled.
*/
public static function uninstall() {
self::deactivate();
// Call the Cache Preload module deactivation here to ensure it's cleaned up properly.
Cache_Preload::deactivate();
$result = Filesystem_Utils::delete_directory( WP_CONTENT_DIR . '/boost-cache' );
if ( $result instanceof Boost_Cache_Error ) {
return $result->to_wp_error();
}
return true;
}
/**
* Deletes the file advanced-cache.php if it exists.
*/
public static function delete_advanced_cache() {
$advanced_cache_filename = self::get_advanced_cache_path();
if ( ! file_exists( $advanced_cache_filename ) ) {
return false;
}
$deleted_file = false;
$content = file_get_contents( $advanced_cache_filename ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
if ( strpos( $content, Page_Cache::ADVANCED_CACHE_SIGNATURE ) !== false ) {
wp_delete_file( $advanced_cache_filename );
// wp_delete_file doesn't return anything
// so we manually check if the file was deleted.
$deleted_file = ! file_exists( $advanced_cache_filename );
}
self::clear_opcache( $advanced_cache_filename );
return $deleted_file;
}
/**
* Deletes the WP_CACHE define from wp-config.php
*
* @return \WP_Error if an error occurred.
*/
public static function delete_wp_cache_constant() {
$config_file = self::find_wp_config();
if ( $config_file === false ) {
return;
}
$lines = file( $config_file );
$found = false;
foreach ( $lines as $key => $line ) {
if ( preg_match( '#define\s*\(\s*[\'"]WP_CACHE[\'"]#', $line ) === 1 && strpos( $line, Page_Cache::ADVANCED_CACHE_SIGNATURE ) !== false ) {
unset( $lines[ $key ] );
$found = true;
}
}
if ( ! $found ) {
return;
}
$content = implode( '', $lines );
Filesystem_Utils::write_to_file( $config_file, $content );
self::clear_opcache( $config_file );
}
/**
* Removes the comment after WP_CACHE defined in wp-config.php (if any).
*
* @return void
*/
public static function cleanup_wp_cache_constant() {
$config_file = self::find_wp_config();
if ( $config_file === false ) {
return;
}
$lines = file( $config_file );
$found = false;
foreach ( $lines as $key => $line ) {
if ( preg_match( '#define\s*\(\s*[\'"]WP_CACHE[\'"]#', $line ) === 1 && strpos( $line, Page_Cache::ADVANCED_CACHE_SIGNATURE ) !== false ) {
$lines[ $key ] = preg_replace( '#\s*?\/\/.*$#', '', $line );
$found = true;
}
}
if ( ! $found ) {
return;
}
$content = implode( '', $lines );
Filesystem_Utils::write_to_file( $config_file, $content );
self::clear_opcache( $config_file );
}
/**
* Find location of wp-config.php file.
*
* @return string|false - The path to the wp-config.php file, or false if it was not found.
*/
private static function find_wp_config() {
if ( file_exists( ABSPATH . 'wp-config.php' ) ) {
return ABSPATH . 'wp-config.php';
} elseif ( file_exists( dirname( ABSPATH ) . '/wp-config.php' ) && ! file_exists( dirname( ABSPATH ) . '/wp-settings.php' ) ) {
// While checking one directory up, check for wp-settings.php as well similar to WordPress core, to avoid nested WordPress installations.
return dirname( ABSPATH ) . '/wp-config.php';
}
return false;
}
/**
* Clear opcache for a file.
*/
private static function clear_opcache( $file ) {
// If API functions are restricted, we can't do anything.
if ( ini_get( 'opcache.restrict_api' ) ) {
return;
}
if ( function_exists( 'opcache_invalidate' ) ) {
opcache_invalidate( $file, true );
}
}
private static function write_to_file_direct( $file, $content ) {
$filesystem = self::get_wp_filesystem();
$chmod = $filesystem->getchmod( $file );
if ( $chmod === false ) {
$chmod = 0644; // Default to a common permission for files
} else {
$chmod = intval( '0' . $chmod, 8 ); // Ensure leading zero
}
return $filesystem->put_contents( $file, $content, $chmod );
}
private static function get_wp_filesystem() {
require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php';
require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php';
return new \WP_Filesystem_Direct( null );
}
}
@@ -0,0 +1,127 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache;
use Automattic\Jetpack\Schema\Schema;
use Automattic\Jetpack\Status\Host;
use Automattic\Jetpack\WP_JS_Data_Sync\Data_Sync;
use Automattic\Jetpack_Boost\Contracts\Feature;
use Automattic\Jetpack_Boost\Contracts\Has_Data_Sync;
use Automattic\Jetpack_Boost\Contracts\Has_Deactivate;
use Automattic\Jetpack_Boost\Contracts\Needs_To_Be_Ready;
use Automattic\Jetpack_Boost\Contracts\Optimization;
use Automattic\Jetpack_Boost\Lib\Cache_Compatibility;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Data_Sync\Page_Cache_Entry;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Data_Sync_Actions\Clear_Page_Cache;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Data_Sync_Actions\Deactivate_WPSC;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Data_Sync_Actions\Run_Setup;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache_Settings;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Logger;
class Page_Cache implements Feature, Has_Deactivate, Has_Data_Sync, Optimization, Needs_To_Be_Ready {
/**
* @var array - The errors that occurred when removing the cache.
*/
private $removal_errors = array();
/**
* The signature used to identify the advanced-cache.php file owned by Jetpack Boost.
*/
const ADVANCED_CACHE_SIGNATURE = 'Boost Cache Plugin';
/**
* The full signature including the current version, to verify the Advanced-cache file is current.
*/
const ADVANCED_CACHE_VERSION = 'v0.0.3';
/**
* @var Boost_Cache_Settings - The settings for the page cache.
*/
private $settings;
public function __construct() {
$this->settings = Boost_Cache_Settings::get_instance();
}
public function setup() {
Garbage_Collection::setup();
add_action( 'jetpack_boost_page_output_changed', array( $this, 'handle_page_output_change' ) );
}
public function register_data_sync( Data_Sync $instance ) {
$page_cache_schema = Schema::as_assoc_array(
array(
'bypass_patterns' => Schema::as_array( Schema::as_string() ),
'logging' => Schema::as_boolean(),
)
);
$page_cache_error_schema = Schema::as_assoc_array(
array(
'code' => Schema::as_string(),
'message' => Schema::as_string(),
'dismissed' => Schema::as_boolean()->fallback( false ),
)
)->nullable();
$instance->register_readonly( 'cache_debug_log', Schema::as_unsafe_any(), array( Logger::class, 'read' ) );
$instance->register_readonly( 'cache_engine_loading', Schema::as_unsafe_any(), array( Boost_Cache::class, 'is_loaded' ) );
$instance->register( 'page_cache', $page_cache_schema, new Page_Cache_Entry() );
// Page Cache error
$instance->register( 'page_cache_error', $page_cache_error_schema );
$instance->register_action( 'page_cache', 'run-setup', Schema::as_void(), new Run_Setup() );
$instance->register_action( 'page_cache', 'clear-page-cache', Schema::as_void(), new Clear_Page_Cache() );
$instance->register_action( 'page_cache', 'deactivate-wpsc', Schema::as_void(), new Deactivate_WPSC() );
}
public function handle_page_output_change() {
Garbage_Collection::schedule_single_garbage_collection();
// Remove the action so it doesn't run again during the same request.
remove_action( 'jetpack_boost_page_output_changed', array( $this, 'handle_page_output_change' ) );
}
/**
* Runs cleanup when the feature is deactivated.
*/
public static function deactivate() {
Garbage_Collection::deactivate();
Boost_Cache_Settings::get_instance()->set( array( 'enabled' => false ) );
Page_Cache_Setup::delete_advanced_cache();
}
/**
* The module is active if cache engine is loaded.
*
* @return bool
*/
public function is_ready() {
return Boost_Cache::is_loaded();
}
public static function is_available() {
// Disable Page Cache on WoA and WP Cloud clients.
// They already have caching enabled.
if ( ( new Host() )->is_woa_site() || ( new Host() )->is_atomic_platform() ) {
if ( Page_Cache_Setup::can_run_cache() ) {
return true;
}
return false;
}
// Disable Page Cache on sites that have their own caching service.
if ( Cache_Compatibility::has_cache() ) {
return false;
}
return true;
}
public static function get_slug() {
return 'page_cache';
}
}
@@ -0,0 +1,37 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Data_Sync_Actions;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Data_Sync_Action;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache;
/**
* Page Cache: Clear page cache
*/
class Clear_Page_Cache implements Data_Sync_Action {
/**
* Handles the action logic.
*
* @param mixed $_data JSON Data passed to the action.
* @param \WP_REST_Request $_request The request object.
*/
public function handle( $_data, $_request ) {
$cache = new Boost_Cache();
$delete = $cache->delete_recursive( home_url() );
if ( $delete === null || is_wp_error( $delete ) ) {
return array(
'message' => __( 'Cache already cleared.', 'jetpack-boost' ),
);
}
if ( $delete ) {
return array(
'message' => __( 'Cache cleared.', 'jetpack-boost' ),
);
}
return new \WP_Error( 'unable-to-clear-cache', __( 'Unable to clear cache.', 'jetpack-boost' ) );
}
}
@@ -0,0 +1,37 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Data_Sync_Actions;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Data_Sync_Action;
/**
* Cache Action: Disable Super Cache
*/
class Deactivate_WPSC implements Data_Sync_Action {
/**
* Handles the action logic.
*
* @param mixed $_data JSON Data passed to the action.
* @param null|\WP_REST_Request $_request The request object.
*/
public function handle( $_data = null, $_request = null ) {
// Super Cache will define WPCACHEHOME if it's active.
if ( ! defined( 'WPCACHEHOME' ) ) {
return true;
}
// Find the plugin base path for super cache.
$super_cache_dir = basename( WPCACHEHOME );
$plugin = $super_cache_dir . '/wp-cache.php';
// Load the necessary files to deactivate the plugin.
require_once ABSPATH . 'wp-admin/includes/misc.php';
require_once ABSPATH . 'wp-admin/includes/file.php';
deactivate_plugins( $plugin );
$success = ! is_plugin_active( $plugin );
return $success;
}
}
@@ -0,0 +1,36 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Data_Sync_Actions;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Data_Sync_Action;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Garbage_Collection;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Page_Cache_Setup;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache_Settings;
/**
* Critical CSS Action: request regeneration.
*/
class Run_Setup implements Data_Sync_Action {
/**
* Handles the action logic.
*
* @param mixed $_data JSON Data passed to the action.
* @param \WP_REST_Request $_request The request object.
*/
public function handle( $_data, $_request ) {
$setup_result = Page_Cache_Setup::run_setup();
if ( is_wp_error( $setup_result ) ) {
return $setup_result;
}
Garbage_Collection::activate();
Boost_Cache_Settings::get_instance()->set( array( 'enabled' => true ) );
return array(
'success' => true,
'notices' => Page_Cache_Setup::get_notices(),
);
}
}
@@ -0,0 +1,105 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Data_Sync;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Entry_Can_Get;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Entry_Can_Set;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache_Settings;
class Page_Cache_Entry implements Entry_Can_Get, Entry_Can_Set {
public function get( $_fallback = false ) {
$cache_settings = Boost_Cache_Settings::get_instance();
$settings = array(
'bypass_patterns' => $cache_settings->get_bypass_patterns(),
'logging' => $cache_settings->get_logging(),
);
return $settings;
}
public function set( $value ) {
$cache_settings = Boost_Cache_Settings::get_instance();
$value['bypass_patterns'] = $this->sanitize_value( $value['bypass_patterns'] );
$cache_settings->set( $value );
}
/**
* Sanitizes the given value, ensuring that it is list of valid patterns.
*
* @param mixed $value The value to sanitize.
*
* @return array The sanitized value.
*/
private function sanitize_value( $value ) {
if ( is_array( $value ) ) {
$value = array_values( array_unique( array_filter( array_map( 'trim', array_map( 'strtolower', $value ) ) ) ) );
$home_url = home_url( '/' );
foreach ( $value as &$path ) {
// Strip home URL (both secure and non-secure).
$path = str_ireplace(
array(
$home_url,
str_replace( 'http:', 'https:', $home_url ),
),
array(
'/',
'/',
),
$path
);
// Remove double shashes.
$path = str_replace( '//', '/', $path );
// Remove symbols, as they are included in the regex check.
$path = ltrim( $path, '^' );
$path = rtrim( $path, '$' );
$path = preg_replace( '/\/\?$/', '', $path );
// Make sure there's a leading slash.
$path = '/' . ltrim( $path, '/' );
// Fix up any wildcards.
$path = $this->sanitize_wildcards( $path );
}
$value = array_values( array_unique( array_filter( $value ) ) );
} else {
$value = array();
}
return $value;
}
/**
* Sanitize wildcards in a given path.
*
* @param string $path The path to sanitize.
* @return string The sanitized path.
*/
private function sanitize_wildcards( $path ) {
if ( ! $path ) {
return '';
}
$path_components = explode( '/', $path );
$arr = array(
'.*' => '(.*)',
'*' => '(.*)',
'(*)' => '(.*)',
'(.*)' => '(.*)',
);
foreach ( $path_components as &$path_component ) {
$path_component = strtr( $path_component, $arr );
}
$path = implode( '/', $path_components );
return $path;
}
}
@@ -0,0 +1,11 @@
### Pre_WordPress namespace
Everything in this directory / namespace contains code which can execute before WordPress is fully
initialized. It can be called from `advanced-cache.php`, but it can also be called directly from
the main Boost code-base.
Nothing in the `Pre_WordPress` namespace may rely on autolaoding to load things; you must include
an explicit `require_once` instruction in the entrypoint file `Boost_Cache.php`. It also must not rely on any WordPress functionality that is
unavailable at the time that `advanced-cache.php` is executed.
You can use this code from elsewhere in Boost, and you can autoload it from outside this namespace, though.
@@ -0,0 +1,89 @@
<?php
/**
* This file contains all the public actions for the Page Cache module.
* This file is loaded before WordPress is fully initialized.
*/
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Logger;
/**
* Delete all cache.
*
* Allow third-party plugins to clear all cache.
*/
add_action( 'jetpack_boost_clear_page_cache_all', 'jetpack_boost_delete_cache' );
/**
* Delete cache for homepage and paged archives.
*
* Allow third-party plugins to clear front-page cache.
*/
add_action( 'jetpack_boost_clear_page_cache_home', 'jetpack_boost_delete_cache_for_home' );
/**
* Delete cache for a specific URL.
*
* Allow third-party plugins to clear the cache for a specific URL.
*
* @param string $url - The URL to delete the cache for.
*/
add_action( 'jetpack_boost_clear_page_cache_url', 'jetpack_boost_delete_cache_for_url' );
/**
* Delete cache for a specific post.
*
* Allow third-party plugins to clear the cache for a specific post.
*
* @param int $post_id - The ID of the post to delete the cache for.
*/
add_action( 'jetpack_boost_clear_page_cache_post', 'jetpack_boost_delete_cache_by_post_id' );
/**
* Delete all cache files.
*/
function jetpack_boost_delete_cache() {
$boost_cache = new Boost_Cache();
$boost_cache->delete_recursive( home_url() );
}
/**
* Delete cache for homepage and paged archives.
*/
function jetpack_boost_delete_cache_for_home() {
$boost_cache = new Boost_Cache();
$boost_cache->delete_page( home_url() );
Logger::debug( 'jetpack_boost_delete_cache_for_home: deleting front page cache' );
if ( get_option( 'show_on_front' ) === 'page' ) {
$posts_page_id = get_option( 'page_for_posts' ); // posts page
if ( $posts_page_id ) {
$boost_cache->delete_recursive( get_permalink( $posts_page_id ) );
Logger::debug( 'jetpack_boost_delete_cache_for_home: deleting posts page cache' );
}
}
}
/**
* Delete cache for a specific URL.
*
* @param string $url - The URL to delete the cache for.
*/
function jetpack_boost_delete_cache_for_url( $url ) {
$boost_cache = new Boost_Cache();
$boost_cache->delete_recursive( $url );
}
/**
* Delete cache for a specific post.
*
* @param int $post_id - The ID of the post to delete the cache for.
*/
function jetpack_boost_delete_cache_by_post_id( $post_id ) {
$post = get_post( (int) $post_id );
Logger::debug( 'invalidate_cache_for_post: ' . $post->ID );
$boost_cache = new Boost_Cache();
$boost_cache->delete_post_cache( $post );
}
@@ -0,0 +1,69 @@
<?php
/*
* This file may be called before WordPress is fully initialized. See the README file for info.
*/
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress;
/**
* A replacement for WP_Error when working in a Pre_WordPress setting.
*
* This class deliberately offers a similar API to WP_Error for familiarity. All Pre_WordPress functions
* which may return an error object use this class to represent an Error state.
*
* If you call a Pre_WordPress function after loading WordPress, use to_wp_error to convert these
* objects to a standard WP_Error object.
*/
class Boost_Cache_Error {
/**
* @var string - The error code.
*/
private $code;
/**
* @var string - The error message.
*/
private $message;
/**
* Create a Boost_Cache_Error object, with a code and a message.
*/
public function __construct( $code, $message ) {
$this->code = $code;
$this->message = $message;
}
/**
* Return the error message.
*/
public function get_error_message() {
return $this->message;
}
/**
* Return the error code.
*/
public function get_error_code() {
return $this->code;
}
/**
* Convert to a WP_Error.
*
* When calling a Pre_WordPress function from a WordPress context, use this method to convert
* any resultant errors to WP_Errors for interfacing with other WordPress APIs.
*
* **Warning** - this function should only be called if WordPress has been loaded!
*/
public function to_wp_error() {
if ( ! class_exists( '\WP_Error' ) ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( 'Warning: Boost_Cache_Error::to_wp_error called from a Pre-WordPress context' );
}
}
return new \WP_Error( $this->get_error_code(), $this->get_error_message() );
}
}
@@ -0,0 +1,197 @@
<?php
/*
* This file may be called before WordPress is fully initialized. See the README file for info.
*/
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress;
/**
* Cache settings class.
* Settings are stored in a file in the boost-cache directory.
*/
class Boost_Cache_Settings {
private static $instance = null;
private $settings = array();
private $config_file_path;
private $config_file;
/**
* An uninitialized config holds these settings.
*
* @var array
*/
private $default_settings = array(
'enabled' => false,
'bypass_patterns' => array(),
'logging' => false,
);
private function __construct() {
$this->config_file_path = WP_CONTENT_DIR . '/boost-cache/';
$this->config_file = $this->config_file_path . 'config.php';
}
/**
* Gets the instance of the class.
*
* @return Boost_Cache_Settings The instance of the class.
*/
public static function get_instance() {
if ( self::$instance === null ) {
self::$instance = new Boost_Cache_Settings();
self::$instance->init_settings();
}
return self::$instance;
}
/**
* Ensure a settings file exists, if one isn't there already.
*
* @return Boost_Cache_Error|bool - True if it was changed, or a Boost_Cache_Error on failure, false if it was already created.
*/
public function create_settings_file() {
if ( file_exists( $this->config_file ) ) {
return false;
}
if ( ! file_exists( $this->config_file_path ) ) {
if ( ! Filesystem_Utils::create_directory( $this->config_file_path ) ) {
return new Boost_Cache_Error( 'failed-settings-write', 'Failed to create settings directory at ' . $this->config_file_path );
}
}
$write_result = $this->set( $this->default_settings );
if ( $write_result instanceof Boost_Cache_Error ) {
return $write_result;
}
return true;
}
/**
* If an error occurs while reading the options, it will be impossible to ever log this to the Boost Cache logs.
* So, if WP_DEBUG is enabled write it to the error_log instead.
*
* @param string $message - The message to log.
*/
private function log_init_error( $message ) {
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
error_log( $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
}
}
/**
* Load the settings from the config file, if available. Falls back to defaults if not.
*/
private function init_settings() {
$this->settings = $this->default_settings;
// If no settings file exists yet, don't try to create one until we are writing a value.
if ( ! file_exists( $this->config_file ) ) {
return;
}
$lines = @file( $this->config_file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( empty( $lines ) || count( $lines ) < 4 ) {
$this->log_init_error( 'Invalid config file at ' . $this->config_file );
return;
}
$file_settings = null;
foreach ( $lines as $line ) {
if ( strpos( $line, '{' ) !== false ) {
$file_settings = json_decode( $line, true );
break;
}
}
if ( ! is_array( $file_settings ) ) {
$this->log_init_error( 'Invalid config file at ' . $this->config_file );
return false;
}
$this->settings = $file_settings;
}
/**
* Returns the value of the given setting.
*
* @param string $setting - The setting to get.
* @return mixed - The value of the setting, or the default if the setting does not exist.
*/
public function get( $setting, $default = false ) {
if ( ! isset( $this->settings[ $setting ] ) ) {
return $default;
}
return $this->settings[ $setting ];
}
/**
* Returns true if the cache is enabled.
*
* @return bool
*/
public function get_enabled() {
return $this->get( 'enabled', false );
}
/**
* Returns an array of URLs that should not be cached.
*
* @return array
*/
public function get_bypass_patterns() {
return $this->get( 'bypass_patterns', array() );
}
/**
* Returns whether logging is enabled or not.
*
* @return bool
*/
public function get_logging() {
return $this->get( 'logging', false );
}
/**
* Sets the given settings, and saves them to the config file.
*
* @param array $settings - The settings to set in a key => value associative
* array. This will be merged with the existing settings.
* Example:
* $result = $this->set( array( 'enabled' => true ) );
*
* @return Boost_Cache_Error|true - true if the settings were saved, Boost_Cache_Error otherwise.
*/
public function set( $settings ) {
// If the settings file does not exist, attempt to create one.
if ( ! file_exists( $this->config_file_path ) ) {
$result = $this->create_settings_file();
if ( $result instanceof Boost_Cache_Error ) {
return $result;
}
}
if ( ! is_writable( $this->config_file_path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable
$error = new Boost_Cache_Error( 'failed-settings-write', 'Could not write to the config file at ' . $this->config_file_path );
Logger::debug( $error->get_error_message() );
return $error;
}
$this->settings = array_merge( $this->settings, $settings );
$contents = "<?php die();\n/*\n * Configuration data for Jetpack Boost Cache. Do not edit.\n" . json_encode( // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
$this->settings,
JSON_HEX_TAG // Need to escape slashes because this is, for some reason, going into a PHP comment and we need to guard against `*/`.
) . "\n */";
$result = Filesystem_Utils::write_to_file( $this->config_file, $contents );
if ( $result instanceof Boost_Cache_Error ) {
Logger::debug( $result->get_error_message() );
return new Boost_Cache_Error( 'failed-settings-write', 'Failed to write settings file: ' . $result->get_error_message() );
}
return true;
}
}
@@ -0,0 +1,100 @@
<?php
/*
* This file may be called before WordPress is fully initialized. See the README file for info.
*/
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress;
use WP_Post;
class Boost_Cache_Utils {
/**
* Performs a deep string replace operation to ensure the values in $search are no longer present.
* Copied from wp-includes/formatting.php
*
* Repeats the replacement operation until it no longer replaces anything to remove "nested" values
* e.g. $subject = '%0%0%0DDD', $search ='%0D', $result ='' rather than the '%0%0DD' that
* str_replace would return
*
* @param string|array $search The value being searched for, otherwise known as the needle.
* An array may be used to designate multiple needles.
* @param string $subject The string being searched and replaced on, otherwise known as the haystack.
* @return string The string with the replaced values.
*/
public static function deep_replace( $search, $subject ) {
$subject = (string) $subject;
$count = 1;
while ( $count ) {
$subject = str_replace( $search, '', $subject, $count );
}
return $subject;
}
public static function trailingslashit( $string ) {
return rtrim( $string, '/' ) . '/';
}
/**
* Returns a sanitized directory path.
*
* @param string $path - The path to sanitize.
* @return string
*/
public static function sanitize_file_path( $path ) {
$path = self::trailingslashit( $path );
$path = self::deep_replace(
array( '..', '\\' ),
preg_replace(
'/[ <>\'\"\r\n\t\(\)]/',
'',
preg_replace(
'/(\?.*)?(#.*)?$/',
'',
$path
)
)
);
return $path;
}
/**
* Normalize the request uri so it can be used for caching purposes.
* It removes the query string and the trailing slash, and characters
* that might cause problems with the filesystem.
*
* **THIS DOES NOT SANITIZE THE VARIABLE IN ANY WAY.**
* Only use it for comparison purposes or to generate an MD5 hash.
*
* @param string $request_uri - The request uri to normalize.
* @return string - The normalized request uri.
*/
public static function normalize_request_uri( $request_uri ) {
// get path from request uri
$request_uri = parse_url( $request_uri, PHP_URL_PATH ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url
if ( empty( $request_uri ) ) {
$request_uri = '/';
} elseif ( substr( $request_uri, -1 ) !== '/' && ! is_file( ABSPATH . $request_uri ) ) {
$request_uri .= '/';
}
return $request_uri;
}
/**
* Checks if the post type is public.
*
* @param WP_Post $post - The post to check.
* @return bool - True if the post type is public.
*/
public static function is_visible_post_type( $post ) {
$post_type = is_a( $post, 'WP_Post' ) ? get_post_type_object( $post->post_type ) : null;
if ( empty( $post_type ) || ! $post_type->public ) {
return false;
}
return true;
}
}
@@ -0,0 +1,728 @@
<?php
/*
* This file is loaded by advanced-cache.php, and so cannot rely on autoloading.
*/
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress;
use WP_Comment;
use WP_Post;
/*
* Require all pre-wordpress files here. These files aren't autoloaded as they are loaded before WordPress is fully initialized.
* pre-wordpress files assume all other pre-wordpress files are loaded here.
*/
require_once __DIR__ . '/path-actions/interface-path-action.php';
require_once __DIR__ . '/path-actions/class-filter-older.php';
require_once __DIR__ . '/path-actions/class-rebuild-file.php';
require_once __DIR__ . '/path-actions/class-simple-delete.php';
require_once __DIR__ . '/boost-cache-actions.php';
require_once __DIR__ . '/class-boost-cache-error.php';
require_once __DIR__ . '/class-boost-cache-settings.php';
require_once __DIR__ . '/class-boost-cache-utils.php';
require_once __DIR__ . '/class-filesystem-utils.php';
require_once __DIR__ . '/class-logger.php';
require_once __DIR__ . '/class-request.php';
require_once __DIR__ . '/storage/interface-storage.php';
require_once __DIR__ . '/storage/class-file-storage.php';
// Define how many seconds the cache should last for each cached page.
if ( ! defined( 'JETPACK_BOOST_CACHE_DURATION' ) ) {
define( 'JETPACK_BOOST_CACHE_DURATION', HOUR_IN_SECONDS );
}
// Define how many seconds the rebuild cache should be considered stale, but usable, for each cached page.
if ( ! defined( 'JETPACK_BOOST_CACHE_REBUILD_DURATION' ) ) {
define( 'JETPACK_BOOST_CACHE_REBUILD_DURATION', 10 );
}
class Boost_Cache {
/**
* @var Boost_Cache_Settings - The settings for the page cache.
*/
private $settings;
/**
* @var Storage\Storage - The storage system used by Boost Cache.
*/
private $storage;
/**
* @var Request - The request object that provides utility for the current request.
*/
private $request = null;
/**
* @var bool - Indicates whether the cache engine has been loaded.
*/
private static $cache_engine_loaded = false;
/**
* @var bool - Indicates whether WordPress initialized correctly and we can cache the page.
*/
private $do_cache = false;
/**
* @var string - The ignored cookies that were removed from the cache parameters.
*/
private $ignored_cookies = '';
/**
* @var string - The ignored GET parameters that were removed from the cache parameters.
*/
private $ignored_get_parameters = '';
/**
* @param ?Storage\Storage $storage - Optionally provide a Storage subclass to handle actually storing and retrieving cached content. Defaults to a new instance of File_Storage.
*/
public function __construct( $storage = null ) {
$this->settings = Boost_Cache_Settings::get_instance();
$home = isset( $_SERVER['HTTP_HOST'] ) ? strtolower( $_SERVER['HTTP_HOST'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
$this->storage = $storage ?? new Storage\File_Storage( $home );
$this->request = Request::current();
}
/**
* Initialize the actions for the cache.
*/
public function init_actions() {
add_action( 'transition_post_status', array( $this, 'invalidate_on_post_transition' ), 10, 3 );
add_action( 'transition_comment_status', array( $this, 'invalidate_on_comment_transition' ), 10, 3 );
add_action( 'comment_post', array( $this, 'rebuild_on_comment_post' ), 10, 3 );
add_action( 'edit_comment', array( $this, 'rebuild_on_comment_edit' ), 10, 2 );
add_action( 'switch_theme', array( $this, 'rebuild_all' ) );
add_action( 'wp_trash_post', array( $this, 'delete_on_post_trash' ), 10, 2 );
add_filter( 'wp_php_error_message', array( $this, 'disable_caching_on_error' ) );
add_filter( 'init', array( $this, 'init_do_cache' ) );
add_filter( 'jetpack_boost_cache_parameters', array( $this, 'ignore_cookies' ) );
add_filter( 'jetpack_boost_cache_parameters', array( $this, 'ignore_get_parameters' ) );
$this->load_extra();
}
private function load_extra() {
if ( file_exists( WP_CONTENT_DIR . '/boost-cache-extra.php' ) ) {
include_once WP_CONTENT_DIR . '/boost-cache-extra.php';
}
}
/**
* Serve the cached page if it exists, otherwise start output buffering.
*/
public function serve() {
if ( ! $this->settings->get_enabled() ) {
return;
}
// Indicate that the cache engine has been loaded.
self::$cache_engine_loaded = true;
if ( ! $this->request->is_cacheable() ) {
return;
}
if ( ! $this->serve_cached() ) {
$this->ob_start();
}
}
/**
* Check if the cache engine has been loaded.
*
* @return bool - True if the cache engine has been loaded, false otherwise.
*/
public static function is_loaded() {
return self::$cache_engine_loaded;
}
/**
* Get the storage instance used by Boost Cache.
*
* @return Storage\Storage
*/
public function get_storage() {
return $this->storage;
}
/**
* Serve cached content, if any is available for the current request. Will terminate if it does so.
* Otherwise, returns false.
*/
public function serve_cached() {
if ( ! $this->request->is_cacheable() ) {
return false;
}
// check if rebuild file exists and rename it to the correct file
$rebuild_found = $this->storage->reset_rebuild_file( $this->request->get_uri(), $this->request->get_parameters() );
if ( $rebuild_found ) {
Logger::debug( 'Rebuild file found. Will be used for cache until new file created.' );
$cached = false;
} else {
$cached = $this->storage->read( $this->request->get_uri(), $this->request->get_parameters() );
}
if ( is_string( $cached ) ) {
$this->send_header( 'X-Jetpack-Boost-Cache: hit' );
$ignored_cookies_message = $this->ignored_cookies === '' ? '' : " and ignored cookies: {$this->ignored_cookies}";
$ignored_get_message = $this->ignored_get_parameters === '' ? '' : " and ignored GET parameters: {$this->ignored_get_parameters}";
Logger::debug( 'Serving cached page' . $ignored_cookies_message . $ignored_get_message );
echo $cached; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
die( 0 );
}
$cache_status = $rebuild_found ? 'rebuild' : 'miss';
$this->send_header( 'X-Jetpack-Boost-Cache: ' . $cache_status );
return false;
}
private function send_header( $header ) {
if ( ! headers_sent() ) {
header( $header );
}
}
/**
* Starts output buffering and sets the callback to save the cache file.
*
* @return bool - false if page is not cacheable.
*/
public function ob_start() {
if ( ! $this->request->is_cacheable() ) {
return false;
}
ob_start( array( $this, 'ob_callback' ) );
return true;
}
/**
* Callback function from output buffer. This function saves the output
* buffer to a cache file and then returns the buffer so PHP will send it
* to the browser.
*
* @param string $buffer - The output buffer to save to the cache file.
* @return string - The output buffer.
*/
public function ob_callback( $buffer ) {
if ( strlen( $buffer ) > 0 && $this->request->is_cacheable() ) {
// Do not cache the page as WordPress did not initialize correctly.
if ( ! $this->do_cache ) {
Logger::debug( 'Page exited early. Do not cache.' );
return $buffer;
}
if ( false === stripos( $buffer, '</html>' ) ) {
Logger::debug( 'Closing HTML tag not found, not caching' );
return $buffer;
}
$result = $this->storage->write( $this->request->get_uri(), $this->request->get_parameters(), $buffer );
if ( $result instanceof Boost_Cache_Error ) {
Logger::debug( 'Error writing cache file: ' . $result->get_error_message() );
} else {
$ignored_cookies_message = $this->ignored_cookies === '' ? '' : " and ignored cookies: {$this->ignored_cookies}";
$ignored_get_message = $this->ignored_get_parameters === '' ? '' : " and ignored GET parameters: {$this->ignored_get_parameters}";
Logger::debug( 'Cache file created' . $ignored_cookies_message . $ignored_get_message );
}
}
return $buffer;
}
/**
* Delete/rebuild the cache for the front page and paged archives.
* This is called when a post is edited, deleted, or published.
*/
public function rebuild_front_page() {
if ( get_option( 'show_on_front' ) === 'page' ) {
$this->rebuild_page( home_url() );
$posts_page_id = get_option( 'page_for_posts' ); // posts page
if ( $posts_page_id ) {
Logger::debug( 'rebuild_front_page: deleting posts page cache' );
$this->rebuild_post_cache( get_post( $posts_page_id ) );
}
} else {
$this->rebuild_page( home_url() );
Logger::debug( 'delete front page cache ' . Boost_Cache_Utils::normalize_request_uri( home_url() ) );
}
}
/**
* Rebuild the cache for the post if the comment transitioned from one state to another.
*
* @param string $new_status - The new status of the comment.
* @param string $old_status - The old status of the comment.
* @param WP_Comment $comment - The comment that transitioned.
*/
public function invalidate_on_comment_transition( $new_status, $old_status, $comment ) {
if ( $new_status === $old_status ) {
return;
}
Logger::debug( "invalidate_on_comment_transition: $new_status, $old_status" );
if ( $new_status !== 'approved' && $old_status !== 'approved' ) {
Logger::debug( 'invalidate_on_comment_transition: comment not approved' );
return;
}
$post = get_post( (int) $comment->comment_post_ID );
$this->rebuild_post_cache( $post );
}
/**
* After editing a comment, rebuild the cache for the post if the comment is approved.
* If changing state and editing, both actions will be called, but the cache will only be rebuilt once.
*
* @param int $comment_id - The id of the comment.
* @param array $commentdata - The comment data.
*/
public function rebuild_on_comment_edit( $comment_id, $commentdata ) {
$post = get_post( $commentdata['comment_post_ID'] );
if ( (int) $commentdata['comment_approved'] === 1 ) {
$this->rebuild_post_cache( $post );
}
}
/**
* After a comment is posted, rebuild the cache for the post if the comment is approved.
* If the comment is not approved, only rebuild the cache for this post for this visitor.
*
* @param int $comment_id - The id of the comment.
* @param int $comment_approved - The approval status of the comment.
* @param array $commentdata - The comment data.
*/
public function rebuild_on_comment_post( $comment_id, $comment_approved, $commentdata ) {
$post = get_post( $commentdata['comment_post_ID'] );
Logger::debug( "rebuild_on_comment_post: $comment_id, $comment_approved, {$post->ID}" );
/**
* If a comment is not approved, we only need to delete the cache for
* this post for this visitor so the unmoderated comment is shown to them.
*/
if ( $comment_approved !== 1 ) {
$parameters = $this->request->get_parameters();
/*
* If there are no cookies, then visitor did not click "remember me".
* They'll be redirected to a page with a hash in the URL for the
* moderation message.
* Only delete the cache for visitors who clicked "remember me".
*/
if ( isset( $parameters['cookies'] ) && ! empty( $parameters['cookies'] ) ) {
$this->delete_page( get_permalink( $post->ID ), $parameters );
}
return;
}
$this->rebuild_post_cache( $post );
}
/**
* Returns true if the post is published or private.
*
* @param string $status - The status of the post.
* @return bool
*/
private function is_published( $status ) {
return $status === 'publish' || $status === 'private';
}
/**
* Delete the cached post if it transitioned from one state to another.
*
* @param string $new_status - The new status of the post.
* @param string $old_status - The old status of the post.
* @param WP_Post $post - The post that transitioned.
*/
public function invalidate_on_post_transition( $new_status, $old_status, $post ) {
// Special case: Delete cache if the post type can effect the whole site.
$special_post_types = array( 'wp_template', 'wp_template_part', 'wp_global_styles' );
if ( in_array( $post->post_type, $special_post_types, true ) ) {
Logger::debug( 'invalidate_on_post_transition: special post type ' . $post->post_type );
$this->rebuild_all();
return;
}
if ( ! Boost_Cache_Utils::is_visible_post_type( $post ) ) {
return;
}
if ( $new_status === 'trash' ) {
return;
}
Logger::debug( "invalidate_on_post_transition: $new_status, $old_status, {$post->ID}" );
// Don't delete the cache for posts that weren't published and aren't published now
if ( ! $this->is_published( $new_status ) && ! $this->is_published( $old_status ) ) {
Logger::debug( 'invalidate_on_post_transition: not published' );
return;
}
// delete the cache files entirely if the post was unpublished
if ( 'publish' === $old_status && 'publish' !== $new_status ) {
Logger::debug( 'invalidate_on_post_transition: delete cache on new private page' );
$this->delete_on_post_trash( $post->ID, $old_status );
return;
}
Logger::debug( "invalidate_on_post_transition: rebuilding post {$post->ID}" );
$this->rebuild_post_cache( $post );
$this->rebuild_post_terms_cache( $post );
$this->rebuild_front_page();
$this->rebuild_author_page( (int) $post->post_author );
}
/**
* Delete the cache for the post if it was trashed.
*
* @param int $post_id - The id of the post.
* @param string $old_status - The old status of the post.
*/
public function delete_on_post_trash( $post_id, $old_status ) {
if ( $this->is_published( $old_status ) ) {
$post = get_post( $post_id );
$post_path = $this->get_post_path_for_invalidation( $post );
if ( $post_path ) {
$this->delete_recursive( $post_path );
}
$this->rebuild_post_terms_cache( $post );
$this->rebuild_front_page();
$this->rebuild_author_page( (int) $post->post_author );
}
}
private function get_post_path_for_invalidation( $post ) {
static $already_deleted = -1;
if ( $already_deleted === $post->ID ) {
return null;
}
/**
* Don't invalidate the cache for post types that are not public.
*/
if ( ! Boost_Cache_Utils::is_visible_post_type( $post ) ) {
return null;
}
$already_deleted = $post->ID;
/**
* If a post is unpublished, the permalink will be deleted. In that case,
* get_sample_permalink() will return a permalink with ?p=123 instead of
* the post name. We need to get the post name from the post object.
*/
$permalink = get_permalink( $post->ID );
if ( strpos( $permalink, '?p=' ) !== false || strpos( $permalink, '?page_id=' ) !== false ) {
if ( $post->post_type === 'page' ) {
$permalink = get_page_link( $post->ID, false, true );
} else {
if ( ! function_exists( 'get_sample_permalink' ) ) {
require_once ABSPATH . 'wp-admin/includes/post.php';
}
list( $permalink, $post_name ) = get_sample_permalink( $post->ID );
$permalink = str_replace( '%postname%', $post_name, $permalink );
}
}
return $permalink;
}
/**
* Delete the cache for terms associated with this post.
*
* @param WP_Post $post - The post to delete the cache for.
*/
public function rebuild_post_terms_cache( $post ) {
$categories = get_the_category( $post->ID );
if ( is_array( $categories ) ) {
foreach ( $categories as $category ) {
$link = trailingslashit( get_category_link( $category->term_id ) );
$this->rebuild_recursive( $link );
}
}
$tags = get_the_tags( $post->ID );
if ( is_array( $tags ) ) {
foreach ( $tags as $tag ) {
$link = trailingslashit( get_tag_link( $tag->term_id ) );
$this->rebuild_recursive( $link );
}
}
}
/**
* Delete the entire cache for the author's archive page.
*
* @param int $author_id - The id of the author.
*/
public function rebuild_author_page( $author_id ) {
$author = get_userdata( $author_id );
if ( ! $author ) {
return;
}
$author_link = get_author_posts_url( $author_id, $author->user_nicename );
$this->rebuild_recursive( $author_link );
}
/**
* Rebuild the entire cache.
*/
public function rebuild_all() {
$this->rebuild_recursive( home_url() );
}
public function delete_post_cache( $post ) {
$post_path = $this->get_post_path_for_invalidation( $post );
if ( null === $post_path ) {
return;
}
if ( Boost_Cache_Utils::trailingslashit( $post_path ) !== Boost_Cache_Utils::trailingslashit( home_url() ) ) {
$this->delete_recursive( $post_path );
} else {
$this->delete_page( $post_path );
}
}
public function rebuild_post_cache( $post ) {
$post_path = $this->get_post_path_for_invalidation( $post );
if ( null === $post_path ) {
return;
}
if ( Boost_Cache_Utils::trailingslashit( $post_path ) !== Boost_Cache_Utils::trailingslashit( home_url() ) ) {
$this->rebuild_recursive( $post_path );
} else {
$this->rebuild_page( $post_path );
}
}
public function rebuild_page( $path, $parameters = false ) {
$this->storage->clear(
$path,
array(
'rebuild' => true,
'parameters' => $parameters,
)
);
$this->invalidate_cache_success( $path, 'rebuild', 'page' );
}
public function delete_page( $path, $parameters = false ) {
$this->storage->clear(
$path,
array(
'rebuild' => false,
'parameters' => $parameters,
)
);
$this->invalidate_cache_success( $path, 'delete', 'page' );
}
public function rebuild_recursive( $path ) {
$this->storage->clear(
$path,
array(
'rebuild' => true,
'recursive' => true,
)
);
$this->invalidate_cache_success( $path, 'rebuild', 'recursive' );
}
public function delete_recursive( $path ) {
$this->storage->clear(
$path,
array(
'rebuild' => false,
'recursive' => true,
)
);
$this->invalidate_cache_success( $path, 'delete', 'recursive' );
}
private function invalidate_cache_success( $path, $type, $scope ) {
do_action( 'jetpack_boost_invalidate_cache_success', $path, $type, $scope );
}
/**
* Ignore certain GET parameters in the cache parameters so cached pages can be served to these visitors.
*
* @param array $parameters - The parameters with the GET array to filter.
* @return array - The parameters with GET parameters removed.
*/
public function ignore_get_parameters( $parameters ) {
static $params = false;
// Only run this once as it may be called multiple times on uncached pages.
if ( $params ) {
return $params;
}
/**
* Filters the GET parameters so cached pages can be served to these visitors.
* The list is an array of regex patterns. The default list contains the
* most common GET parameters used by analytics services.
*
* @since 3.8.0
*
* @param array $get_parameters An array of regexes to remove items from the GET parameter list.
*/
$get_parameters = apply_filters(
'jetpack_boost_ignore_get_parameters',
array( 'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'ysclid', 'srsltid', 'yclid' )
);
$get_parameters = array_unique(
array_map(
'trim',
$get_parameters
)
);
foreach ( $get_parameters as $get_parameter ) {
foreach ( array_keys( $parameters['get'] ) as $get_parameter_name ) {
if ( preg_match( '/^' . $get_parameter . '$/', $get_parameter_name ) ) {
unset( $parameters['get'][ $get_parameter_name ] );
$this->ignored_get_parameters .= $get_parameter_name . ',';
}
}
}
if ( $this->ignored_get_parameters !== '' ) {
$this->ignored_get_parameters = rtrim( $this->ignored_get_parameters, ',' );
}
$params = $parameters;
return $parameters;
}
/**
* Ignore certain cookies in the cache parameters so cached pages can be served to these visitors.
*
* @param array $parameters - The parameters with the cookies array to filter.
* @return array - The parameters with cookies removed.
*/
public function ignore_cookies( $parameters ) {
static $params = false;
// Only run this once as it may be called multiple times on uncached pages.
if ( $params ) {
return $params;
}
$default_cookies = array(
'cf_clearance',
'cf_chl_rc_i',
'cf_chl_rc_ni',
'cf_chl_rc_m',
'_cfuvid',
'__cfruid',
'__cfwaitingroom',
'cf_ob_info',
'cf_use_ob',
'__cfseq',
'__cf_bm',
'__cflb',
// Sourcebuster
'sbjs_(.*)',
// Google Analytics
'_ga(?:_[A-Z0-9]*)?',
// AWS Load Balancer
'AWSELB',
'AWSELBCORS',
'AWSALB',
'AWSALBCORS',
);
$jetpack_cookies = array( 'tk_ai', 'tk_qs' );
$cookies = array_merge( $default_cookies, $jetpack_cookies );
/**
* Filters the browser cookies so cached pages can be served to these visitors.
* The list is an array of regex patterns. The default list contains the
* cookies used by Cloudflare, and the regex pattern for the sbjs_ cookies
* used by sourcebuster.js
*
* @since 3.8.0
*
* @param array $cookies An array of regexes to remove items from the cookie list.
*/
$cookies = apply_filters(
'jetpack_boost_ignore_cookies',
$cookies
);
$cookies = array_unique(
array_map(
'trim',
$cookies
)
);
/**
* The Jetpack Cookie Banner plugin sets a cookie to indicate that the
* user has accepted the cookie policy.
* The value of the cookie is the expiry date of the cookie, which means
* that everyone who has accepted the cookie policy will use a different
* cache file.
* Set it to 1 here so those visitors will use the same cache file.
*/
if ( isset( $parameters['cookies']['eucookielaw'] ) ) {
$parameters['cookies']['eucookielaw'] = 1;
}
/**
* This is for the personalized ads consent cookie.
*/
if ( isset( $parameters['cookies']['personalized-ads-consent'] ) ) {
$parameters['cookies']['personalized-ads-consent'] = 1;
}
$cookie_keys = array();
if ( isset( $parameters['cookies'] ) && is_array( $parameters['cookies'] ) ) {
$cookie_keys = array_keys( $parameters['cookies'] );
} else {
return $parameters;
}
foreach ( $cookies as $cookie ) {
foreach ( $cookie_keys as $cookie_name ) {
if ( preg_match( '/^' . $cookie . '$/', $cookie_name ) ) {
unset( $parameters['cookies'][ $cookie_name ] );
$this->ignored_cookies .= $cookie_name . ',';
}
}
}
if ( $this->ignored_cookies !== '' ) {
$this->ignored_cookies = rtrim( $this->ignored_cookies, ',' );
}
$params = $parameters;
return $parameters;
}
public function disable_caching_on_error( $message ) {
if ( ! defined( 'DONOTCACHEPAGE' ) ) {
define( 'DONOTCACHEPAGE', true );
}
Logger::debug( 'Fatal error detected, caching disabled' );
return $message;
}
/**
* This function is called after WordPress is loaded, on "init".
* It is used to indicate that it is safe to cache and that no
* fatal errors occurred.
*/
public function init_do_cache() {
$this->do_cache = true;
}
}
@@ -0,0 +1,382 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions\Path_Action;
use SplFileInfo;
class Filesystem_Utils {
const DELETE_ALL = 'delete-all'; // delete all files and directories in a given directory, recursively.
const DELETE_FILE = 'delete-single'; // delete a single file or recursively delete a single directory in a given directory.
const DELETE_FILES = 'delete-files'; // delete all files in a given directory.
const REBUILD_ALL = 'rebuild-all'; // rebuild all files and directories in a given directory, recursively.
const REBUILD_FILE = 'rebuild-single'; // rebuild a single file or recursively rebuild a single directory in a given directory.
const REBUILD_FILES = 'rebuild-files'; // rebuild all files in a given directory.
const REBUILD = 'rebuild'; // rebuild mode for managing expired files
const DELETE = 'delete'; // delete mode for managing expired files
const REBUILD_FILE_EXTENSION = '.rebuild.html'; // The extension used for rebuilt files.
/**
* Iterate over a directory and apply an action to each file.
*
* This applies the action to all files and subdirectories in the given directory.
*
* @param string $path - The directory to iterate over.
* @param Path_Action $action - The action to apply to each file.
* @return int|Boost_Cache_Error - The number of files processed, or Boost_Cache_Error on failure.
*/
public static function iterate_directory( $path, Path_Action $action ) {
clearstatcache();
$validation_error = self::validate_path( $path );
if ( $validation_error instanceof Boost_Cache_Error ) {
return $validation_error;
}
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator( $path, \RecursiveDirectoryIterator::SKIP_DOTS ),
\RecursiveIteratorIterator::CHILD_FIRST
);
$count = 0;
foreach ( $iterator as $file ) {
$count += $action->apply_to_path( new SplFileInfo( $file ) );
}
$count += $action->apply_to_path( new SplFileInfo( $path ) );
return $count;
}
/**
* Iterate over a directory and apply an action to each file.
*
* This applies the action to all files in the directory, except index.html. And doesn't go into subdirectories.
*
* @param string $path - The directory to iterate over.
* @param Path_Action $action - The action to apply to each file.
* @return int|Boost_Cache_Error - The number of files processed, or Boost_Cache_Error on failure.
*/
public static function iterate_files( $path, Path_Action $action ) {
clearstatcache();
$validation_error = self::validate_path( $path );
if ( $validation_error instanceof Boost_Cache_Error ) {
return $validation_error;
}
$path = Boost_Cache_Utils::trailingslashit( $path );
// Files to delete are all files in the given directory, except index.html. index.html is used to prevent directory listing.
$files = array_diff( scandir( $path ), array( '.', '..', 'index.html' ) );
$count = 0;
foreach ( $files as $file ) {
$fileinfo = new SplFileInfo( $path . $file );
$count += (int) $action->apply_to_path( $fileinfo );
}
return $count;
}
/**
* Recursively delete a directory and everything in it, including cache files,
* index.html placeholder files, subdirectories and the directory itself.
*
* Unlike iterate_directory() with a Simple_Delete action, this does not keep
* index.html placeholder files, does not log each deletion, and removes each
* entry as the iterator visits it instead of building a file list in memory,
* so it stays time- and memory-efficient even for very large caches. Used to
* completely remove the boost-cache directory when the plugin is uninstalled.
*
* @param string $path - The directory to delete.
* @return bool|Boost_Cache_Error - True on success (or if the directory is already gone), Boost_Cache_Error on failure.
*/
public static function delete_directory( $path ) {
clearstatcache();
// Strip a trailing slash so the is_link() guard below sees the link itself.
// is_link( 'foo/' ) is false on POSIX, which would let a trailing-slash path
// slip past the symlink-root check; rtrim() closes that for this public,
// destructive primitive even though the current caller passes no slash.
$path = rtrim( $path, '/' );
// Refuse to follow a symlinked cache root. realpath() resolves a symlink
// to its target, so a boost-cache symlink pointing outside wp-content would
// resolve identically to $cache_root below and pass the containment check,
// causing the target tree to be deleted. Boost never creates boost-cache as
// a symlink, so a symlinked root is unexpected and we refuse it outright.
// This is checked on the literal $path, not the resolved target, and only
// guards the root itself; symlinks encountered inside the tree are unlinked
// (never followed) by the deletion loop below.
if ( is_link( $path ) ) {
return new Boost_Cache_Error( 'invalid-directory', 'Refusing to delete a symlinked directory: ' . $path );
}
$resolved = realpath( $path );
if ( false === $resolved ) {
// Nothing to delete if the directory is already gone.
return true;
}
// Strict containment check. is_boost_cache_directory() only does a substring
// match, which would also accept sibling paths like boost-cache-old; since
// this helper deletes whole trees during uninstall, only the cache root
// itself or paths inside it are accepted, compared on resolved paths.
$cache_root = realpath( WP_CONTENT_DIR . '/boost-cache' );
if ( false === $cache_root || ( $resolved !== $cache_root && strpos( $resolved, $cache_root . '/' ) !== 0 ) ) {
return new Boost_Cache_Error( 'invalid-directory', 'Invalid directory ' . $path );
}
if ( ! is_dir( $resolved ) ) {
return new Boost_Cache_Error( 'not-a-directory', 'Not a directory' );
}
// Deleting a large cache can take a while; try not to time out half-way through.
if ( function_exists( 'set_time_limit' ) ) {
@set_time_limit( 0 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
}
try {
// CATCH_GET_CHILD keeps the walk best-effort: an unreadable subdirectory
// is skipped instead of throwing and aborting the whole cleanup, so the
// rest of the tree is still deleted. Anything left behind is reported by
// the final is_dir() re-check below.
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator( $resolved, \RecursiveDirectoryIterator::SKIP_DOTS ),
\RecursiveIteratorIterator::CHILD_FIRST,
\RecursiveIteratorIterator::CATCH_GET_CHILD
);
// Errors for individual entries are suppressed so a single failure doesn't abort the cleanup.
foreach ( $iterator as $file ) {
if ( $file->isDir() && ! $file->isLink() ) {
@rmdir( $file->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged
} else {
@unlink( $file->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged
}
}
} catch ( \Throwable $e ) {
// The iterator itself can throw (e.g. an unreadable subdirectory).
// Uninstall cleanup must fail with a controlled error, not an
// uncaught exception.
return new Boost_Cache_Error( 'could-not-delete-directory', 'Could not completely delete directory: ' . $e->getMessage() );
}
@rmdir( $resolved ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged
// Re-check against the filesystem, not a stale stat cache, so a successful
// removal is not misreported as a failure.
clearstatcache();
if ( is_dir( $resolved ) ) {
return new Boost_Cache_Error( 'could-not-delete-directory', 'Could not completely delete directory: ' . $path );
}
return true;
}
private static function validate_path( $path ) {
$path = realpath( $path );
if ( ! $path ) {
// translators: %s is the directory that does not exist.
return new Boost_Cache_Error( 'directory-missing', 'Directory does not exist: ' . $path ); // realpath returns false if a file does not exist.
}
// make sure that $dir is a directory inside WP_CONTENT . '/boost-cache/';
if ( self::is_boost_cache_directory( $path ) === false ) {
// translators: %s is the directory that is invalid.
return new Boost_Cache_Error( 'invalid-directory', 'Invalid directory %s' . $path );
}
if ( ! is_dir( $path ) ) {
return new Boost_Cache_Error( 'not-a-directory', 'Not a directory' );
}
return true;
}
/**
* Returns true if the given directory is inside the boost-cache directory.
*
* @param string $dir - The directory to check.
* @return bool
*/
public static function is_boost_cache_directory( $dir ) {
$dir = Boost_Cache_Utils::sanitize_file_path( $dir );
return strpos( $dir, WP_CONTENT_DIR . '/boost-cache' ) !== false;
}
/**
* Given a request_uri and its parameters, return the filename to use for this cached data. Does not include the file path.
*
* @param array $parameters - An associative array of all the things that make this request special/different. Includes GET parameters and COOKIEs normally.
*/
public static function get_request_filename( $parameters ) {
/**
* Filters the components used to generate the cache key.
*
* @param array $parameters The array of components, url, cookies, get parameters, etc.
*
* @since 1.0.0
* @deprecated 3.8.0
*/
$key_components = apply_filters_deprecated( 'boost_cache_key_components', array( $parameters ), '3.8.0', 'jetpack_boost_cache_parameters' );
return md5(
json_encode( // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
$key_components,
0 // phpcs:ignore Jetpack.Functions.JsonEncodeFlags.ZeroFound -- No `json_encode()` flags because this needs to match whatever is calculating the hash on the other end.
)
) . '.html';
}
/**
* Check if a file is a rebuild file.
*
* @param string $file - The file to check.
* @return bool - True if the file is a rebuild file, false otherwise.
*/
public static function is_rebuild_file( $file ) {
return substr( $file, -strlen( self::REBUILD_FILE_EXTENSION ) ) === self::REBUILD_FILE_EXTENSION;
}
/**
* Creates the directory if it doesn't exist.
*
* @param string $path - The path to the directory to create.
*/
public static function create_directory( $path ) {
if ( ! is_dir( $path ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.dir_mkdir_dirname, WordPress.WP.AlternativeFunctions.file_system_operations_mkdir, WordPress.PHP.NoSilencedErrors.Discouraged
$dir_created = @mkdir( $path, 0755, true );
if ( $dir_created ) {
self::create_empty_index_files( $path );
}
return $dir_created;
}
return true;
}
/**
* Create an empty index.html file in the given directory.
* This is done to prevent directory listing.
*/
private static function create_empty_index_files( $path ) {
if ( self::is_boost_cache_directory( $path ) ) {
self::write_to_file( $path . '/index.html', '' );
// Create an empty index.html file in the parent directory as well.
self::create_empty_index_files( dirname( $path ) );
}
}
/**
* Rebuild a file. Make a copy of the file with a different extension instead of deleting it.
*
* @param string $file_path - The file to rebuild.
* @return bool - True if the file was rebuilt, false otherwise.
*/
public static function rebuild_file( $file_path ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable
if ( is_writable( $file_path ) ) {
// only rename the file if it is not already a rebuild file.
if ( ! self::is_rebuild_file( $file_path ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename, WordPress.PHP.NoSilencedErrors.Discouraged
@rename( $file_path, $file_path . self::REBUILD_FILE_EXTENSION );
@touch( $file_path . self::REBUILD_FILE_EXTENSION ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch, WordPress.PHP.NoSilencedErrors.Discouraged
return true;
}
}
return false;
}
/**
* Restore a file that was rebuilt so the cache file can be used for other visitors.
*
* @param string $file_path - The rebuilt file
* @return bool - True if the file was restored, false otherwise.
*/
public static function restore_file( $file_path ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable
if ( is_writable( $file_path ) ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename, WordPress.PHP.NoSilencedErrors.Discouraged
return @rename( $file_path, str_replace( self::REBUILD_FILE_EXTENSION, '', $file_path ) );
}
return false;
}
/**
* Delete a file.
*
* @param string $file_path - The file to delete.
* @return bool - True if the file was deleted, false otherwise.
*/
public static function delete_file( $file_path ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable
$deletable = is_writable( $file_path );
if ( $deletable ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged
return @unlink( $file_path );
}
return false;
}
/**
* Delete an empty cache directory.
*
* @param string $dir - The directory to delete.
* @return int - 1 if the directory was deleted, 0 otherwise.
*
* This function will delete the index.html file and the directory itself.
*/
public static function delete_empty_dir( $dir ) {
if ( self::is_dir_empty( $dir ) ) {
@unlink( $dir . '/index.html' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged
@rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged
return 1;
}
return 0;
}
/**
* Check if a directory is empty.
*
* @param string $dir - The directory to check.
*/
public static function is_dir_empty( $dir ) {
if ( ! is_readable( $dir ) ) {
return new Boost_Cache_Error( 'directory_not_readable', 'Directory is not readable' );
}
$files = array_diff( scandir( $dir ), array( '.', '..', 'index.html' ) );
return empty( $files );
}
/**
* Writes data to a file.
* This creates a temporary file first, then renames the file to the final filename.
* This is done to prevent the file from being read while it is being written to.
*
* @param string $filename - The filename to write to.
* @param string $data - The data to write to the file.
* @return bool|Boost_Cache_Error - true on sucess or Boost_Cache_Error on failure.
*/
public static function write_to_file( $filename, $data ) {
$tmp_filename = $filename . uniqid( uniqid(), true ) . '.tmp';
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.NoSilencedErrors.Discouraged
if ( false === @file_put_contents( $tmp_filename, $data ) ) {
return new Boost_Cache_Error( 'could-not-write', 'Could not write to tmp file: ' . $tmp_filename );
}
// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename
if ( ! rename( $tmp_filename, $filename ) ) {
return new Boost_Cache_Error( 'could-not-rename', 'Could not rename tmp file to final file: ' . $filename );
}
return true;
}
}
@@ -0,0 +1,195 @@
<?php
/*
* This file may be called before WordPress is fully initialized. See the README file for info.
*/
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions\Filter_Older;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions\Simple_Delete;
/**
* A utility that manages logging for the boost cache.
*/
class Logger {
/**
* The singleton instance of the logger.
*
* @var self
*/
private static $instance = null;
/**
* The header to place on top of every log file.
*/
const LOG_HEADER = "<?php die(); // This file is not intended to be accessed directly. ?>\n\n";
/**
* The directory where log files are stored.
*/
const LOG_DIRECTORY = WP_CONTENT_DIR . '/boost-cache/logs';
/**
* The Process Identifier used by this Logger instance.
*
* @var int|float
*/
private $pid = null;
/**
* Get the singleton instance of the logger.
*/
public static function get_instance() {
if ( self::$instance !== null ) {
return self::$instance;
}
$instance = new Logger();
$prepared_log_file = $instance->prepare_file();
if ( $prepared_log_file instanceof Boost_Cache_Error ) {
return $prepared_log_file;
}
self::$instance = $instance;
return $instance;
}
private function __construct() {
if ( function_exists( 'getmypid' ) ) {
$this->pid = getmypid();
} else {
// Where PID is not available, use the microtime of the first log of the session.
$this->pid = microtime( true );
}
}
/**
* Ensure that the log file exists, and if not, create it.
*/
private function prepare_file() {
$log_file = $this->get_log_file();
if ( file_exists( $log_file ) ) {
return true;
}
$directory = dirname( $log_file );
if ( ! Filesystem_Utils::create_directory( $directory ) ) {
return new Boost_Cache_Error( 'could-not-create-log-dir', 'Could not create boost cache log directory' );
}
return Filesystem_Utils::write_to_file( $log_file, self::LOG_HEADER );
}
/**
* Add a debug message to the log file after doing necessary checks.
*/
public static function debug( $message ) {
$settings = Boost_Cache_Settings::get_instance();
if ( ! $settings->get_logging() ) {
return;
}
$logger = self::get_instance();
// TODO: Check to make sure that current request IP is allowed to create logs.
if ( $logger instanceof Boost_Cache_Error ) {
return;
}
$logger->log( $message );
}
/**
* Writes a message to the log file.
*
* @param string $message - The message to write to the log file.
*/
public function log( $message ) {
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
$request_uri = htmlspecialchars( $_SERVER['REQUEST_URI'] ?? '<unknown request uri>', ENT_QUOTES, 'UTF-8' );
// don't log the ABSPATH constant. Logs may be copied to a public forum.
$message = str_replace( ABSPATH, '[...]/', $message );
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
$line = json_encode(
array(
'time' => gmdate( 'Y-m-d H:i:s' ),
'pid' => $this->pid,
'uri' => $request_uri,
'msg' => $message,
'uid' => uniqid(), // Uniquely identify this log line.
),
JSON_UNESCAPED_SLASHES
);
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
error_log( $line . PHP_EOL, 3, $this->get_log_file() );
}
/**
* Reads the log file and returns the contents.
*
* @return string
*/
public static function read() {
$instance = self::get_instance();
// If we failed to set up a Logger instance (e.g.: unwriteable directory), return the error as log content.
if ( $instance instanceof Boost_Cache_Error ) {
return $instance->get_error_message();
}
$log_file = $instance->get_log_file();
if ( ! file_exists( $log_file ) ) {
return '';
}
// Get the content after skipping the LOG_HEADER.
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
$logs = file_get_contents( $log_file, false, null, strlen( self::LOG_HEADER ) ) ?? '';
$logs = explode( PHP_EOL, $logs );
$lines = array();
foreach ( $logs as $log ) {
$line = json_decode( $log, true );
if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $line ) ) {
continue;
}
// The current log format requires time, pid, uri, and msg.
if ( ! isset( $line['time'] ) || ! isset( $line['pid'] ) || ! isset( $line['uri'] ) || ! isset( $line['msg'] ) ) {
continue;
}
$info = sprintf(
'[%s] [%s] ',
$line['time'],
$line['pid']
);
$formatted = $info . $line['uri'];
// Add msg to the next line offset by the length of the info string.
$formatted .= PHP_EOL . str_repeat( ' ', strlen( $info ) ) . $line['msg'];
$lines[] = $formatted;
}
return implode( PHP_EOL, $lines );
}
/**
* Returns the path to the log file.
*
* @return string
*/
private static function get_log_file() {
$today = gmdate( 'Y-m-d' );
return self::LOG_DIRECTORY . "/log-{$today}.log.php";
}
public static function delete_old_logs() {
Filesystem_Utils::iterate_directory( self::LOG_DIRECTORY, new Filter_Older( time() - 24 * 60 * 60, new Simple_Delete() ) );
}
}
@@ -0,0 +1,345 @@
<?php
/*
* This file may be called before WordPress is fully initialized. See the README file for info.
*/
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress;
class Request {
/**
* @var Request - The request instance for current request.
*/
private static $current_request = null;
/**
* @var string - The normalized path for the current request. This is not sanitized. Only to be used for comparison purposes.
*/
private $request_uri = false;
/**
* @var array - The GET parameters and cookies for the current request. Everything considered in the cache key.
*/
private $request_parameters;
/**
* Gets the singleton request instance.
*
* @return Request The instance of the class.
*/
public static function current() {
if ( self::$current_request === null ) {
self::$current_request = new self(
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
isset( $_SERVER['REQUEST_URI'] ) ? Boost_Cache_Utils::normalize_request_uri( $_SERVER['REQUEST_URI'] ) : false,
// Set the cookies and get parameters for the current request. Sometimes these arrays are modified by WordPress or other plugins.
// We need to cache them here so they can be used for the cache key later. We don't need to sanitize them, as they are only used for comparison.
array(
'cookies' => $_COOKIE,
'get' => $_GET, // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended
)
);
}
return self::$current_request;
}
public function __construct( $uri, $parameters ) {
$this->request_uri = $uri;
$this->request_parameters = $parameters;
}
public function get_uri() {
return $this->request_uri;
}
/**
* Returns the parameters for the current request.
*
* @return array The parameters for the current request, made up of cookies and get parameters.
*/
public function get_parameters() {
/**
* Filters the parameters for the current request to identify the cache key.
*
* @since 3.8.0
*
* @param array $parameters The parameters for the current request, made up of cookies and get parameters.
*/
return apply_filters( 'jetpack_boost_cache_parameters', $this->request_parameters );
}
/**
* Returns true if the current request has a fatal error.
*
* @return bool
*/
private function is_fatal_error() {
$error = error_get_last();
if ( $error === null ) {
return false;
}
$fatal_errors = array(
E_ERROR,
E_PARSE,
E_CORE_ERROR,
E_COMPILE_ERROR,
E_USER_ERROR,
);
return in_array( $error['type'], $fatal_errors, true );
}
public function is_url_excluded( $request_uri = '' ) {
if ( $request_uri === '' ) {
$request_uri = $this->request_uri;
}
// Check if the query parameters `jb-disable-modules` or `jb-generate-critical-css` exist.
$request_parameters = $this->get_parameters();
$query_params = $request_parameters['get'] ?? array();
if ( isset( $query_params['jb-disable-modules'] ) || isset( $query_params['jb-generate-critical-css'] ) ) {
return true;
}
$bypass_patterns = Boost_Cache_Settings::get_instance()->get_bypass_patterns();
/**
* Filters the bypass patterns for the page cache.
* If you need to sanitize them, do it before passing them to this filter,
* as there's no sanitization done after this filter.
*
* @since 3.2.0
*
* @param array $bypass_patterns An array of regex patterns that define URLs that bypass caching.
*/
$bypass_patterns = apply_filters( 'jetpack_boost_cache_bypass_patterns', $bypass_patterns );
$bypass_patterns[] = 'wp-.*\.php';
foreach ( $bypass_patterns as $expr ) {
if ( ! empty( $expr ) && preg_match( "~^$expr/?$~", $request_uri ) ) {
return true;
}
}
return false;
}
/**
* Returns true if the request is cacheable.
*
* If a request is in the backend, or is a POST request, or is not an
* html request, it is not cacheable.
* The filter boost_cache_cacheable can be used to override this.
*
* @return bool
*/
public function is_cacheable() {
/**
* Determines if the request is considered cacheable.
*
* Can be used to prevent a request from being cached.
*
* @since 3.2.0
*
* @param bool $default_status The default cacheability status (true for cacheable).
* @param string $request_uri The request URI to be evaluated for cacheability.
*/
if ( ! apply_filters( 'jetpack_boost_cache_request_cacheable', true, $this->request_uri ) ) {
return false;
}
if ( defined( 'DONOTCACHEPAGE' ) ) {
return false;
}
// do not cache post previews or customizer previews
if ( ! empty( $_GET ) && ( isset( $_GET['preview'] ) || isset( $_GET['customize_changeset_uuid'] ) ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended
return false;
}
if ( $this->is_fatal_error() ) {
return false;
}
if ( function_exists( 'is_user_logged_in' ) && is_user_logged_in() ) {
return false;
}
if ( $this->is_404() ) {
return false;
}
if ( $this->is_feed() ) {
return false;
}
if ( $this->is_backend() ) {
return false;
}
if ( $this->is_bypassed_extension() ) {
return false;
}
if ( isset( $_SERVER['REQUEST_METHOD'] ) && $_SERVER['REQUEST_METHOD'] !== 'GET' ) {
return false;
}
if ( $this->is_url_excluded() ) {
Logger::debug( 'Url excluded, not cached!' );
return false;
}
if ( $this->is_module_disabled() ) {
return false;
}
/**
* Filters the accept headers to determine if the request should be cached.
*
* This filter allows modification of the content types that browsers send
* to the server during a request. If the acceptable browser content type header (HTTP_ACCEPT)
* matches one of these content types the request will not be cached,
* or a cached file served to this visitor.
*
* @since 3.2.0
*
* @param array $accept_headers An array of header values that should prevent a request from being cached.
*/
$accept_headers = apply_filters( 'jetpack_boost_cache_accept_headers', array( 'application/json', 'application/activity+json', 'application/ld+json' ) );
$accept_headers = array_map( 'strtolower', $accept_headers );
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- $accept is checked and set below.
$accept = isset( $_SERVER['HTTP_ACCEPT'] ) ? strtolower( filter_var( $_SERVER['HTTP_ACCEPT'] ) ) : '';
if ( $accept !== '' ) {
foreach ( $accept_headers as $header ) {
if ( str_contains( $accept, $header ) ) {
return false;
}
}
}
return true;
}
/**
* Returns true if the request appears to be for something with a known file extension that is not
* usually HTML. e.g.:
* - *.txt (including robots.txt, license.txt)
* - *.ico (favicon.ico)
* - *.jpg, *.png, *.webm (image files).
*/
public function is_bypassed_extension() {
$file_extension = pathinfo( $this->request_uri, PATHINFO_EXTENSION );
return in_array(
$file_extension,
array(
'txt',
'ico',
'jpg',
'jpeg',
'png',
'webp',
'gif',
),
true
);
}
/**
* Returns true if the current request is one of the following:
* 1. wp-admin
* 2. wp-login.php, xmlrpc.php or wp-cron.php/cron request
* 3. WP_CLI
* 4. REST request.
*
* @return bool
*/
public function is_backend() {
$is_backend = is_admin();
if ( $is_backend ) {
return $is_backend;
}
$script = isset( $_SERVER['PHP_SELF'] ) ? basename( $_SERVER['PHP_SELF'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
if ( $script !== 'index.php' ) {
if ( in_array( $script, array( 'wp-login.php', 'xmlrpc.php', 'wp-cron.php' ), true ) ) {
$is_backend = true;
}
}
if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
$is_backend = true;
}
if ( PHP_SAPI === 'cli' || ( defined( 'WP_CLI' ) && constant( 'WP_CLI' ) ) ) {
$is_backend = true;
}
if ( defined( 'REST_REQUEST' ) ) {
$is_backend = true;
}
return $is_backend;
}
/**
* "Safe" version of WordPress' is_404 method. When called before WordPress' query is run, returns
* `null` (a falsey value) instead of outputting a _doing_it_wrong warning.
*/
public function is_404() {
global $wp_query;
if ( ! isset( $wp_query ) || ! function_exists( '\is_404' ) ) {
return null;
}
return \is_404();
}
/**
* "Safe" version of WordPress' is_feed method. When called before WordPress' query is run, returns
* `null` (a falsey value) instead of outputting a _doing_it_wrong warning.
*/
public function is_feed() {
global $wp_query;
if ( ! isset( $wp_query ) || ! function_exists( '\is_feed' ) ) {
return null;
}
return \is_feed();
}
/**
* Return true if the Page Cache module is disabled, or null if we don't know yet.
*
* If Status and Page_Cache are not available, it means the plugin is not loaded.
* This function will be called later when writing a cache file to disk.
* It's then that we can check if the module is active.
*
* @return null|bool
*/
public function is_module_disabled() {
// A simple check to make sure we're in the output buffer callback.
if ( ! function_exists( '\is_feed' ) ) {
return null;
}
if (
class_exists( '\Automattic\Jetpack_Boost\Lib\Status' ) &&
class_exists( '\Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Page_Cache' )
) {
$page_cache_status = new \Automattic\Jetpack_Boost\Lib\Status(
\Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Page_Cache::get_slug()
);
return ! $page_cache_status->get();
} else {
return true; // if the classes aren't available, the plugin isn't loaded.
}
}
}
@@ -0,0 +1,38 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions;
use SplFileInfo;
/**
* Apply a given sub-action to all files in the path that are older than a given timestamp.
*/
class Filter_Older implements Path_Action {
private $timestamp;
private $sub_action;
public function __construct( $timestamp, Path_Action $action ) {
$this->timestamp = $timestamp;
$this->sub_action = $action;
}
public function apply_to_path( SplFileInfo $file ) {
$file_path = $file->getPathname();
$filemtime = @filemtime( $file_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( ! $filemtime ) {
return 0;
}
/*
* if the file is a directory, then we process it, regardless of age.
* Any modification of items in the directory will update the filemtime of the directory.
* That's why we always process directories.
*/
if ( $file->isDir() || $filemtime <= $this->timestamp ) {
return $this->sub_action->apply_to_path( $file );
}
return 0;
}
}
@@ -0,0 +1,31 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Filesystem_Utils;
use SplFileInfo;
/**
* Rebuild a file.
*/
class Rebuild_File implements Path_Action {
public function apply_to_path( SplFileInfo $file ) {
if ( $file->isDir() && Filesystem_Utils::is_dir_empty( $file->getPathname() ) ) {
Filesystem_Utils::delete_empty_dir( $file->getPathname() );
return false;
}
if ( $file->isDir() || $file->getFilename() === 'index.html' ) {
return false;
}
// If it's already a rebuild file, delete it because it expired long ago.
if ( Filesystem_Utils::is_rebuild_file( $file->getFilename() ) ) {
$action = new Simple_Delete();
return $action->apply_to_path( $file );
}
$rebuilt = Filesystem_Utils::rebuild_file( $file->getPathname() );
return $rebuilt ? 1 : false;
}
}
@@ -0,0 +1,52 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Filesystem_Utils;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Logger;
use SplFileInfo;
/**
* Delete a file or directory, non-recursively.
*/
class Simple_Delete implements Path_Action {
/**
* Delete a file or directory.
*
* @param SplFileInfo $file The file or directory to delete.
* @return int The number of files or directories deleted.
*/
public function apply_to_path( SplFileInfo $file ) {
if ( $file->isDir() && Filesystem_Utils::is_dir_empty( $file->getPathname() ) ) {
Logger::debug( 'rmdir: ' . $file->getPathname() );
return $this->delete_dir( $file );
} elseif ( $file->isFile() ) {
// Do not delete index.html files independently. We will only delete them when the directory is empty.
if ( $file->getFilename() === 'index.html' ) {
return 0;
}
// Delete a file in the directory
Logger::debug( 'unlink: ' . $file->getPathname() );
$this->delete_file( $file );
return 1;
}
return 0;
}
private function delete_dir( SplFileInfo $file ) {
$count = 0;
if ( Filesystem_Utils::is_dir_empty( $file->getPathname() ) ) {
// An empty directory will still have an index.html file, which we will delete with the directory.
$count += Filesystem_Utils::delete_empty_dir( $file->getPathname() );
}
return $count;
}
private function delete_file( SplFileInfo $file ) {
@unlink( $file->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged
return true;
}
}
@@ -0,0 +1,15 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions;
use SplFileInfo;
interface Path_Action {
/**
* Apply the action to the path.
*
* @param SplFileInfo $path The path to apply the action to.
* @return false|int False if nothing was done, or the number of files deleted.
*/
public function apply_to_path( SplFileInfo $path );
}
@@ -0,0 +1,211 @@
<?php
/*
* This file may be called before WordPress is fully initialized. See the README file for info.
*/
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Storage;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache_Error;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache_Utils;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Filesystem_Utils;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Logger;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions\Filter_Older;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions\Rebuild_File;
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions\Simple_Delete;
use SplFileInfo;
/**
* File Storage - handles writing to disk, reading from disk, purging and pruning old content.
*/
class File_Storage implements Storage {
/**
* @var string - The root path where all cached files go.
*/
private $root_path;
public function __construct( $root_path ) {
$this->root_path = WP_CONTENT_DIR . '/boost-cache/cache/' . Boost_Cache_Utils::sanitize_file_path( Boost_Cache_Utils::trailingslashit( $root_path ) );
}
/**
* Given a request_uri and its parameters, store the given data in the cache.
*
* @param string $request_uri - The URI of this request (excluding GET parameters)
* @param array $parameters - An associative array of all the things that make this request special/different. Includes GET parameters and COOKIEs normally.
* @param string $data - The data to write to disk.
*/
public function write( $request_uri, $parameters, $data ) {
$directory = self::get_uri_directory( $request_uri );
$filename = Filesystem_Utils::get_request_filename( $parameters );
if ( ! Filesystem_Utils::create_directory( $directory ) ) {
return new Boost_Cache_Error( 'cannot-create-cache-dir', 'Could not create cache directory' );
}
return Filesystem_Utils::write_to_file( $directory . $filename, $data );
}
/**
* Given a request_uri and its parameters, reset the filename of a rebuild
* cache file and return true, or false otherwise.
* If a rebuild file is too old, it will be deleted and false will be returned.
*
* @param string $request_uri - The URI of this request (excluding GET parameters)
* @param array $parameters - An associative array of all the things that make this request special/different. Includes GET parameters and COOKIEs normally.
*/
public function reset_rebuild_file( $request_uri, $parameters ) {
$directory = self::get_uri_directory( $request_uri );
$filename = Filesystem_Utils::get_request_filename( $parameters ) . Filesystem_Utils::REBUILD_FILE_EXTENSION;
$hash_path = $directory . $filename;
if ( file_exists( $hash_path ) ) {
$expired = ( @filemtime( $hash_path ) + JETPACK_BOOST_CACHE_REBUILD_DURATION ) <= time(); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
if ( $expired ) {
if ( Filesystem_Utils::delete_file( $hash_path ) ) {
Logger::debug( "Deleted expired rebuilt file: $hash_path" );
} else {
Logger::debug( "Could not delete expired rebuilt file: $hash_path" );
}
return false;
}
if ( Filesystem_Utils::restore_file( $hash_path ) ) {
Logger::debug( "Restored rebuilt file: $hash_path" );
return true;
} else {
Logger::debug( "Could not restore rebuilt file: $hash_path" );
return false;
}
}
return false;
}
/**
* Given a request_uri and its parameters, return any stored data from the cache, or false otherwise.
*
* @param string $request_uri - The URI of this request (excluding GET parameters)
* @param array $parameters - An associative array of all the things that make this request special/different. Includes GET parameters and COOKIEs normally.
*/
public function read( $request_uri, $parameters ) {
$directory = self::get_uri_directory( $request_uri );
$filename = Filesystem_Utils::get_request_filename( $parameters );
$hash_path = $directory . $filename;
if ( file_exists( $hash_path ) ) {
$filemtime = @filemtime( $hash_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
$expired = ( $filemtime + JETPACK_BOOST_CACHE_DURATION ) <= time();
// If file exists and is not expired, return the file contents.
if ( ! $expired ) {
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.Security.EscapeOutput.OutputNotEscaped
return file_get_contents( $hash_path );
}
// If file exists but is expired, delete it.
if ( Filesystem_Utils::delete_file( $hash_path ) ) {
Logger::debug( "Deleted expired file: $hash_path" );
} else {
Logger::debug( "Could not delete expired file: $hash_path" );
}
}
return false;
}
/**
* Garbage collect expired files.
*
* @param int $cache_ttl If file is is created before this specified number of seconds, it will be deleted.
*/
public function garbage_collect( $cache_ttl = JETPACK_BOOST_CACHE_DURATION ) {
if ( $cache_ttl === -1 ) {
// Garbage collection is disabled.
return false;
}
$created_before = time() - $cache_ttl;
$count = Filesystem_Utils::iterate_directory( $this->root_path, new Filter_Older( $created_before, new Rebuild_File() ) );
if ( $count instanceof Boost_Cache_Error ) {
Logger::debug( 'Garbage collection failed: ' . $count->get_error_message() );
return false;
}
Logger::debug( "Garbage collected $count files" );
return $count;
}
/**
* Given a request_uri, return the filesystem path where it should get stored. Handles sanitization.
* Note that the directory path does not take things like GET parameters or cookies into account, for easy cache purging.
*
* @param string $request_uri - The URI of this request (excluding GET parameters)
*/
private function get_uri_directory( $request_uri ) {
return Boost_Cache_Utils::trailingslashit( $this->root_path . self::sanitize_path( $request_uri ) );
}
/**
* Sanitize a path for safe usage on the local filesystem.
*
* @param string $path - The path to sanitize.
*/
private function sanitize_path( $path ) {
static $_cache = array();
if ( isset( $_cache[ $path ] ) ) {
return $_cache[ $path ];
}
$path = Boost_Cache_Utils::sanitize_file_path( $path );
$_cache[ $path ] = $path;
return $path;
}
/**
* Delete cache based on given parameters.
*
* @param string $path - The path to delete the cache for.
* @param array $args - The parameters defining the cache filename.
* Example:
* array(
* 'rebuild' => (boolean) default true - If true, cache files will be rebuilt instead of being deleted.
* 'parameters' => false | array, default false - If array is provided, the files created for that specific request matching the parameter will be effected. If parameter is provided, recursive is ignored and considered as false.
* 'recursive' => (boolean) default false - If true, the cache will be deleted recursively in subdirectories.
* )
*/
public function clear( $path, $args = array() ) {
$normalized_path = Boost_Cache_Utils::normalize_request_uri( $this->sanitize_path( $path ) );
$normalized_path = Boost_Cache_Utils::trailingslashit( $this->root_path . $normalized_path );
// Ensure the path is within the cache directory
if ( strpos( $normalized_path, $this->root_path ) !== 0 ) {
Logger::debug( 'Attempted to delete cache for path outside of cache directory: ' . $path );
return;
}
$recursive = $args['recursive'] ?? false;
$rebuild = $args['rebuild'] ?? true;
$parameters = $args['parameters'] ?? false;
if ( $rebuild ) {
$action = new Rebuild_File();
} else {
$action = new Simple_Delete();
}
// If parameters are provided, delete the specific file and skip any iteration.
if ( $parameters ) {
$action->apply_to_path( new SplFileInfo( $normalized_path . Filesystem_Utils::get_request_filename( $parameters ) ) );
return;
}
if ( $recursive ) {
Filesystem_Utils::iterate_directory( $normalized_path, $action );
} else {
Filesystem_Utils::iterate_files( $normalized_path, $action );
}
}
}
@@ -0,0 +1,19 @@
<?php
/*
* This file may be called before WordPress is fully initialized. See the README file for info.
*/
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Storage;
/**
* Interface for Cache storage - a system for storing and purging caches.
*/
interface Storage {
public function write( $request_uri, $parameters, $data );
public function read( $request_uri, $parameters );
public function reset_rebuild_file( $request_uri, $parameters );
public function clear( $path, $args = array() );
public function garbage_collect( $cache_ttl = JETPACK_BOOST_CACHE_DURATION );
}