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,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 );
}