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,53 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Cloud_CSS;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Critical_CSS_State;
class Cloud_CSS_Followup {
const SCHEDULER_HOOK = 'jetpack_boost_cloud_css_followup';
/**
* Initiate the scheduler
*
* Whenever Cloud CSS module is setup, it will call this method.
*
* @return void
*/
public static function init() {
/*
* Run the scheduled job
*/
add_action( self::SCHEDULER_HOOK, array( self::class, 'run' ) );
}
/**
* Run the cron job.
*/
public static function run() {
$state = new Critical_CSS_State();
if ( $state->has_errors() ) {
$cloud_css = new Cloud_CSS();
$cloud_css->regenerate_cloud_css( Cloud_CSS::REGENERATE_REASON_FOLLOWUP, $cloud_css->get_existing_sources() );
}
}
/**
* Add a cron-job to maintain cloud CSS
*
* @return void
*/
public static function schedule() {
// Remove any existing schedule
self::unschedule();
wp_schedule_single_event( time() + HOUR_IN_SECONDS, self::SCHEDULER_HOOK );
}
/**
* Remove the cron-job
*/
public static function unschedule() {
wp_clear_scheduled_hook( self::SCHEDULER_HOOK );
}
}
@@ -0,0 +1,272 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Cloud_CSS;
use Automattic\Jetpack\Boost_Core\Lib\Boost_API;
use Automattic\Jetpack_Boost\Contracts\Changes_Output_After_Activation;
use Automattic\Jetpack_Boost\Contracts\Feature;
use Automattic\Jetpack_Boost\Contracts\Has_Activate;
use Automattic\Jetpack_Boost\Contracts\Needs_To_Be_Ready;
use Automattic\Jetpack_Boost\Contracts\Needs_Website_To_Be_Public;
use Automattic\Jetpack_Boost\Contracts\Optimization;
use Automattic\Jetpack_Boost\Lib\Cornerstone\Cornerstone_Utils;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Admin_Bar_Compatibility;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Critical_CSS_Invalidator;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Critical_CSS_State;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Critical_CSS_Storage;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Display_Critical_CSS;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Generator;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Regenerate;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Source_Providers\Source_Providers;
use Automattic\Jetpack_Boost\Lib\Premium_Features;
use Automattic\Jetpack_Boost\REST_API\Contracts\Has_Always_Available_Endpoints;
use Automattic\Jetpack_Boost\REST_API\Endpoints\Update_Cloud_CSS;
class Cloud_CSS implements Feature, Has_Activate, Has_Always_Available_Endpoints, Changes_Output_After_Activation, Optimization, Needs_To_Be_Ready, Needs_Website_To_Be_Public {
/** User has requested regeneration manually or through activating the module. */
const REGENERATE_REASON_USER_REQUEST = 'user_request';
/** A post was updated/created. */
const REGENERATE_REASON_SAVE_POST = 'save_post';
/** A cornerstone page or the list of cornerstone pages was updated. */
const REGENERATE_REASON_CORNERSTONE_UPDATE = 'cornerstone_update';
/** Existing critical CSS invalidated because of a significant change, e.g. Theme changed. */
const REGENERATE_REASON_INVALIDATED = 'invalidated';
/** Requesting a regeneration because the previous request had failed and this is a followup attempt to regenerate Critical CSS. */
const REGENERATE_REASON_FOLLOWUP = 'followup';
/**
* Critical CSS storage class instance.
*
* @var Critical_CSS_Storage
*/
protected $storage;
/**
* Critical CSS Provider Paths.
*
* @var Source_Providers
*/
protected $paths;
public function __construct() {
$this->storage = new Critical_CSS_Storage();
$this->paths = new Source_Providers();
}
public function setup() {
add_action( 'wp', array( $this, 'display_critical_css' ) );
add_action( 'save_post', array( $this, 'handle_save_post' ), 10, 2 );
add_action( 'jetpack_boost_critical_css_invalidated', array( $this, 'handle_critical_css_invalidated' ) );
add_filter( 'jetpack_boost_total_problem_count', array( $this, 'update_total_problem_count' ) );
Generator::init();
Critical_CSS_Invalidator::init();
Cloud_CSS_Followup::init();
return true;
}
public static function activate() {
( new Regenerate() )->start();
}
/**
* Check if the module is ready and already serving critical CSS.
*
* @return bool
*/
public function is_ready() {
return ( new Critical_CSS_State() )->is_generated();
}
/**
* Get the action names that will be triggered when the module is ready and serving critical CSS.
*
* @return string[]
*/
public static function get_change_output_action_names() {
return array( 'jetpack_boost_critical_css_invalidated', 'jetpack_boost_critical_css_generated' );
}
public static function is_available() {
return true === Premium_Features::has_feature( Premium_Features::CLOUD_CSS );
}
public static function get_slug() {
return 'cloud_css';
}
public function get_always_available_endpoints() {
return array(
new Update_Cloud_CSS(),
);
}
public function display_critical_css() {
// Don't look for Critical CSS in the dashboard.
if ( is_admin() ) {
return;
}
// Don't show Critical CSS in customizer previews.
if ( is_customize_preview() ) {
return;
}
// Don't display Critical CSS, if current page load is by the Critical CSS generator.
if ( Generator::is_generating_critical_css() ) {
return;
}
// Get the Critical CSS to show.
$critical_css = $this->paths->get_current_request_css();
if ( ! $critical_css ) {
$keys = $this->paths->get_current_request_css_keys();
$pending = ( new Critical_CSS_State() )->has_pending_provider( $keys );
// If Cloud CSS is still generating and the user is logged in, render the status information in a comment.
if ( $pending && is_user_logged_in() ) {
$display = new Display_Critical_CSS( '/* ' . __( 'Jetpack Boost is currently generating critical css for this page', 'jetpack-boost' ) . ' */' );
add_action( 'wp_head', array( $display, 'display_critical_css' ), 0 );
}
return;
}
if ( defined( 'WP_DEBUG' ) && WP_DEBUG === true ) {
$critical_css = "/* Critical CSS Key: {$this->paths->get_current_critical_css_key()} */\n" . $critical_css;
}
$display = new Display_Critical_CSS( $critical_css );
add_action( 'wp_head', array( $display, 'display_critical_css' ), 0 );
add_filter( 'style_loader_tag', array( $display, 'asynchronize_stylesheets' ), 10, 4 );
add_action( 'wp_footer', array( $display, 'onload_flip_stylesheets' ) );
// Ensure admin bar compatibility.
Admin_Bar_Compatibility::init();
}
/**
* Create a Cloud CSS requests for provider groups.
*
* Initialize the Cloud CSS request. Provide $post parameter to limit generating to provider groups only associated
* with a specific post.
*/
public function generate_cloud_css( $reason, $providers = array() ) {
$grouped_urls = array();
$grouped_ratios = array();
foreach ( $providers as $source ) {
$provider = $source['key'];
$grouped_urls[ $provider ] = $source['urls'];
$grouped_ratios[ $provider ] = $source['success_ratio'];
}
// Send the request to the Cloud.
$payload = array(
'providers' => $grouped_urls,
'successRatios' => $grouped_ratios,
);
$payload['requestId'] = md5(
wp_json_encode(
$payload,
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.
) . time()
);
$payload['reason'] = $reason;
return Boost_API::post( 'cloud-css', $payload );
}
/**
* Handle regeneration of Cloud CSS when a post is saved.
*/
public function handle_save_post( $post_id, $post ) {
if ( ! $post || ! isset( $post->post_type ) || ! is_post_publicly_viewable( $post ) ) {
return;
}
if ( Cornerstone_Utils::is_cornerstone_page( $post_id ) ) {
$this->regenerate_cloud_css( self::REGENERATE_REASON_CORNERSTONE_UPDATE, $this->get_all_providers() );
return;
}
// This checks against the latest providers list, not the list
// stored in the database because newly added posts are always
// included in the providers list that will be used to generate
// the Cloud CSS.
if ( $this->is_post_in_latest_providers_list( $post ) ) {
$this->regenerate_cloud_css( self::REGENERATE_REASON_SAVE_POST, $this->get_all_providers( array( $post ) ) );
}
}
public function regenerate_cloud_css( $reason, $providers ) {
$result = $this->generate_cloud_css( $reason, $providers );
if ( is_wp_error( $result ) ) {
$state = new Critical_CSS_State();
$state->set_error( $result->get_error_message() )->save();
}
return $result;
}
/**
* Check if the post is in the latest providers list.
*
* @param int|\WP_Post $post The post to check.
*
* @return bool
*/
public function is_post_in_latest_providers_list( $post ) {
$post_link = get_permalink( $post );
$providers = $this->get_all_providers();
foreach ( $providers as $provider ) {
if ( in_array( $post_link, $provider['urls'], true ) ) {
return true;
}
}
return false;
}
/**
* Called when stored Critical CSS has been invalidated. Triggers a new Cloud CSS request.
*/
public function handle_critical_css_invalidated() {
$this->regenerate_cloud_css( self::REGENERATE_REASON_INVALIDATED, $this->get_all_providers() );
Cloud_CSS_Followup::schedule();
}
public function get_all_providers( $context_posts = array() ) {
$source_providers = new Source_Providers();
return $source_providers->get_provider_sources( $context_posts );
}
public function get_existing_sources() {
$state = new Critical_CSS_State();
$data = $state->get();
if ( ! empty( $data['providers'] ) ) {
$providers = $data['providers'];
} else {
$providers = $this->get_all_providers();
}
return $providers;
}
/**
* Updates the total problem count for Boost if something's
* wrong with Cloud CSS.
*
* @param int $count The current problem count.
*
* @return int
*/
public function update_total_problem_count( $count ) {
return ( new Critical_CSS_State() )->has_errors() ? ++$count : $count;
}
}
@@ -0,0 +1,142 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Critical_CSS;
use Automattic\Jetpack\Schema\Schema;
use Automattic\Jetpack\WP_JS_Data_Sync\Data_Sync;
use Automattic\Jetpack_Boost\Admin\Regenerate_Admin_Notice;
use Automattic\Jetpack_Boost\Contracts\Changes_Output_After_Activation;
use Automattic\Jetpack_Boost\Contracts\Feature;
use Automattic\Jetpack_Boost\Contracts\Has_Data_Sync;
use Automattic\Jetpack_Boost\Contracts\Needs_To_Be_Ready;
use Automattic\Jetpack_Boost\Contracts\Optimization;
use Automattic\Jetpack_Boost\Data_Sync\Critical_CSS_Meta_Entry;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Admin_Bar_Compatibility;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Critical_CSS_Invalidator;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Critical_CSS_State;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Critical_CSS_Storage;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Data_Sync\Data_Sync_Schema;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Data_Sync_Actions\Regenerate_CSS;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Data_Sync_Actions\Set_Provider_CSS;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Data_Sync_Actions\Set_Provider_Error_Dismissed;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Data_Sync_Actions\Set_Provider_Errors;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Display_Critical_CSS;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Generator;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Source_Providers\Source_Providers;
use Automattic\Jetpack_Boost\Lib\Premium_Features;
class Critical_CSS implements Feature, Changes_Output_After_Activation, Optimization, Has_Data_Sync, Needs_To_Be_Ready {
/**
* Critical CSS storage class instance.
*
* @var Critical_CSS_Storage
*/
protected $storage;
/**
* Critical CSS Provider Paths.
*
* @var Source_Providers
*/
protected $paths;
/**
* Prepare module. This is run irrespective of the module activation status.
*/
public function __construct() {
$this->storage = new Critical_CSS_Storage();
$this->paths = new Source_Providers();
}
/**
* Check if the module is ready and already serving critical CSS.
*
* @return bool
*/
public function is_ready() {
return ( new Critical_CSS_State() )->is_generated();
}
/**
* Get the action names that will be triggered when the module is ready and serving critical CSS.
*
* @return string[]
*/
public static function get_change_output_action_names() {
return array( 'jetpack_boost_critical_css_invalidated', 'jetpack_boost_critical_css_generated' );
}
public static function is_available() {
return true !== Premium_Features::has_feature( Premium_Features::CLOUD_CSS );
}
/**
* This is only run if Critical CSS module has been activated.
*/
public function setup() {
add_action( 'wp', array( $this, 'display_critical_css' ) );
add_filter( 'jetpack_boost_total_problem_count', array( $this, 'update_total_problem_count' ) );
Generator::init();
Critical_CSS_Invalidator::init();
CSS_Proxy::init();
// Admin Notices
Regenerate_Admin_Notice::init();
return true;
}
public function register_data_sync( Data_Sync $instance ) {
$instance->register( 'critical_css_state', Data_Sync_Schema::critical_css_state() );
$instance->register( 'critical_css_meta', Data_Sync_Schema::critical_css_meta(), new Critical_CSS_Meta_Entry() );
$instance->register( 'critical_css_suggest_regenerate', Data_Sync_Schema::critical_css_suggest_regenerate() );
$instance->register_action( 'critical_css_state', 'request-regenerate', Schema::as_void(), new Regenerate_CSS() );
$instance->register_action( 'critical_css_state', 'set-provider-css', Data_Sync_Schema::critical_css_set_provider(), new Set_Provider_CSS() );
$instance->register_action( 'critical_css_state', 'set-provider-errors', Data_Sync_Schema::critical_css_set_provider_errors(), new Set_Provider_Errors() );
$instance->register_action( 'critical_css_state', 'set-provider-errors-dismissed', Data_Sync_Schema::critical_css_set_provider_errors_dismissed(), new Set_Provider_Error_Dismissed() );
}
public static function get_slug() {
return 'critical_css';
}
public function display_critical_css() {
// Don't look for Critical CSS in the dashboard.
if ( is_admin() ) {
return;
}
// Don't display Critical CSS when generating Critical CSS.
if ( Generator::is_generating_critical_css() ) {
return;
}
// Don't show Critical CSS in customizer previews.
if ( is_customize_preview() ) {
return;
}
// Get the Critical CSS to show.
$critical_css = $this->paths->get_current_request_css();
if ( ! $critical_css ) {
return;
}
if ( defined( 'WP_DEBUG' ) && WP_DEBUG === true ) {
$critical_css = "/* Critical CSS Key: {$this->paths->get_current_critical_css_key()} */\n" . $critical_css;
}
$display = new Display_Critical_CSS( $critical_css );
add_action( 'wp_head', array( $display, 'display_critical_css' ), 0 );
add_filter( 'style_loader_tag', array( $display, 'asynchronize_stylesheets' ), 10, 4 );
add_action( 'wp_footer', array( $display, 'onload_flip_stylesheets' ) );
// Ensure admin bar compatibility.
Admin_Bar_Compatibility::init();
}
public function update_total_problem_count( $count ) {
return ( new Critical_CSS_State() )->has_errors() ? ++$count : $count;
}
}
@@ -0,0 +1,153 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Critical_CSS;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Critical_CSS_State;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Display_Critical_CSS;
/**
* Add an ajax endpoint to proxy external CSS files.
*/
class CSS_Proxy {
const NONCE_ACTION = 'jb-generate-proxy-nonce';
public static function init() {
$instance = new self();
if ( is_admin() ) {
add_action( 'wp_ajax_boost_proxy_css', array( $instance, 'handle_css_proxy' ) );
}
}
/**
* AJAX handler to handle proxying of external CSS resources.
*
* @return void
*/
public function handle_css_proxy() {
// Verify valid nonce.
if ( empty( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['nonce'] ), self::NONCE_ACTION ) ) {
wp_die( '', 400 );
}
// Make sure currently logged in as admin.
if ( ! current_user_can( 'manage_options' ) ) {
wp_die( '', 400 );
}
// Reject any request made when not generating.
if ( ! ( new Critical_CSS_State() )->is_requesting() ) {
wp_die( '', 400 );
}
// Validate URL and fetch.
$proxy_url = filter_var( wp_unslash( $_POST['proxy_url'] ?? '' ), FILTER_VALIDATE_URL );
if ( ! wp_http_validate_url( $proxy_url ) ) {
die( 'Invalid URL' );
}
$url_path = wp_parse_url( $proxy_url, PHP_URL_PATH );
if ( ! $url_path || substr( strtolower( $url_path ), -4 ) !== '.css' ) {
wp_die( 'Invalid CSS file URL', 400 );
}
$css = $this->get_proxied_css( $proxy_url );
if ( $css ) {
$this->serve_proxied_css( $css );
die( 0 );
}
}
/**
* Output the proxied CSS body with the appropriate headers.
*
* Separated from handle_css_proxy() so the output (and its </style
* neutralization) is testable without the request teardown (die()).
*
* @param string $css CSS body to serve.
*/
protected function serve_proxied_css( $css ) {
if ( ! headers_sent() ) {
header( 'Content-type: text/css' );
header( 'X-Content-Type-Options: nosniff' );
}
/*
* Outputting proxied CSS contents unescaped. Do not strip tags here;
* valid CSS values may contain markup (e.g. inline SVGs in data: URIs),
* and stripping them corrupts the CSS fed to the generator. The
* text/css + nosniff headers stop a browser from sniffing this body as
* HTML; neutralizing </style is defense-in-depth in case the body is
* ever embedded inside a <style> element downstream.
*/
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo Display_Critical_CSS::neutralize_style_closing_tags( $css );
}
/**
* Resolve the CSS for a proxied URL from cache, or by fetching and caching it.
*
* Separated from handle_css_proxy() so the cache/fetch logic is unit-testable
* without the surrounding request teardown (die()).
*
* @param string $proxy_url Validated external CSS URL.
* @return string The CSS body, or '' when there is none to serve.
*/
protected function get_proxied_css( $proxy_url ) {
$cache_key = 'jb_css_proxy_' . md5( $proxy_url );
$response = get_transient( $cache_key );
if ( is_array( $response ) && isset( $response['error'] ) ) {
wp_die( esc_html( $response['error'] ), 400 );
}
if ( is_string( $response ) ) {
// Cache hit: the transient stores the CSS body from a previous
// successful fetch. Without this branch a cached body was ignored
// (the handler saw no CSS), so it returned nothing to the Critical
// CSS generator.
return $response;
}
// Anything other than a missing entry (false) has been handled above.
if ( false !== $response ) {
return '';
}
$response = wp_safe_remote_get( $proxy_url );
// A transport failure must fail loudly with a 5xx. Falling through would
// either mis-cache it as a bad content type or (via die()) emit an HTTP
// 200 the Critical CSS generator would happily consume as a stylesheet,
// silently corrupting that provider's Critical CSS.
if ( is_wp_error( $response ) ) {
wp_die( '', 502 );
}
// Likewise, only a 2xx response is usable: an upstream 404/500 served with
// a text/css content type must not be cached and served as valid CSS.
$status_code = (int) wp_remote_retrieve_response_code( $response );
if ( $status_code < 200 || $status_code >= 300 ) {
wp_die( '', 502 );
}
$content_type = wp_remote_retrieve_header( $response, 'content-type' );
if ( strpos( $content_type, 'text/css' ) === false ) {
set_transient( $cache_key, array( 'error' => 'Invalid content type. Expected CSS.' ), HOUR_IN_SECONDS );
wp_die( 'Invalid content type. Expected CSS.', 400 );
}
$css = wp_remote_retrieve_body( $response );
// Only cache a non-empty body. Caching an empty string would make
// is_string() cache hits replay it for the full TTL, pinning empty
// CSS for an hour after a single transient empty/failed fetch.
if ( '' !== $css ) {
set_transient( $cache_key, $css, HOUR_IN_SECONDS );
}
return $css;
}
}
@@ -0,0 +1,24 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Image_CDN;
use Automattic\Jetpack\Image_CDN\Image_CDN_Setup;
use Automattic\Jetpack_Boost\Contracts\Changes_Output_On_Activation;
use Automattic\Jetpack_Boost\Contracts\Feature;
use Automattic\Jetpack_Boost\Contracts\Needs_Website_To_Be_Public;
use Automattic\Jetpack_Boost\Contracts\Optimization;
class Image_CDN implements Feature, Changes_Output_On_Activation, Needs_Website_To_Be_Public, Optimization {
public function setup() {
Image_CDN_Setup::load();
}
public static function get_slug() {
return 'image_cdn';
}
public static function is_available() {
return true;
}
}
@@ -0,0 +1,43 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Image_CDN;
use Automattic\Jetpack_Boost\Contracts\Changes_Output_On_Activation;
use Automattic\Jetpack_Boost\Contracts\Sub_Feature;
use Automattic\Jetpack_Boost\Lib\Premium_Features;
class Liar implements Sub_Feature, Changes_Output_On_Activation {
public function setup() {
add_action( 'wp_footer', array( $this, 'inject_image_cdn_liar_script' ) );
}
public static function get_slug() {
return 'image_cdn_liar';
}
public static function is_available() {
return Premium_Features::has_feature( Premium_Features::IMAGE_CDN_LIAR );
}
/**
* Injects the image-cdn-liar.js script as an inline script in the footer.
*/
public function inject_image_cdn_liar_script() {
$file = __DIR__ . '/dist/inline-liar.js';
if ( file_exists( $file ) ) {
// Include the JavaScript directly inline.
// phpcs:ignore
$data = file_get_contents( $file );
// There's no meaningful way to escape JavaScript in this context.
// phpcs:ignore
echo wp_get_inline_script_tag( $data, array( 'async' => true ) );
}
}
public static function get_parent_features(): array {
return array(
Image_CDN::class,
);
}
}
@@ -0,0 +1,135 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Image_CDN;
use Automattic\Jetpack\Schema\Schema;
use Automattic\Jetpack\WP_JS_Data_Sync\Data_Sync;
use Automattic\Jetpack_Boost\Contracts\Changes_Output_After_Activation;
use Automattic\Jetpack_Boost\Contracts\Has_Data_Sync;
use Automattic\Jetpack_Boost\Contracts\Is_Always_On;
use Automattic\Jetpack_Boost\Contracts\Sub_Feature;
use Automattic\Jetpack_Boost\Lib\Premium_Features;
class Quality_Settings implements Sub_Feature, Is_Always_On, Has_Data_Sync, Changes_Output_After_Activation {
public function setup() {
add_filter( 'jetpack_photon_pre_args', array( $this, 'add_quality_args' ), 10, 2 );
}
public static function get_slug() {
return 'image_cdn_quality';
}
public static function is_available() {
return Premium_Features::has_feature( Premium_Features::IMAGE_CDN_QUALITY );
}
public function register_data_sync( Data_Sync $instance ) {
$image_cdn_quality_schema = Schema::as_assoc_array(
array(
'jpg' => Schema::as_assoc_array(
array(
'quality' => Schema::as_number(),
'lossless' => Schema::as_boolean(),
)
),
'png' => Schema::as_assoc_array(
array(
'quality' => Schema::as_number(),
'lossless' => Schema::as_boolean(),
)
),
'webp' => Schema::as_assoc_array(
array(
'quality' => Schema::as_number(),
'lossless' => Schema::as_boolean(),
)
),
)
)->fallback(
array(
'jpg' => array(
'quality' => 89,
'lossless' => false,
),
'png' => array(
'quality' => 80,
'lossless' => false,
),
'webp' => array(
'quality' => 80,
'lossless' => false,
),
)
);
$instance->register( 'image_cdn_quality', $image_cdn_quality_schema );
}
public static function get_change_output_action_names() {
return array( 'update_option_' . JETPACK_BOOST_DATASYNC_NAMESPACE . '_image_cdn_quality' );
}
/**
* Add quality arg to existing photon args.
*
* @param array $args - Existing photon args.
*
* @return mixed
*/
public function add_quality_args( $args, $image_url ) {
$quality = $this->get_quality_for_image( $image_url );
if ( $quality !== null ) {
$args['quality'] = $quality;
}
return $args;
}
/**
* Get the quality for an image based on the extension.
*/
private function get_quality_for_image( $image_url ) {
static $quality_cache = array();
// Extract the file extension from the URL
$file_extension = strtolower( pathinfo( $image_url, PATHINFO_EXTENSION ) );
static $extension_to_type = array(
'jpg' => 'jpg',
'jpeg' => 'jpg',
'webp' => 'webp',
'png' => 'png',
);
if ( ! isset( $extension_to_type[ $file_extension ] ) ) {
return null;
}
$type = $extension_to_type[ $file_extension ];
if ( ! isset( $quality_cache[ $type ] ) ) {
$quality_cache[ $type ] = $this->get_quality_for_type( $type );
}
return $quality_cache[ $type ];
}
private function get_quality_for_type( $image_type ) {
$quality_settings = jetpack_boost_ds_get( 'image_cdn_quality' );
if ( ! isset( $quality_settings[ $image_type ] ) ) {
return null;
}
// Passing 100 to photon will result in a lossless image
return $quality_settings[ $image_type ]['lossless'] ? 100 : $quality_settings[ $image_type ]['quality'];
}
public static function get_parent_features(): array {
return array(
Image_CDN::class,
);
}
}
@@ -0,0 +1 @@
<?php return array('dependencies' => array('wp-polyfill'), 'version' => '31700564e390fbbc6384');
@@ -0,0 +1 @@
(()=>{"use strict";var t={164(t,e,r){function n(){return window.devicePixelRatio||1}function i(t){const e=new URL(t).searchParams.get("resize");return e?function(t){const[e,r]=t.split(",").map(Number);return isNaN(e)||isNaN(r)?null:{width:e,height:r}}(e):null}function s(t,e){if(e<=0)return!1;const r=e-t;if(r<0)return!1;if(r<50)return!0;const n=t/e;return n>.9&&n<=1}function o(t){if(!(t.getAttribute("width")&&t.getAttribute("height")&&t.srcset&&t.src&&t.src.includes(".wp.com")))return;const e=function(t){const e=n(),r=t.width/t.height,i=10*Math.ceil(t.width*e/10);return{width:i,height:Math.ceil(i/r)}}(t.getBoundingClientRect()),r=t.srcset.split(","),o=function(t,e){for(const r of t){const[t,n]=r.trim().split(" ");if(!n?.trim().endsWith("w"))continue;const o=i(t);if(o&&s(e,o.width))return{url:new URL(t),...o}}}([`${t.src} 0w`,...r],e.width);if(o)o.url.searchParams.set("_jb","closest"),r.push(`${o.url} ${window.innerWidth*n()}w`),t.srcset=r.join(","),t.sizes="auto";else{const i=function(t,e){const r=new URL(t);return r.searchParams.set("resize",`${e.width},${e.height}`),r}(t.src,e);i.searchParams.set("_jb","custom"),r.push(`${i} ${window.innerWidth*n()}w`),t.srcset=r.join(","),t.sizes="auto"}}r.d(e,{Er:()=>o})}},e={};function r(n){var i=e[n];if(void 0!==i)return i.exports;var s=e[n]={exports:{}};return t[n](s,s.exports,r),s.exports}r.d=(t,e)=>{for(var n in e)r.o(e,n)&&!r.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:e[n]})},r.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e);var n=r(164);document.querySelectorAll("img[loading=lazy]").forEach(n.Er)})();
@@ -0,0 +1,6 @@
import { dynamicSrcset } from './srcset';
( function () {
const lazyImages = document.querySelectorAll< HTMLImageElement >( 'img[loading=lazy]' );
lazyImages.forEach( dynamicSrcset );
} )();
@@ -0,0 +1,308 @@
import {
calculateTargetSize,
dynamicSrcset,
findClosestImageSize,
getImageSizeFromUrl,
isSizeReusable,
parseImageSize,
} from './srcset';
function createImageSize( resize: string, mq: string ) {
const url = new URL( 'https://i0.wp.com/example.com/image.jpg' );
const [ width, height ] = resize.split( ',' ).map( item => parseInt( item, 10 ) );
const size = calculateTargetSize( { width, height } );
url.searchParams.set( 'resize', `${ size.width },${ size.height }` );
return `${ url } ${ mq }`;
}
function setBoundingRect( img: HTMLImageElement, width: number, height: number ) {
Object.defineProperty( img, 'getBoundingClientRect', {
value: () => ( {
width,
height,
top: 0,
right: width,
bottom: height,
left: 0,
} ),
writable: true,
} );
}
describe( 'parse image resize param', () => {
it( 'should parse valid resize param', () => {
expect( parseImageSize( '100,200' ) ).toEqual( { width: 100, height: 200 } );
} );
it( 'should return null for invalid resize param', () => {
expect( parseImageSize( 'invalid' ) ).toBeNull();
} );
} );
describe( 'getImageSizeFromUrl', () => {
it( 'should extract image size from URL', () => {
const url = 'https://i0.wp.com/example.com/image.jpg?resize=100,200';
expect( getImageSizeFromUrl( url ) ).toEqual( { width: 100, height: 200 } );
} );
it( 'should return null if resize param is missing', () => {
const url = 'https://i0.wp.com/example.com/image.jpg';
expect( getImageSizeFromUrl( url ) ).toBeNull();
} );
} );
describe( 'calculateTargetSize', () => {
it( 'should calculate target size based on bounding rect and dpr', () => {
const rect: DOMRect = {
width: 500,
height: 250,
x: 0,
y: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
toJSON: () => ( {} ),
};
window.devicePixelRatio = 2;
expect( calculateTargetSize( rect ) ).toEqual( { width: 1000, height: 500 } );
} );
it( 'should round up the target size to the nearest 10', () => {
const ratio = 600 / 200;
const rect: DOMRect = {
width: 600 - 9,
height: ( 600 - 9 ) / ratio, // = 197px
x: 0,
y: 0,
top: 0,
right: 0,
bottom: 0,
left: 0,
toJSON: () => ( {} ),
};
window.devicePixelRatio = 1;
expect( calculateTargetSize( rect ) ).toEqual( {
width: 600,
height: Math.ceil( 600 / ratio ),
} );
window.devicePixelRatio = 2;
expect( calculateTargetSize( rect ) ).toEqual( {
width: 1190,
height: Math.ceil( 1190 / ratio ),
} );
} );
} );
describe( 'isSizeReusable', () => {
it( 'should return true if the target width is close to the width', () => {
expect( isSizeReusable( 100, 100 ) ).toBe( true );
expect( isSizeReusable( 90, 100 ) ).toBe( true );
expect( isSizeReusable( 51, 100 ) ).toBe( true );
expect( isSizeReusable( 901, 1000 ) ).toBe( true );
expect( isSizeReusable( 50, 100 ) ).toBe( false );
expect( isSizeReusable( 101, 100 ) ).toBe( false );
expect( isSizeReusable( 150, 100 ) ).toBe( false );
expect( isSizeReusable( 900, 1000 ) ).toBe( false );
} );
} );
describe( 'findClosestImageSize', () => {
beforeEach( () => {
window.devicePixelRatio = 1;
} );
const srcset = [
createImageSize( '100,50', '200w' ),
createImageSize( '400,250', '800w' ),
createImageSize( '1400,700', '2400w' ),
];
it( 'should return undefined if the urls are invalid', () => {
expect( findClosestImageSize( [ 'foo.com', 'bar.com' ], 500 ) ).toBeUndefined();
} );
it( "should find the closest image that's larger than the target width", () => {
expect( findClosestImageSize( srcset, 51 ) ).toEqual( {
url: new URL( srcset[ 0 ].split( ' ' )[ 0 ] ),
width: 100,
height: 50,
} );
expect( findClosestImageSize( srcset, 1300 ) ).toEqual( {
url: new URL( srcset[ 2 ].split( ' ' )[ 0 ] ),
width: 1400,
height: 700,
} );
} );
it( 'should find the closest image even if the srcset is unordered', () => {
const unorderedSrcset = [
createImageSize( '1400,700', '1400w' ),
createImageSize( '100,50', '100w' ),
createImageSize( '400,250', '400w' ),
];
expect( findClosestImageSize( unorderedSrcset, 1300 ) ).toEqual( {
url: new URL( unorderedSrcset[ 0 ].split( ' ' )[ 0 ] ),
width: 1400,
height: 700,
} );
expect( findClosestImageSize( unorderedSrcset, 51 ) ).toEqual( {
url: new URL( unorderedSrcset[ 1 ].split( ' ' )[ 0 ] ),
width: 100,
height: 50,
} );
expect( findClosestImageSize( unorderedSrcset, 351 ) ).toEqual( {
url: new URL( unorderedSrcset[ 2 ].split( ' ' )[ 0 ] ),
width: 400,
height: 250,
} );
} );
it( "shouldn't find closest image if the closest image isn't close enough in size", () => {
expect( findClosestImageSize( srcset, 50 ) ).toBeUndefined();
expect( findClosestImageSize( srcset, 110 ) ).toBeUndefined();
expect( findClosestImageSize( srcset, 1100 ) ).toBeUndefined();
expect( findClosestImageSize( srcset, 1200 ) ).toBeUndefined();
} );
} );
function untrackedDynamicSrcset( img: HTMLImageElement ) {
dynamicSrcset( img );
img.srcset = img.srcset.replaceAll( /&_jb=\w+/gim, '' );
}
describe( 'dynamicSrcset', () => {
let img: HTMLImageElement;
it( '[manual validation] should create a new srcset entry for the target size', () => {
window.innerWidth = 9999;
window.devicePixelRatio = 1;
const manualValidationImg = document.createElement( 'img' );
manualValidationImg.setAttribute( 'src', 'https://i0.wp.com/example.com/image.jpg' );
manualValidationImg.setAttribute(
'srcset',
`https://i0.wp.com/example.com/image.jpg?resize=100%2C200 1000w, https://i0.wp.com/example.com/image.jpg?resize=400%2C200 400w, https://i0.wp.com/example.com/image.jpg?resize=1400%2C700 1400w`
);
manualValidationImg.setAttribute( 'width', '800' );
manualValidationImg.setAttribute( 'height', '400' );
setBoundingRect( manualValidationImg, 800, 400 );
untrackedDynamicSrcset( manualValidationImg );
expect( manualValidationImg.srcset ).toContain(
`https://i0.wp.com/example.com/image.jpg?resize=800%2C400 9999w`
);
window.devicePixelRatio = 2;
untrackedDynamicSrcset( manualValidationImg );
expect( manualValidationImg.srcset ).toContain(
`https://i0.wp.com/example.com/image.jpg?resize=1600%2C800 19998w`
);
} );
const testDevicePixelRatios = [ 1, 1.5 ];
testDevicePixelRatios.forEach( devicePixelRatio => {
const w = ( value: number ) => `${ value * devicePixelRatio }w`;
describe( `with devicePixelRatio ${ devicePixelRatio }`, () => {
beforeEach( () => {
window.devicePixelRatio = devicePixelRatio;
window.innerWidth = 5000;
img = document.createElement( 'img' );
img.src = 'https://i0.wp.com/example.com/image.jpg?resize=4444%2C2222';
const srcset = [
createImageSize( '100,50', '100w' ),
createImageSize( '400,250', '400w' ),
createImageSize( '1400,700', '1400w' ),
];
img.srcset = srcset.join( ',' );
img.setAttribute( 'width', '1000' );
img.setAttribute( 'height', '500' );
// Mocking the bounding rect of the image
setBoundingRect( img, 1000, 500 );
} );
it( 'srcset should include all original image sizes', () => {
untrackedDynamicSrcset( img );
expect( img.srcset ).toContain( createImageSize( '100,50', '100w' ) );
expect( img.srcset ).toContain( createImageSize( '400,250', '400w' ) );
expect( img.srcset ).toContain( createImageSize( '1400,700', '1400w' ) );
} );
it( 'should create a new srcset entry for the target size', () => {
untrackedDynamicSrcset( img );
expect( img.srcset ).toContain( createImageSize( '1000,500', w( window.innerWidth ) ) );
} );
it( 'should reuse existing srcset entry if the target size is close enough', () => {
setBoundingRect( img, 396, 248 );
untrackedDynamicSrcset( img );
expect( img.srcset ).toContain( createImageSize( '400,250', w( window.innerWidth ) ) );
} );
it( "shouldn't upscale the image when reusing an srcset entry", () => {
setBoundingRect( img, 420, 260 );
untrackedDynamicSrcset( img );
expect( img.srcset ).toContain( createImageSize( '420,260', w( window.innerWidth ) ) );
} );
it( 'should not update attributes if conditions are not met', () => {
const image = document.createElement( 'img' );
image.src = 'https://i0.wp.com/example.com/image.jpg';
untrackedDynamicSrcset( image );
expect( image.srcset ).toBe( '' );
expect( image.sizes ).toBe( '' );
} );
} );
} );
it( 'should reuse existing src image if the target size is close enough', () => {
window.innerWidth = 5000;
window.devicePixelRatio = 1;
setBoundingRect( img, 4400, 2200 );
untrackedDynamicSrcset( img );
expect( img.srcset ).toContain(
`https://i0.wp.com/example.com/image.jpg?resize=4444%2C2222 5000w`
);
window.devicePixelRatio = 1.5;
untrackedDynamicSrcset( img );
expect( img.srcset ).toContain(
`https://i0.wp.com/example.com/image.jpg?resize=6600%2C3300 7500w`
);
} );
it( 'should reuse existing src image after accounting for DPR > 1', () => {
window.innerWidth = 5000;
window.devicePixelRatio = 1;
// Fixed DPR 1 sizes as they'd appear in DOM
const srcset = [
createImageSize( '100,50', '100w' ),
createImageSize( '400,250', '400w' ),
createImageSize( '1400,700', '1400w' ),
];
// Pretend this is a dense display rendering a small image
window.devicePixelRatio = 3;
img.srcset = srcset.join( ',' );
setBoundingRect( img, 460, 230 );
untrackedDynamicSrcset( img );
expect( img.srcset ).toContain(
`https://i0.wp.com/example.com/image.jpg?resize=1400%2C700 15000w`
);
setBoundingRect( img, 500, 250 );
untrackedDynamicSrcset( img );
expect( img.srcset ).toContain(
`https://i0.wp.com/example.com/image.jpg?resize=1500%2C750 15000w`
);
} );
} );
@@ -0,0 +1,113 @@
type Dimensions = {
width: number;
height: number;
};
type ImageMeta = {
url: URL;
width: number;
height: number;
};
function getDpr() {
return window.devicePixelRatio || 1;
}
export function parseImageSize( resizeParam: string ): Dimensions | null {
const [ width, height ] = resizeParam.split( ',' ).map( Number );
if ( isNaN( width ) || isNaN( height ) ) {
return null;
}
return { width, height };
}
export function getImageSizeFromUrl( url: string ): Dimensions | null {
const resizeParam = new URL( url ).searchParams.get( 'resize' );
if ( ! resizeParam ) {
return null;
}
return parseImageSize( resizeParam );
}
export function calculateTargetSize( dimensions: Dimensions ): Dimensions {
const dpr = getDpr();
const ratio = dimensions.width / dimensions.height;
const targetWidth = Math.ceil( ( dimensions.width * dpr ) / 10 ) * 10;
const targetHeight = Math.ceil( targetWidth / ratio );
return {
width: targetWidth,
height: targetHeight,
};
}
export function isSizeReusable( desiredWidth: number, existingWidth: number ) {
if ( existingWidth <= 0 ) {
return false;
}
const diff = existingWidth - desiredWidth;
if ( diff < 0 ) {
return false;
}
if ( diff < 50 ) {
return true;
}
const ratio = desiredWidth / existingWidth;
return ratio > 0.9 && ratio <= 1;
}
export function findClosestImageSize( urls: string[], targetWidth: number ): ImageMeta | undefined {
for ( const src of urls ) {
const [ url, widthStr ] = src.trim().split( ' ' );
if ( ! widthStr?.trim().endsWith( 'w' ) ) {
continue;
}
const imageSize = getImageSizeFromUrl( url );
if ( ! imageSize ) {
continue;
}
if ( isSizeReusable( targetWidth, imageSize.width ) ) {
return { url: new URL( url ), ...imageSize };
}
}
return undefined;
}
function resizeImage( imageUrl: string, targetSize: Dimensions ): URL {
const newUrl = new URL( imageUrl );
newUrl.searchParams.set( 'resize', `${ targetSize.width },${ targetSize.height }` );
return newUrl;
}
export function dynamicSrcset( img: HTMLImageElement ) {
if (
! img.getAttribute( 'width' ) ||
! img.getAttribute( 'height' ) ||
! img.srcset ||
! img.src ||
! img.src.includes( '.wp.com' )
) {
return;
}
const rect = img.getBoundingClientRect();
const targetSize = calculateTargetSize( rect );
const srcset = img.srcset.split( ',' );
const closestImage = findClosestImageSize( [ `${ img.src } 0w`, ...srcset ], targetSize.width );
if ( closestImage ) {
closestImage.url.searchParams.set( '_jb', 'closest' );
srcset.push( `${ closestImage.url } ${ window.innerWidth * getDpr() }w` );
img.srcset = srcset.join( ',' );
img.sizes = 'auto';
} else {
const newUrl = resizeImage( img.src, targetSize );
newUrl.searchParams.set( '_jb', 'custom' );
srcset.push( `${ newUrl } ${ window.innerWidth * getDpr() }w` );
img.srcset = srcset.join( ',' );
img.sizes = 'auto';
}
}
@@ -0,0 +1,6 @@
<?php
/**
* Empty file.
*/
// Silence is golden.
@@ -0,0 +1,127 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp;
use Automattic\Jetpack\Boost_Core\Lib\Boost_API;
use Automattic\Jetpack_Boost\Lib\Cornerstone\Cornerstone_Utils;
class LCP_Analyzer {
/** @var LCP_State */
private $state;
/** @var LCP_Storage */
private $storage;
/**
* @since 4.0.0
*/
public function __construct() {
$this->state = new LCP_State();
$this->storage = new LCP_Storage();
}
/**
* Start the LCP analysis process
*
* @return array The current state data
*/
public function start() {
// Get cornerstone pages to analyze
$pages = $this->get_cornerstone_pages();
// Store those pages in the LCP State
$this->state
->prepare_request()
->set_pages( $pages )
->set_pending_pages( $pages )
->save();
// Get the data
$data = $this->state->get();
// Start the analysis process
$this->analyze_pages( $pages );
// Clear previous LCP analysis data from storage
$this->storage->clear();
return $data;
}
/**
* Start a partial analysis for the given pages.
* Sets status to pending and updates only the pages that are in the $pages array.
* Pages that are not in the $pages array will not be updated.
*
* @param array $pages The pages to analyze.
* @return array The current state data
*/
public function start_partial_analysis( $pages ) {
$current_pages = $this->state->get_pages();
$this->state
->prepare_request()
->set_pages( $current_pages )
->set_pending_pages( $pages )
->save();
$this->analyze_pages( $pages );
// @todo - Clear previous LCP analysis data from storage for pages in the $pages array.
// We currently don't have a way to do this.
return $this->state->get();
}
/**
* Get cornerstone pages for analysis
*
* @return array
*/
protected function get_cornerstone_pages() {
$pages = array();
$cornerstone_pages_list = Cornerstone_Utils::get_list();
foreach ( $cornerstone_pages_list as $url ) {
$pages[] = Cornerstone_Utils::prepare_provider_data( $url );
}
return $pages;
}
/**
* Run analysis for the given pages
*
* @param array $pages Pages to analyze
*/
private function analyze_pages( $pages ) {
$payload = array(
'pages' => $pages,
'requestId' => md5(
wp_json_encode(
$pages,
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.
)
),
);
return Boost_API::post( 'lcp', $payload );
}
/**
* Get the current state
*
* @return LCP_State
*/
public function get_state() {
return $this->state;
}
/**
* Get the storage instance
*
* @return LCP_Storage
*/
public function get_storage() {
return $this->storage;
}
}
@@ -0,0 +1,78 @@
<?php
/**
* LCP Invalidator
*
* Reset LCP analysis data on certain events.
*/
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp;
use Automattic\Jetpack_Boost\Lib\Cornerstone\Cornerstone_Utils;
class LCP_Invalidator {
public static function init() {
add_action( 'jetpack_boost_deactivate', array( self::class, 'reset_data' ) );
add_action( 'update_option_jetpack_boost_ds_cornerstone_pages_list', array( self::class, 'reset_and_analyze' ) );
add_action( 'jetpack_boost_environment_changed', array( self::class, 'handle_environment_change' ) );
add_action( 'post_updated', array( self::class, 'handle_post_update' ) );
}
/**
* Reset any LCP analysis data (state and storage).
*
* @since 4.0.0
*/
public static function reset_data() {
$state = new LCP_State();
$state->clear();
$storage = new LCP_Storage();
$storage->clear();
}
/**
* Reset the LCP analysis data, and analyze the pages again.
*
* @since 4.0.0
*/
public static function reset_and_analyze() {
self::reset_data();
/**
* Indicate that the latest LCP analysis data has been invalidated.
*/
do_action( 'jetpack_boost_lcp_invalidated' );
}
/**
* Respond to environment changes; deciding whether or not to clear LCP analysis data.
*
* @since 4.0.0
*/
public static function handle_environment_change( $is_major_change ) {
if ( $is_major_change ) {
self::reset_and_analyze();
}
}
/**
* Handle post updates to check if the post is a cornerstone page and schedule preload if needed.
*
* @since 4.0.0
* @param int $post_id The ID of the post being updated.
* @return void
*/
public static function handle_post_update( int $post_id ) {
if ( Cornerstone_Utils::is_cornerstone_page( $post_id ) ) {
$url = get_permalink( $post_id );
$analyzer = new LCP_Analyzer();
$analyzer->start_partial_analysis(
array(
Cornerstone_Utils::prepare_provider_data( $url ),
)
);
}
}
}
@@ -0,0 +1,191 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp;
use WP_HTML_Tag_Processor;
class LCP_Optimization_Util {
/**
* Each LCP data is an array that includes the LCP for a certain viewport.
*
* @var array
*/
private $lcp_data;
public function __construct( $lcp_data ) {
$this->lcp_data = $lcp_data;
}
/**
* Check if LCP optimization should be skipped for the current request.
*
* @since 4.0.0
* @return bool True if optimization should be skipped, false otherwise.
*/
public static function should_skip_optimization() {
/**
* Filters whether to short-circuit LCP optimization.
*
* Returning a value other than null from the filter will short-circuit
* the optimization check, returning that value instead.
*
* @since 4.0.0
*
* @param null|bool $skip Whether to skip optimization. Default null.
*/
$pre = apply_filters( 'jetpack_boost_pre_should_skip_lcp_optimization', null );
if ( null !== $pre ) {
return $pre;
}
// Disable in robots.txt.
if ( isset( $_SERVER['REQUEST_URI'] ) && strpos( home_url( wp_unslash( $_SERVER['REQUEST_URI'] ) ), 'robots.txt' ) !== false ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- This is validating.
return true;
}
// Disable in other possible AJAX requests setting cors related header.
if ( isset( $_SERVER['HTTP_SEC_FETCH_MODE'] ) && 'cors' === strtolower( $_SERVER['HTTP_SEC_FETCH_MODE'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- This is validating.
return true;
}
// Disable in other possible AJAX requests setting XHR related header.
if ( isset( $_SERVER['HTTP_X_REQUESTED_WITH'] ) && 'xmlhttprequest' === strtolower( $_SERVER['HTTP_X_REQUESTED_WITH'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- This is validating.
return true;
}
// Disable in all XLS (see the WP_Sitemaps_Renderer class).
if ( isset( $_SERVER['REQUEST_URI'] ) &&
(
// phpcs:disable WordPress.Security.ValidatedSanitizedInput -- This is validating.
str_contains( $_SERVER['REQUEST_URI'], '.xsl' ) ||
str_contains( $_SERVER['REQUEST_URI'], 'sitemap-stylesheet=index' ) ||
str_contains( $_SERVER['REQUEST_URI'], 'sitemap-stylesheet=sitemap' )
// phpcs:enable WordPress.Security.ValidatedSanitizedInput
) ) {
return true;
}
// Disable in all POST Requests.
// phpcs:disable WordPress.Security.NonceVerification.Missing
if ( ! empty( $_POST ) ) {
return true;
}
// Disable in customizer previews
if ( is_customize_preview() ) {
return true;
}
// Disable in feeds, AJAX, Cron, XML.
if ( is_feed() || wp_doing_ajax() || wp_doing_cron() || wp_is_xml_request() ) {
return true;
}
// Disable in sitemaps.
if ( ! empty( get_query_var( 'sitemap' ) ) ) {
return true;
}
// Disable in AMP pages.
if ( function_exists( 'amp_is_request' ) && amp_is_request() ) {
return true;
}
return false;
}
/**
* Check if a specific optimization should be applied based on cloud response.
* Returns true if no optimizations object exists (backward compatibility) or if the flag is truthy.
*
* @since 4.5.6
*
* @param array $lcp_data The LCP data array.
* @param string $key The optimization key to check.
* @return bool True if the optimization should be applied.
*/
public static function should_apply_optimization( $lcp_data, $key ) {
return ! isset( $lcp_data['optimizations'] )
|| ! empty( $lcp_data['optimizations'][ $key ] );
}
public function get_lcp_image_url() {
if ( ! $this->can_optimize() ) {
return null;
}
if ( LCP::TYPE_BACKGROUND_IMAGE !== $this->lcp_data['type'] && LCP::TYPE_IMAGE !== $this->lcp_data['type'] ) {
return null;
}
if ( empty( $this->lcp_data['url'] ) ) {
return null;
}
if ( ! wp_http_validate_url( $this->lcp_data['url'] ) ) {
return null;
}
return $this->lcp_data['url'];
}
/**
* Check if the LCP data is valid and can be optimized.
*
* @return bool True if the LCP data is valid and can be optimized, false otherwise.
*
* @since 4.1.0
*/
public function can_optimize() {
if ( empty( $this->lcp_data ) || ! is_array( $this->lcp_data ) ) {
return false;
}
if ( ! isset( $this->lcp_data['success'] ) || ! $this->lcp_data['success'] ) {
return false;
}
return true;
}
/**
* Check if the element is present in the LCP data.
*
* @param string $buffer The HTML to check.
* @param string $tag The tag to check. Default is 'img'.
* @return WP_HTML_Tag_Processor|false The HTML tag processor if the element is present, false otherwise.
*
* @since 4.1.0
*/
public function find_element( $buffer, $tag = 'img' ) {
$html_processor = new WP_HTML_Tag_Processor( $buffer );
$element = new WP_HTML_Tag_Processor( $this->lcp_data['html'] );
// Ensure the LCP HTML is a valid tag before proceeding.
if ( ! $element->next_tag( $tag ) ) {
return false;
}
// Extract attributes from the LCP tag for matching
$lcp_id = $element->get_attribute( 'id' );
$lcp_class = $element->get_attribute( 'class' );
// Perform a quick check to see if the class is present in the HTML.
if ( ! empty( $lcp_class ) && ! str_contains( $buffer, $lcp_class ) ) {
return false;
}
// Loop through all img tags in the buffer with the same class until we find a match.
// We do this because next_tag does not support matching on IDs and sources.
while ( $html_processor->next_tag( $tag ) ) {
// Tag is considered a match if the class and id match.
if ( $lcp_id === $html_processor->get_attribute( 'id' ) &&
$lcp_class === $html_processor->get_attribute( 'class' ) ) {
return $html_processor;
}
}
return false;
}
}
@@ -0,0 +1,229 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp;
use Automattic\Jetpack\Image_CDN\Image_CDN_Core;
class LCP_Optimize_Bg_Image {
/**
* The LCP data for optimizing the current page.
*
* @var array
*/
private $lcp_data;
public static function init( $lcp_data ) {
if ( LCP_Optimization_Util::should_skip_optimization() ) {
return;
}
if ( empty( $lcp_data ) ) {
return;
}
$instance = new self( $lcp_data );
// Preload the background image as early as possible.
add_action( 'wp_head', array( $instance, 'preload_background_images' ), 1 );
// Add the background image styling as late as possible.
add_action( 'wp_body_open', array( $instance, 'add_bg_style_override' ), 999999 );
}
public function __construct( $lcp_data ) {
$this->lcp_data = $lcp_data;
}
public function preload_background_images() {
$selectors = array();
foreach ( $this->lcp_data as $lcp_data ) {
$lcp_optimizer = new LCP_Optimization_Util( $lcp_data );
if ( ! $lcp_optimizer->can_optimize() ) {
continue;
}
if ( in_array( $lcp_data['selector'], $selectors, true ) ) {
// If we already printed the styling for this element, skip it.
continue;
}
$selectors[] = $lcp_data['selector'];
$responsive_image_rules = $this->get_responsive_image_rules( $lcp_data );
$this->print_preload_links( $responsive_image_rules );
}
}
private function print_preload_links( $responsive_image_rules ) {
foreach ( $responsive_image_rules as $breakpoint ) {
$image_set = array();
foreach ( $breakpoint['image_set'] as $image ) {
$image_set[] = sprintf( '%s %sx', $image['url'], $image['dpr'] );
}
$image_set_string = implode( ', ', $image_set );
printf(
'<link rel="preload" href="%s" as="image" fetchpriority="high" media="%s" imagesrcset="%s" />' . PHP_EOL,
esc_url( Image_CDN_Core::cdn_url( $breakpoint['base_image'] ) ),
esc_attr( $breakpoint['media_query'] ),
esc_attr( $image_set_string )
);
}
}
public function add_bg_style_override() {
$selectors = array();
foreach ( $this->lcp_data as $lcp_data ) {
$lcp_optimizer = new LCP_Optimization_Util( $lcp_data );
if ( ! $lcp_optimizer->can_optimize() ) {
continue;
}
if ( in_array( $lcp_data['selector'], $selectors, true ) ) {
// If we already printed the styling for this element, skip it.
continue;
}
$selectors[] = $lcp_data['selector'];
$image_url = $lcp_optimizer->get_lcp_image_url();
if ( empty( $image_url ) ) {
continue;
}
$styles = array();
$responsive_image_rules = $this->get_responsive_image_rules( $lcp_data );
// Add responsive image styling.
foreach ( $responsive_image_rules as $breakpoint ) {
$image_set = array();
foreach ( $breakpoint['image_set'] as $image ) {
$image_set[] = sprintf( 'url(%s) %sx', $image['url'], $image['dpr'] );
}
$image_set_string = implode( ', ', $image_set );
$styles[] = sprintf(
'@media %1$s { %2$s { background-image: url(%3$s) !important; background-image: -webkit-image-set(%4$s) !important; background-image: image-set(%4$s) !important; } }',
$breakpoint['media_query'],
$lcp_data['selector'],
$breakpoint['base_image'],
$image_set_string
);
}
// Skip outputting empty style block when cssOverride is disabled.
if ( empty( $styles ) ) {
continue;
}
$bg_styling = PHP_EOL . '<style id="jetpack-boost-lcp-background-image">' . PHP_EOL;
// Ensure no </style> tag (or any HTML tags) in output.
$bg_styling .= wp_strip_all_tags( implode( PHP_EOL, $styles ) ) . PHP_EOL;
$bg_styling .= '</style>' . PHP_EOL;
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $bg_styling;
}
}
private function get_responsive_image_rules( $lcp_data ) {
if ( $lcp_data['type'] !== LCP::TYPE_BACKGROUND_IMAGE || empty( $lcp_data['breakpoints'] ) ) {
return array();
}
// Check optimizations object from cloud.
// If cssOverride is false, skip all background-image optimizations (preload, !important CSS, resize URLs).
// This handles responsive backgrounds and custom focal points - the cloud determines what's safe.
// If no optimizations object exists (old cloud response), default to applying all optimizations.
if ( ! LCP_Optimization_Util::should_apply_optimization( $lcp_data, 'cssOverride' ) ) {
return array();
}
$lcp_optimizer = new LCP_Optimization_Util( $lcp_data );
$image_url = $lcp_optimizer->get_lcp_image_url();
if ( empty( $image_url ) ) {
return array();
}
$styles = array();
// Reverse the array to go from smallest to largest.
foreach ( array_reverse( $lcp_data['breakpoints'] ) as $breakpoint ) {
if ( empty( $breakpoint ) ) {
continue;
}
if ( ! isset( $breakpoint['widthValue'] ) ) {
continue;
}
// The Cloud should always return a fixed pixel width for background images, so catering for that is easy peasy.
if ( empty( $breakpoint['imageDimensions'] ) || ! is_array( $breakpoint['imageDimensions'] ) ) {
continue;
}
$image_dimensions = $breakpoint['imageDimensions'][0];
if ( ! isset( $image_dimensions['width'] ) || ! is_numeric( $image_dimensions['width'] ) ) {
continue;
}
if ( ! isset( $image_dimensions['height'] ) || ! is_numeric( $image_dimensions['height'] ) ) {
continue;
}
// The width and height should already be an integer, but just in case.
$image_width = (int) $image_dimensions['width'];
$image_height = (int) $image_dimensions['height'];
$media_query = array();
if ( isset( $breakpoint['minWidth'] ) ) {
$media_query[] = sprintf( '(min-width: %spx)', $breakpoint['minWidth'] );
}
if ( isset( $breakpoint['maxWidth'] ) ) {
$media_query[] = sprintf( '(max-width: %spx)', $breakpoint['maxWidth'] );
}
$styles[] = array(
'media_query' => empty( $media_query ) ? 'all' : implode( ' and ', $media_query ),
'image_set' => $this->get_image_set( $image_url, $image_width, $image_height ),
'base_image' => Image_CDN_Core::cdn_url(
$image_url,
array(
'resize' => array( $image_width, $image_height ),
)
),
);
}
return $styles;
}
private function get_image_set( $url, $width, $height ) {
$dprs = array( 1, 2 );
// Mobile devices usually have a DPR of 3 which is not common for desktop.
if ( $width <= 480 ) {
$dprs[] = 3;
}
// Accurately reflect the performance improvement in lighthouse by including a 1.75x DPR image for the Moto G Power.
if ( $width === 412 ) {
$dprs[] = 1.75;
}
$image_set = array();
foreach ( $dprs as $dpr ) {
$image_set[] = array(
'url' => Image_CDN_Core::cdn_url(
$url,
array(
'resize' => array( $width * $dpr, $height * $dpr ),
)
),
'dpr' => $dpr,
);
}
return $image_set;
}
}
@@ -0,0 +1,249 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp;
use Automattic\Jetpack\Image_CDN\Image_CDN;
use Automattic\Jetpack\Image_CDN\Image_CDN_Core;
use WP_HTML_Tag_Processor;
class LCP_Optimize_Img_Tag {
/**
* LCP data.
*
* @var array
*/
private $lcp_data;
public function __construct( $lcp_data ) {
$this->lcp_data = $lcp_data;
}
/**
* Optimize a viewport's LCP HTML.
*
* @param string $buffer The buffer/html to optimize.
* @return string The optimized buffer, or the original buffer if no optimization was needed
*
* @since 4.0.0
*/
public function optimize_buffer( $buffer ) {
$optimization_util = new LCP_Optimization_Util( $this->lcp_data );
if ( ! $optimization_util->can_optimize() ) {
return $buffer;
}
// Defensive check to ensure the LCP HTML is not empty.
if ( empty( $this->lcp_data['html'] ) ) {
return $buffer;
}
// Only optimize if the type is one we know how to handle.
if ( $this->lcp_data['type'] !== LCP::TYPE_IMAGE ) {
return $buffer;
}
$buffer_processor = $optimization_util->find_element( $buffer );
if ( ! $buffer_processor ) {
return $buffer;
}
// Create the optimized tag with required attributes.
return $this->optimize_image( $buffer_processor );
}
/**
* Optimize an image tag by adding required attributes.
*
* @param WP_HTML_Tag_Processor $buffer_processor The original HTML chunk of the page..
*
* @return string The optimized buffer.
*
* @since 4.0.0
*/
private function optimize_image( $buffer_processor ) {
// Check optimizations object for fetchpriority.
// If no optimizations object exists (old cloud response), default to applying.
$apply_fetchpriority = LCP_Optimization_Util::should_apply_optimization( $this->lcp_data, 'fetchpriority' );
if ( $apply_fetchpriority ) {
$buffer_processor->set_attribute( 'fetchpriority', 'high' );
}
// Check optimizations object for loading.
$apply_loading = LCP_Optimization_Util::should_apply_optimization( $this->lcp_data, 'loading' );
if ( $apply_loading ) {
$buffer_processor->set_attribute( 'loading', 'eager' );
}
$buffer_processor->set_attribute( 'data-jp-lcp-optimized', 'true' );
$image_url = $buffer_processor->get_attribute( 'src' );
// Ensure the image URL is valid.
if ( ! wp_http_validate_url( $image_url ) ) {
return $buffer_processor->get_updated_html();
}
// If the image isn't photonized, it might be resized by WP.
// We need the original image URL, as we'll be generating
// the necessary sizes based on it.
if ( ! Image_CDN_Core::is_cdn_url( $image_url ) ) {
$image_url = Image_CDN::strip_image_dimensions_maybe( $image_url );
} else {
// In case it's Photonized, we need to remove any size change arguments.
$image_url = remove_query_arg( array( 'w', 'h' ), $image_url );
}
// Additional validation after cleaning.
if ( ! wp_http_validate_url( $image_url ) ) {
return $buffer_processor->get_updated_html();
}
// Check optimizations object for cdnUrl.
$apply_cdn = LCP_Optimization_Util::should_apply_optimization( $this->lcp_data, 'cdnUrl' );
if ( $apply_cdn ) {
$buffer_processor->set_attribute( 'src', Image_CDN_Core::cdn_url( $image_url ) );
}
// Check optimizations object for srcset.
// The cloud sets srcset to false when custom focal points are detected
// (resize would use center-crop, losing the author's object-position).
$apply_srcset = LCP_Optimization_Util::should_apply_optimization( $this->lcp_data, 'srcset' );
// srcset uses CDN URLs internally (via Image_CDN_Core::cdn_url), so skip if CDN is disabled.
if ( $apply_srcset && $apply_cdn ) {
$this->add_responsive_image_attributes( $buffer_processor, $image_url );
}
return $buffer_processor->get_updated_html();
}
/**
* Optimize an image tag by adding srcset and sizes attributes.
*
* @param WP_HTML_Tag_Processor $element The original image tag.
* @param string $image_url The image URL.
* @return WP_HTML_Tag_Processor The optimized image tag.
*
* @since 4.0.0
*/
private function add_responsive_image_attributes( $element, $image_url ) {
if ( empty( $this->lcp_data['breakpoints'] ) ) {
return $element;
}
$srcset = $this->get_srcset( $image_url );
if ( ! empty( $srcset ) ) {
$element->set_attribute( 'srcset', $srcset );
}
$sizes = $this->get_sizes();
if ( ! empty( $sizes ) ) {
$element->set_attribute( 'sizes', $sizes );
}
return $element;
}
/**
* Get the srcsets for an image.
*
* @param string $original_url The original image URL.
* @return string The srcset for the image.
*
* @since 4.1.0
*/
private function get_srcset( $original_url ) {
$dimensions = array();
foreach ( $this->lcp_data['breakpoints'] as $breakpoint ) {
foreach ( $breakpoint['imageDimensions'] as $breakpoint_dimensions ) {
if ( ! is_numeric( $breakpoint_dimensions['width'] ) || ! is_numeric( $breakpoint_dimensions['height'] ) ) {
continue;
}
$width = (int) $breakpoint_dimensions['width'];
$height = (int) $breakpoint_dimensions['height'];
$dimensions[ $width ] = $height;
// If it's a Moto G Power, include a 1.75 DPR for accurate lighthouse representation of the optimized image.
if ( isset( $breakpoint['maxWidth'] ) && $breakpoint['maxWidth'] === 412 ) {
$dimensions[ (int) round( $width * 1.75 ) ] = round( $height * 1.75 );
}
// Include 2x DPR.
$dimensions[ $width * 2 ] = $height * 2;
// If it's a mobile breakpoint, include 3x DPR.
if ( isset( $breakpoint['maxWidth'] ) && $breakpoint['maxWidth'] <= 480 ) {
$dimensions[ $width * 3 ] = $height * 3;
}
}
}
// Remove unnecessary widths to save some bytes in the HTML.
$reduced_widths = $this->reduce_widths( array_keys( $dimensions ) );
$srcset = array();
foreach ( $reduced_widths as $width ) {
$srcset[] = Image_CDN_Core::cdn_url(
$original_url,
array(
'resize' => array( $width, $dimensions[ $width ] ),
)
) . " {$width}w";
}
return implode( ', ', $srcset );
}
/**
* Remove any width if we have another higher width that is within 20px.
*
* @param array $widths The widths to reduce.
* @return array The reduced widths.
*/
private function reduce_widths( $widths ) {
rsort( $widths );
$reduced_widths = array();
$previous_width = 999999;
foreach ( $widths as $width ) {
if ( $previous_width - 20 < $width ) {
continue;
}
$previous_width = $width;
$reduced_widths[] = $width;
}
return $reduced_widths;
}
/**
* Get the sizes for an image.
*
* @return string The sizes for the image.
*
* @since 4.1.0
*/
private function get_sizes() {
$sizes = array();
foreach ( $this->lcp_data['breakpoints'] as $breakpoint ) {
// Make sure widthValue is a known format.
if ( ! isset( $breakpoint['widthValue'] ) || ! preg_match( '/^[0-9]+(?:\.[0-9]+)?(?:px|vw)$/', $breakpoint['widthValue'] ) ) {
continue;
}
// Make sure minWidth is valid if set.
if ( isset( $breakpoint['minWidth'] ) && ! is_numeric( $breakpoint['minWidth'] ) ) {
continue;
}
$min_width_query = isset( $breakpoint['minWidth'] ) ? '(min-width: ' . $breakpoint['minWidth'] . 'px) ' : '';
$sizes[] = $min_width_query . $breakpoint['widthValue'];
}
return implode( ', ', $sizes );
}
}
@@ -0,0 +1,236 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp;
use WP_Error;
class LCP_State {
const ANALYSIS_STATES = array(
'not_analyzed' => 'not_analyzed',
'pending' => 'pending',
'analyzed' => 'analyzed',
'error' => 'error',
);
const PAGE_STATES = array(
'pending' => 'pending',
'success' => 'success',
'error' => 'error',
);
/**
* LCP analysis state data
*
* @var array
*/
public $state = array();
/**
* Retrieves and validates the LCP state
*
* @param bool $refresh Whether to refresh the state from storage.
* @return array The validated state
* @since 3.13.1
*/
private function get_state( $refresh = false ) {
if ( $refresh ) {
$stored_state = jetpack_boost_ds_get( 'lcp_state' );
$this->state = is_array( $stored_state ) ? $stored_state : array();
} elseif ( ! is_array( $this->state ) ) {
$this->state = array();
}
return $this->state;
}
public function __construct() {
$this->get_state( true );
}
public function clear() {
jetpack_boost_ds_delete( 'lcp_state' );
}
public function save() {
$this->state['updated'] = microtime( true );
jetpack_boost_ds_set( 'lcp_state', $this->state );
if ( $this->is_analyzed() ) {
/**
* Fires when LCP analysis has successfully completed.
*/
do_action( 'jetpack_boost_lcp_analyzed' );
}
}
public function set_error( $message ) {
if ( empty( $message ) ) {
return $this;
}
$this->state['status_error'] = $message;
$this->state['status'] = self::ANALYSIS_STATES['error'];
return $this;
}
/**
* Update a page's state. The page must already exist in the state to be updated.
*
* @param string $page_key The page key.
* @param array $state An array to overlay over the current state.
* @return bool|\WP_Error True on success, WP_Error on failure.
*/
public function update_page_state( $page_key, $state ) {
if ( empty( $this->state['pages'] ) ) {
return new WP_Error( 'invalid_page_key', 'No pages exist' );
}
$page_index = array_search( $page_key, array_column( $this->state['pages'], 'key' ), true );
if ( $page_index === false ) {
return new WP_Error( 'invalid_page_key', 'Invalid page key' );
}
$this->state['pages'][ $page_index ] = array_merge(
$this->state['pages'][ $page_index ],
$state
);
$this->maybe_set_analyzed();
return true;
}
/**
* Set a page's state to success.
*
* @param string $page_key The page key.
* @return bool|\WP_Error True on success, WP_Error on failure.
*/
public function set_page_success( $page_key ) {
return $this->update_page_state(
$page_key,
array(
'status' => self::PAGE_STATES['success'],
'errors' => null,
)
);
}
/**
* Signifies that the page was not optimized for reason(s) in $errors.
*
* @param string $page_key The page key.
* @param array $errors The errors to set for the page.
* @return bool|\WP_Error True on success, WP_Error on failure.
*/
public function set_page_errors( $page_key, $errors ) {
return $this->update_page_state(
$page_key,
array(
'status' => self::PAGE_STATES['error'],
'errors' => $errors,
)
);
}
/**
* Set the state to analyzed if all pages are done. Should be called wherever
* a page's state is updated.
*/
private function maybe_set_analyzed() {
if ( empty( $this->state['pages'] ) ) {
return;
}
$page_states = array_column( $this->state['pages'], 'status' );
$is_done = ! in_array( self::PAGE_STATES['pending'], $page_states, true );
if ( $is_done ) {
$this->state['status'] = self::ANALYSIS_STATES['analyzed'];
}
}
public function is_analyzed() {
return ! empty( $this->state )
&& isset( $this->state['status'] )
&& self::ANALYSIS_STATES['analyzed'] === $this->state['status'];
}
public function is_pending() {
return ! empty( $this->state )
&& isset( $this->state['status'] )
&& self::ANALYSIS_STATES['pending'] === $this->state['status'];
}
public function prepare_request() {
$this->state = array(
'status' => self::ANALYSIS_STATES['pending'],
'pages' => array(),
'created' => microtime( true ),
'updated' => microtime( true ),
);
return $this;
}
/**
* Get the pages from the state.
*
* @return array The pages from the state.
*
* @since 4.0.0
*/
public function get_pages() {
return $this->state['pages'];
}
/**
* Set the pages in the state.
*
* @param array $pages The pages to set in the state.
* @return $this
*
* @since 4.0.0
*/
public function set_pages( $pages ) {
$this->state['pages'] = $pages;
return $this;
}
/**
* Set the status to pending for the pages that are in the $pages array.
* Pages that are not in the $pages array will not be touched.
*
* @param array $pages The pages to set to pending.
* @return $this
*
* @since 4.0.0
*/
public function set_pending_pages( $pages ) {
$current_pages = $this->state['pages'];
foreach ( $pages as $page ) {
$page_key = $page['key'];
foreach ( $current_pages as $index => $current_page ) {
if ( $current_page['key'] === $page_key ) {
$current_pages[ $index ]['status'] = self::PAGE_STATES['pending'];
break;
}
}
}
$this->state['pages'] = $current_pages;
return $this;
}
/**
* Get fresh state
*
* @return array Current LCP state
* @since 3.13.1
*/
public function get() {
return $this->get_state( true );
}
}
@@ -0,0 +1,71 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Source_Providers\Providers\Cornerstone_Provider;
use Automattic\Jetpack_Boost\Lib\Storage_Post_Type;
/**
* LCP Storage class
*/
class LCP_Storage {
/**
* Storage post type.
*
* @var Storage_Post_Type
*/
protected $storage;
/**
* LCP_Storage constructor.
*/
public function __construct() {
$this->storage = new Storage_Post_Type( 'lcp' );
}
/**
* Store LCP data for a specific page.
*
* @param string $key Page key.
* @param array $data LCP data.
*/
public function store_lcp( $key, $data ) {
$this->storage->set(
$key,
$data
);
}
/**
* Clear the whole LCP storage.
*/
public function clear() {
$this->storage->clear();
}
/**
* Get LCP data for a specific page key.
*
* @param string $page_key Page key.
*
* @return array|false
*/
public function get_lcp( $page_key ) {
return $this->storage->get( $page_key, false );
}
/**
* Get LCP data for the current request.
*
* @return array|false LCP data for the current request, or false if not found.
*/
public function get_current_request_lcp() {
// @TODO: this is a temporary solution to get the LCP data for the current request. Cornerstone Provider should be decoupled from the CSS storage.
$current_url = Cornerstone_Provider::get_request_url();
$key = Cornerstone_Provider::get_provider_key( $current_url );
if ( empty( $key ) ) {
return false;
}
return $this->get_lcp( $key );
}
}
@@ -0,0 +1,32 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp;
use Automattic\Jetpack_Boost\Lib\Cornerstone\Cornerstone_Utils;
/**
* Utility class for LCP functionality.
*/
class LCP_Utils {
/**
* Get LCP analysis data for all cornerstone pages.
*
* @return array Array of LCP analysis data keyed by page URL.
*/
public static function get_analysis_data() {
$cornerstone_pages = Cornerstone_Utils::get_list();
$storage = new LCP_Storage();
$analysis_data = array();
foreach ( $cornerstone_pages as $page_url ) {
$key = Cornerstone_Utils::get_provider_key( $page_url );
$lcp_data = $storage->get_lcp( $key );
if ( ! empty( $lcp_data ) ) {
$analysis_data[ $page_url ] = $lcp_data;
}
}
return $analysis_data;
}
}
@@ -0,0 +1,195 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp;
use Automattic\Jetpack\Schema\Schema;
use Automattic\Jetpack\WP_JS_Data_Sync\Data_Sync;
use Automattic\Jetpack_Boost\Contracts\Changes_Output_After_Activation;
use Automattic\Jetpack_Boost\Contracts\Feature;
use Automattic\Jetpack_Boost\Contracts\Has_Activate;
use Automattic\Jetpack_Boost\Contracts\Has_Data_Sync;
use Automattic\Jetpack_Boost\Contracts\Needs_To_Be_Ready;
use Automattic\Jetpack_Boost\Contracts\Needs_Website_To_Be_Public;
use Automattic\Jetpack_Boost\Contracts\Optimization;
use Automattic\Jetpack_Boost\Lib\Output_Filter;
use Automattic\Jetpack_Boost\REST_API\Contracts\Has_Always_Available_Endpoints;
use Automattic\Jetpack_Boost\REST_API\Endpoints\Update_LCP;
class Lcp implements Feature, Changes_Output_After_Activation, Optimization, Has_Activate, Needs_To_Be_Ready, Has_Data_Sync, Has_Always_Available_Endpoints, Needs_Website_To_Be_Public {
/** LCP type for background images. */
const TYPE_BACKGROUND_IMAGE = 'background-image';
/** LCP type for standard images. */
const TYPE_IMAGE = 'img';
/**
* The LCP data of the current request.
*
* @var array|false
*/
private $lcp_data;
public function setup() {
add_action( 'wp', array( $this, 'on_wp_load' ), 1 );
add_action( 'template_redirect', array( $this, 'add_output_filter' ), -999999 );
add_action( 'jetpack_boost_lcp_invalidated', array( $this, 'handle_lcp_invalidated' ) );
LCP_Invalidator::init();
}
public function on_wp_load() {
$this->lcp_data = ( new LCP_Storage() )->get_current_request_lcp();
LCP_Optimize_Bg_Image::init( $this->lcp_data );
}
public function add_output_filter() {
if ( LCP_Optimization_Util::should_skip_optimization() ) {
return;
}
$output_filter = new Output_Filter();
$output_filter->add_callback( array( $this, 'optimize_lcp_img_tag' ) );
}
/**
* Optimize the HTML content by finding the LCP image and adding required attributes.
*
* @param string $buffer_start First part of the buffer.
* @param string $buffer_end Second part of the buffer.
*
* @return array Parts of the buffer.
*
* @since 3.13.1
*/
public function optimize_lcp_img_tag( $buffer_start, $buffer_end ) {
if ( empty( $this->lcp_data ) ) {
return array( $buffer_start, $buffer_end );
}
// Combine the buffers for processing
$combined_buffer = $buffer_start . $buffer_end;
foreach ( $this->lcp_data as $lcp_element ) {
$optimizer = new LCP_Optimize_Img_Tag( $lcp_element );
$combined_buffer = $optimizer->optimize_buffer( $combined_buffer );
}
// Split the modified buffer back into two parts
$buffer_start_length = strlen( $buffer_start );
$new_buffer_start = substr( $combined_buffer, 0, $buffer_start_length );
$new_buffer_end = substr( $combined_buffer, $buffer_start_length );
// Check for successful split
if ( false === $new_buffer_start || false === $new_buffer_end ) {
// If splitting failed, return the original buffers
return array( $buffer_start, $buffer_end );
}
return array( $new_buffer_start, $new_buffer_end );
}
/**
* @since 3.13.1
*/
public static function activate() {
( new LCP_Analyzer() )->start();
}
/**
* @since 3.13.1
*/
public static function get_slug() {
return 'lcp';
}
public function get_always_available_endpoints() {
return array(
new Update_LCP(),
);
}
/**
* @since 3.13.1
*/
public static function is_available() {
return true;
}
/**
* Check if the module is ready and already serving optimized pages.
*
* @return bool
*/
public function is_ready() {
return ( new LCP_State() )->is_analyzed();
}
/**
* Get the action names that will be triggered when the module is ready.
*
* @return string[]
*/
public static function get_change_output_action_names() {
return array( 'jetpack_boost_lcp_invalidated', 'jetpack_boost_lcp_analyzed' );
}
/**
* Register data sync actions.
*
* @param Data_Sync $instance The Data_Sync object.
*/
public function register_data_sync( $instance ) {
$instance->register(
'lcp_state',
Schema::as_assoc_array(
array(
'pages' => Schema::as_array(
Schema::as_assoc_array(
array(
'key' => Schema::as_string(),
'url' => Schema::as_string(),
'status' => Schema::as_string(),
'errors' => Schema::as_array(
Schema::as_assoc_array(
array(
'type' => Schema::as_string(),
'meta' => Schema::as_assoc_array(
array(
'code' => Schema::as_number()->nullable(),
'selector' => Schema::as_string()->nullable(),
)
)->nullable(),
)
)
)->nullable(),
)
)
),
'status' => Schema::enum( array( 'not_analyzed', 'analyzed', 'pending', 'error' ) )->fallback( 'not_analyzed' ),
'created' => Schema::as_float()->nullable(),
'updated' => Schema::as_float()->nullable(),
'status_error' => Schema::as_string()->nullable(),
)
)->fallback(
array(
'pages' => array(),
'status' => 'not_analyzed',
'created' => null,
'updated' => null,
)
)
);
$instance->register_action( 'lcp_state', 'request-analyze', Schema::as_void(), new Optimize_LCP_Endpoint() );
}
/**
* Handle the LCP invalidated action.
*/
public function handle_lcp_invalidated() {
( new LCP_Analyzer() )->start();
}
}
@@ -0,0 +1,43 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Data_Sync_Action;
use Automattic\Jetpack_Boost\Modules\Modules_Setup;
class Optimize_LCP_Endpoint implements Data_Sync_Action {
/**
* Handles the optimize LCP action.
*
* @param mixed $_data JSON Data passed to the action.
* @param \WP_REST_Request $_request The request object.
*/
public function handle( $_data, $_request ) {
// Check if the module is enabled first.
$states = ( new Modules_Setup() )->get_status();
if ( ! $states['lcp'] ) {
return array(
'success' => false,
'state' => array(),
);
}
$analyzer = new LCP_Analyzer();
$state = $analyzer->get_state();
if ( $state->is_pending() ) {
// If the analysis is already in progress, return the current state.
return array(
'success' => true,
'state' => $state->get(),
);
}
$state = $analyzer->start();
return array(
'success' => true,
'state' => $state,
);
}
}
@@ -0,0 +1,81 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Minify;
use Automattic\Jetpack\Schema\Schema;
use Automattic\Jetpack\WP_JS_Data_Sync\Data_Sync;
use Automattic\Jetpack_Boost\Contracts\Has_Activate;
use Automattic\Jetpack_Boost\Contracts\Has_Data_Sync;
use Automattic\Jetpack_Boost\Contracts\Has_Deactivate;
use Automattic\Jetpack_Boost\Contracts\Is_Always_On;
use Automattic\Jetpack_Boost\Contracts\Optimization;
use Automattic\Jetpack_Boost\Contracts\Sub_Feature;
class Minify_Common implements Sub_Feature, Optimization, Is_Always_On, Has_Activate, Has_Deactivate, Has_Data_Sync {
/**
* Setup the module. This runs on every page load.
*/
public function setup() {
require_once JETPACK_BOOST_DIR_PATH . '/app/lib/minify/functions-helpers.php';
jetpack_boost_minify_init();
}
public static function get_slug() {
return 'minify_common';
}
public function register_data_sync( Data_Sync $instance ) {
$instance->register_readonly(
'minify_legacy_notice',
Schema::as_unsafe_any(),
array( self::class, 'show_legacy_notice' )
);
}
public static function is_available() {
return true;
}
public static function show_legacy_notice() {
// If the JETPACK_BOOST_DISABLE_404_TESTER is set and true, we don't need to show the legacy notice.
if ( defined( 'JETPACK_BOOST_DISABLE_404_TESTER' ) && JETPACK_BOOST_DISABLE_404_TESTER ) {
return false;
}
// If this is a multisite, and the user is not a super admin, don't show the legacy notice, as they won't be able to do anything about it.
if ( is_multisite() && ! current_user_can( 'manage_network_options' ) ) {
return false;
}
// If the static minfification has not ran yet, don't show the legacy notice.
$static_minification_enabled = get_site_option( 'jetpack_boost_static_minification', 'na' );
if ( $static_minification_enabled === 'na' ) {
return false;
}
// Otherwise show it if the 404 tester determined it can't be used.
return ! (bool) $static_minification_enabled;
}
/**
* This is called when either minify module is activated
*/
public static function activate() {
jetpack_boost_minify_activation();
}
/**
* This is called when either minify module is deactivated.
*/
public static function deactivate() {
jetpack_boost_minify_clear_scheduled_events();
}
public static function get_parent_features(): array {
return array(
Minify_JS::class,
Minify_CSS::class,
);
}
}
@@ -0,0 +1,71 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Minify;
use Automattic\Jetpack\Schema\Schema;
use Automattic\Jetpack\WP_JS_Data_Sync\Data_Sync;
use Automattic\Jetpack_Boost\Contracts\Changes_Output_After_Activation;
use Automattic\Jetpack_Boost\Contracts\Changes_Output_On_Activation;
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\Optimization;
use Automattic\Jetpack_Boost\Data_Sync\Minify_Excludes_State_Entry;
use Automattic\Jetpack_Boost\Lib\Minify\Concatenate_CSS;
class Minify_CSS implements Feature, Changes_Output_On_Activation, Changes_Output_After_Activation, Optimization, Has_Deactivate, Has_Data_Sync {
public static $default_excludes = array( 'admin-bar', 'dashicons', 'elementor-app' );
/**
* Setup the module. This runs on every page load.
*/
public function setup() {
if ( jetpack_boost_page_optimize_bail() ) {
return;
}
add_action( 'init', array( $this, 'init_minify' ) );
}
public function register_data_sync( Data_Sync $instance ) {
$parser = Schema::as_array( Schema::as_string() )->fallback( self::$default_excludes );
$instance->register( 'minify_css_excludes', $parser, new Minify_Excludes_State_Entry( 'minify_css_excludes' ) );
$instance->register_readonly(
'minify_css_excludes_default',
Schema::as_unsafe_any(),
function () {
return Minify_CSS::$default_excludes;
}
);
}
public static function get_slug() {
return 'minify_css';
}
public static function get_change_output_action_names() {
return array( 'update_option_' . JETPACK_BOOST_DATASYNC_NAMESPACE . '_minify_css_excludes' );
}
public static function is_available() {
return true;
}
public function init_minify() {
global $wp_styles;
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$wp_styles = new Concatenate_CSS( $wp_styles );
$wp_styles->allow_gzip_compression = true; // @todo - used constant ALLOW_GZIP_COMPRESSION = true if not defined.
}
/**
* This is called only when the module is deactivated.
*/
public static function deactivate() {
jetpack_boost_page_optimize_cleanup_cache( 'css' );
}
}
@@ -0,0 +1,71 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Minify;
use Automattic\Jetpack\Schema\Schema;
use Automattic\Jetpack\WP_JS_Data_Sync\Data_Sync;
use Automattic\Jetpack_Boost\Contracts\Changes_Output_After_Activation;
use Automattic\Jetpack_Boost\Contracts\Changes_Output_On_Activation;
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\Optimization;
use Automattic\Jetpack_Boost\Data_Sync\Minify_Excludes_State_Entry;
use Automattic\Jetpack_Boost\Lib\Minify\Concatenate_JS;
class Minify_JS implements Feature, Changes_Output_On_Activation, Changes_Output_After_Activation, Optimization, Has_Deactivate, Has_Data_Sync {
public static $default_excludes = array( 'jquery', 'jquery-core', 'underscore', 'backbone' );
/**
* Setup the module. This runs on every page load.
*/
public function setup() {
if ( jetpack_boost_page_optimize_bail() ) {
return;
}
add_action( 'init', array( $this, 'init_minify' ) );
}
public function register_data_sync( Data_Sync $instance ) {
$parser = Schema::as_array( Schema::as_string() )->fallback( self::$default_excludes );
$instance->register( 'minify_js_excludes', $parser, new Minify_Excludes_State_Entry( 'minify_js_excludes' ) );
$instance->register_readonly(
'minify_js_excludes_default',
Schema::as_unsafe_any(),
function () {
return Minify_JS::$default_excludes;
}
);
}
public static function get_slug() {
return 'minify_js';
}
public static function get_change_output_action_names() {
return array( 'update_option_' . JETPACK_BOOST_DATASYNC_NAMESPACE . '_minify_js_excludes' );
}
public static function is_available() {
return true;
}
public function init_minify() {
global $wp_scripts;
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$wp_scripts = new Concatenate_JS( $wp_scripts );
$wp_scripts->allow_gzip_compression = true; // @todo - used constant ALLOW_GZIP_COMPRESSION = true if not defined.
}
/**
* This is called only when the module is deactivated.
*/
public static function deactivate() {
jetpack_boost_page_optimize_cleanup_cache( 'js' );
}
}
@@ -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 );
}
@@ -0,0 +1,722 @@
<?php
/**
* Implements the system to avoid render blocking JS execution.
*
* @link https://automattic.com
* @since 0.2
* @package automattic/jetpack-boost
*/
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Render_Blocking_JS;
use Automattic\Jetpack\Schema\Schema;
use Automattic\Jetpack\WP_JS_Data_Sync\Data_Sync;
use Automattic\Jetpack_Boost\Contracts\Changes_Output_After_Activation;
use Automattic\Jetpack_Boost\Contracts\Changes_Output_On_Activation;
use Automattic\Jetpack_Boost\Contracts\Feature;
use Automattic\Jetpack_Boost\Contracts\Has_Data_Sync;
use Automattic\Jetpack_Boost\Contracts\Optimization;
use Automattic\Jetpack_Boost\Data_Sync\Minify_Excludes_State_Entry;
use Automattic\Jetpack_Boost\Lib\Output_Filter;
/**
* Class Render_Blocking_JS
*/
class Render_Blocking_JS implements Feature, Changes_Output_On_Activation, Changes_Output_After_Activation, Optimization, Has_Data_Sync {
/**
* Substring that marks an inline script as producing position-dependent
* output (document.write()/document.writeln()). Such scripts must stay where
* they are rather than being moved to the end of the document. The fast-path
* guard and the per-script check must use the same needle to stay in lockstep.
*
* @var string
*/
private const POSITION_DEPENDENT_OUTPUT_NEEDLE = 'document.write';
/**
* Holds the script tags removed from the output buffer.
*
* @var array
*/
protected $buffered_script_tags = array();
/**
* HTML attribute name to be added to <script> tag to make it
* ignored by this class.
*
* @var string|null
*/
private $ignore_attribute;
/**
* HTML attribute value to be added to <script> tag to make it
* ignored by this class.
*
* @var string
*/
private $ignore_value = 'ignore';
/**
* Utility class that supports output filtering.
*
* @var Output_Filter
*/
private $output_filter = null;
/**
* Flag indicating an opened <script> tag in output.
*
* @var string
*/
private $is_opened_script = false;
public function setup() {
$this->output_filter = new Output_Filter();
/**
* Filters the ignore attribute
*
* @param $string $ignore_attribute The string used to ignore elements of the page.
*
* @since 1.0.0
*/
$this->ignore_attribute = apply_filters( 'jetpack_boost_render_blocking_js_ignore_attribute', 'data-jetpack-boost' );
add_action( 'template_redirect', array( $this, 'start_output_filtering' ), -999999 );
/**
* Shortcodes can sometimes output script to embed widget. It's safer to ignore them.
*/
add_filter( 'do_shortcode_tag', array( $this, 'add_ignore_attribute' ) );
}
public static function is_available() {
return true;
}
/**
* Register the data sync entry holding the list of URL patterns
* excluded from JS deferring.
*
* @param Data_Sync $instance The data sync instance.
*/
public function register_data_sync( Data_Sync $instance ) {
$instance->register(
'render_blocking_js_excludes',
Schema::as_array( Schema::as_string() )->fallback( array() ),
new Minify_Excludes_State_Entry( 'render_blocking_js_excludes' )
);
}
/**
* Cached pages need to be invalidated when the exclusion list changes.
*
* @return string[] Action names fired when the exclusion list is updated.
*/
public static function get_change_output_action_names() {
$option = JETPACK_BOOST_DATASYNC_NAMESPACE . '_render_blocking_js_excludes';
// `add_option_*` covers the very first save, when the option is created
// rather than updated, so the cache is invalidated on that write too.
return array(
'add_option_' . $option,
'update_option_' . $option,
);
}
/**
* Set up an output filtering callback.
*
* @return void
*/
public function start_output_filtering() {
/**
* We're doing heavy output filtering in this module
* by using output buffering.
*
* Here are a few scenarios when we shouldn't do it:
*/
/**
* Filter to disable defer blocking JS
*
* @param bool $defer return false to disable defer blocking
*
* @since 1.0.0
*/
if ( false === apply_filters( 'jetpack_boost_should_defer_js', '__return_true' ) ) {
return;
}
// Disable in robots.txt.
if ( isset( $_SERVER['REQUEST_URI'] ) && strpos( home_url( wp_unslash( $_SERVER['REQUEST_URI'] ) ), 'robots.txt' ) !== false ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- This is validating.
return;
}
// Disable in other possible AJAX requests setting cors related header.
if ( isset( $_SERVER['HTTP_SEC_FETCH_MODE'] ) && 'cors' === strtolower( $_SERVER['HTTP_SEC_FETCH_MODE'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- This is validating.
return;
}
// Disable in other possible AJAX requests setting XHR related header.
if ( isset( $_SERVER['HTTP_X_REQUESTED_WITH'] ) && 'xmlhttprequest' === strtolower( $_SERVER['HTTP_X_REQUESTED_WITH'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- This is validating.
return;
}
// Disable in all XLS (see the WP_Sitemaps_Renderer class which is responsible for rendering Sitemaps data to XML
// in accordance with sitemap protocol).
if ( isset( $_SERVER['REQUEST_URI'] ) &&
(
// phpcs:disable WordPress.Security.ValidatedSanitizedInput -- This is validating.
str_contains( $_SERVER['REQUEST_URI'], '.xsl' ) ||
str_contains( $_SERVER['REQUEST_URI'], 'sitemap-stylesheet=index' ) ||
str_contains( $_SERVER['REQUEST_URI'], 'sitemap-stylesheet=sitemap' )
// phpcs:enable WordPress.Security.ValidatedSanitizedInput
) ) {
return;
}
// Disable in all POST Requests.
// phpcs:disable WordPress.Security.NonceVerification.Missing
if ( ! empty( $_POST ) ) {
return;
}
// Disable in customizer previews
if ( is_customize_preview() ) {
return;
}
// Disable in feeds, AJAX, Cron, XML.
if ( is_feed() || wp_doing_ajax() || wp_doing_cron() || wp_is_xml_request() ) {
return;
}
// Disable in sitemaps.
if ( ! empty( get_query_var( 'sitemap' ) ) ) {
return;
}
// Disable in AMP pages.
if ( function_exists( 'amp_is_request' ) && amp_is_request() ) {
return;
}
// Disable on URLs excluded by the user.
if ( $this->is_current_request_excluded() ) {
// Leave the page output completely untouched, as if the module was off.
remove_filter( 'do_shortcode_tag', array( $this, 'add_ignore_attribute' ) );
return;
}
// Print the filtered script tags to the very end of the page.
add_filter( 'jetpack_boost_output_filtering_last_buffer', array( $this, 'append_script_tags' ), 10, 1 );
// Handle exclusions.
add_filter( 'script_loader_tag', array( $this, 'handle_exclusions' ), 10, 2 );
$this->output_filter->add_callback( array( $this, 'handle_output_stream' ) );
}
/**
* Remove all inline and external <script> tags from the default output.
*
* @param string $buffer_start First part of the buffer.
* @param string $buffer_end Second part of the buffer.
*
* For explanation on why there are two parts of a buffer here, see
* the comments and examples in the Output_Filter class.
*
* @return array Parts of the buffer.
*/
public function handle_output_stream( $buffer_start, $buffer_end ) {
$joint_buffer = $this->ignore_exclusion_scripts( $buffer_start . $buffer_end );
$script_tags = $this->get_script_tags( $joint_buffer );
if ( ! $script_tags ) {
if ( $this->is_opened_script ) {
// We have an opened script tag, move everything to the second buffer to avoid printing it to the page.
// We will do this until the </script> closing tag is encountered.
return array( '', $joint_buffer );
}
// No script tags detected, return both chunks unaltered.
return array( $buffer_start, $buffer_end );
}
// Makes sure all whole <script>...</script> tags are in $buffer_start.
list( $buffer_start, $buffer_end ) = $this->recalculate_buffer_split( $joint_buffer, $script_tags );
foreach ( $script_tags as $script_tag ) {
$this->buffered_script_tags[] = $script_tag[0];
$buffer_start = str_replace( $script_tag[0], '', $buffer_start );
}
// Detect a lingering opened script.
$this->is_opened_script = $this->is_opened_script( $buffer_start . $buffer_end );
return array( $buffer_start, $buffer_end );
}
/**
* Matches <script> tags with their content in a string buffer.
*
* @param string $buffer Captured piece of output buffer.
*
* @return array
*/
protected function get_script_tags( $buffer ) {
$regex = '~<script' . $this->ignore_attribute_lookahead() . '([^>]*)>[\s\S]*?<\/script>~si';
preg_match_all( $regex, $buffer, $script_tags, PREG_OFFSET_CAPTURE );
// No script_tags in the joint buffer.
if ( empty( $script_tags[0] ) ) {
return array();
}
/**
* Filter to remove any scripts that should not be moved to the end of the document.
*
* @param array $script_tags array of script tags. Remove any scripts that should not be moved to the end of the documents.
*
* @since 1.0.0
*/
return apply_filters( 'jetpack_boost_render_blocking_js_exclude_scripts', $script_tags[0] );
}
/**
* Adds the ignore attribute to scripts in the exclusion list.
*
* @param string $buffer Captured piece of output buffer.
*
* @return string
*/
protected function ignore_exclusion_scripts( $buffer ) {
$exclusions = array(
// Scripts inside HTML comments.
'~<!--.*?-->~si',
// Scripts with types that do not execute complex code. Moving them down can be dangerous
// and does not benefit performance. Includes types: application/json, application/ld+json and importmap.
'~<script\s+[^\>]*type=(?<q>["\']*)(application\/(ld\+)?json|importmap)\k<q>.*?>.*?<\/script>~si',
);
$excluded = preg_replace_callback(
$exclusions,
function ( $script_match ) {
return $this->add_ignore_attribute( $script_match[0] );
},
$buffer
);
// preg_replace_callback() returns null on PCRE failure; keep the original
// buffer in that case rather than propagating null downstream.
if ( null !== $excluded ) {
$buffer = $excluded;
}
return $this->pin_position_dependent_scripts( $buffer );
}
/**
* Keep inline scripts whose output is position-dependent in their original place.
*
* Scripts using document.write()/document.writeln() insert markup at the script's
* location, so moving such a script to the end of the document renders its output
* after the footer instead of inside the content (e.g. a Custom HTML block).
* Marking the script with the ignore attribute keeps the rest of the pipeline
* from moving it. Scripts that already carry the ignore attribute are skipped so
* their behavior and markup are unchanged.
*
* Best-effort and deliberately conservative: it pins the common case (an inline
* script that calls document.write) and otherwise leaves the script to the
* default move behavior. It does not pin scripts that write their own
* '<script ...>' markup (no safe in-place edit exists), nor exotic call forms a
* substring check cannot see. A miss never corrupts the page — worst case is a
* script that still moves, exactly as it does without this method.
*
* @param string $buffer Captured piece of output buffer.
*
* @return string
*/
private function pin_position_dependent_scripts( $buffer ) {
// Fast path: skip the inline-script scan entirely when the buffer cannot
// contain a position-dependent script.
if ( false === stripos( $buffer, self::POSITION_DEPENDENT_OUTPUT_NEEDLE ) ) {
return $buffer;
}
// Match inline scripts only (no src attribute) that do not already carry
// the ignore attribute. This runs on the buffer Output_Filter hands us
// (a bounded window), not a whole page; the lazy [\s\S]*? would be
// superlinear on a multi-megabyte single buffer, so keep it window-scoped.
$inline_script_regex = '~<script\b(?![^>]*\ssrc\s*=)' . $this->ignore_attribute_lookahead() . '[^>]*>[\s\S]*?</script>~i';
$result = preg_replace_callback(
$inline_script_regex,
function ( $script_match ) {
// Intentionally conservative: a simple case-insensitive substring check
// for "document.write" (which also covers "document.writeln"). It does
// not parse JS, so exotic call forms it cannot see — document['write'](),
// "document . write()", or an uppercase <SCRIPT> tag that
// add_ignore_attribute()'s lowercase replace won't touch — simply fall
// back to the default behavior (the script is moved, as it is today).
// That is the safe direction: a miss never corrupts the page.
if ( false === stripos( $script_match[0], self::POSITION_DEPENDENT_OUTPUT_NEEDLE ) ) {
return $script_match[0];
}
// Do not touch a script that writes its own '<script ...>' markup. There
// is no safe in-place edit for it: add_ignore_attribute() does a global
// str_replace() on '<script', which rewrites the inner literal and can
// break the quoting of the string the script writes; tagging only the
// outer tag would instead let get_script_tags() match and move that inner
// literal. Such scripts keep the default behavior rather than risk
// corrupting the page.
if ( substr_count( strtolower( $script_match[0] ), '<script' ) > 1 ) {
return $script_match[0];
}
return $this->add_ignore_attribute( $script_match[0] );
},
$buffer
);
// preg_replace_callback() returns null on PCRE failure (e.g. backtrack limit
// on a pathological buffer); fall back to the unmodified buffer so the page is
// never blanked. Mirrors the guard in is_opened_script().
return null === $result ? $buffer : $result;
}
/**
* Negative lookahead asserting a <script> tag does not already carry the
* ignore attribute. Shared by the regexes that select movable scripts so the
* attribute-matching rule lives in one place.
*
* @return string Regex fragment (uses named group "q"; safe to use once per pattern).
*/
private function ignore_attribute_lookahead() {
return sprintf(
'(?![^>]*%s=(?<q>["\']*)%s\k<q>)',
preg_quote( $this->ignore_attribute, '~' ),
preg_quote( $this->ignore_value, '~' )
);
}
/**
* Splits the buffer into two parts.
*
* First part contains all whole <script> tags, the second part
* contains the rest of the buffer.
*
* @param string $buffer Captured piece of output buffer.
* @param array $script_tags Matched <script> tags.
*
* @return array
*/
protected function recalculate_buffer_split( $buffer, $script_tags ) {
$last_script_tag_index = count( $script_tags ) - 1;
$last_script_tag_end_position = strrpos( $buffer, $script_tags[ $last_script_tag_index ][0] ) + strlen( $script_tags[ $last_script_tag_index ][0] );
// Bundle all script tags into the first buffer.
$buffer_start = substr( $buffer, 0, $last_script_tag_end_position );
// Leave the rest of the data in the second buffer.
$buffer_end = substr( $buffer, $last_script_tag_end_position );
return array( $buffer_start, $buffer_end );
}
/**
* Insert the buffered script tags just before the body tag if possible in the last buffer
* otherwise at append it at the end.
*
* @param string $buffer String buffer.
*
* @return string
*/
public function append_script_tags( $buffer ) {
$script_tags = implode( '', $this->buffered_script_tags );
// Reset tags in case there's another buffer after this one.
$this->buffered_script_tags = array();
if ( str_contains( $buffer, '</body>' ) ) {
$buffer = str_replace( '</body>', $script_tags . '</body>', $buffer );
} else {
$buffer .= $script_tags;
}
return $buffer;
}
/**
* Exclude certain scripts from being processed by this class.
*
* @param string $tag <script> opening tag.
* @param string $handle Script handle from register_ or enqueue_ methods.
*
* @return string
*/
public function handle_exclusions( $tag, $handle ) {
/**
* Filter to provide an array of registered script handles that should not be moved to the end of the document.
*
* @param array $script_handles array of script handles. Remove any scripts that should not be moved to the end of the documents.
*
* @since 1.0.0
*/
$exclude_handles = apply_filters( 'jetpack_boost_render_blocking_js_exclude_handles', array() );
if ( ! in_array( $handle, $exclude_handles, true ) ) {
return $tag;
}
return $this->add_ignore_attribute( $tag );
}
/**
* Add the ignore attribute to the script tags.
*
* Case-insensitive so uppercase/mixed-case tags (`<SCRIPT>`, valid HTML and
* common in hand-written Custom HTML / legacy embeds) are handled too; a
* case-sensitive match would silently no-op on them and leave them movable.
*
* @param string $html HTML code possibly containing a <script> opening tag.
*
* @return string
*/
public function add_ignore_attribute( $html ) {
return str_ireplace( '<script', sprintf( '<script %s="%s"', esc_html( $this->ignore_attribute ), esc_attr( $this->ignore_value ) ), $html );
}
/**
* Detects an unclosed script tag in a buffer.
*
* @param string $buffer Joint buffer.
*
* @return bool
*/
public function is_opened_script( $buffer ) {
// Strip fully-paired ignored <script>...</script> blocks so the counts below are symmetric.
$ignored_pair_regex = sprintf(
'~<script[^>]*%s=(?<q>["\']*)%s\k<q>[^>]*>[\s\S]*?</script>~si',
preg_quote( $this->ignore_attribute, '~' ),
preg_quote( $this->ignore_value, '~' )
);
$stripped = preg_replace( $ignored_pair_regex, '', $buffer );
if ( null === $stripped ) {
$stripped = $buffer;
}
// Strip HTML comments so a commented-out </script> doesn't skew the count.
$stripped = preg_replace( '~<!--[\s\S]*?-->~', '', $stripped ) ?? $stripped;
$opening_tags_count = preg_match_all( '~<\s*script(\s[^>]*)?>~i', $stripped );
$closing_tags_count = preg_match_all( '~<\s*/\s*script\s*>~i', $stripped );
return $opening_tags_count > $closing_tags_count;
}
/**
* Checks if the current request URL matches one of the exclusion patterns
* configured by the user.
*
* Runs at template_redirect time, when REQUEST_URI is available.
*
* @return bool
*/
private function is_current_request_excluded() {
if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
return false;
}
$patterns = function_exists( 'jetpack_boost_ds_get' ) ? jetpack_boost_ds_get( 'render_blocking_js_excludes' ) : array();
if ( empty( $patterns ) || ! is_array( $patterns ) ) {
return false;
}
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only used for comparison.
return self::is_url_excluded( wp_unslash( $_SERVER['REQUEST_URI'] ), $patterns );
}
/**
* Checks whether a request URI matches any of the given exclusion patterns.
*
* Patterns follow the semantics documented for Page Cache bypass patterns:
* they are compared against the URL path (query strings are ignored),
* a `(.*)` or `*` wildcard matches any part of the path, trailing slashes
* are optional and the comparison is case-insensitive.
*
* Two things differ from Page Cache, so keep them in mind before unifying the
* two implementations: every character outside the wildcard tokens is escaped
* via preg_quote() and matched literally (a pattern like `page.html` never
* acts as a regular expression), and the path is percent-decoded so a pattern
* typed as it appears in the address bar matches an encoded request path.
*
* @param string $request_uri The request URI to check.
* @param array $patterns List of URL patterns.
*
* @return bool
*/
public static function is_url_excluded( $request_uri, $patterns ) {
$path = self::normalize_url_path( $request_uri );
foreach ( $patterns as $pattern ) {
$regex = self::get_exclusion_regex( $pattern );
if ( null === $regex ) {
continue;
}
$matched = preg_match( $regex, $path );
/*
* preg_match() returns false when PCRE cannot evaluate the pattern —
* e.g. a pathological pattern with several literal-separated wildcards
* hits the backtrack limit on a long URL. Treat that as a match so a
* deliberate exclusion is honoured (defer stays off on the page)
* rather than silently ignored.
*/
if ( 1 === $matched || false === $matched ) {
return true;
}
}
return false;
}
/**
* Extracts a normalized path from a URL or request URI.
*
* Drops the query string, ensures a leading slash and removes trailing
* slashes (except for the root path).
*
* @param string $url URL or request URI.
*
* @return string
*/
private static function normalize_url_path( $url ) {
$path = (string) wp_parse_url( $url, PHP_URL_PATH );
// Decode percent-encoding so a pattern typed as it appears in the address
// bar (e.g. `foo bar`, or a non-ASCII slug) matches the encoded request
// path (`/foo%20bar`). Both the pattern and the request pass through here,
// so the two sides stay symmetric.
$path = rawurldecode( $path );
$path = '/' . ltrim( $path, '/' );
if ( '/' !== $path ) {
$path = rtrim( $path, '/' );
}
return self::strip_home_path( $path );
}
/**
* Removes the site's home directory prefix from a path.
*
* On a subdirectory install (e.g. a site at `/blog/`) the request URI
* includes the subdirectory but user-entered patterns generally do not.
* Stripping the home directory from both sides makes the comparison relative
* to the home root, so a `checkout` pattern matches `/blog/checkout`.
*
* @param string $path A normalized URL path (leading slash, no query/trailing slash).
*
* @return string
*/
private static function strip_home_path( $path ) {
$home_path = rtrim( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' );
if ( '' === $home_path ) {
return $path;
}
if ( 0 === strcasecmp( $path, $home_path ) ) {
return '/';
}
if ( 0 === strncasecmp( $path, $home_path . '/', strlen( $home_path ) + 1 ) ) {
return substr( $path, strlen( $home_path ) );
}
return $path;
}
/**
* Turns a single exclusion pattern into an anchored regular expression.
*
* @param mixed $pattern A user-provided URL pattern.
*
* @return string|null The regular expression, or null if the pattern is empty.
*/
private static function get_exclusion_regex( $pattern ) {
if ( ! is_string( $pattern ) ) {
return null;
}
$pattern = trim( $pattern );
if ( '' === $pattern ) {
return null;
}
/*
* Reject malformed URL patterns. A full URL with a scheme but no path
* (e.g. a typo'd `http://[::1`) would otherwise collapse to `/` and
* silently exclude only the homepage. A pathless URL that points at this
* site (e.g. the home URL pasted as `https://example.com`) is allowed
* through, since it legitimately means the homepage.
*/
$parsed = wp_parse_url( $pattern );
if ( false === $parsed ) {
return null;
}
if ( isset( $parsed['scheme'] ) && empty( $parsed['path'] ) ) {
$home_host = wp_parse_url( home_url( '/' ), PHP_URL_HOST );
if ( empty( $parsed['host'] ) || 0 !== strcasecmp( $parsed['host'], (string) $home_host ) ) {
return null;
}
}
// Allow full URLs by stripping the home URL prefix (both secure and non-secure).
$home_url = home_url( '/' );
$pattern = str_ireplace(
array(
$home_url,
str_replace( 'http:', 'https:', $home_url ),
),
'/',
$pattern
);
$pattern = self::normalize_url_path( $pattern );
/*
* Split on wildcard tokens, treating any run of adjacent wildcards as a
* single split point. The possessive `++` is important: without coalescing,
* a pattern such as `****` would expand to one `.*` group per character, and
* thousands of wildcards would compile to thousands of groups and exhaust
* memory when matched against every front-end request. Possessive (rather
* than greedy `+`) keeps the split itself linear, so a pathological run of
* thousands of adjacent wildcards cannot exhaust the PCRE backtrack/JIT
* stack and make preg_split() return false.
*/
$tokens = preg_split( '/(?:\(\.\*\)|\(\*\)|\.\*|\*)++/', $pattern );
if ( false === $tokens ) {
return null;
}
// Everything between wildcards is matched literally; only the wildcards
// become a (non-capturing) `.*` group.
$quoted = array_map(
function ( $token ) {
return preg_quote( $token, '~' );
},
$tokens
);
return '~^' . implode( '(?:.*)', $quoted ) . '/?$~i';
}
public static function get_slug() {
return 'render_blocking_js';
}
}
@@ -0,0 +1,101 @@
<?php
/**
* Speculation Rules implementation for cornerstone pages
*
* @package Boost
* @since 3.13.0
*/
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Speculation_Rules;
use Automattic\Jetpack_Boost\Contracts\Changes_Output_On_Activation;
use Automattic\Jetpack_Boost\Contracts\Feature;
use Automattic\Jetpack_Boost\Contracts\Optimization;
use Automattic\Jetpack_Boost\Lib\Cornerstone\Cornerstone_Utils;
/**
* Class to handle speculation rules for cornerstone pages
*/
class Speculation_Rules implements Feature, Changes_Output_On_Activation, Optimization {
/**
* Get the slug for this module.
*
* @return string
*/
public static function get_slug() {
return 'speculation_rules';
}
/**
* Check if the feature is available
*
* @return bool
*/
public static function is_available() {
return true;
}
/**
* Initialize the speculation rules
*
* @since 3.13.0
* @return void
*/
public function setup() {
// Use WP core action to add speculation rules
add_action( 'wp_load_speculation_rules', array( $this, 'add_cornerstone_rules' ) );
}
/**
* Add speculation rules for cornerstone pages
*
* @param \WP_Speculation_Rules $speculation_rules The speculation rules instance.
* @since 3.13.0
* @return void
*/
public function add_cornerstone_rules( $speculation_rules ) {
$cornerstone_urls = $this->get_cornerstone_urls();
if ( empty( $cornerstone_urls ) ) {
return;
}
// remove the protocol and domain from the list of cornerstone urls
$home_url = wp_parse_url( home_url() );
$domain = $home_url['host'];
$protocol = $home_url['scheme'];
$cornerstone_urls = array_map(
function ( $url ) use ( $protocol, $domain ) {
return trailingslashit( str_replace( $protocol . '://' . $domain, '', $url ) );
},
$cornerstone_urls
);
// Add prerender rule for cornerstone pages with moderate eagerness
$speculation_rules->add_rule(
'prerender',
'cornerstone-pages-prerender',
array(
'source' => 'document',
'where' => array(
'href_matches' => $cornerstone_urls,
),
'eagerness' => 'moderate',
)
);
}
/**
* Get the list of cornerstone page URLs
*
* @since 3.13.0
* @return array Array of cornerstone page URLs
*/
private function get_cornerstone_urls() {
$cornerstone_urls = Cornerstone_Utils::get_list();
if ( empty( $cornerstone_urls ) ) {
return array();
}
return $cornerstone_urls;
}
}